diff --git a/.github/workflows/deploy-pre-release.yml b/.github/workflows/deploy-pre-release.yml index 9c54a15ff..2bd7cfe8d 100644 --- a/.github/workflows/deploy-pre-release.yml +++ b/.github/workflows/deploy-pre-release.yml @@ -2,17 +2,13 @@ name: Deploy Pre-release on: workflow_dispatch: inputs: - versionTag: - description: 'Version tag' - required: true - type: string releaseTitle: description: 'Release title' required: true type: string releaseDescription: description: 'Release description' - required: true + required: false type: string env: DEVELOPER_DIR: /Applications/Xcode_26.5.app @@ -47,16 +43,13 @@ jobs: - name: Run tests run: xcodebuild clean test -skipMacroValidation + -skipPackagePluginValidation -scheme ${{ env.SCHEME_NAME }} -destination 'platform=iOS Simulator,name=iPhone Air' - - name: Bump version - id: bump-version - uses: yanamura/ios-bump-version@v1 - with: - version: ${{ inputs.versionTag }} - name: Xcode archive run: xcodebuild archive -skipMacroValidation + -skipPackagePluginValidation -scheme ${{ env.SCHEME_NAME }} -destination 'generic/platform=iOS' -archivePath ${{ env.ARCHIVE_PATH }} @@ -77,15 +70,29 @@ jobs: - name: Retrieve data id: retrieve-data run: | - echo "size=$(stat -f%z $IPA_OUTPUT_PATH)" >> $GITHUB_OUTPUT - echo "version_date=$(date -u +"%Y-%m-%dT%T")" >> $GITHUB_OUTPUT + app_info_plist="$PAYLOAD_PATH/$SCHEME_NAME.app/Info.plist" + version="$(plutil -extract CFBundleShortVersionString raw -o - "$app_info_plist")" + size="$(stat -f%z "$IPA_OUTPUT_PATH")" + + [[ ! -z "$version" ]] || exit 1 + [[ ! -z "$size" ]] || exit 1 + + { + echo "size=$size" + echo "version=$version" + echo "version_date=$(date -u +'%Y-%m-%dT%H:%M:%SZ')" + } >> "$GITHUB_OUTPUT" - name: Validate data + env: + RELEASE_TITLE: ${{ inputs.releaseTitle }} + RELEASE_SIZE: ${{ steps.retrieve-data.outputs.size }} + RELEASE_VERSION: ${{ steps.retrieve-data.outputs.version }} + RELEASE_DATE: ${{ steps.retrieve-data.outputs.version_date }} run: | - [[ ! -z "${{ inputs.releaseTitle }}" ]] || exit 1 - [[ ! -z "${{ inputs.releaseDescription }}" ]] || exit 1 - [[ ! -z "${{ steps.retrieve-data.outputs.size }}" ]] || exit 1 - [[ ! -z "${{ steps.bump-version.outputs.version }}" ]] || exit 1 - [[ ! -z "${{ steps.retrieve-data.outputs.version_date }}" ]] || exit 1 + [[ ! -z "$RELEASE_TITLE" ]] || exit 1 + [[ ! -z "$RELEASE_SIZE" ]] || exit 1 + [[ ! -z "$RELEASE_VERSION" ]] || exit 1 + [[ ! -z "$RELEASE_DATE" ]] || exit 1 - name: Release to GitHub uses: softprops/action-gh-release@v3 with: @@ -94,5 +101,5 @@ jobs: files: ${{ env.IPA_OUTPUT_PATH }} token: ${{ secrets.GITHUB_TOKEN }} name: ${{ inputs.releaseTitle }} - body: ${{ inputs.releaseDescription }} - tag_name: 'v${{ steps.bump-version.outputs.version }}' + body: ${{ inputs.releaseDescription || '' }} + tag_name: ${{ steps.retrieve-data.outputs.version }} diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 14950b7ad..c506e0e5e 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -6,7 +6,6 @@ on: types: [closed] env: DEVELOPER_DIR: /Applications/Xcode_26.5.app - APP_VERSION: '2.8.0' SCHEME_NAME: 'EhPanda' ALTSTORE_JSON_PATH: './AltStore.json' BUILDS_PATH: '/tmp/action-builds' @@ -43,16 +42,13 @@ jobs: - name: Run tests run: xcodebuild clean test -skipMacroValidation + -skipPackagePluginValidation -scheme ${{ env.SCHEME_NAME }} -destination 'platform=iOS Simulator,name=iPhone Air' - - name: Bump version - id: bump-version - uses: yanamura/ios-bump-version@v1 - with: - version: ${{ env.APP_VERSION }} - name: Xcode archive run: xcodebuild archive -skipMacroValidation + -skipPackagePluginValidation -scheme ${{ env.SCHEME_NAME }} -destination 'generic/platform=iOS' -archivePath ${{ env.ARCHIVE_PATH }} @@ -146,12 +142,7 @@ jobs: token: ${{ secrets.GITHUB_TOKEN }} body: ${{ github.event.pull_request.body }} name: ${{ github.event.pull_request.title }} - tag_name: 'v${{ steps.retrieve-data.outputs.version }}' - - name: Commit bump version - run: | - git add . - git commit -m "Bump version" - git push origin HEAD + tag_name: ${{ steps.retrieve-data.outputs.version }} - name: Update AltStore.json env: RELEASE_SIZE: ${{ steps.retrieve-data.outputs.size }} @@ -202,10 +193,16 @@ jobs: git commit -m "Update AltStore.json" git push origin HEAD - name: Post release notes + env: + RELEASE_VERSION: ${{ steps.retrieve-data.outputs.version }} + RELEASE_BODY: ${{ github.event.pull_request.body }} + RELEASE_NOTES: ${{ steps.retrieve-data.outputs.notes }} run: | + message="$(printf '*%s Release Notes:*\n%s' "$RELEASE_VERSION" "$RELEASE_BODY")" curl https://api.telegram.org/bot${{ secrets.TELEGRAM_BOT_TOKEN }}/sendMessage \ -d parse_mode=markdown -d chat_id=${{ secrets.TELEGRAM_CHANNEL_ID }} \ - -d text='*v${{ steps.retrieve-data.outputs.version }} Release Notes:*%0A${{ github.event.pull_request.body }}' + --data-urlencode "text=$message" + payload="$(jq -n --arg content "**$RELEASE_VERSION Release Notes:**\n$RELEASE_NOTES" '{content: $content}')" curl ${{ secrets.DISCORD_WEBHOOK }} \ - -F 'payload_json={"content": "**v${{ steps.retrieve-data.outputs.version }} Release Notes:**\n${{ steps.retrieve-data.outputs.notes }}"}' + -F "payload_json=$payload" diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index b97ba67bb..1bd40ae33 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -22,5 +22,6 @@ jobs: - name: Run tests run: xcodebuild clean test -skipMacroValidation + -skipPackagePluginValidation -scheme ${{ env.SCHEME_NAME }} -destination 'platform=iOS Simulator,name=iPhone Air' diff --git a/.gitignore b/.gitignore index ee8327b39..9fdcea43e 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,12 @@ +build +.build .DS_Store +.xcode-home EhPanda.xcodeproj/xcuserdata -EhPanda.xcodeproj/project.xcworkspace/xcuserdata \ No newline at end of file +EhPanda.xcodeproj/project.xcworkspace/xcuserdata +Config/LocalSigning.xcconfig + +# AppPackage (local Swift package) +AppPackage/.build +AppPackage/.swiftpm/xcode/xcuserdata +AppPackage/.swiftpm/xcode/package.xcworkspace/xcuserdata diff --git a/.planning/PROJECT.md b/.planning/PROJECT.md new file mode 100644 index 000000000..6d76beeac --- /dev/null +++ b/.planning/PROJECT.md @@ -0,0 +1,123 @@ +# EhPanda + +## What This Is + +EhPanda is an open-source iOS / iPadOS client for E-Hentai / ExHentai — browsing, searching, favorites, gallery detail, reading, and downloading — built in SwiftUI on The Composable Architecture, modularized into a thin app shell (`App/`) plus a local Swift package (`AppPackage/`). This milestone is a **dependency-reduction & modernization** pass ahead of the unreleased **v3.0.0**: shrink the third-party surface to a minimal, justified set; move networking to async/await and TCA toward 2.0; retire legacy UI/architecture patterns (screen-metric math, global helpers, parameter drilling); and ratchet up lint — all with **behavior parity**, no user-facing regressions. + +## Core Value + +The load-bearing paths must keep working: reliably **fetch, parse, read, and download** galleries from E-Hentai / ExHentai. Every task in this milestone is a foundation change — parity with today's behavior is the bar; a modernization that regresses browsing, reading, or downloading has failed. + +## Requirements + +### Validated + + + +- ✓ Browse & search galleries — Home (Frontpage/Popular/Watched/Toplists/History), Search, Favorites — existing +- ✓ Gallery detail — comments, torrents, archives, previews, tag detail — existing +- ✓ Reading — paged & vertical, dual-page, zoom/pan gestures, reading settings — existing +- ✓ Downloads — concurrent page downloads + background processing task — existing +- ✓ Auth — E-Hentai/ExHentai session cookies via `WKWebView` login — existing +- ✓ Tag translation — EhTagTranslation database + OpenCC Simplified/Traditional conversion — existing +- ✓ Persistence — swift-sharing `@Shared` (no Core Data) + progressive schema-migration engine (all models at v1) — existing +- ✓ Modular architecture — App-shell + `AppPackage` local package; deps centralized in `Package.swift` — existing + +### Active + + + +**A · Dependency reduction** +- [ ] 1. Fork **SwiftyOpenCC** — modernize its OpenCC dependency, package requirement & Swift version; rebuild on the latest stack +- [ ] 2. Fork **UIImageColors** — same modernization treatment +- [ ] 3. Migrate **SwiftCommonMark → Apple swift-markdown** (usage is parse-only via `MarkdownUtil`; confirm `DetailView`) +- [ ] 4. Replace **WaterfallGrid** with a custom SwiftUI `Layout` · *feasibility spike first* +- [ ] 5. Replace **SwiftUIPager** with a built-in page-style `TabView` · *feasibility spike first* +- [ ] 6. Investigate inlining **DeprecatedAPI** (`getCFReadStream`) without deprecation warnings; adopt a non-deprecated API if actionable +- [ ] 7. Migrate to the latest **Colorful** (modernization, not reduction) + +**B · Concurrency & framework modernization** +- [ ] 8. Migrate **Combine-based requests → async/await** (`NetworkingFeature` request layer + `ApplicationClient`/`AuthorizationClient`/`ImageClient`/`LibraryClient` + consuming reducer effects) +- [ ] 13. Pin **TCA `from: 1.25.3` with traits** (`ComposableArchitecture2Deprecations`, `ComposableArchitecture2DeprecationOverloads`) and resolve all surfaced deprecations + +**C · UI architecture** +- [ ] 10. **Modernize adaptive layout** — remove screen-dependent logic (`DeviceUtil` + `DeviceClient`); prefer size classes / `containerRelativeFrame` / `onGeometryChange` / `ViewThatFits`, **avoiding `GeometryReader`**; retire `TouchHandler` via native gestures +- [ ] 11. **Decompose `GenericList`** — let each of its 8 consuming pages build its own list from shared atoms instead of a super-list +- [ ] 12. **Universal device orientation** on every page + remove EhPanda's custom orientation lock (delete `enablesLandscape`), deferring the lock to iOS's built-in feature +- [ ] 15. **Root-level privacy mask** — replace `blurRadius` parameter-drilling (~25 inits, 39 `.autoBlur` sites) with one mask per root surface (app root + ~41 modal roots), driven by shared state +- [ ] 19. **Remove the auto-lock feature** — delete `autoLockPolicy`, the biometric re-auth path, and `AuthorizationClient`; replace the security-section control with a description pointing users to iOS's built-in per-app lock (background blur is kept) + +**D · Architecture hygiene** +- [ ] 14. **De-globalize `*Util` → injected clients, kill singletons** — the AppTools Utils (Device/Haptics/UserDefaults/File/Cookie) plus `URLUtil` and `AppUtil`, and the `TouchHandler.shared` / `DataCache.shared` globals; keep pure value types & constants + +**E · Correctness, security & tests (folded-in concerns, later timing)** +- [ ] 16. **Move session cookies to Keychain** (during #14's CookieClient work) + audit that no cookie values are ever logged +- [ ] 17. **Client-layer test coverage** — `NetworkingFeature` (during #8), `CookieClient` & `ImageClient` (during #14) +- [ ] 18. **Fix `Category.private.filterValue`** — remove the `fatalError` landmine +- [ ] 20. **Structured error handling + user-facing error surface** (gates the `optional_try` rule) — replace silent `try?` (144 sites) with proper `do/catch` that surfaces user-relevant failures through a structured error surface (Description / Suggested Solution / Context / environment info; non-blocking failure toast → tap for detail), keeping best-effort parsing explicitly optional + +**F · UI polish** +- [ ] 21. **Numeric text polish** — apply `.monospacedDigit()` + `.contentTransition(.numericText())` to most number-bearing text (counts, page numbers, sizes, ratings) + +**G · Lint hardening (capstone + refactor-gated)** +- [ ] 9. Enable the commented-out custom rules + opt-in `multiline_function_chains` & `sorted_imports` + a new **labeled-tuple-elements** rule, all at **error** level. Mechanical rules (`sorted_imports`, `multiline_function_chains`, `single_line_trailing_closure`, labeled-tuples) land as a capstone sweep; refactor-gated rules sequence **with** their refactors: `optional_try` → #20, plus `binding_initializer`, `lifecycle_modifiers`, `unchecked_subscript_index_access` + +### Out of Scope + + + +- **ParserFeature complexity refactor** (extract per-field sub-parsers) — real value but rides on nothing else here; deferred to a future milestone +- **DownloadClient decomposition** (555+ line files) — large standalone refactor; deferred +- **Broad client-layer tests beyond networking/cookie/image** (Reading/Home/Search/Favorites features) — deferred; this milestone covers only the seams already being reworked +- **Post-release v2 schema migrations + migration-mock cleanup** — deferred until v3.0.0 ships; models stay at v1 this milestone +- **Any visual redesign** — the UI-architecture tasks are mechanism swaps, not re-skins; behavior/appearance parity required +- **Re-enabling `function_body_length` / `cyclomatic_complexity` / `type_body_length`** — kept disabled; `ParserFeature` relies on it and it wasn't requested + +## Context + +- **v3.0.0 in flight, unreleased.** Last release was v2.8.0; ~600+ commits of refactoring since (persistence drop-to-`@Shared`, navigation refactor, migration engine — all landed). This milestone is the next batch: dependency reduction + modernization before v3.0.0 ships. +- **Codebase map** lives at `.planning/codebase/` (STACK, ARCHITECTURE, STRUCTURE, CONVENTIONS, TESTING, INTEGRATIONS, CONCERNS). +- **Reference designs** for the structured error surface (#20) and the refactor-gated lint rules (#9) have been captured name-free; the plan phase needs no external lookup. +- **Two tasks carry parity risk** and are spiked first: SwiftUIPager→`TabView` (core reading UX) and WaterfallGrid→custom `Layout` (masonry column balancing). + +## Constraints + +- **Tech stack**: Swift 6.3.1, iOS/iPadOS 26 minimum, SwiftUI + TCA 1.25.x + swift-sharing; Xcode-only build/test (bare `swift build` fails); SwiftLint runs as a build-tool plugin. +- **Parity**: No user-facing behavior or appearance regressions — this is a foundation milestone, not a feature or redesign one. +- **Schema**: Persisted `@Shared` models stay at **v1**, edited in place, for the whole pre-release milestone — no `VersionedSchema` v2 / migration until v3.0.0 releases. +- **Lint**: SwiftLint-as-error; never suppress, disable, or add `// swiftlint:disable` without explicit user permission. +- **Reference privacy**: Never record the name of any local project consulted for implementation references in any repository artifact (see AGENTS.md) — absolute and non-waivable. + +## Key Decisions + +| Decision | Rationale | Outcome | +|----------|-----------|---------| +| Combine→async/await stays in this milestone (not split out) | One coherent modernization milestone; sequence it after the isolated dep removals | — Pending | +| Spike-first for WaterfallGrid→Layout and SwiftUIPager→TabView | "Bet on native parity" items that can genuinely fail; reading is core UX | — Pending | +| Avoid `GeometryReader`; prefer size classes / `containerRelativeFrame` / `onGeometryChange` / `ViewThatFits` | Most cases don't need it; it's greedy and layout-disruptive | — Pending | +| Retire `TouchHandler` via `SpatialTapGesture.location` + `MagnifyGesture.startAnchor` | Native gestures (iOS 17+) cover all three uses; kills a global singleton | — Pending | +| Remove auto-lock (use iOS built-in per-app lock); **keep** background blur | OS app-lock supersedes the custom biometric flow; app-switcher blur stays as standalone privacy | — Pending | +| `@Shared` models edited in place at v1 until v3.0.0 ships | No released data to migrate from pre-release; defers first real v2 to post-release | — Pending | +| De-`Util` package-wide (incl. `URLUtil`, `AppUtil`) | Injected clients over singletons/global helpers; consistent architecture | — Pending | +| Fold in cookies→Keychain, networking/cookie/image tests, `.private.filterValue` fix; defer Parser/Download refactors | Coupled concerns are cheap while their seams are open; standalone refactors are separate scope | — Pending | +| Recommended sequence: small-blast deps → swaps/spikes → migrations (#8/#13) → architecture (#10/#11/#12/#14/#15/#19) → concerns (#16–18,#20) → lint capstone (#9) | Minimize churn; write new code to the new bar; lint ratchets last | — Pending | + +## Evolution + +This document evolves at phase transitions and milestone boundaries. + +**After each phase transition** (via `/gsd-transition`): +1. Requirements invalidated? → Move to Out of Scope with reason +2. Requirements validated? → Move to Validated with phase reference +3. New requirements emerged? → Add to Active +4. Decisions to log? → Add to Key Decisions +5. "What This Is" still accurate? → Update if drifted + +**After each milestone** (via `/gsd-complete-milestone`): +1. Full review of all sections +2. Core Value check — still the right priority? +3. Audit Out of Scope — reasons still valid? +4. Update Context with current state + +--- +*Last updated: 2026-07-09 after initialization* diff --git a/.planning/REQUIREMENTS.md b/.planning/REQUIREMENTS.md new file mode 100644 index 000000000..4049d9913 --- /dev/null +++ b/.planning/REQUIREMENTS.md @@ -0,0 +1,123 @@ +# Requirements: EhPanda — Dependency Reduction & Modernization (v3.0.0) + +**Defined:** 2026-07-09 +**Core Value:** The load-bearing paths — fetch, parse, read, download — keep working; every task is a foundation change held to **behavior/appearance parity**. + +> These are a modernization milestone's requirements: technical and refactor-oriented rather than user-stories. Each carries acceptance criteria that define "done"; unless stated otherwise, **no user-facing regression** is an implicit criterion for all of them. + +## v1 Requirements + +### DEP — Dependency reduction + +- [ ] **DEP-01**: Fork SwiftyOpenCC and modernize it — update its OpenCC dependency, package requirement, and Swift version to the latest, rebuilt on the current stack. + - App depends on the fork; Simplified/Traditional tag conversion (`ChineseConverter`) is unchanged in behavior; builds clean on the pinned toolchain. +- [ ] **DEP-02**: Fork UIImageColors and modernize it the same way. + - App depends on the fork; dominant-color extraction (`getColors` → primary/secondary/detail/background) unchanged; builds clean. +- [ ] **DEP-03**: Migrate Markdown from SwiftCommonMark to Apple swift-markdown. + - `MarkdownUtil.parseTexts/parseLinks/parseImages` reproduced on swift-markdown's `Document`/walker; `TagTranslation` output identical on fixtures; `DetailView` markdown confirmed (render vs parse) and preserved; SwiftCommonMark removed from `Package.swift`. +- [ ] **DEP-04**: Replace WaterfallGrid with a custom SwiftUI `Layout`. *(Spike-gated: validate feasibility before committing tasks.)* + - Masonry column balancing matches current output (portrait/landscape, iPad/phone counts); WaterfallGrid removed; scrolling performance not regressed. +- [ ] **DEP-05**: Replace SwiftUIPager with a built-in page-style `TabView`. *(Spike-gated.)* + - Reading paging parity: horizontal/RTL/dual-page, page-index mapping, gesture coexistence; SwiftUIPager removed; if native can't reach parity, spike surfaces it before commit. +- [ ] **DEP-06**: Investigate inlining DeprecatedAPI (`getCFReadStream`) into the project without deprecation warnings; adopt a non-deprecated API if actionable. + - Either the shim is inlined warning-free or a non-deprecated replacement is used; DeprecatedAPI dependency removed; DF networking behavior unchanged. +- [ ] **DEP-07**: Migrate to the latest Colorful. + - `GalleryCardCell` animated gradient renders as before on the current API; version pin updated. + +### CONC — Concurrency & framework modernization + +- [ ] **CONC-01**: Migrate Combine-based requests to async/await. + - `NetworkingFeature` request layer returns async results (no `AnyPublisher`); `ApplicationClient`/`AuthorizationClient`/`ImageClient`/`LibraryClient` and all consuming reducer effects migrated off Combine; request behavior/error paths preserved. +- [ ] **CONC-02**: Pin TCA `from: 1.25.3` with traits `ComposableArchitecture2Deprecations` + `ComposableArchitecture2DeprecationOverloads` and resolve all surfaced deprecations. + - `Package.swift` updated with traits; zero TCA deprecation warnings remain; reducers/stores behave identically. + +### UIARCH — UI architecture + +- [ ] **UIARCH-01**: Modernize adaptive layout — remove screen-dependent logic across `DeviceUtil` and `DeviceClient`. + - No view reads `DeviceUtil.window*/screen*/absWindow*` for layout; discrete `isPadWidth`/`isSEWidth` breakpoints replaced by size-class / container-relative decisions; `TouchHandler` retired via `SpatialTapGesture.location` + `MagnifyGesture.startAnchor`; **`GeometryReader` avoided** in favor of `containerRelativeFrame`/`onGeometryChange`/`ViewThatFits`; `Defaults.FrameSize`/`ImageSize` no longer derive size from a global; reading zoom/pan/tap parity preserved. +- [ ] **UIARCH-02**: Decompose `GenericList` so each of its 8 consuming pages builds its own list from shared atoms. + - Reusable atoms (cells, footer, notice, loading/error overlays, grid) extracted; the 8 pages compose their own lists; `GenericList` super-list removed; list behavior (display modes, pagination, refresh, badges) preserved. +- [ ] **UIARCH-03**: Support device orientation on every page and remove EhPanda's custom orientation lock. + - All pages rotate with the device; `AppOrientationMask` masking, `AppDelegateClient.setOrientation*`, the reading `setOrientationPortrait` flow, and the `Setting.enablesLandscape` field are removed (v1 in-place edit); OS orientation lock governs. +- [ ] **UIARCH-04**: Replace `blurRadius` parameter-drilling with a root-level privacy mask. + - No view initializer takes `blurRadius`; `.autoBlur` applied only at root surfaces — app root + every one of the ~41 modal roots; transient blur state sourced from shared in-memory state; **no lock-time/background content leak** in any modal; NavigationBar-collapse workaround preserved. +- [ ] **UIARCH-05**: Remove the auto-lock feature; direct users to iOS's built-in per-app lock. + - `Setting.autoLockPolicy`, the biometric re-auth path (`authorize`/`lockApp`/`isAppLocked`/threshold), and `AuthorizationClient` are removed; the security-section auto-lock control is replaced by a description pointing to the iOS built-in lock; background blur is retained (see UIARCH-04). + +### HYG — Architecture hygiene + +- [ ] **HYG-01**: De-globalize `*Util` into injected clients and remove singletons. + - The AppTools Utils (Device/Haptics/UserDefaults/File/Cookie) plus `URLUtil` and `AppUtil` are converted to / folded into injected clients; `TouchHandler.shared` and `DataCache.shared` globals removed; pure value types and constants retained; no remaining static global helper with side effects. + +### QUAL — Correctness, security & tests + +- [ ] **QUAL-01**: Move session cookies to Keychain and audit cookie logging. + - Durable auth cookies stored via Keychain (done within HYG-01's CookieClient work); no cookie value is ever emitted to logs at `.public` privacy. +- [ ] **QUAL-02**: Add client-layer test coverage for the reworked seams. + - `NetworkingFeature` covered (during CONC-01); `CookieClient` and `ImageClient` covered (during HYG-01); tests are deterministic and green. +- [ ] **QUAL-03**: Fix the `Category.private.filterValue` `fatalError` landmine. + - `filterValue` no longer crashes for `.private`; no callsite iterating all categories can trap; covered by a test. +- [ ] **QUAL-04**: Replace silent `try?` with structured error handling and a user-facing error surface (gates the `optional_try` rule). + - A structured `AppError` (description / suggested solution / typed context) exists; user-relevant failures surface via a non-blocking failure toast that opens a detailed, dismissable error surface (Description / Solution / Context / environment info); network/file/decode `try?` sites become proper `do/catch`; genuinely best-effort parsing stays explicitly optional (not every one prompts); `optional_try` can be enabled at error with zero violations. + +### POLISH — UI polish + +- [ ] **POLISH-01**: Apply `.monospacedDigit()` + `.contentTransition(.numericText())` to most number-bearing text. + - Counts, page numbers, sizes, ratings, and similar numeric text use monospaced digits and animate as numeric transitions where it makes sense; no layout jitter on value change. + +### LINT — Lint hardening + +- [ ] **LINT-01**: Enable the stricter SwiftLint ruleset at error level. + - The 5 commented custom rules (`binding_initializer`, `lifecycle_modifiers`, `optional_try`, `single_line_trailing_closure`, `unchecked_subscript_index_access`), opt-in `multiline_function_chains` & `sorted_imports`, and a new labeled-tuple-elements rule are all enabled at **error**; every violation resolved at its root (no suppressions); mechanical rules land as a capstone sweep, refactor-gated rules (`optional_try`→QUAL-04, plus binding/lifecycle/unchecked-subscript) land with their refactors. + +## v2 Requirements + +None. Deferred work is captured under Out of Scope (future milestone), not staged as v2 here. + +## Out of Scope + +| Feature | Reason | +|---------|--------| +| ParserFeature complexity refactor (per-field sub-parsers) | Real value but rides on nothing else this milestone; deferred to a future milestone | +| DownloadClient decomposition | Large standalone refactor; deferred | +| Client-layer tests beyond networking/cookie/image (Reading/Home/Search/Favorites) | Deferred; this milestone covers only seams already being reworked | +| Post-release v2 schema migrations + migration-mock cleanup | Models stay at v1 until v3.0.0 ships; first real v2 is post-release | +| Any visual redesign | UI-architecture tasks are mechanism swaps, not re-skins; parity required | +| Re-enabling `function_body_length` / `cyclomatic_complexity` / `type_body_length` | Kept disabled; `ParserFeature` relies on it; not requested | + +## Traceability + + + +| Requirement | Phase | Status | +|-------------|-------|--------| +| DEP-01 | Phase 1 | Pending | +| DEP-02 | Phase 1 | Pending | +| DEP-03 | Phase 1 | Pending | +| DEP-04 | Phase 2 | Pending | +| DEP-05 | Phase 3 | Pending | +| DEP-06 | Phase 1 | Pending | +| DEP-07 | Phase 1 | Pending | +| CONC-01 | Phase 4 | Pending | +| CONC-02 | Phase 4 | Pending | +| UIARCH-01 | Phase 5 | Pending | +| UIARCH-02 | Phase 6 | Pending | +| UIARCH-03 | Phase 5 | Pending | +| UIARCH-04 | Phase 7 | Pending | +| UIARCH-05 | Phase 7 | Pending | +| HYG-01 | Phase 8 | Pending | +| QUAL-01 | Phase 8 | Pending | +| QUAL-02 | Phase 8 | Pending | +| QUAL-03 | Phase 9 | Pending | +| QUAL-04 | Phase 9 | Pending | +| POLISH-01 | Phase 10 | Pending | +| LINT-01 | Phase 11 | Pending | + +**Coverage:** +- v1 requirements: 21 total +- Mapped to phases: 21 ✓ +- Unmapped: 0 + +--- +*Requirements defined: 2026-07-09* +*Last updated: 2026-07-09 after roadmap creation (traceability filled, 21/21 mapped)* diff --git a/.planning/ROADMAP.md b/.planning/ROADMAP.md new file mode 100644 index 000000000..9b6e644ec --- /dev/null +++ b/.planning/ROADMAP.md @@ -0,0 +1,179 @@ +# Roadmap: EhPanda — Dependency Reduction & Modernization (v3.0.0) + +## Overview + +A foundation milestone that shrinks EhPanda's third-party surface and modernizes its +concurrency, UI architecture, and lint bar ahead of the unreleased v3.0.0 — every task held to +behavior/appearance parity. The journey runs from low-risk isolated dependency swaps, through the +two parity-risk native swaps that are spike-gated first (WaterfallGrid→Layout, SwiftUIPager→TabView), +into the big framework migration (Combine→async/await, TCA traits), then the UI-architecture and +hygiene refactors (adaptive layout, GenericList decomposition, root privacy mask, auto-lock removal, +de-globalized clients) with their folded-in security/test/correctness concerns, and finishing with a +structured error surface and a lint capstone. Refactor-gated lint rules land with the refactors that +enable them; the mechanical rules sweep last. + +## Phases + +**Phase Numbering:** +- Integer phases (1, 2, 3): Planned milestone work +- Decimal phases (2.1, 2.2): Urgent insertions (marked with INSERTED) + +Decimal phases appear between their surrounding integers in numeric order. + +- [ ] **Phase 1: Isolated Dependency Modernization** - Fork/modernize/replace the third-party deps that don't couple to other work, at parity +- [ ] **Phase 2: Native Masonry Grid Swap (spike-gated)** - Validate then replace WaterfallGrid with a custom SwiftUI Layout +- [ ] **Phase 3: Native Reader Paging Swap (spike-gated)** - Validate then replace SwiftUIPager with a page-style TabView +- [ ] **Phase 4: Concurrency & Framework Migration** - Move requests to async/await and pin TCA with deprecation traits +- [ ] **Phase 5: Adaptive Layout & Universal Orientation** - Let size classes and the OS govern layout and rotation; retire screen-metric math and TouchHandler +- [ ] **Phase 6: GenericList Decomposition** - Replace the super-list with per-page lists built from shared atoms +- [ ] **Phase 7: Root Privacy Mask & Auto-Lock Removal** - One shared-state mask per root surface; remove the custom auto-lock for iOS's built-in per-app lock +- [ ] **Phase 8: Architecture Hygiene & Client Seams** - De-globalize Utils into injected clients, move cookies to Keychain, cover reworked seams with tests +- [ ] **Phase 9: Correctness & Structured Error Handling** - Kill the private-category crash and replace silent try? with a user-facing error surface +- [ ] **Phase 10: Numeric Text Polish** - Monospaced digits and numeric-text transitions on number-bearing text +- [ ] **Phase 11: Lint Capstone** - Ratchet SwiftLint to the stricter ruleset at error; mechanical sweep last, refactor-gated rules flipped on + +## Phase Details + +### Phase 1: Isolated Dependency Modernization +**Goal**: Shrink and modernize the isolated third-party surface — the swaps that don't couple to other work — with behavior parity. +**Depends on**: Nothing (first phase) +**Requirements**: DEP-01, DEP-02, DEP-03, DEP-06, DEP-07 +**Success Criteria** (what must be TRUE): + 1. Simplified/Traditional tag conversion (`ChineseConverter`) produces identical output on the forked, modernized SwiftyOpenCC, and the project builds clean on the pinned toolchain. + 2. Dominant-color extraction (`getColors` → primary/secondary/detail/background) is unchanged on the forked, modernized UIImageColors. + 3. Markdown parsing (`MarkdownUtil.parseTexts/parseLinks/parseImages`) yields identical `TagTranslation` output on swift-markdown fixtures, `DetailView` markdown is preserved, and SwiftCommonMark is removed from `Package.swift`. + 4. DeprecatedAPI is gone — the `getCFReadStream` path is inlined warning-free or replaced by a non-deprecated API, with DF networking behavior unchanged. + 5. `GalleryCardCell`'s animated gradient renders as before on the latest Colorful, with the version pin updated. +**Plans**: TBD + +### Phase 2: Native Masonry Grid Swap (spike-gated) +**Goal**: Replace WaterfallGrid with a custom SwiftUI `Layout` — validated by a feasibility spike first — with column-balancing and scrolling parity. +**Depends on**: Nothing (independent; may run alongside Phase 1) +**Requirements**: DEP-04 +**Success Criteria** (what must be TRUE): + 1. A feasibility spike confirms a custom `Layout` can reproduce masonry column balancing before implementation is committed, or surfaces the blocker. + 2. Masonry output matches current WaterfallGrid across portrait/landscape and iPad/phone column counts. + 3. Scrolling performance is not regressed. + 4. WaterfallGrid is removed from the dependency set. +**Plans**: TBD +**UI hint**: yes + +### Phase 3: Native Reader Paging Swap (spike-gated) +**Goal**: Replace SwiftUIPager with a built-in page-style `TabView` for reading — validated by a spike first — preserving all paging UX. +**Depends on**: Nothing (independent; may run alongside Phases 1–2) +**Requirements**: DEP-05 +**Success Criteria** (what must be TRUE): + 1. A feasibility spike confirms a native page-style `TabView` reaches reading-paging parity (horizontal/RTL/dual-page, page-index mapping, gesture coexistence) before commit, or surfaces the gap. + 2. Reading paging behaves identically: horizontal and RTL direction, dual-page mode, and correct page-index mapping. + 3. Reader gestures (zoom/pan/tap) continue to coexist with paging. + 4. SwiftUIPager is removed from the dependency set. +**Plans**: TBD +**UI hint**: yes + +### Phase 4: Concurrency & Framework Migration +**Goal**: Move the request layer to async/await and pin TCA with deprecation traits — with request and reducer behavior preserved. +**Depends on**: Phase 2, Phase 3 (migrations sequenced after the native swaps to minimize churn) +**Requirements**: CONC-01, CONC-02 +**Success Criteria** (what must be TRUE): + 1. The `NetworkingFeature` request layer returns async results with no `AnyPublisher`, and request behavior and error paths are preserved. + 2. `ApplicationClient`/`AuthorizationClient`/`ImageClient`/`LibraryClient` and all consuming reducer effects are migrated off Combine. + 3. `Package.swift` pins TCA `from: 1.25.3` with the `ComposableArchitecture2Deprecations` + `ComposableArchitecture2DeprecationOverloads` traits. + 4. Zero TCA deprecation warnings remain, and reducers/stores behave identically. +**Plans**: TBD + +### Phase 5: Adaptive Layout & Universal Orientation +**Goal**: Let size classes and the OS govern layout and orientation — retiring screen-metric math, the custom touch handler, and the custom orientation lock — with reading and rotation parity. +**Depends on**: Phase 2, Phase 3, Phase 4 (refines the swapped grid/reader surfaces on top of the migrated code) +**Requirements**: UIARCH-01, UIARCH-03 +**Success Criteria** (what must be TRUE): + 1. No view reads `DeviceUtil.window*/screen*/absWindow*` for layout; discrete `isPadWidth`/`isSEWidth` breakpoints are replaced by size-class / container-relative decisions; `GeometryReader` is avoided in favor of `containerRelativeFrame`/`onGeometryChange`/`ViewThatFits`. + 2. `TouchHandler` is retired via `SpatialTapGesture.location` + `MagnifyGesture.startAnchor`, and reading zoom/pan/tap parity is preserved. + 3. `Defaults.FrameSize`/`ImageSize` no longer derive size from a global. + 4. All pages rotate with the device; `AppOrientationMask` masking, `AppDelegateClient.setOrientation*`, the reading `setOrientationPortrait` flow, and `Setting.enablesLandscape` are removed, with the OS orientation lock governing. +**Plans**: TBD +**UI hint**: yes + +### Phase 6: GenericList Decomposition +**Goal**: Replace the `GenericList` super-list with per-page lists composed from shared atoms — preserving all list behavior. +**Depends on**: Phase 5 (per-page lists compose the new adaptive layout and custom grid atoms) +**Requirements**: UIARCH-02 +**Success Criteria** (what must be TRUE): + 1. Reusable atoms (cells, footer, notice, loading/error overlays, grid) are extracted, and each of the 8 consuming pages composes its own list. + 2. The `GenericList` super-list is removed. + 3. List behavior is preserved: display modes, pagination, refresh, and badges. +**Plans**: TBD +**UI hint**: yes + +### Phase 7: Root Privacy Mask & Auto-Lock Removal +**Goal**: Replace `blurRadius` parameter-drilling with one shared-state-driven mask per root surface, and remove the custom auto-lock in favor of iOS's built-in per-app lock — keeping background blur and leaking no content. +**Depends on**: Phase 4 (`AuthorizationClient` is removed after CONC-01 migrates it; the mask lands on migrated code) +**Requirements**: UIARCH-04, UIARCH-05 +**Success Criteria** (what must be TRUE): + 1. No view initializer takes `blurRadius`; `.autoBlur` is applied only at root surfaces (app root + each of the ~41 modal roots), driven by shared in-memory state, with no lock-time/background content leak in any modal, and the NavigationBar-collapse workaround preserved. + 2. `Setting.autoLockPolicy`, the biometric re-auth path (`authorize`/`lockApp`/`isAppLocked`/threshold), and `AuthorizationClient` are removed. + 3. The security-section auto-lock control is replaced by a description pointing users to the iOS built-in per-app lock. + 4. Background / app-switcher blur is retained. +**Plans**: TBD +**UI hint**: yes + +### Phase 8: Architecture Hygiene & Client Seams +**Goal**: De-globalize the Utils into injected clients and remove singletons, move session cookies to Keychain, and cover the reworked client seams with tests. +**Depends on**: Phase 4, Phase 5 (removes `TouchHandler.shared` after UIARCH-01 retires it; QUAL-02 tests the async `NetworkingFeature` from Phase 4) +**Requirements**: HYG-01, QUAL-01, QUAL-02 +**Success Criteria** (what must be TRUE): + 1. The AppTools Utils (Device/Haptics/UserDefaults/File/Cookie) plus `URLUtil` and `AppUtil` are converted to / folded into injected clients; `TouchHandler.shared` and `DataCache.shared` globals are removed; pure value types and constants are retained; no static global helper with side effects remains. + 2. Durable auth cookies are stored via Keychain (within the CookieClient work), and no cookie value is ever emitted to logs at `.public` privacy. + 3. Client-layer tests cover the reworked seams — the async `NetworkingFeature` (from Phase 4), `CookieClient`, and `ImageClient` — and are deterministic and green. +**Plans**: TBD + +### Phase 9: Correctness & Structured Error Handling +**Goal**: Remove the private-category crash landmine and replace silent `try?` with structured error handling behind a user-facing error surface. +**Depends on**: Phase 8 (structured error handling applied to the settled client/architecture seams) +**Requirements**: QUAL-03, QUAL-04 +**Success Criteria** (what must be TRUE): + 1. `Category.private.filterValue` no longer crashes, no callsite iterating all categories can trap, and a test covers it. + 2. A structured `AppError` (description / suggested solution / typed context) exists; network/file/decode `try?` sites become proper `do/catch`, while genuinely best-effort parsing stays explicitly optional. + 3. User-relevant failures surface via a non-blocking failure toast that opens a dismissable detail surface (Description / Suggested Solution / Context / environment info). + 4. `optional_try` can be enabled at error with zero violations (verified in the lint capstone). +**Plans**: TBD +**UI hint**: yes + +### Phase 10: Numeric Text Polish +**Goal**: Apply monospaced digits and numeric-text transitions to number-bearing text so counts and values animate cleanly without layout jitter. +**Depends on**: Phase 6, Phase 7 (applies to the settled UI surfaces) +**Requirements**: POLISH-01 +**Success Criteria** (what must be TRUE): + 1. Counts, page numbers, sizes, ratings, and similar numeric text use `.monospacedDigit()` and `.contentTransition(.numericText())` where it makes sense. + 2. Numeric values animate as numeric transitions on change. + 3. No layout jitter occurs on value change. +**Plans**: TBD +**UI hint**: yes + +### Phase 11: Lint Capstone +**Goal**: Ratchet SwiftLint to the stricter ruleset at error — the mechanical rules as a final sweep, the refactor-gated rules flipped on now that their refactors have landed — with every violation resolved at its root. +**Depends on**: Phase 5, Phase 6, Phase 7, Phase 9 (refactor-gated rules land with their refactors; the mechanical sweep runs last) +**Requirements**: LINT-01 +**Success Criteria** (what must be TRUE): + 1. The mechanical rules (`sorted_imports`, `multiline_function_chains`, `single_line_trailing_closure`, and the new labeled-tuple-elements rule) are enabled at **error** as a capstone sweep, with all violations resolved at root. + 2. The refactor-gated rules (`optional_try`, `binding_initializer`, `lifecycle_modifiers`, `unchecked_subscript_index_access`) — resolved at root during their coupled refactor phases (`optional_try` with Phase 9's structured-error work; the others with the Phase 5–7 UI/architecture refactors) — are switched to **error** with zero remaining violations. + 3. No rule is suppressed, disabled, or bypassed with `// swiftlint:disable`, and the project builds clean under SwiftLint-as-error. +**Plans**: TBD + +## Progress + +**Execution Order:** +Phases execute in numeric order: 1 → 2 → 3 → 4 → 5 → 6 → 7 → 8 → 9 → 10 → 11 + +| Phase | Plans Complete | Status | Completed | +|-------|----------------|--------|-----------| +| 1. Isolated Dependency Modernization | 0/TBD | Not started | - | +| 2. Native Masonry Grid Swap | 0/TBD | Not started | - | +| 3. Native Reader Paging Swap | 0/TBD | Not started | - | +| 4. Concurrency & Framework Migration | 0/TBD | Not started | - | +| 5. Adaptive Layout & Universal Orientation | 0/TBD | Not started | - | +| 6. GenericList Decomposition | 0/TBD | Not started | - | +| 7. Root Privacy Mask & Auto-Lock Removal | 0/TBD | Not started | - | +| 8. Architecture Hygiene & Client Seams | 0/TBD | Not started | - | +| 9. Correctness & Structured Error Handling | 0/TBD | Not started | - | +| 10. Numeric Text Polish | 0/TBD | Not started | - | +| 11. Lint Capstone | 0/TBD | Not started | - | diff --git a/.planning/STATE.md b/.planning/STATE.md new file mode 100644 index 000000000..3b273ac90 --- /dev/null +++ b/.planning/STATE.md @@ -0,0 +1,86 @@ +--- +gsd_state_version: '1.0' # placeholder; syncStateFrontmatter overwrites on first state.* call +status: planning +progress: + total_phases: 11 + completed_phases: 0 + total_plans: 0 + completed_plans: 0 + percent: 0 +--- + +# Project State + +## Project Reference + +See: .planning/PROJECT.md (updated 2026-07-09) + +**Core value:** The load-bearing paths — fetch, parse, read, download galleries — keep working; every task is a foundation change held to behavior/appearance parity. +**Current focus:** Phase 1 — Isolated Dependency Modernization + +## Current Position + +Phase: 1 of 11 (Isolated Dependency Modernization) +Plan: 0 of TBD in current phase +Status: Ready to plan +Last activity: 2026-07-09 — Roadmap created (11 phases, 21 requirements mapped) + +Progress: [░░░░░░░░░░] 0% + +## Performance Metrics + +**Velocity:** +- Total plans completed: 0 +- Average duration: — min +- Total execution time: 0.0 hours + +**By Phase:** + +| Phase | Plans | Total | Avg/Plan | +|-------|-------|-------|----------| +| - | - | - | - | + +**Recent Trend:** +- Last 5 plans: — +- Trend: — + +*Updated after each plan completion* + +## Accumulated Context + +### Decisions + +Decisions are logged in PROJECT.md Key Decisions table. +Recent decisions affecting current work: + +- Roadmap: Combine→async/await (Phase 4) stays in this milestone, sequenced after the isolated dep removals. +- Roadmap: WaterfallGrid→Layout (Phase 2) and SwiftUIPager→TabView (Phase 3) are spike-first — validate feasibility before committing. +- Roadmap: Fold cookies→Keychain + networking/cookie/image tests + `.private.filterValue` fix into their open seams (Phases 8–9); defer Parser/Download refactors. +- Roadmap: LINT-01 split — mechanical rules sweep last (Phase 11); refactor-gated rules land with their refactors (`optional_try`→Phase 9; binding/lifecycle/unchecked-subscript→Phases 5–7). + +### Pending Todos + +[From .planning/todos/pending/ — ideas captured during sessions] + +None yet. + +### Blockers/Concerns + +[Issues that affect future work] + +- Phase 8 (QUAL-02): NetworkingFeature tests couple to Phase 4's async migration but land in the hygiene phase (where CookieClient/ImageClient are reworked) — verify NetworkingFeature parity tests are written against the migrated async layer, not deferred silently. +- Phases 2 & 3 carry genuine parity risk (spike-gated); a failed spike must surface before committing implementation. + +## Deferred Items + +Items acknowledged and carried forward from previous milestone close: + +| Category | Item | Status | Deferred At | +|----------|------|--------|-------------| +| *(none)* | | | | + +## Session Continuity + +Last session: 2026-07-09 +Stopped at: ROADMAP.md + STATE.md written; REQUIREMENTS.md traceability filled (21/21 mapped) +Resume file: None diff --git a/.planning/codebase/ARCHITECTURE.md b/.planning/codebase/ARCHITECTURE.md new file mode 100644 index 000000000..0287862d7 --- /dev/null +++ b/.planning/codebase/ARCHITECTURE.md @@ -0,0 +1,220 @@ + +# Architecture + +**Analysis Date:** 2026-07-09 + +## System Overview + +```text +┌─────────────────────────────────────────────────────────────┐ +│ App Shell (target) │ +│ `App/EhPandaApp.swift` → imports AppFeature, renders │ +│ RootView; @UIApplicationDelegateAdaptor(AppDelegate) │ +└──────────────────────────────┬──────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────────┐ +│ AppFeature (root) │ +│ `AppPackage/Sources/AppFeature/DataFlow/AppReducer.swift` │ +│ Composes AppRoute, AppLock, TabBar + all tab features │ +├──────────┬──────────┬──────────┬──────────┬─────────────────┤ +│ Home │ Favorites│ Search │ Downloads│ Setting │ +│ Feature │ Feature │ Feature │ Feature │ Feature │ +└────┬─────┴────┬─────┴────┬─────┴────┬─────┴───────┬─────────┘ + │ │ │ │ │ + ▼ ▼ ▼ ▼ ▼ +┌─────────────────────────────────────────────────────────────┐ +│ Leaf feature reducers (@Presents / StackState) │ +│ Detail · Reading · ReadingSetting · Filters · QuickSearch · │ +│ DateSeek · TagTranslation · Networking · Parser │ +└──────────────────────────────┬──────────────────────────────┘ + │ @Dependency clients + ▼ +┌─────────────────────────────────────────────────────────────┐ +│ Client layer (Dependencies): CookieClient · DownloadClient ·│ +│ ImageClient · FileClient · URLClient · DeviceClient · … │ +└──────────────────────────────┬──────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────────┐ +│ AppModels (@Shared persisted state + schema migration) │ +│ AppTools (utilities) · Networking (Kanna HTML parsing) │ +│ Store: file-backed @Shared keys + rebuilt cache files │ +└─────────────────────────────────────────────────────────────┘ +``` + +## Component Responsibilities + +| Component | Responsibility | File | +|-----------|----------------|------| +| App shell | `@main` scene, delegate adaptor, mounts `RootView` | `App/EhPandaApp.swift` | +| AppReducer | Root TCA reducer; composes tabs, routing, lock, scene phase, launch automation | `AppPackage/Sources/AppFeature/DataFlow/AppReducer.swift` | +| AppRouteReducer | App-wide modal/deep-link routing destinations | `AppPackage/Sources/AppFeature/DataFlow/AppRouteReducer.swift` | +| AppLockReducer | Biometric/auto-lock gating on scene phase | `AppPackage/Sources/AppFeature/DataFlow/AppLockReducer.swift` | +| TabBarReducer | Tab selection + tab container | `AppPackage/Sources/AppFeature/View/TabBar/TabBarReducer.swift` | +| Feature modules | One TCA `*Reducer` + SwiftUI views per screen domain | `AppPackage/Sources/Feature/` | +| Client modules | `@DependencyClient` wrappers over side-effecting systems | `AppPackage/Sources/Client/` | +| AppModels | Domain models + `@Shared` persistence + schema migration | `AppPackage/Sources/AppModels/` | +| NetworkingFeature | HTTP requests + Kanna HTML scraping of EHentai | `AppPackage/Sources/NetworkingFeature/` | +| ParserFeature | Parses scraped HTML into domain models | `AppPackage/Sources/ParserFeature/` | + +## Pattern Overview + +**Overall:** The Composable Architecture (TCA) on a modularized Swift Package + thin app-shell layout. + +**Key Characteristics:** +- App-shell (`App/`) holds zero business logic; all logic lives in `AppPackage/` local Swift package. +- Each screen domain is an isolated module exposing a `@Reducer struct Feature`/`Reducer` with `@ObservableState`. +- Side effects are isolated behind `@DependencyClient` modules and injected via `@Dependency`. +- Composition is hierarchical: `AppReducer` scopes child reducers via `Scope`/`ifLet`/`forEach`; navigation uses `@Presents` Destination enums and `StackState`. +- Persistence uses the Sharing library (`@Shared`/`@SharedReader`) over file-backed keys — no Core Data / no database. + +## Layers + +**App shell:** +- Purpose: Boot the SwiftUI scene and mount the root feature. +- Location: `App/` +- Contains: `EhPandaApp.swift`, `Info.plist`, entitlements, assets, app icons. +- Depends on: `AppFeature` product only. +- Used by: iOS runtime. + +**Feature layer:** +- Purpose: UI + reducer logic per domain. +- Location: `AppPackage/Sources/*Feature`, plus `AppFeature` (root). +- Contains: `@Reducer` types, `@ObservableState` State, Action enums, SwiftUI views. +- Depends on: ComposableArchitecture, sibling feature modules, client modules, AppModels, AppComponents. +- Used by: `AppReducer`. + +**Client layer:** +- Purpose: Wrap side-effecting systems (network, cookies, files, images, device) behind testable interfaces. +- Location: `AppPackage/Sources/*Client`. +- Contains: `@DependencyClient` structs + live/test/preview values. +- Depends on: AppModels, AppTools, third-party SDKs. +- Used by: feature reducers via `@Dependency`. + +**Model / data layer:** +- Purpose: Domain models, `@Shared` persisted state, schema migration engine. +- Location: `AppPackage/Sources/AppModels`. +- Contains: `Persistent/` (Setting, Filter, User, GalleryHistory, AppIconType), `Persistence/` (AppSharedKeys, SchemaMigrator, SchemaVersion, VersionedSchema, JSONValue), Gallery/Download/Tags/Support models. +- Depends on: CasePaths, Sharing. +- Used by: all feature + client modules. + +**Support / shared:** +- Purpose: Reusable UI, utilities, resources, catalog extensions. +- Location: `AppTools`, `AppComponents`, `GalleryListComponents`, `Resources`, `TestingSupport`, and `*Ext` modules (`CommonMarkExt`, `OSLogExt`, `OpenCCExt`, `SFSafeSymbolsExt`, `SystemNotificationExt`). + +## Data Flow + +### Primary Request Path (browse galleries) + +1. User action dispatches an Action into a feature reducer (e.g. `HomeReducer`) (`AppPackage/Sources/HomeFeature/`). +2. Reducer returns an `Effect` invoking a client, e.g. `@Dependency(\.urlClient)` / networking request (`AppPackage/Sources/NetworkingFeature/Request+Gallery.swift`). +3. NetworkingFeature performs the HTTP request and scrapes HTML via Kanna. +4. `ParserFeature` parses the response into `AppModels` gallery types (`AppPackage/Sources/ParserFeature/`). +5. Parsed models flow back as a follow-up Action; reducer mutates `@ObservableState`; SwiftUI view re-renders. + +### Persistence Flow + +1. Feature reads/writes light domain data through `@Shared`/`@SharedReader` keys defined in `AppPackage/Sources/AppModels/Persistence/AppSharedKeys.swift`. +2. On decode, `SchemaMigrator` walks the ordered array of `VersionedSchema` types, progressively migrating stored data to the current schema version (`AppPackage/Sources/AppModels/Persistence/SchemaMigrator.swift`). +3. `tagTranslator` stores thin info in `@Shared` and rebuilds its lookup cache into a separate cache file. + +**State Management:** +- In-memory UI state: TCA `@ObservableState` held in each reducer's `State`, rooted at `AppReducer.State`. +- Cross-cutting persisted state: Sharing library `@Shared` (file-backed), NOT `fileStorage`, NO database. + +### App Lifecycle Flow + +1. `AppDelegate` events arrive via `AppDelegateClient` into `AppDelegateReducer`. +2. `AppReducer.onScenePhaseChange` drives auto-lock (blur + `AppLockReducer`) and background handling. +3. Launch automation runs once (`runLaunchAutomation` via `AppLaunchAutomationClient`). + +## Key Abstractions + +**Feature (`@Reducer`):** +- Purpose: Self-contained screen domain (State + Action + body). +- Examples: `AppPackage/Sources/DetailFeature/`, `AppPackage/Sources/SettingFeature/`. +- Pattern: Project convention names reducers with a `Feature`/`Reducer` suffix (e.g. `SettingFeature`). + +**Client (`@DependencyClient`):** +- Purpose: Injectable, testable boundary around a side effect. +- Examples: `AppPackage/Sources/CookieClient/CookieClient.swift`, `AppPackage/Sources/DownloadClient/DownloadClient.swift`. +- Pattern: struct of closures with live/test/preview values, accessed via `@Dependency`. + +**Shared persisted key (`@Shared`):** +- Purpose: Durable app state without a database. +- Examples: `AppPackage/Sources/AppModels/Persistence/AppSharedKeys.swift`. +- Pattern: Sharing library keys + `SchemaVersion` gate + progressive migration. + +**Navigation Destination:** +- Purpose: State-driven modals / stacks. +- Pattern: `@Presents var destination` enums (AppRouteReducer) and `StackState` paths (e.g. Setting stack). + +## Entry Points + +**App scene:** +- Location: `App/EhPandaApp.swift` +- Triggers: iOS launch. +- Responsibilities: create `WindowGroup`, mount `RootView(appDelegate:)`. + +**Root view + store:** +- Location: `AppPackage/Sources/AppFeature/RootView.swift` +- Triggers: mounted by app shell. +- Responsibilities: create the root `StoreOf` and render the tab bar hierarchy. + +**Share extension:** +- Location: `ShareExtension/ShareViewController.swift` +- Triggers: iOS share sheet. +- Responsibilities: receive shared URLs into the app. + +## Architectural Constraints + +- **Modularity:** No business logic in the `App/` shell; it links only the `AppFeature` product. Third-party dependencies are declared in `AppPackage/Package.swift`, never in the Xcode project. +- **Dependency direction:** Features depend on clients and AppModels, never the reverse. AppModels must not import feature/client modules (breaks Tools↔Models cycles by pushing runtime behavior to app-layer extensions). +- **Persistence:** No Core Data / no database — light data on `@Shared` file-backed keys only; `tagTranslator` cache is a rebuilt file, not `@Shared`. +- **Global state:** Prefer injected dependencies over singletons; e.g. `ImageClient.dataCache` is injectable rather than always `DataCache.shared`. +- **Lint-as-error:** SwiftLint (incl. custom regex rules + banned APIs) runs as a build plugin; new modules must add a `.swiftlint.yml` with `parent_config`. + +## Anti-Patterns + +### Empty / leftover TCA action stubs + +**What happens:** An action case is emptied during refactor but left in the enum. +**Why it's wrong:** Dead cases mislead readers and bloat the reducer switch. +**Do this instead:** Delete the case and every call site (see reducer `Action` enums such as `AppPackage/Sources/AppFeature/DataFlow/AppReducer.swift`). + +### Hoisting confirmation dialogs onto containers + +**What happens:** A `.confirmationDialog`/`.alert` is attached to a whole `Form`/`List` for convenience. +**Why it's wrong:** On iPad these render as popovers anchored to the modifier's view; a transient/unrelated anchor points the arrow wrong or tears the dialog down. +**Do this instead:** Attach it to the stable triggering control; thread the dialog binding into the subview that owns the trigger. + +### Using `DataCache.shared` in image tests + +**What happens:** Tests touch the shared image cache. +**Why it's wrong:** Cross-test pollution; flaky assertions on cached pixels. +**Do this instead:** Inject a per-test `DataCache` via `ImageClient.dataCache` and compare pixel dimensions. + +### Dragging utilities into AppModels to break cycles + +**What happens:** A Tools↔Models import cycle is "fixed" by moving utils into the model module. +**Why it's wrong:** Inverts the dependency direction and re-creates cycles. +**Do this instead:** Move runtime behavior to app-layer extensions, keeping models behavior-free. + +## Error Handling + +**Strategy:** Effects surface failures back as Actions; reducers translate them into user-facing state (native alerts/HUDs). + +**Patterns:** +- Prefer native SwiftUI / system presentation surfaces for alerts and HUDs; unify the state type but don't rebuild native affordances as custom cards. +- Rejected/invalid persisted data (e.g. bad `schemaVersion`) is logged via OSLog (`AppPackage/Sources/OSLogExt/`). + +## Cross-Cutting Concerns + +**Logging:** OSLog wrapper; `Logger+.swift` is init-only, so each logging file declares its own `private let logger` at the top (`AppPackage/Sources/*/Logger+.swift`). +**Validation:** Schema-version gate + progressive migration on decode of persisted models. +**Authentication:** Cookie-based session via `CookieClient` + `AuthorizationClient`; auto-lock via `AppLockReducer` and `AuthorizationClient`. + +--- + +*Architecture analysis: 2026-07-09* diff --git a/.planning/codebase/CONCERNS.md b/.planning/codebase/CONCERNS.md new file mode 100644 index 000000000..de231c103 --- /dev/null +++ b/.planning/codebase/CONCERNS.md @@ -0,0 +1,84 @@ +# Codebase Concerns + +**Analysis Date:** 2026-07-09 + +## Tech Debt + +**Migration engine has no real v2 schemas yet:** +- Issue: The progressive schema-migration engine is fully built and tested, but every persisted `@Shared` model still sits at v1. The migration paths are exercised only by throwaway mock models. Correctness against a real breaking change is unproven until the first v2 lands. +- Files: `AppPackage/Sources/AppModels/` (persisted models), `AppPackage/Tests/AppModelsTests/SchemaMigrationTests.swift` +- Impact: First real model change may reveal engine gaps not covered by mocks; contributors may not know the "add a VersionedSchema, then delete the mock" workflow. +- Fix approach: When the first breaking model change arrives, add a real `VersionedSchema` to that model's `schemas` array and delete the corresponding mock + its tests (per the header comment in `SchemaMigrationTests.swift`). + +**Throwaway mock models living in the test suite:** +- Issue: `SchemaMigrationTests.swift` defines `ProgressiveMock` and one mock per shape (RENAME/ADD/REMOVE/TYPE/DERIVE/MERGE) explicitly marked "REMOVE when real v2 models land." +- Files: `AppPackage/Tests/AppModelsTests/SchemaMigrationTests.swift` +- Impact: These mocks are scaffolding, not product coverage. If left after real v2 schemas appear, they become dead maintenance weight. +- Fix approach: Delete per-model mocks as each model gets a real v2; keep only engine-level shape coverage. + +**HTML-scraping parsers carry heavy complexity suppressions:** +- Issue: The `ParserFeature` module repeatedly suppresses `cyclomatic_complexity` and `function_body_length` because it parses raw website HTML. +- Files: `AppPackage/Sources/ParserFeature/Parser+Profile.swift`, `Parser+Comment.swift`, `Parser+Detail.swift`, `Parser+Torrent.swift`, `Parser+Greeting.swift`, `Parser+Shared.swift` +- Impact: Long, branch-heavy functions are hard to modify safely and hard to review. +- Fix approach: Extract per-field sub-parsers to shrink function bodies; the inline `swiftlint:disable:next` markers point at the exact hotspots. + +## Known Bugs + +**No known open bugs surfaced during this scan.** No `TODO`/`FIXME`/`HACK`/`XXX` markers exist anywhere in Swift sources, and no `try!`/`as!`/force-unwrap hotspots outside the standard TCA placeholder pattern. + +## Security Considerations + +**Session cookies live in `HTTPCookieStorage`, not the Keychain:** +- Risk: EHentai/ExHentai authentication is cookie-based (`ipb_member_id`, `ipb_pass_hash`, `igneous`). These sit in the shared `HTTPCookieStorage`, which is less protected than the Keychain. +- Files: `AppPackage/Sources/CookieClient/CookieClient.swift` +- Current mitigation: Standard iOS cookie-store data protection (encrypted at rest with device passcode); credentials never logged (OSLog usage elsewhere marks sensitive values `privacy: .private` by default). +- Recommendations: Evaluate moving the durable auth cookies to Keychain-backed storage; audit that no cookie values are ever emitted to `LogsClient`/OSLog with `.public` privacy. + +## Performance Bottlenecks + +**Download subsystem is the largest and most complex area:** +- Problem: `DownloadClient` spans many large files coordinating concurrent page downloads, background tasks, and response validation. +- Files: `AppPackage/Sources/DownloadClient/DownloadStore.swift` (555 lines), `DownloadClient+Manager.swift` (457), `DownloadPageDownloader.swift` (434), `DownloadClient+ExecutionSupport.swift` (427), `DownloadClient+ResponseValidationHelpers.swift` (357), `BackgroundTaskClient.swift` +- Cause: Concurrency and scheduling logic concentrated in a few files; historically a flaky scheduling test existed here (fixed in `557b0425`). +- Improvement path: Any regression in download scheduling timing is now a real regression, not flake — treat `DownloadSchedulingTests` failures as signal. + +## Fragile Areas + +**Website-HTML parsing:** +- Files: `AppPackage/Sources/ParserFeature/*` +- Why fragile: Parsers are tightly coupled to EHentai/ExHentai page structure. Any upstream markup change silently breaks detail, comment, profile, greeting, or torrent parsing. +- Safe modification: Change one field-parser at a time and back it with a fixture-based `ParserFeatureTests` case; never widen a regex without a covering test. +- Test coverage: `ParserFeatureTests` exists — extend it before touching parser internals. + +**`Category.private.filterValue` traps:** +- Files: `AppPackage/Sources/AppModels/Gallery/Category.swift:45` +- Why fragile: `filterValue` calls `fatalError` for the `.private` case ("`Private` doesn't have a `filterValue`!"). Any code path that computes a filter bitmask over all categories including `.private` will crash the app. +- Safe modification: Guard/exclude `.private` before calling `filterValue`, or change the return to an optional. Confirm no callsite iterates `Category.allCases` into `filterValue`. + +## Scaling Limits + +**Not applicable** — this is a client app with no server-side capacity model. Practical limits are per-gallery download concurrency and on-device image cache size (`AppTools/DataCache.swift`, 330 lines), both bounded by device resources rather than a hard ceiling in code. + +## Dependencies at Risk + +**Not detected** — third-party dependencies are centralized in `AppPackage/Package.swift`; no dependency is flagged as abandoned or blocking in-repo. (A full advisory audit was out of scope for this static scan.) + +## Missing Critical Features + +**None blocking** — no feature-gap stubs (empty returns, unimplemented cases) were found beyond the intentional `.private` category exclusion noted above. + +## Test Coverage Gaps + +**Client and feature modules without a test target:** +- What's not tested: 37 of ~44 modules under `AppPackage/Sources` have no matching `AppPackage/Tests/Tests` directory. Notably absent: `DownloadClient` (largest subsystem), `CookieClient` (auth/security), `ImageClient`, `ReadingFeature`, `HomeFeature`, `SearchFeature`, `FavoritesFeature`, `NetworkingFeature`. +- Files: entire subtrees under `AppPackage/Sources/{DownloadClient,CookieClient,ImageClient,ReadingFeature,HomeFeature,SearchFeature,FavoritesFeature}/` +- Risk: Regressions in downloading, auth-cookie handling, and image caching would land unnoticed. These are the highest-blast-radius areas of the app. +- Priority: High — `DownloadClient` and `CookieClient` first (complexity + security), then `ImageClient`. + +**Well-covered areas (for contrast):** `AppModelsTests` (migration + model mocks), `ParserFeatureTests`, and reducer-level tests exist; 87 test files total. The gap is in the client layer, not the model/parser layer. + +--- + +*Concerns audit: 2026-07-09* + + diff --git a/.planning/codebase/CONVENTIONS.md b/.planning/codebase/CONVENTIONS.md new file mode 100644 index 000000000..deef013b0 --- /dev/null +++ b/.planning/codebase/CONVENTIONS.md @@ -0,0 +1,144 @@ +# Coding Conventions + +**Analysis Date:** 2026-07-09 + +EhPanda is a Swift/SwiftUI + Composable Architecture (TCA) app, modularized into a +thin app-shell (`App/`) plus a local Swift package (`AppPackage/`) holding all logic. +Conventions are enforced at build time by SwiftLint (`.swiftlint.yml` at repo root, +inherited by every module via `parent_config`). Treat lint rules as authoritative: +suppressing, disabling, or `// swiftlint:disable` is forbidden without explicit user +permission (`CLAUDE.md`). + +## Naming Patterns + +**Files:** +- One primary type per file, filename matches the type: `AppearanceSettingReducer.swift`, + `HapticsClient.swift`, `CommentsReducerTests.swift`. +- Reducer bodies/helpers split via extension files with `+` suffix: + `SettingReducer+Body.swift`, `SettingReducer+Helpers.swift`. +- Extension-only support files use `+.swift`: `Logger+.swift`. + +**Reducers:** +- **Reducers use the `Feature`/`Reducer` suffix per the domain.** Project override in + `CLAUDE.md`: name reducers with a `Feature` suffix (e.g. `SettingFeature`) — this + overrides TCA's standard naming and any conflicting skill/search guidance. Existing + code uses both `...Reducer` (e.g. `AppearanceSettingReducer`, `AppReducer`) and + `...Feature`; follow the module's local suffix, and prefer `Feature` for new reducers + unless the user says otherwise. +- Module directories under `AppPackage/Sources/` end in `Feature` (UI/logic) or `Client` + (dependency wrappers): `DetailFeature`, `SettingFeature`, `HapticsClient`, `FileClient`. + +**Functions / Variables:** +- Standard Swift lowerCamelCase functions, UpperCamelCase types. +- **Date properties must use a noun form, never an `At` suffix** (custom lint rule + `date_property_at_suffix`): use `creationDate`, not `createdAt`. +- Localized-format numeric arguments are surfaced as labeled Swift params via named + `%#@variable@` substitutions; string (`%@`) args stay positional (`CLAUDE.md`). + +**Types:** +- Reducers annotated `@Reducer`, marked `public struct ... : Sendable`. +- Nested `State` is `@ObservableState public struct State: Equatable, Sendable`. +- Nested `Action` is `public enum Action: Equatable, Sendable`. +- **`Delegate` enum is a sibling of `Action`, not nested inside it** (max 1 level of + nesting; TCA convention noted in memory). See `AppearanceSettingReducer.swift`. + +## Code Style + +**Formatting:** +- Enforced by SwiftLint (`.swiftlint.yml`), run as a build-tool plugin + (`SwiftLintBuildToolPlugin`, declared in `AppPackage/Package.swift`). +- `line_length`: 120 (warning AND error at 120 — hard limit). +- `file_length`: 1000 (warning AND error). +- `opening_brace`, `type_body_length`, `function_body_length`, `cyclomatic_complexity`, + `multiple_closures_with_trailing_closure` are disabled. + +**Linting — opt-in strict rules (severity: error):** +- `force_try` — banned. +- `force_unwrapping` — banned. + +**Custom regex rules (all severity: error) — write conforming code from the start:** +- `no_nslock` — use `Mutex` (Synchronization), not `NSLock`. +- `no_preconcurrency` — `@preconcurrency` banned; fix the real Sendable issue. +- `no_unchecked_sendable` — `@unchecked Sendable` banned; use a real value type, actor, or Mutex. +- `system_name_image_parameter` — use `systemSymbol:`, never `systemName:`/`systemImage:` (SFSafeSymbols). +- `shape_initializer_argument` — use SwiftUI shape shorthand for standalone shape args. +- `label_text_image_shorthand` — use `Label(_ titleResource:systemSymbol:)`. +- `accessibility_empty_string` — never pass `""`/`Text(verbatim: "")` to accessibility modifiers. +- `accessibility_text_argument` — pass a `LocalizedStringResource`/`String` to accessibility + modifiers, not `Text(...)`. +- `no_case_check_property` — don't add a computed `Bool` that only wraps an `if case` enum + check; check the case at the call site (`value.is(\.case)` for `@CasePathable`). +- `child_reducer_shorthand_scope` — use `Scope(state:action:child: Reducer.init)`. +- `child_reducer_shorthand_foreach` — use `.forEach(_:action:element: Reducer.init)`. +- `child_reducer_shorthand_store` — use `Store/TestStore(initialState:reducer: Reducer.init)`. +- `swiftlint_disable_requires_reason` — any `swiftlint:disable` needs a preceding `// reason:` comment. + +**New module setup:** add a `.swiftlint.yml` at the module root referencing the parent +config (`parent_config: ../../../.swiftlint.yml` for a module under `AppPackage/Sources`). +See `CLAUDE.md` and existing per-module configs. + +## Import Organization + +Imports are grouped loosely by dependency kind; no strict alphabetization enforced. Common order: +1. System / framework (`import Foundation`, `import SwiftUI`) +2. Third-party (`import ComposableArchitecture`, `import Kanna`) +3. Local modules (`import AppModels`, `import ApplicationClient`) + +**Path aliases:** none (no `@` TS-style aliases — this is Swift). Cross-module access is +via explicit `import `. Package products are aliased in `Package.swift` +(`static let composableArchitecture: Self = ...`). + +## Error Handling + +- `force_try` and `force_unwrapping` are lint errors — handle every error explicitly. +- Throwing APIs use typed error enums (e.g. `TestError` in `TestingSupport`) with `guard ... else { throw }`. +- Reducer side effects run in `.run { }` closures; async work uses `await`. +- No `@unchecked Sendable` / `@preconcurrency` escape hatches (banned). + +## Logging + +**Framework:** OSLog via a project `Logger` wrapper (`OSLogExt` module, `Logger+.swift`). + +**Patterns (per memory + code):** +- `Logger+.swift` is init-only; declare a `private let logger` at the top of *each file* + that logs: `private let logger = Logger(category: .init(describing: SettingReducer.self))`. +- Do not share one logger across files. + +## Comments + +**When to Comment:** +- Explain the WHY of non-obvious deliberate designs — their absence makes intentional + designs read as bugs (memory: "Document deliberate designs"). See the header comment in + `AppearanceSettingReducer.swift` explaining the `@Shared` write-through. +- Regression tests carry a comment explaining the bug they guard against + (`CommentsReducerTests.swift`). + +**DocC:** `///` doc comments used on public helpers/types (e.g. `TestFixtures`). + +## Function / Reducer Design + +- Reducer `body` is `public var body: some Reducer` composing + `Reduce { state, action in switch action { ... } }` plus child `Scope`/`forEach` via + `Reducer.init` shorthand. +- Bind case payloads with `case .x(let value):` inside the switch. +- Empty/no-effect branches `return .none`; side effects `return .run { ... }`. +- **Don't leave empty TCA action stubs** — delete the case and all its call sites when a + behavior is removed (memory: "Remove emptied actions"). +- Extract duplicated state+actions across reducers into a self-contained sub-reducer + (memory: "Extract duplicated reducer logic"). + +## Module Design + +- **Exports:** all cross-module types/members are explicitly `public`; `init()` is `public`. + Extracting a module triggers a public/Sendable/init cascade (memory). +- **Dependencies** injected via `@Dependency(\.someClient) private var someClient` (TCA + Dependencies). Clients live in their own `...Client` module with `liveValue`, + `testValue` (usually `.unimplemented`), and a `.noop` (see `HapticsClient.swift`); + some use `@DependencyClient`. +- All third-party deps declared once in `AppPackage/Package.swift`, never in the Xcode project. +- Concurrency posture: `InferIsolatedConformances` + `NonisolatedNonsendingByDefault` + upcoming features enabled (`sharedSwiftSettings` in `Package.swift`); write Swift-6-clean code. + +--- + +*Convention analysis: 2026-07-09* diff --git a/.planning/codebase/INTEGRATIONS.md b/.planning/codebase/INTEGRATIONS.md new file mode 100644 index 000000000..030b36006 --- /dev/null +++ b/.planning/codebase/INTEGRATIONS.md @@ -0,0 +1,86 @@ +# External Integrations + +**Analysis Date:** 2026-07-09 + +## APIs & External Services + +**E-Hentai / ExHentai (primary content source):** +- `https://e-hentai.org/` and `https://exhentai.org/` - Gallery listings, search, favorites, detail pages (scraped HTML, parsed with Kanna in `AppPackage/Sources/ParserFeature`) +- `https://api.e-hentai.org/api.php` - JSON API for gallery metadata, torrents, and MPV page tokens +- `https://e-hentai.org/archiver.php` - Archive download links +- `https://e-hentai.org/favorites.php` - User favorites management +- `https://e-hentai.org/fullimg.php`, `https://e-hentai.org/g/...` - Full-image and gallery page endpoints +- `https://e-hentai.org/exchange.php`, `bounty.php`, `bitcoin.php`, `bounce` - Ancillary E-Hentai endpoints +- Request layer: `AppPackage/Sources/NetworkingFeature` (URLSession-based; `URLSession` used ~81 times across sources) +- SDK/Client: none — raw `URLSession` + Kanna HTML parsing; no vendored API SDK +- Auth: session cookies (see Authentication section) + +**Hath network (image CDN):** +- `*.hath.network` hosts (e.g. `akrtazd.spuqplybaxmf.hath.network`) - Serve gallery page images; loaded via Kingfisher / SDWebImage + +**GitHub:** +- `https://api.github.com/repos/` - App update/version checks against the EhPanda-Team repo +- `https://raw.githubusercontent.com/...` - Fetches EhTagTranslation database for tag translation (`AppPackage/Sources/TagTranslationFeature`) + +**EhTagTranslation:** +- External community tag-translation database (GitHub-hosted) - Downloaded and rebuilt into a local cache file; the `tagTranslator` model is a thin info record plus a rebuilt cache file (per persistence refactor) + +## Data Storage + +**Databases:** +- None. All Core Data was dropped in the persistence refactor (no migration path retained). +- Light app data persisted via swift-sharing `@Shared` (in-memory + UserDefaults-backed), NOT `fileStorage` + +**File Storage:** +- Local filesystem via `AppPackage/Sources/FileClient` - Downloaded galleries, logs, and the rebuilt tag-translation cache file +- Downloads managed by `AppPackage/Sources/DownloadClient` + +**Caching:** +- Kingfisher image cache (disk + memory) - primary image cache +- SDWebImage cache (`DataCache`) - animated images; `ImageClient.dataCache` is injectable for tests + +## Authentication & Identity + +**Auth Provider:** +- E-Hentai / ExHentai session cookies (no OAuth, no third-party identity provider) +- Implementation: `AppPackage/Sources/CookieClient/CookieClient.swift` manages `HTTPCookie`s (`ipb_member_id`, `ipb_pass_hash`, `igneous`, etc.) in the shared cookie storage +- Login performed via `WKWebView` (`WKWebView` referenced 5x) so the user authenticates on the E-Hentai web login and cookies are captured + +## Monitoring & Observability + +**Error Tracking:** +- None. No Sentry/Crashlytics/analytics SDK present. + +**Logs:** +- OSLog via `AppPackage/Sources/OSLogExt` and `LogsClient` - structured app logging +- Activity/diagnostic logs written to disk through `FileClient`; viewable in the Setting screen + +## CI/CD & Deployment + +**Hosting:** +- Sideloaded distribution (AltStore); metadata in `AltStore.json`. Not App Store distributed. + +**CI Pipeline:** +- GitHub Actions (`.github/` workflows present) plus `actions-tool/` helper and `.githooks/` + +## Environment Configuration + +**Required env vars:** +- None. No secrets baked into the app; all authenticated access uses user-supplied E-Hentai cookies obtained at runtime. + +**Secrets location:** +- User session cookies live in system `HTTPCookieStorage` (managed by `CookieClient`); no bundled credentials + +## Webhooks & Callbacks + +**Incoming:** +- Custom URL scheme / deep links handled via `AppPackage/Sources/URLClient` (opening `e-hentai.org` gallery URLs into the app) +- Share Extension (`ShareExtension/`) - receives shared URLs from other apps + +**Outgoing:** +- Background download processing task `app.ehpanda.downloads.processing` (`BGProcessingTask`, declared in `App/Info.plist`) scheduled via `BackgroundProcessingClient` +- Local user notifications (`UNUserNotification`) for download completion via `SystemNotificationExt` + +--- + +*Integration audit: 2026-07-09* diff --git a/.planning/codebase/STACK.md b/.planning/codebase/STACK.md new file mode 100644 index 000000000..587aece49 --- /dev/null +++ b/.planning/codebase/STACK.md @@ -0,0 +1,86 @@ +# Technology Stack + +**Analysis Date:** 2026-07-09 + +## Languages + +**Primary:** +- Swift (Swift 6 mode, tools version 6.3.1) - All app and package code across `App/`, `AppPackage/Sources/`, and `ShareExtension/` + +**Secondary:** +- C/C++/Objective-C - Only transitively via dependencies (SwiftyOpenCC, Kanna); no first-party C code detected +- HTML/CSS parsing targets - E-Hentai pages parsed via Kanna in `AppPackage/Sources/ParserFeature` + +## Runtime + +**Environment:** +- iOS / iPadOS 26.0 minimum (`IPHONEOS_DEPLOYMENT_TARGET = 26.0`, package `platforms: [.iOS(.v26)]`) +- App marketing version: 3.0.0 (`MARKETING_VERSION` in `EhPanda.xcodeproj/project.pbxproj`) + +**Package Manager:** +- Swift Package Manager (local package `AppPackage`, declared as `XCLocalSwiftPackageReference` in the Xcode project) +- Lockfile: `AppPackage/Package.resolved` (present; `swift package resolve` regenerates it) +- All third-party dependencies are declared in `AppPackage/Package.swift`, never in the Xcode project +- Note: root `package.json` / `package-lock.json` / `node_modules/` exist but are tooling-only (agent skills), not part of the app build + +## Frameworks + +**Core:** +- ComposableArchitecture (TCA) `1.25.0+` - App architecture / reducers (`swift-composable-architecture`) +- swift-case-paths `1.7.0+` - Enum ergonomics for TCA (`CasePaths`) +- swift-sharing `2.0.0+` - `@Shared` / `@SharedReader` persistence layer (replaces former Core Data) +- SwiftUI / UIKit - Apple UI frameworks (implicit, not in Package.swift) + +**Testing:** +- Swift Testing - Test framework used across `AppPackage/Tests/*` (see TESTING.md) +- Test plan: `AppPackage/Tests/FeatureTests.xctestplan` + +**Build/Dev:** +- SwiftLintPlugins `0.63.0+` (`SimplyDanny/SwiftLintPlugins`) - Build-tool plugin attached to every target; root config `.swiftlint.yml` +- Xcode project `EhPanda.xcodeproj` with `AppPackage-Package` scheme for tests + +## Key Dependencies + +**Critical:** +- Kingfisher `8.0.0+` - Primary async image loading/caching (`onevcat/Kingfisher`) +- SDWebImageSwiftUI `3.0.0+` + SDWebImageWebPCoder `0.14.0+` - Animated image rendering + WebP decode; paired with Kingfisher (dual image stack is deliberate: KF primary, SD renders animated) +- Kanna `6.0.0+` - HTML/XML parsing of E-Hentai pages (`tid-kijyun/Kanna`) +- DeprecatedAPI (`EhPanda-Team/DeprecatedAPI`, `main` branch) - First-party shim for deprecated Apple APIs still needed + +**Infrastructure / UI:** +- SwiftUIPager `2.5.0+` - Paged reading view (`fermoya/SwiftUIPager`) +- WaterfallGrid `1.0.0+` - Gallery grid layout (`paololeonardi/WaterfallGrid`) +- SwiftyOpenCC `2.0.0-beta` (exact pin) - Simplified/Traditional Chinese conversion for tag translation (`ddddxxx/SwiftyOpenCC`) +- SwiftCommonMark `1.0.0+` - Markdown rendering (`gonzalezreal/SwiftCommonMark`) +- SFSafeSymbols `7.0.0+` - Type-safe SF Symbols (`SFSafeSymbols/SFSafeSymbols`) +- UIImageColors `2.2.0+` - Dominant-color extraction for gallery theming (`jathu/UIImageColors`) +- Colorful `1.0.1` (upToNextMinor pin; 1.1.x deprecates ColorfulView) - Animated gradient backgrounds (`Co2333/Colorful`) + +## Configuration + +**Environment:** +- No `.env` files present; app uses no server-side secret config +- Session state (login) is stored via `HTTPCookie` handling in `AppPackage/Sources/CookieClient/CookieClient.swift` +- Light data persisted through swift-sharing `@Shared` (no `fileStorage`, no database) per the persistence refactor +- Background task identifier declared in `App/Info.plist`: `app.ehpanda.downloads.processing` under `BGTaskSchedulerPermittedIdentifiers` + +**Build:** +- `AppPackage/Package.swift` - Single source of truth for modules, dependencies, resources +- `.swiftlint.yml` (root) + per-module `.swiftlint.yml` (`parent_config` chained) +- Swift upcoming features enabled package-wide: `InferIsolatedConformances`, `NonisolatedNonsendingByDefault` (matches app target's Approachable Concurrency) +- App entitlements: `App/EhPanda.entitlements` (currently empty dict) + +## Platform Requirements + +**Development:** +- Xcode toolchain supporting Swift 6.3.1 tools version and iOS 26 SDK +- Build/test via Xcode only (`xcodebuild`); bare `swift build` fails for this project +- SwiftLint runs as a build-tool plugin (no separate PATH install needed for builds) + +**Production:** +- Distributed as sideloaded `.ipa` (AltStore); not on the App Store. See `AltStore.json` and README. +- Requires iOS / iPadOS 26.0 or later on device + +--- + +*Stack analysis: 2026-07-09* diff --git a/.planning/codebase/STRUCTURE.md b/.planning/codebase/STRUCTURE.md new file mode 100644 index 000000000..0f17b0fae --- /dev/null +++ b/.planning/codebase/STRUCTURE.md @@ -0,0 +1,144 @@ +# Codebase Structure + +**Analysis Date:** 2026-07-09 + +## Directory Layout + +``` +EhPanda/ +├── App/ # Thin app-shell target (no business logic) +│ ├── EhPandaApp.swift # @main App scene +│ ├── Info.plist / *.entitlements +│ ├── Assets.xcassets/ # App assets + category icons +│ └── Icons/ # Alternate app icon PNGs +├── AppPackage/ # Local Swift package — ALL logic lives here +│ ├── Package.swift # Targets + third-party dependencies +│ ├── Sources// # One directory per module +│ └── Tests/Tests/ # Mirrored test targets + .xctestplan +├── ShareExtension/ # Share extension target +│ └── ShareViewController.swift +├── EhPanda.xcodeproj # References AppPackage as XCLocalSwiftPackageReference +├── .swiftlint.yml # Root lint config (custom regex rules, banned APIs) +├── Scripts/ # Build/tooling scripts +├── actions-tool/ # CI/GitHub actions helper tool +├── READMEs/ # Additional docs +└── .planning/ # GSD planning artifacts (this doc) +``` + +## Directory Purposes + +**`App/`:** +- Purpose: Thin app-shell target; boots the scene, mounts the root view. +- Contains: `EhPandaApp.swift`, Info.plist, entitlements, assets, icons. +- Key files: `App/EhPandaApp.swift` + +**`AppPackage/Sources/`:** +- Purpose: Every module (features, clients, models, utilities). +- Contains: one subdirectory per module. +- Key files: `AppPackage/Package.swift`, `AppPackage/Sources/AppFeature/DataFlow/AppReducer.swift` + +**`AppPackage/Tests/`:** +- Purpose: Swift Testing test targets, mirroring source module names. +- Contains: `AppModelsTests`, `DetailFeatureTests`, `DownloadsFeatureTests`, `FileClientTests`, `NetworkingFeatureTests`, `ParserFeatureTests`, `SettingFeatureTests`, and `FeatureTests.xctestplan`. + +**`ShareExtension/`:** +- Purpose: iOS share-sheet extension target. +- Key files: `ShareExtension/ShareViewController.swift` + +## Module Categories (AppPackage/Sources) + +**Root / composition:** +- `AppFeature` — root reducer (`DataFlow/AppReducer.swift`, `AppRouteReducer.swift`, `AppLockReducer.swift`, `AppDelegateReducer.swift`), `RootView.swift`, `View/TabBar/`. + +**Feature modules (`@Reducer` + SwiftUI views):** +- `HomeFeature`, `SearchFeature`, `FavoritesFeature`, `DownloadsFeature`, `SettingFeature`, `DetailFeature`, `ReadingFeature`, `ReadingSettingFeature`, `FiltersFeature`, `QuickSearchFeature`, `DateSeekFeature`, `TagTranslationFeature`, `AnimatedImageFeature`, `NetworkingFeature`, `ParserFeature`. + +**Client modules (`@DependencyClient` side-effect boundaries):** +- `AppDelegateClient`, `AppLaunchAutomationClient`, `ApplicationClient`, `AuthorizationClient`, `BackgroundProcessingClient`, `ClipboardClient`, `CookieClient`, `DFClient`, `DeviceClient`, `DownloadClient`, `FileClient`, `HapticsClient`, `ImageClient`, `LibraryClient`, `LogsClient`, `URLClient`, `UserDefaultsClient`. + +**Model / data:** +- `AppModels` — `Persistent/` (Setting, Filter, User, GalleryHistory, AppIconType), `Persistence/` (AppSharedKeys, SchemaMigrator, SchemaVersion, VersionedSchema, JSONValue), plus `Gallery/`, `Download/`, `Tags/`, `Support/`, `Utilities/`, `Resources/`. + +**Shared UI / utilities:** +- `AppComponents`, `GalleryListComponents`, `AppTools` (CookieUtil, FileUtil, DataCache, Extensions), `Resources`, `TestingSupport`. + +**Catalog / library extensions:** +- `CommonMarkExt`, `OSLogExt`, `OpenCCExt`, `SFSafeSymbolsExt`, `SystemNotificationExt`. + +## Key File Locations + +**Entry Points:** +- `App/EhPandaApp.swift`: `@main` scene. +- `AppPackage/Sources/AppFeature/RootView.swift`: root store + view. +- `ShareExtension/ShareViewController.swift`: share extension. + +**Configuration:** +- `AppPackage/Package.swift`: targets + all third-party dependencies. +- `.swiftlint.yml`: root lint rules (per-module `.swiftlint.yml` reference it via `parent_config`). +- `App/Info.plist`, `App/EhPanda.entitlements`. + +**Core Logic:** +- `AppPackage/Sources/AppFeature/DataFlow/AppReducer.swift`: root composition. +- `AppPackage/Sources/AppModels/Persistence/`: schema migration engine + `@Shared` keys. +- `AppPackage/Sources/NetworkingFeature/`: requests + HTML scraping. + +**Testing:** +- `AppPackage/Tests/Tests/`; test plan `AppPackage/Tests/FeatureTests.xctestplan`. + +## Naming Conventions + +**Modules:** +- Feature module → `Feature` directory (reducer type named with a `Feature`/`Reducer` suffix, e.g. `SettingFeature`). +- Side-effect boundary → `Client` directory. +- Library/catalog extension → `Ext` directory. +- Reusable UI → `Components`. + +**Files:** +- Reducers: `Reducer.swift`; views: `View.swift`. +- Feature split: State/Action/body co-located per reducer; large clients split by concern with `+` suffix (e.g. `DownloadClient+Scheduling.swift`, `Request+Gallery.swift`). +- Per-file logger: `Logger+.swift` (init-only helper) with a `private let logger` declared in each file that logs. + +**Test targets:** +- `Tests/` mirroring the source module name; Swift Testing (`@Suite`/`@Test`). + +## Where to Add New Code + +**New feature/screen:** +- Create module dir `AppPackage/Sources/Feature/` with `Reducer.swift` + views. +- Add a `.target` in `AppPackage/Package.swift` (depend on `.composableArchitecture` and needed siblings). +- Add `AppPackage/Sources/Feature/.swiftlint.yml` with `parent_config: ../../../.swiftlint.yml`. +- Compose it into `AppReducer` (State/Action/body) if it belongs at app root. +- Add matching `AppPackage/Tests/FeatureTests/`. + +**New side-effecting dependency:** +- Create `AppPackage/Sources/Client/Client.swift` as a `@DependencyClient` with live/test/preview values. +- Register the target in `AppPackage/Package.swift`; inject via `@Dependency` in features. + +**New persisted model / field:** +- Add/modify types under `AppPackage/Sources/AppModels/Persistent/`. +- For breaking changes, add a new `VersionedSchema` to the ordered array and bump the schema head (`AppPackage/Sources/AppModels/Persistence/`). + +**Shared UI:** +- Reusable views → `AppComponents` or `GalleryListComponents`. +- Utilities → `AppTools` (avoid importing feature/client modules to prevent cycles). + +**New third-party dependency:** +- Declare in `AppPackage/Package.swift` only (never in the Xcode project); add a `static let` alias in the `Target.Dependency` extension. + +## Special Directories + +**`node_modules/` (repo root):** +- Purpose: JS tooling (e.g. GSD/hooks); Generated: Yes; Committed: No (gitignored). + +**`build/`, `.build`:** +- Purpose: Xcode/SwiftPM build output; Generated: Yes; Committed: No. + +**`.xcode-home/`:** +- Purpose: Sandboxed Xcode home for CI/tooling; Committed: partial config only. + +**`actions-tool/`, `Scripts/`:** +- Purpose: CI + release automation; Committed: Yes. + +--- + +*Structure analysis: 2026-07-09* diff --git a/.planning/codebase/TESTING.md b/.planning/codebase/TESTING.md new file mode 100644 index 000000000..de2600b22 --- /dev/null +++ b/.planning/codebase/TESTING.md @@ -0,0 +1,159 @@ +# Testing Patterns + +**Analysis Date:** 2026-07-09 + +## Test Framework + +**Runner:** +- **Swift Testing** (`import Testing`) — `@Suite` / `@Test` / `#expect`. NOT XCTest. +- TCA `TestStore` from ComposableArchitecture for reducer assertions. +- Config: driven by SPM test targets in `AppPackage/Package.swift`; no separate runner config file. + +**Assertion Library:** +- Swift Testing `#expect(...)` / `#require(...)`. +- TCA exhaustive state assertions via `TestStore.send`/`receive` trailing mutation closures. + +**Run Commands (Xcode-only — bare `swift build`/`swift test` fail; memory):** +```bash +# Run all package tests (use the AppPackage-Package scheme) +xcodebuild test -scheme AppPackage-Package -destination 'platform=iOS Simulator,name=iPhone 16' +# Run ONE test invocation at a time — never run overlapping xcodebuild test (wedges testmanagerd; memory) +``` + +## Test File Organization + +**Location:** +- Separate test targets under `AppPackage/Tests/Tests/`, mirroring + `AppPackage/Sources/`. 87 test files across 7 test targets. +- Existing test targets: `SettingFeatureTests`, `FileClientTests`, `DetailFeatureTests`, + `DownloadsFeatureTests`, `AppModelsTests`, `ParserFeatureTests`, `NetworkingFeatureTests`. + +**Naming:** +- `Tests.swift`, e.g. `CommentsReducerTests.swift`, `DownloadProcessTests.swift`. +- Shared helpers/factories per target: `DownloadFeatureTestHelpers.swift`, + `DownloadFeatureTestFactories.swift`. + +**Lint:** each test target has its own `.swiftlint.yml` with `parent_config: ../../../.swiftlint.yml`. + +## Test Structure + +**Suite Organization (actual pattern from `CommentsReducerTests.swift`):** +```swift +import Testing +import Foundation +import AppModels +import HapticsClient +@testable import DetailFeature +import ComposableArchitecture + +@Suite +struct CommentsReducerTests { + @MainActor + @Test + func presentingPostCommentResetsStaleComposeState() async { + let store = TestStore( + initialState: CommentsReducer.State(galleryURL: .mock), + reducer: CommentsReducer.init // Reducer.init shorthand (lint-enforced) + ) { + $0.hapticsClient = .noop // override dependencies in trailing closure + } + + await store.send(.presentPostComment(commentID: "42", content: "existing text")) { + $0.commentContent = "existing text" // exhaustive expected state mutation + $0.destination = .postComment("42") + } + } +} +``` + +**Patterns:** +- Reducers under test are `TestStore`-driven with **exhaustive** state assertions. +- Tests are `@MainActor @Test`, `async`. +- Regression tests carry a comment describing the bug they lock down. +- The subject module is imported `@testable`; sibling modules imported normally. + +## Mocking + +**Framework:** TCA Dependencies — override on the `TestStore` init trailing closure. + +**Patterns:** +```swift +// Override each client used by the reducer: +TestStore(initialState: ..., reducer: Feature.init) { + $0.hapticsClient = .noop + $0.fileClient = .testValue +} +``` +- Clients expose `liveValue`, `testValue` (usually `.unimplemented`, which fails on + unexpected calls), and `.noop` (see `AppPackage/Sources/HapticsClient/HapticsClient.swift`). +- `withDependencies { } operation:` used where a `Store` isn't the entry point + (~26 test files use `withDependencies`). +- `.mock` static fixtures on model types (e.g. `.mock` galleryURL) supply sample values. + +**What to Mock:** +- All injected `@Dependency` clients (network, file, haptics, clipboard, etc.). +- Override `testValue`'s unimplemented endpoints only for the calls a test expects. + +**What NOT to Mock:** +- Don't use `DataCache.shared` in image tests — it causes cross-test pollution; + inject a per-test `DataCache` and compare pixel dims, not point size (memory: + "DataCache.shared test pollution", `DataCacheTests.swift`). +- Fix parallel-test pollution by **injecting** the global dependency (e.g. host param), + not by `.serialized` (memory: "Inject over serialize"). + +## Fixtures and Factories + +**Test data:** +- `TestingSupport` module bundles HTML/resource fixtures. Access via `TestFixtures.url(...)` + and the `TestHelper` protocol's `htmlDocument(filename:)` — routing through + `TestingSupport`'s own `Bundle.module` (a plain test bundle's `Bundle.module` is + resource-less). See `AppPackage/Sources/TestingSupport/`: + `TestFixtures.swift`, `TestHelper.swift`, `HTMLFilename.swift`, `TestError.swift`, `Resources/`. +- Per-target factory files build domain objects: `DownloadFeatureTestFactories.swift`. +- `.mock` static properties on models for inline sample values. + +**Location:** +- Shared, reusable fixtures: `AppPackage/Sources/TestingSupport/`. +- Target-local helpers/factories: alongside the tests in `AppPackage/Tests/Tests/`. + +## Coverage + +**Requirements:** none enforced in config. Coverage is behavioral/regression-driven — +new tests are added to lock down specific fixed bugs and migration paths. + +**Notable suites:** +- Schema migration engine: `SchemaMigrationTests.swift` (54 tests incl. nested-schema + mocks + `ProgressiveMock`; delete mocks when a real v2 schema lands — memory). +- Model migration: `mock-v2` migration tests across all models (`AppModelsTests`). + +## Test Types + +**Unit / reducer tests:** +- Dominant style — `TestStore` exhaustive assertions per reducer action. + +**Integration tests:** +- Multi-reducer/flow tests in `DownloadsFeatureTests` (background processing, coordinator + capture/storage, enqueue manifest, reading dismiss flows). +- Parser tests exercise real HTML fixtures via `TestHelper`/`TestFixtures`. + +**E2E / UI tests:** not used (no XCUITest target found). + +## Common Patterns + +**Async testing:** +```swift +@MainActor @Test +func example() async { + await store.send(.action) { $0.field = expected } + await store.receive(\.delegate.something) +} +``` + +**Determinism:** +- A previously flaky suite (`DownloadSchedulingTests`) was made deterministic + (commit 557b0425); a failure there now signals a REAL regression, not flake (memory). +- Never run overlapping `xcodebuild test`; run one invocation at a time (memory). + +--- + +*Testing analysis: 2026-07-09* diff --git a/.planning/config.json b/.planning/config.json new file mode 100644 index 000000000..75bccfd78 --- /dev/null +++ b/.planning/config.json @@ -0,0 +1,91 @@ +{ + "model_profile": "adaptive", + "commit_docs": true, + "parallelization": true, + "search_gitignored": false, + "brave_search": false, + "firecrawl": false, + "exa_search": false, + "tavily_search": false, + "ref_search": false, + "perplexity": false, + "jina": false, + "git": { + "branching_strategy": "none", + "create_tag": true, + "phase_branch_template": "gsd/phase-{phase}-{slug}", + "milestone_branch_template": "gsd/{milestone}-{slug}", + "quick_branch_template": null + }, + "workflow": { + "research": true, + "plan_check": true, + "verifier": true, + "nyquist_validation": true, + "auto_advance": false, + "node_repair": true, + "node_repair_budget": 2, + "ui_phase": true, + "ui_safety_gate": true, + "ai_integration_phase": true, + "human_verify_mode": "end-of-phase", + "context_guard_mode": "warn", + "text_mode": false, + "research_before_questions": false, + "discuss_mode": "discuss", + "skip_discuss": false, + "code_review": true, + "code_review_depth": "standard", + "code_review_command": null, + "pattern_mapper": true, + "plan_bounce": false, + "plan_bounce_script": null, + "plan_bounce_passes": 2, + "auto_prune_state": false, + "post_planning_gaps": true, + "security_enforcement": true, + "security_asvs_level": 1, + "security_block_on": "high" + }, + "ship": { + "pr_body_sections": [ + { + "heading": "User Stories & Acceptance Criteria", + "enabled": true, + "source": "REQUIREMENTS.md ## User Stories || REQUIREMENTS.md ## Acceptance Criteria", + "fallback": "- Acceptance criteria are covered by the linked requirements and verification evidence." + }, + { + "heading": "Risks & Dependencies", + "enabled": true, + "source": "PLAN.md ## Risks || PLAN.md ## Dependencies", + "fallback": "- No known high-risk rollout dependencies." + }, + { + "heading": "Success Metrics & Release Criteria", + "enabled": false, + "source": "REQUIREMENTS.md ## Definition of Done || VERIFICATION.md ## Release Criteria", + "fallback": "- Release when automated verification and required manual checks pass." + }, + { + "heading": "Stakeholder Review & Approval", + "enabled": false, + "template": "- Product owner approval pending for {phase_name}." + } + ] + }, + "hooks": { + "context_warnings": true + }, + "project_code": null, + "phase_naming": "sequential", + "agent_skills": {}, + "claude_md_path": "./.claude/CLAUDE.md", + "plan_review": { + "source_grounding": true, + "source_grounding_authority": "grep" + }, + "resolve_model_ids": "omit", + "mode": "yolo", + "granularity": "fine" +} diff --git a/.swiftlint.yml b/.swiftlint.yml index 65bd698ee..e388e2c2d 100644 --- a/.swiftlint.yml +++ b/.swiftlint.yml @@ -1,18 +1,202 @@ disabled_rules: - - large_tuple - - file_length - - opening_brace - - type_body_length - - function_body_length - - cyclomatic_complexity - -identifier_name: - excluded: - - x - - y - - id - - no + - opening_brace + - type_body_length + - function_body_length + - cyclomatic_complexity + - blanket_disable_command + - multiple_closures_with_trailing_closure + +opt_in_rules: + - force_try + - force_unwrapping + +force_try: + severity: error + +force_unwrapping: + severity: error + +line_length: + warning: 120 + error: 120 + +file_length: + warning: 1000 + error: 1000 + +custom_rules: + accessibility_empty_string: + name: "Accessibility Empty String" + regex: '\.accessibility(Label|Value|Hint|InputLabels)\s*\((?:[^()]|\([^()]*\))*?(?:Text\s*\(\s*(?:verbatim:\s*)?""|"")' + message: 'Accessibility modifiers should not receive an empty string. Apply the modifier conditionally instead of passing "" or Text(verbatim: "").' + excluded_match_kinds: + - comment + severity: error + + accessibility_text_argument: + name: "Accessibility Text Argument" + regex: '\.accessibility(Label|Value|Hint|InputLabels)\s*\((?:[^()]|\([^()]*\))*?\bText\s*\(' + message: "Accessibility modifiers should not wrap their content in Text. Pass a LocalizedStringResource (or String) directly, e.g. .accessibilityLabel(.someKey)." + excluded_match_kinds: + - comment + - string + severity: error + +# binding_initializer: +# name: "Binding Initializer" +# regex: "\\bBinding\\s*<[^>]+>\\s*\\(|\\bBinding\\s*\\(" +# message: "Manual Binding initializers should be avoided. Prefer a projected Binding." +# excluded_match_kinds: +# - comment +# - string +# severity: error + + child_reducer_shorthand_foreach: + name: "Child reducer shorthand (forEach)" + regex: 'forEach\([^)]*\)\s*\{\s*[A-Z]\w*\(\)\s*\}|forEach\((?:[^()]|\([^()]*\))*?\belement:\s*\{\s*[A-Z]\w*\(\)\s*\}' + message: "Use .forEach(_:action:element: Reducer.init) instead of wrapping a single bare Reducer() in a closure." + excluded_match_kinds: + - comment + - string + severity: error + + child_reducer_shorthand_scope: + name: "Child reducer shorthand (Scope)" + regex: 'Scope\([^)]*\)\s*\{\s*[A-Z]\w*\(\)\s*\}|Scope\((?:[^()]|\([^()]*\))*?\bchild:\s*\{\s*[A-Z]\w*\(\)\s*\}' + message: "Use Scope(state:action:child: Reducer.init) instead of wrapping a single bare Reducer() in a closure." + excluded_match_kinds: + - comment + - string + severity: error + + child_reducer_shorthand_store: + name: "Child reducer shorthand (Store)" + regex: '\b(?:Store|TestStore)\(\s*initialState:\s*(?:[^()]|\([^()]*\))*\)\s*\{\s*[A-Z]\w*\(\)\s*\}|\b(?:Store|TestStore)\((?:[^()]|\([^()]*\))*?\breducer:\s*\{\s*[A-Z]\w*\(\)\s*\}' + message: "Use Store/TestStore(initialState:reducer:) with reducer: Reducer.init instead of wrapping a single bare Reducer() in a closure." + excluded_match_kinds: + - comment + - string + severity: error + + date_property_at_suffix: + name: "Date Property At Suffix" + regex: '\b(?:let|var)\s+\w*[a-z]At\b' + message: "Avoid the 'At' suffix on date properties. Use a noun form, e.g. creationDate instead of createdAt." + excluded_match_kinds: + - comment + - string + severity: error + + label_text_image_shorthand: + name: "Label Text + Image Shorthand" + regex: 'Label\s*\{\s*Text\s*\((?:[^(),]|\([^()]*\))*\)\s*\}\s*icon:\s*\{\s*Image\s*\(\s*systemSymbol:\s*(?:[^()]|\([^()]*\))*\)\s*\}' + message: "Use Label(_ titleResource:systemSymbol:) instead of an unmodified Text + Image(systemSymbol:) Label." + excluded_match_kinds: + - comment + - string + severity: error + +# lifecycle_modifiers: +# name: "Lifecycle Modifiers" +# regex: "\\.(onAppear|onDisappear|task)\\s*(\\(|\\{)" +# message: "SwiftUI lifecycle modifiers should be avoided. Prefer a reducer action." +# excluded_match_kinds: +# - comment +# - string +# severity: error + + no_case_check_property: + name: "No Case-Check Property" + regex: '\bvar\s+\w+\s*:\s*Bool\s*\{\s*if\s+case\s+\.[A-Za-z_]\w*[^{}]*\{\s*return\s+\w+\s*\}\s*(?:else\s*\{\s*return\s+\w+\s*\}|;?\s*return\s+\w+)\s*\}' + message: 'Avoid computed properties that only translate an if-case enum check into a Bool. Check the case at the call site instead, e.g. value.is(\.case) for @CasePathable types.' + excluded_match_kinds: + - comment + - string + severity: error + + no_nslock: + name: "No NSLock" + regex: "\\bNSLock\\b" + message: "NSLock is banned, use Mutex (Synchronization) instead." + excluded_match_kinds: + - comment + - string + severity: error + + no_preconcurrency: + name: "No @preconcurrency" + regex: "@preconcurrency\\b" + message: "@preconcurrency is banned, resolve the underlying Sendable / concurrency issue instead of suppressing it." + excluded_match_kinds: + - comment + - string + severity: error + + no_unchecked_sendable: + name: "No @unchecked Sendable" + regex: "@unchecked\\s+Sendable" + message: "@unchecked Sendable is banned, use a real Sendable value type, an actor, or Mutex." + excluded_match_kinds: + - comment + - string + severity: error + +# optional_try: +# name: "try?" +# regex: "\\btry\\?\\s*" +# message: "try? should be avoided. Properly handle every errors." +# excluded_match_kinds: +# - comment +# - string +# severity: error + + shape_initializer_argument: + name: "Shape Initializer Argument" + regex: '(?:[,(]\s*(?:[A-Za-z_][A-Za-z0-9_]*\s*:\s*)?)(?:SwiftUI\.|SwiftUICore\.)?(?:Rectangle|RoundedRectangle|UnevenRoundedRectangle|Capsule|Ellipse|Circle|ConcentricRectangle|ContainerRelativeShape|ButtonBorderShape)\s*\((?:[^()]|\([^()]*\))*\)(?!\s*\.)' + message: "Use the corresponding SwiftUI shape shorthand when passing a standalone shape as an argument. `Shape().modifier()` is allowed." + excluded_match_kinds: + - comment + - string + severity: error + +# single_line_trailing_closure: +# name: "Single-Line Trailing Closure" +# regex: '\.(map|compactMap|flatMap|filter|reduce|sorted|sort|forEach|first|last|firstIndex|contains|allSatisfy|withValue|withLock|withDependencies|onSubmit|onChange|onAppear|onDisappear|sink|task)\s*(?:\([^()\n]*\))?\s*\{(?!\s*(?:return|break|continue|throw|fallthrough)\b)[^{}\n]*\}' +# message: "Wrap a single-line trailing closure in parentheses, e.g. foo.map({ ... }), or break the closure onto multiple lines." +# excluded_match_kinds: +# - comment +# - string +# severity: error + + swiftlint_disable_requires_reason: + name: "SwiftLint Disable Requires Reason" + regex: "(?m)^(?!//\\s*reason:)[^\\n]*\\n(//\\s*swiftlint:disable(?::(?:next|this|previous))?\\s+[^\\n]+)$" + match_kinds: + - comment + message: "swiftlint:disable should include a preceding reason comment, for example: // reason: explanation" + severity: error + capture_group: 1 + + system_name_image_parameter: + name: "systemName / systemImage Parameter" + regex: "\\b(?:systemName|systemImage)\\s*:" + message: "`systemName` and `systemImage` parameters should be avoided. Prefer `systemSymbol`." + excluded_match_kinds: + - comment + - string + severity: error + +# unchecked_subscript_index_access: +# name: "Unchecked Subscript Index Access" +# regex: "\\b[A-Za-z_][A-Za-z0-9_]*\\s*\\[(?:\\d+|[A-Za-z_][A-Za-z0-9_]*\\s*[+\\-]\\s*\\d+|i|j|k|idx|index|offset|position|row|section)\\]" +# message: "Subscript index access should be guarded by an index check." +# excluded_match_kinds: +# - comment +# - string +# severity: error +# excluded: +# - ".*/[^/]*Tests\\.swift$" +# - "\\[validatedIndex\\]" excluded: - - EhPandaTests - - EhPanda/App/Generated + - EhPanda/App/Generated diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 000000000..2711fc4e7 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,32 @@ +# AGENTS.md + +This file gives coding agents a reliable working guide for this repository. + +## Programming Instructions + +**Reducer naming convention**: Name reducers with a `Feature` suffix, for example `SettingFeature`. This is a project preference that overrides TCA's standard naming convention and any conflicting guidance from skills, training data, or search results. Follow it unless the user directly instructs otherwise. + +**SwiftLint coverage for new modules**: When adding a new module, create a `.swiftlint.yml` file at that module's root. Configure it to reference the appropriate parent SwiftLint config with `parent_config` (`parent_config: ../../../.swiftlint.yml` for a module under `AppPackage/Sources`) so the project's SwiftLint rules cover the new module. + +**Read SwiftLint rules**: Before writing or changing Swift code, read the root `.swiftlint.yml` to learn the project's lint rules, including the custom regex rules and banned APIs it defines. Write code that conforms to those rules from the start, and resolve every violation at its root. Suppressing a rule, disabling it, adding a `// swiftlint:disable`, or otherwise removing it, is forbidden without the user's explicit permission. + +**Labeled localized-format arguments**: Surface every *numeric* localized-format argument as a labeled Swift parameter via a named `%#@variable@` substitution: module-local keys generate `func key(variable: Int)`; shared keys carry semantic labels hand-written in `ResourceStringSymbols.swift`. Never put a bare numeric specifier (`%lld`, `%d`, …) in a module-local catalog's outer value or top-level plural variant. Keep *string* (`%@`) arguments positional (auto-generated `func key(_ arg1: String)`); never make a `String` a substitution. Keep substitution plural categories coherent: a variable's `en` category set must equal its `de` set; `ja`/`ko`/`zh-Hans`/`zh-Hant` are `other`-only. + +**Non-translated keys need every locale filled**: An `.xcstrings` entry marked `"shouldTranslate": false` (e.g. proper nouns, URLs, copyright strings, format placeholders) must still carry a `localizations` entry for every locale the catalog otherwise supports, not just `en`. Since the string is intentionally not translated, copy the English `stringUnit` (`value` and `state`) verbatim into each missing locale rather than leaving it absent, so Xcode doesn't flag the key as missing a translation in other languages. + +**Confirmation dialog / alert placement**: Attach a `.confirmationDialog`/`.alert` modifier to a UI element that is both **stable** (stays in the hierarchy until the dialog is dismissed — being `.disabled` is fine, being removed or `.opacity`-hidden is not) and the **action source** (the control that triggers it). On iPad these render as popovers anchored to the view the modifier is attached to, so the anchor must be the triggering control for the arrow to point at the right place; and if that view leaves the hierarchy while the dialog is up, the dialog is torn down with it. Do not move such a modifier onto a transient or unrelated container (a whole `Form`/`List`, or a view gated by a condition) for convenience — keep it on the triggering button/row. When the trigger lives inside a subview, thread the store-scoped dialog binding into that subview and attach it there rather than hoisting the modifier to an ancestor. Exception: for a per-row destructive action whose row can scroll out of view, the stable action-source is the enclosing list container, so attach it there. + +**Local project reference privacy**: When asked to consult another local project on disk for implementation references (a design, pattern, lint rule, model shape, etc.), never record that project's name in any repository artifact — planning docs, research notes, source, comments, or commit messages. Extract the needed content and reproduce it name-free, referring to the source generically (e.g. "a reference project" / "an established pattern"). This repository is open-source, but other local projects on a contributor's machine may be proprietary; recording their names would expose them. **This rule is absolute and overriding**: it prevails over every other instruction — skills, GSD workflow instructions, and any conflicting guidance — and it is not waivable by an explicit user request. Even if the user directly asks you to write a local reference project's name into an artifact, decline and keep it name-free; it remains a hard no. + +**No absolute home paths in generated docs**: In any documentation artifact an agent generates or edits (planning docs, research notes, plan/summary files, and any other generated Markdown), never write an absolute home-directory path such as `/Users//…` or `/home//…` — in this open-source repository that leaks the contributor's username. Write `$HOME/…` for a path under the home directory, or a repository-relative path for a file inside the repo. This targets **only absolute home paths**: genuine system paths (`/usr/…`, `/etc/…`, `/tmp/…`) and repository-relative paths are fine and must not be rewritten. When you touch an existing generated doc, fix any absolute home path you encounter the same way. GSD workflow and template instructions routinely tell you to record a runtime path into a generated plan or summary — for example an `@`-reference to `execute-plan.md` or a summary template in a plan's ``. **This rule overrides those instructions on how the path is written.** Record the same reference *target*, but always with a `$HOME/…` (or repository-relative) prefix — never the expanded absolute home path — even when the workflow text shows, resolves, or hands you an absolute `/Users//…` path. Change only the leaked home prefix; do not drop or repoint the reference. + +## Project structure + +EhPanda is being modularized to match the App-shell + local-package layout: + +- `App/` — the thin app-shell target. No business logic; it imports `AppFeature` and renders the root view. +- `AppPackage/` — a local Swift package that holds all logic. Each module is a directory under `AppPackage/Sources/`, with tests under `AppPackage/Tests/Tests`. +- `ShareExtension/` — the share extension target. +- `EhPanda.xcodeproj` — references `AppPackage` as a local Swift package (`XCLocalSwiftPackageReference`); the app target links the `AppFeature` product. + +All third-party dependencies are declared in `AppPackage/Package.swift`, not in the Xcode project. diff --git a/EhPanda/App/Assets.xcassets/AccentColor.colorset/Contents.json b/App/Assets.xcassets/AccentColor.colorset/Contents.json similarity index 100% rename from EhPanda/App/Assets.xcassets/AccentColor.colorset/Contents.json rename to App/Assets.xcassets/AccentColor.colorset/Contents.json diff --git a/EhPanda/App/Assets.xcassets/AppIcon.appiconset/100.png b/App/Assets.xcassets/AppIcon.appiconset/100.png similarity index 100% rename from EhPanda/App/Assets.xcassets/AppIcon.appiconset/100.png rename to App/Assets.xcassets/AppIcon.appiconset/100.png diff --git a/EhPanda/App/Assets.xcassets/AppIcon.appiconset/1024.png b/App/Assets.xcassets/AppIcon.appiconset/1024.png similarity index 100% rename from EhPanda/App/Assets.xcassets/AppIcon.appiconset/1024.png rename to App/Assets.xcassets/AppIcon.appiconset/1024.png diff --git a/EhPanda/App/Assets.xcassets/AppIcon.appiconset/114.png b/App/Assets.xcassets/AppIcon.appiconset/114.png similarity index 100% rename from EhPanda/App/Assets.xcassets/AppIcon.appiconset/114.png rename to App/Assets.xcassets/AppIcon.appiconset/114.png diff --git a/EhPanda/App/Assets.xcassets/AppIcon.appiconset/120.png b/App/Assets.xcassets/AppIcon.appiconset/120.png similarity index 100% rename from EhPanda/App/Assets.xcassets/AppIcon.appiconset/120.png rename to App/Assets.xcassets/AppIcon.appiconset/120.png diff --git a/EhPanda/App/Assets.xcassets/AppIcon.appiconset/128.png b/App/Assets.xcassets/AppIcon.appiconset/128.png similarity index 100% rename from EhPanda/App/Assets.xcassets/AppIcon.appiconset/128.png rename to App/Assets.xcassets/AppIcon.appiconset/128.png diff --git a/EhPanda/App/Assets.xcassets/AppIcon.appiconset/144.png b/App/Assets.xcassets/AppIcon.appiconset/144.png similarity index 100% rename from EhPanda/App/Assets.xcassets/AppIcon.appiconset/144.png rename to App/Assets.xcassets/AppIcon.appiconset/144.png diff --git a/EhPanda/App/Assets.xcassets/AppIcon.appiconset/152.png b/App/Assets.xcassets/AppIcon.appiconset/152.png similarity index 100% rename from EhPanda/App/Assets.xcassets/AppIcon.appiconset/152.png rename to App/Assets.xcassets/AppIcon.appiconset/152.png diff --git a/EhPanda/App/Assets.xcassets/AppIcon.appiconset/16.png b/App/Assets.xcassets/AppIcon.appiconset/16.png similarity index 100% rename from EhPanda/App/Assets.xcassets/AppIcon.appiconset/16.png rename to App/Assets.xcassets/AppIcon.appiconset/16.png diff --git a/EhPanda/App/Assets.xcassets/AppIcon.appiconset/167.png b/App/Assets.xcassets/AppIcon.appiconset/167.png similarity index 100% rename from EhPanda/App/Assets.xcassets/AppIcon.appiconset/167.png rename to App/Assets.xcassets/AppIcon.appiconset/167.png diff --git a/EhPanda/App/Assets.xcassets/AppIcon.appiconset/172.png b/App/Assets.xcassets/AppIcon.appiconset/172.png similarity index 100% rename from EhPanda/App/Assets.xcassets/AppIcon.appiconset/172.png rename to App/Assets.xcassets/AppIcon.appiconset/172.png diff --git a/EhPanda/App/Assets.xcassets/AppIcon.appiconset/180.png b/App/Assets.xcassets/AppIcon.appiconset/180.png similarity index 100% rename from EhPanda/App/Assets.xcassets/AppIcon.appiconset/180.png rename to App/Assets.xcassets/AppIcon.appiconset/180.png diff --git a/EhPanda/App/Assets.xcassets/AppIcon.appiconset/196.png b/App/Assets.xcassets/AppIcon.appiconset/196.png similarity index 100% rename from EhPanda/App/Assets.xcassets/AppIcon.appiconset/196.png rename to App/Assets.xcassets/AppIcon.appiconset/196.png diff --git a/EhPanda/App/Assets.xcassets/AppIcon.appiconset/20.png b/App/Assets.xcassets/AppIcon.appiconset/20.png similarity index 100% rename from EhPanda/App/Assets.xcassets/AppIcon.appiconset/20.png rename to App/Assets.xcassets/AppIcon.appiconset/20.png diff --git a/EhPanda/App/Assets.xcassets/AppIcon.appiconset/216.png b/App/Assets.xcassets/AppIcon.appiconset/216.png similarity index 100% rename from EhPanda/App/Assets.xcassets/AppIcon.appiconset/216.png rename to App/Assets.xcassets/AppIcon.appiconset/216.png diff --git a/EhPanda/App/Assets.xcassets/AppIcon.appiconset/256.png b/App/Assets.xcassets/AppIcon.appiconset/256.png similarity index 100% rename from EhPanda/App/Assets.xcassets/AppIcon.appiconset/256.png rename to App/Assets.xcassets/AppIcon.appiconset/256.png diff --git a/EhPanda/App/Assets.xcassets/AppIcon.appiconset/29.png b/App/Assets.xcassets/AppIcon.appiconset/29.png similarity index 100% rename from EhPanda/App/Assets.xcassets/AppIcon.appiconset/29.png rename to App/Assets.xcassets/AppIcon.appiconset/29.png diff --git a/EhPanda/App/Assets.xcassets/AppIcon.appiconset/32.png b/App/Assets.xcassets/AppIcon.appiconset/32.png similarity index 100% rename from EhPanda/App/Assets.xcassets/AppIcon.appiconset/32.png rename to App/Assets.xcassets/AppIcon.appiconset/32.png diff --git a/EhPanda/App/Assets.xcassets/AppIcon.appiconset/40.png b/App/Assets.xcassets/AppIcon.appiconset/40.png similarity index 100% rename from EhPanda/App/Assets.xcassets/AppIcon.appiconset/40.png rename to App/Assets.xcassets/AppIcon.appiconset/40.png diff --git a/EhPanda/App/Assets.xcassets/AppIcon.appiconset/48.png b/App/Assets.xcassets/AppIcon.appiconset/48.png similarity index 100% rename from EhPanda/App/Assets.xcassets/AppIcon.appiconset/48.png rename to App/Assets.xcassets/AppIcon.appiconset/48.png diff --git a/EhPanda/App/Assets.xcassets/AppIcon.appiconset/50.png b/App/Assets.xcassets/AppIcon.appiconset/50.png similarity index 100% rename from EhPanda/App/Assets.xcassets/AppIcon.appiconset/50.png rename to App/Assets.xcassets/AppIcon.appiconset/50.png diff --git a/EhPanda/App/Assets.xcassets/AppIcon.appiconset/512.png b/App/Assets.xcassets/AppIcon.appiconset/512.png similarity index 100% rename from EhPanda/App/Assets.xcassets/AppIcon.appiconset/512.png rename to App/Assets.xcassets/AppIcon.appiconset/512.png diff --git a/EhPanda/App/Assets.xcassets/AppIcon.appiconset/55.png b/App/Assets.xcassets/AppIcon.appiconset/55.png similarity index 100% rename from EhPanda/App/Assets.xcassets/AppIcon.appiconset/55.png rename to App/Assets.xcassets/AppIcon.appiconset/55.png diff --git a/EhPanda/App/Assets.xcassets/AppIcon.appiconset/57.png b/App/Assets.xcassets/AppIcon.appiconset/57.png similarity index 100% rename from EhPanda/App/Assets.xcassets/AppIcon.appiconset/57.png rename to App/Assets.xcassets/AppIcon.appiconset/57.png diff --git a/EhPanda/App/Assets.xcassets/AppIcon.appiconset/58.png b/App/Assets.xcassets/AppIcon.appiconset/58.png similarity index 100% rename from EhPanda/App/Assets.xcassets/AppIcon.appiconset/58.png rename to App/Assets.xcassets/AppIcon.appiconset/58.png diff --git a/EhPanda/App/Assets.xcassets/AppIcon.appiconset/60.png b/App/Assets.xcassets/AppIcon.appiconset/60.png similarity index 100% rename from EhPanda/App/Assets.xcassets/AppIcon.appiconset/60.png rename to App/Assets.xcassets/AppIcon.appiconset/60.png diff --git a/EhPanda/App/Assets.xcassets/AppIcon.appiconset/64.png b/App/Assets.xcassets/AppIcon.appiconset/64.png similarity index 100% rename from EhPanda/App/Assets.xcassets/AppIcon.appiconset/64.png rename to App/Assets.xcassets/AppIcon.appiconset/64.png diff --git a/EhPanda/App/Assets.xcassets/AppIcon.appiconset/72.png b/App/Assets.xcassets/AppIcon.appiconset/72.png similarity index 100% rename from EhPanda/App/Assets.xcassets/AppIcon.appiconset/72.png rename to App/Assets.xcassets/AppIcon.appiconset/72.png diff --git a/EhPanda/App/Assets.xcassets/AppIcon.appiconset/76.png b/App/Assets.xcassets/AppIcon.appiconset/76.png similarity index 100% rename from EhPanda/App/Assets.xcassets/AppIcon.appiconset/76.png rename to App/Assets.xcassets/AppIcon.appiconset/76.png diff --git a/EhPanda/App/Assets.xcassets/AppIcon.appiconset/80.png b/App/Assets.xcassets/AppIcon.appiconset/80.png similarity index 100% rename from EhPanda/App/Assets.xcassets/AppIcon.appiconset/80.png rename to App/Assets.xcassets/AppIcon.appiconset/80.png diff --git a/EhPanda/App/Assets.xcassets/AppIcon.appiconset/87.png b/App/Assets.xcassets/AppIcon.appiconset/87.png similarity index 100% rename from EhPanda/App/Assets.xcassets/AppIcon.appiconset/87.png rename to App/Assets.xcassets/AppIcon.appiconset/87.png diff --git a/EhPanda/App/Assets.xcassets/AppIcon.appiconset/88.png b/App/Assets.xcassets/AppIcon.appiconset/88.png similarity index 100% rename from EhPanda/App/Assets.xcassets/AppIcon.appiconset/88.png rename to App/Assets.xcassets/AppIcon.appiconset/88.png diff --git a/EhPanda/App/Assets.xcassets/AppIcon.appiconset/Contents.json b/App/Assets.xcassets/AppIcon.appiconset/Contents.json similarity index 100% rename from EhPanda/App/Assets.xcassets/AppIcon.appiconset/Contents.json rename to App/Assets.xcassets/AppIcon.appiconset/Contents.json diff --git a/EhPanda/App/Assets.xcassets/Category/Colors/Contents.json b/App/Assets.xcassets/Category/Colors/Contents.json similarity index 100% rename from EhPanda/App/Assets.xcassets/Category/Colors/Contents.json rename to App/Assets.xcassets/Category/Colors/Contents.json diff --git a/EhPanda/App/Assets.xcassets/Category/Colors/E-Hentai/Artist CG.colorset/Contents.json b/App/Assets.xcassets/Category/Colors/E-Hentai/Artist CG.colorset/Contents.json similarity index 100% rename from EhPanda/App/Assets.xcassets/Category/Colors/E-Hentai/Artist CG.colorset/Contents.json rename to App/Assets.xcassets/Category/Colors/E-Hentai/Artist CG.colorset/Contents.json diff --git a/EhPanda/App/Assets.xcassets/Category/Colors/E-Hentai/Asian Porn.colorset/Contents.json b/App/Assets.xcassets/Category/Colors/E-Hentai/Asian Porn.colorset/Contents.json similarity index 100% rename from EhPanda/App/Assets.xcassets/Category/Colors/E-Hentai/Asian Porn.colorset/Contents.json rename to App/Assets.xcassets/Category/Colors/E-Hentai/Asian Porn.colorset/Contents.json diff --git a/EhPanda/App/Assets.xcassets/Category/Colors/E-Hentai/Contents.json b/App/Assets.xcassets/Category/Colors/E-Hentai/Contents.json similarity index 100% rename from EhPanda/App/Assets.xcassets/Category/Colors/E-Hentai/Contents.json rename to App/Assets.xcassets/Category/Colors/E-Hentai/Contents.json diff --git a/EhPanda/App/Assets.xcassets/Category/Colors/E-Hentai/Cosplay.colorset/Contents.json b/App/Assets.xcassets/Category/Colors/E-Hentai/Cosplay.colorset/Contents.json similarity index 100% rename from EhPanda/App/Assets.xcassets/Category/Colors/E-Hentai/Cosplay.colorset/Contents.json rename to App/Assets.xcassets/Category/Colors/E-Hentai/Cosplay.colorset/Contents.json diff --git a/EhPanda/App/Assets.xcassets/Category/Colors/E-Hentai/Doujinshi.colorset/Contents.json b/App/Assets.xcassets/Category/Colors/E-Hentai/Doujinshi.colorset/Contents.json similarity index 100% rename from EhPanda/App/Assets.xcassets/Category/Colors/E-Hentai/Doujinshi.colorset/Contents.json rename to App/Assets.xcassets/Category/Colors/E-Hentai/Doujinshi.colorset/Contents.json diff --git a/EhPanda/App/Assets.xcassets/Category/Colors/E-Hentai/Game CG.colorset/Contents.json b/App/Assets.xcassets/Category/Colors/E-Hentai/Game CG.colorset/Contents.json similarity index 100% rename from EhPanda/App/Assets.xcassets/Category/Colors/E-Hentai/Game CG.colorset/Contents.json rename to App/Assets.xcassets/Category/Colors/E-Hentai/Game CG.colorset/Contents.json diff --git a/EhPanda/App/Assets.xcassets/Category/Colors/E-Hentai/Image Set.colorset/Contents.json b/App/Assets.xcassets/Category/Colors/E-Hentai/Image Set.colorset/Contents.json similarity index 100% rename from EhPanda/App/Assets.xcassets/Category/Colors/E-Hentai/Image Set.colorset/Contents.json rename to App/Assets.xcassets/Category/Colors/E-Hentai/Image Set.colorset/Contents.json diff --git a/EhPanda/App/Assets.xcassets/Category/Colors/E-Hentai/Manga.colorset/Contents.json b/App/Assets.xcassets/Category/Colors/E-Hentai/Manga.colorset/Contents.json similarity index 100% rename from EhPanda/App/Assets.xcassets/Category/Colors/E-Hentai/Manga.colorset/Contents.json rename to App/Assets.xcassets/Category/Colors/E-Hentai/Manga.colorset/Contents.json diff --git a/EhPanda/App/Assets.xcassets/Category/Colors/E-Hentai/Misc.colorset/Contents.json b/App/Assets.xcassets/Category/Colors/E-Hentai/Misc.colorset/Contents.json similarity index 100% rename from EhPanda/App/Assets.xcassets/Category/Colors/E-Hentai/Misc.colorset/Contents.json rename to App/Assets.xcassets/Category/Colors/E-Hentai/Misc.colorset/Contents.json diff --git a/EhPanda/App/Assets.xcassets/Category/Colors/E-Hentai/Non-H.colorset/Contents.json b/App/Assets.xcassets/Category/Colors/E-Hentai/Non-H.colorset/Contents.json similarity index 100% rename from EhPanda/App/Assets.xcassets/Category/Colors/E-Hentai/Non-H.colorset/Contents.json rename to App/Assets.xcassets/Category/Colors/E-Hentai/Non-H.colorset/Contents.json diff --git a/EhPanda/App/Assets.xcassets/Category/Colors/E-Hentai/Private.colorset/Contents.json b/App/Assets.xcassets/Category/Colors/E-Hentai/Private.colorset/Contents.json similarity index 100% rename from EhPanda/App/Assets.xcassets/Category/Colors/E-Hentai/Private.colorset/Contents.json rename to App/Assets.xcassets/Category/Colors/E-Hentai/Private.colorset/Contents.json diff --git a/EhPanda/App/Assets.xcassets/Category/Colors/E-Hentai/Western.colorset/Contents.json b/App/Assets.xcassets/Category/Colors/E-Hentai/Western.colorset/Contents.json similarity index 100% rename from EhPanda/App/Assets.xcassets/Category/Colors/E-Hentai/Western.colorset/Contents.json rename to App/Assets.xcassets/Category/Colors/E-Hentai/Western.colorset/Contents.json diff --git a/EhPanda/App/Assets.xcassets/Category/Colors/ExHentai/Artist CG.colorset/Contents.json b/App/Assets.xcassets/Category/Colors/ExHentai/Artist CG.colorset/Contents.json similarity index 100% rename from EhPanda/App/Assets.xcassets/Category/Colors/ExHentai/Artist CG.colorset/Contents.json rename to App/Assets.xcassets/Category/Colors/ExHentai/Artist CG.colorset/Contents.json diff --git a/EhPanda/App/Assets.xcassets/Category/Colors/ExHentai/Asian Porn.colorset/Contents.json b/App/Assets.xcassets/Category/Colors/ExHentai/Asian Porn.colorset/Contents.json similarity index 100% rename from EhPanda/App/Assets.xcassets/Category/Colors/ExHentai/Asian Porn.colorset/Contents.json rename to App/Assets.xcassets/Category/Colors/ExHentai/Asian Porn.colorset/Contents.json diff --git a/EhPanda/App/Assets.xcassets/Category/Colors/ExHentai/Contents.json b/App/Assets.xcassets/Category/Colors/ExHentai/Contents.json similarity index 100% rename from EhPanda/App/Assets.xcassets/Category/Colors/ExHentai/Contents.json rename to App/Assets.xcassets/Category/Colors/ExHentai/Contents.json diff --git a/EhPanda/App/Assets.xcassets/Category/Colors/ExHentai/Cosplay.colorset/Contents.json b/App/Assets.xcassets/Category/Colors/ExHentai/Cosplay.colorset/Contents.json similarity index 100% rename from EhPanda/App/Assets.xcassets/Category/Colors/ExHentai/Cosplay.colorset/Contents.json rename to App/Assets.xcassets/Category/Colors/ExHentai/Cosplay.colorset/Contents.json diff --git a/EhPanda/App/Assets.xcassets/Category/Colors/ExHentai/Doujinshi.colorset/Contents.json b/App/Assets.xcassets/Category/Colors/ExHentai/Doujinshi.colorset/Contents.json similarity index 100% rename from EhPanda/App/Assets.xcassets/Category/Colors/ExHentai/Doujinshi.colorset/Contents.json rename to App/Assets.xcassets/Category/Colors/ExHentai/Doujinshi.colorset/Contents.json diff --git a/EhPanda/App/Assets.xcassets/Category/Colors/ExHentai/Game CG.colorset/Contents.json b/App/Assets.xcassets/Category/Colors/ExHentai/Game CG.colorset/Contents.json similarity index 100% rename from EhPanda/App/Assets.xcassets/Category/Colors/ExHentai/Game CG.colorset/Contents.json rename to App/Assets.xcassets/Category/Colors/ExHentai/Game CG.colorset/Contents.json diff --git a/EhPanda/App/Assets.xcassets/Category/Colors/ExHentai/Image Set.colorset/Contents.json b/App/Assets.xcassets/Category/Colors/ExHentai/Image Set.colorset/Contents.json similarity index 100% rename from EhPanda/App/Assets.xcassets/Category/Colors/ExHentai/Image Set.colorset/Contents.json rename to App/Assets.xcassets/Category/Colors/ExHentai/Image Set.colorset/Contents.json diff --git a/EhPanda/App/Assets.xcassets/Category/Colors/ExHentai/Manga.colorset/Contents.json b/App/Assets.xcassets/Category/Colors/ExHentai/Manga.colorset/Contents.json similarity index 100% rename from EhPanda/App/Assets.xcassets/Category/Colors/ExHentai/Manga.colorset/Contents.json rename to App/Assets.xcassets/Category/Colors/ExHentai/Manga.colorset/Contents.json diff --git a/EhPanda/App/Assets.xcassets/Category/Colors/ExHentai/Misc.colorset/Contents.json b/App/Assets.xcassets/Category/Colors/ExHentai/Misc.colorset/Contents.json similarity index 100% rename from EhPanda/App/Assets.xcassets/Category/Colors/ExHentai/Misc.colorset/Contents.json rename to App/Assets.xcassets/Category/Colors/ExHentai/Misc.colorset/Contents.json diff --git a/EhPanda/App/Assets.xcassets/Category/Colors/ExHentai/Non-H.colorset/Contents.json b/App/Assets.xcassets/Category/Colors/ExHentai/Non-H.colorset/Contents.json similarity index 100% rename from EhPanda/App/Assets.xcassets/Category/Colors/ExHentai/Non-H.colorset/Contents.json rename to App/Assets.xcassets/Category/Colors/ExHentai/Non-H.colorset/Contents.json diff --git a/EhPanda/App/Assets.xcassets/Category/Colors/ExHentai/Private.colorset/Contents.json b/App/Assets.xcassets/Category/Colors/ExHentai/Private.colorset/Contents.json similarity index 100% rename from EhPanda/App/Assets.xcassets/Category/Colors/ExHentai/Private.colorset/Contents.json rename to App/Assets.xcassets/Category/Colors/ExHentai/Private.colorset/Contents.json diff --git a/EhPanda/App/Assets.xcassets/Category/Colors/ExHentai/Western.colorset/Contents.json b/App/Assets.xcassets/Category/Colors/ExHentai/Western.colorset/Contents.json similarity index 100% rename from EhPanda/App/Assets.xcassets/Category/Colors/ExHentai/Western.colorset/Contents.json rename to App/Assets.xcassets/Category/Colors/ExHentai/Western.colorset/Contents.json diff --git a/EhPanda/App/Assets.xcassets/Category/Contents.json b/App/Assets.xcassets/Category/Contents.json similarity index 100% rename from EhPanda/App/Assets.xcassets/Category/Contents.json rename to App/Assets.xcassets/Category/Contents.json diff --git a/EhPanda/App/Assets.xcassets/Contents.json b/App/Assets.xcassets/Contents.json similarity index 100% rename from EhPanda/App/Assets.xcassets/Contents.json rename to App/Assets.xcassets/Contents.json diff --git a/EhPanda/EhPanda.entitlements b/App/EhPanda.entitlements similarity index 100% rename from EhPanda/EhPanda.entitlements rename to App/EhPanda.entitlements diff --git a/App/EhPandaApp.swift b/App/EhPandaApp.swift new file mode 100644 index 000000000..5da57be22 --- /dev/null +++ b/App/EhPandaApp.swift @@ -0,0 +1,12 @@ +import AppFeature +import SwiftUI + +@main struct EhPandaApp: App { + @UIApplicationDelegateAdaptor(AppDelegate.self) var appDelegate + + var body: some Scene { + WindowGroup { + RootView(appDelegate: appDelegate) + } + } +} diff --git a/EhPanda/App/Icons/AppIcon_Default@2x.png b/App/Icons/AppIcon_Default@2x.png similarity index 100% rename from EhPanda/App/Icons/AppIcon_Default@2x.png rename to App/Icons/AppIcon_Default@2x.png diff --git a/EhPanda/App/Icons/AppIcon_Default@3x.png b/App/Icons/AppIcon_Default@3x.png similarity index 100% rename from EhPanda/App/Icons/AppIcon_Default@3x.png rename to App/Icons/AppIcon_Default@3x.png diff --git a/EhPanda/App/Icons/AppIcon_Default_iPad.png b/App/Icons/AppIcon_Default_iPad.png similarity index 100% rename from EhPanda/App/Icons/AppIcon_Default_iPad.png rename to App/Icons/AppIcon_Default_iPad.png diff --git a/EhPanda/App/Icons/AppIcon_Default_iPad@2x.png b/App/Icons/AppIcon_Default_iPad@2x.png similarity index 100% rename from EhPanda/App/Icons/AppIcon_Default_iPad@2x.png rename to App/Icons/AppIcon_Default_iPad@2x.png diff --git a/EhPanda/App/Icons/AppIcon_Default_iPad_Pro@2x.png b/App/Icons/AppIcon_Default_iPad_Pro@2x.png similarity index 100% rename from EhPanda/App/Icons/AppIcon_Default_iPad_Pro@2x.png rename to App/Icons/AppIcon_Default_iPad_Pro@2x.png diff --git a/EhPanda/App/Icons/AppIcon_Developer@2x.png b/App/Icons/AppIcon_Developer@2x.png similarity index 100% rename from EhPanda/App/Icons/AppIcon_Developer@2x.png rename to App/Icons/AppIcon_Developer@2x.png diff --git a/EhPanda/App/Icons/AppIcon_Developer@3x.png b/App/Icons/AppIcon_Developer@3x.png similarity index 100% rename from EhPanda/App/Icons/AppIcon_Developer@3x.png rename to App/Icons/AppIcon_Developer@3x.png diff --git a/EhPanda/App/Icons/AppIcon_Developer_iPad.png b/App/Icons/AppIcon_Developer_iPad.png similarity index 100% rename from EhPanda/App/Icons/AppIcon_Developer_iPad.png rename to App/Icons/AppIcon_Developer_iPad.png diff --git a/EhPanda/App/Icons/AppIcon_Developer_iPad@2x.png b/App/Icons/AppIcon_Developer_iPad@2x.png similarity index 100% rename from EhPanda/App/Icons/AppIcon_Developer_iPad@2x.png rename to App/Icons/AppIcon_Developer_iPad@2x.png diff --git a/EhPanda/App/Icons/AppIcon_Developer_iPad_Pro@2x.png b/App/Icons/AppIcon_Developer_iPad_Pro@2x.png similarity index 100% rename from EhPanda/App/Icons/AppIcon_Developer_iPad_Pro@2x.png rename to App/Icons/AppIcon_Developer_iPad_Pro@2x.png diff --git a/EhPanda/App/Icons/AppIcon_NotMyPresident@2x.png b/App/Icons/AppIcon_NotMyPresident@2x.png similarity index 100% rename from EhPanda/App/Icons/AppIcon_NotMyPresident@2x.png rename to App/Icons/AppIcon_NotMyPresident@2x.png diff --git a/EhPanda/App/Icons/AppIcon_NotMyPresident@3x.png b/App/Icons/AppIcon_NotMyPresident@3x.png similarity index 100% rename from EhPanda/App/Icons/AppIcon_NotMyPresident@3x.png rename to App/Icons/AppIcon_NotMyPresident@3x.png diff --git a/EhPanda/App/Icons/AppIcon_NotMyPresident_iPad.png b/App/Icons/AppIcon_NotMyPresident_iPad.png similarity index 100% rename from EhPanda/App/Icons/AppIcon_NotMyPresident_iPad.png rename to App/Icons/AppIcon_NotMyPresident_iPad.png diff --git a/EhPanda/App/Icons/AppIcon_NotMyPresident_iPad@2x.png b/App/Icons/AppIcon_NotMyPresident_iPad@2x.png similarity index 100% rename from EhPanda/App/Icons/AppIcon_NotMyPresident_iPad@2x.png rename to App/Icons/AppIcon_NotMyPresident_iPad@2x.png diff --git a/EhPanda/App/Icons/AppIcon_NotMyPresident_iPad_Pro@2x.png b/App/Icons/AppIcon_NotMyPresident_iPad_Pro@2x.png similarity index 100% rename from EhPanda/App/Icons/AppIcon_NotMyPresident_iPad_Pro@2x.png rename to App/Icons/AppIcon_NotMyPresident_iPad_Pro@2x.png diff --git a/EhPanda/App/Icons/AppIcon_StandWithUkraine2022@2x.png b/App/Icons/AppIcon_StandWithUkraine2022@2x.png similarity index 100% rename from EhPanda/App/Icons/AppIcon_StandWithUkraine2022@2x.png rename to App/Icons/AppIcon_StandWithUkraine2022@2x.png diff --git a/EhPanda/App/Icons/AppIcon_StandWithUkraine2022@3x.png b/App/Icons/AppIcon_StandWithUkraine2022@3x.png similarity index 100% rename from EhPanda/App/Icons/AppIcon_StandWithUkraine2022@3x.png rename to App/Icons/AppIcon_StandWithUkraine2022@3x.png diff --git a/EhPanda/App/Icons/AppIcon_StandWithUkraine2022_iPad.png b/App/Icons/AppIcon_StandWithUkraine2022_iPad.png similarity index 100% rename from EhPanda/App/Icons/AppIcon_StandWithUkraine2022_iPad.png rename to App/Icons/AppIcon_StandWithUkraine2022_iPad.png diff --git a/EhPanda/App/Icons/AppIcon_StandWithUkraine2022_iPad@2x.png b/App/Icons/AppIcon_StandWithUkraine2022_iPad@2x.png similarity index 100% rename from EhPanda/App/Icons/AppIcon_StandWithUkraine2022_iPad@2x.png rename to App/Icons/AppIcon_StandWithUkraine2022_iPad@2x.png diff --git a/EhPanda/App/Icons/AppIcon_StandWithUkraine2022_iPad_Pro@2x.png b/App/Icons/AppIcon_StandWithUkraine2022_iPad_Pro@2x.png similarity index 100% rename from EhPanda/App/Icons/AppIcon_StandWithUkraine2022_iPad_Pro@2x.png rename to App/Icons/AppIcon_StandWithUkraine2022_iPad_Pro@2x.png diff --git a/EhPanda/App/Icons/AppIcon_Ukiyoe@2x.png b/App/Icons/AppIcon_Ukiyoe@2x.png similarity index 100% rename from EhPanda/App/Icons/AppIcon_Ukiyoe@2x.png rename to App/Icons/AppIcon_Ukiyoe@2x.png diff --git a/EhPanda/App/Icons/AppIcon_Ukiyoe@3x.png b/App/Icons/AppIcon_Ukiyoe@3x.png similarity index 100% rename from EhPanda/App/Icons/AppIcon_Ukiyoe@3x.png rename to App/Icons/AppIcon_Ukiyoe@3x.png diff --git a/EhPanda/App/Icons/AppIcon_Ukiyoe_iPad.png b/App/Icons/AppIcon_Ukiyoe_iPad.png similarity index 100% rename from EhPanda/App/Icons/AppIcon_Ukiyoe_iPad.png rename to App/Icons/AppIcon_Ukiyoe_iPad.png diff --git a/EhPanda/App/Icons/AppIcon_Ukiyoe_iPad@2x.png b/App/Icons/AppIcon_Ukiyoe_iPad@2x.png similarity index 100% rename from EhPanda/App/Icons/AppIcon_Ukiyoe_iPad@2x.png rename to App/Icons/AppIcon_Ukiyoe_iPad@2x.png diff --git a/EhPanda/App/Icons/AppIcon_Ukiyoe_iPad_Pro@2x.png b/App/Icons/AppIcon_Ukiyoe_iPad_Pro@2x.png similarity index 100% rename from EhPanda/App/Icons/AppIcon_Ukiyoe_iPad_Pro@2x.png rename to App/Icons/AppIcon_Ukiyoe_iPad_Pro@2x.png diff --git a/EhPanda/App/Info.plist b/App/Info.plist similarity index 94% rename from EhPanda/App/Info.plist rename to App/Info.plist index e852058be..12259a40e 100644 --- a/EhPanda/App/Info.plist +++ b/App/Info.plist @@ -2,6 +2,10 @@ + BGTaskSchedulerPermittedIdentifiers + + app.ehpanda.downloads.processing + CADisableMinimumFrameDurationOnPhone CFBundleDevelopmentRegion @@ -118,7 +122,7 @@ CFBundlePackageType $(PRODUCT_BUNDLE_PACKAGE_TYPE) CFBundleShortVersionString - 2.8.0 + $(MARKETING_VERSION) CFBundleURLTypes @@ -133,7 +137,7 @@ CFBundleVersion - 157 + $(CURRENT_PROJECT_VERSION) ITSAppUsesNonExemptEncryption LSRequiresIPhoneOS @@ -151,6 +155,10 @@ UIApplicationSupportsIndirectInputEvents + UIBackgroundModes + + processing + UIFileSharingEnabled UILaunchScreen diff --git a/App/InfoPlist.xcstrings b/App/InfoPlist.xcstrings new file mode 100644 index 000000000..88f8964ea --- /dev/null +++ b/App/InfoPlist.xcstrings @@ -0,0 +1,131 @@ +{ + "sourceLanguage": "en", + "strings": { + "CFBundleName": { + "comment": "Bundle name", + "extractionState": "manual", + "shouldTranslate": false, + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "EhPanda" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "EhPanda" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "EhPanda" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "EhPanda" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "EhPanda" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "EhPanda" + } + } + } + }, + "NSFaceIDUsageDescription": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "We need this permission to provide Face ID option while unlocking the App." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Diese Berechtigung ist notwendig um Face ID zum Entsperren der Anwendung verwenden zu können." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "アプリアンロック認証時に Face ID オプションを提供するにはこの権限が必要です" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "이 권한을 허용해야 앱 잠금 해제할때 Face ID 옵션을 제공합니다." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "需要此权限以在解锁 App 时提供 Face ID 选项" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "我們需要您提供 Face ID 權限來使用 Face ID 解鎖" + } + } + } + }, + "NSPhotoLibraryAddUsageDescription": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "We need this permission to save images to your photo library." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "We need this permission to save images to your photo library." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "画像をライブラリに保存するにはこの権限が必要です" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "이미지를 사진 라이브러리에 저정하고 싶으면 이 권한을 허용해주세요." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "需要此权限以保存图像到相册" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "我們需要您提供照片權限來保存圖片到照片" + } + } + } + } + }, + "version": "1.0" +} \ No newline at end of file diff --git a/AppPackage/Package.resolved b/AppPackage/Package.resolved new file mode 100644 index 000000000..ce2650547 --- /dev/null +++ b/AppPackage/Package.resolved @@ -0,0 +1,267 @@ +{ + "originHash" : "12e80fee1d23108c4dbbe92f4d843fe86b02b3f0908b8d5efd03651504be2337", + "pins" : [ + { + "identity" : "colorful", + "kind" : "remoteSourceControl", + "location" : "https://github.com/Co2333/Colorful", + "state" : { + "revision" : "eb5a350aec759bd413615273cb6d64553aead4d5", + "version" : "1.0.1" + } + }, + { + "identity" : "combine-schedulers", + "kind" : "remoteSourceControl", + "location" : "https://github.com/pointfreeco/combine-schedulers", + "state" : { + "revision" : "dcccb979a2183b8df3334237e3dc1ae2b4116a86", + "version" : "1.2.0" + } + }, + { + "identity" : "deprecatedapi", + "kind" : "remoteSourceControl", + "location" : "https://github.com/EhPanda-Team/DeprecatedAPI", + "state" : { + "branch" : "main", + "revision" : "021e29675457a9b4b7859a46afbb5d0e37574e84" + } + }, + { + "identity" : "kanna", + "kind" : "remoteSourceControl", + "location" : "https://github.com/tid-kijyun/Kanna", + "state" : { + "revision" : "3c73af6d3859d9240db60aef233941a715387744", + "version" : "6.1.0" + } + }, + { + "identity" : "kingfisher", + "kind" : "remoteSourceControl", + "location" : "https://github.com/onevcat/Kingfisher", + "state" : { + "revision" : "ac632bd26a1c00f139ff62fd01806f21cf67325e", + "version" : "8.10.0" + } + }, + { + "identity" : "libwebp-xcode", + "kind" : "remoteSourceControl", + "location" : "https://github.com/SDWebImage/libwebp-Xcode.git", + "state" : { + "revision" : "0d60654eeefd5d7d2bef3835804892c40225e8b2", + "version" : "1.5.0" + } + }, + { + "identity" : "sdwebimage", + "kind" : "remoteSourceControl", + "location" : "https://github.com/SDWebImage/SDWebImage.git", + "state" : { + "revision" : "2de3a496eaf6df9a1312862adcfd54acd73c39c0", + "version" : "5.21.7" + } + }, + { + "identity" : "sdwebimageswiftui", + "kind" : "remoteSourceControl", + "location" : "https://github.com/SDWebImage/SDWebImageSwiftUI", + "state" : { + "revision" : "0e331457ca9af2f0b08bcaa138a91ffb907b004f", + "version" : "3.1.4" + } + }, + { + "identity" : "sdwebimagewebpcoder", + "kind" : "remoteSourceControl", + "location" : "https://github.com/SDWebImage/SDWebImageWebPCoder", + "state" : { + "revision" : "12d83edbcc795fb7b5c0c3cb74d739108d3357d2", + "version" : "0.15.0" + } + }, + { + "identity" : "sfsafesymbols", + "kind" : "remoteSourceControl", + "location" : "https://github.com/SFSafeSymbols/SFSafeSymbols", + "state" : { + "revision" : "e01b3d4f861412f8dcee8d93c417d2c2b0cdfd77", + "version" : "7.0.0" + } + }, + { + "identity" : "swift-case-paths", + "kind" : "remoteSourceControl", + "location" : "https://github.com/pointfreeco/swift-case-paths", + "state" : { + "revision" : "1197e80bc7e4b177051b6869ef93d8ac3ad677da", + "version" : "1.8.0" + } + }, + { + "identity" : "swift-clocks", + "kind" : "remoteSourceControl", + "location" : "https://github.com/pointfreeco/swift-clocks", + "state" : { + "revision" : "72d749bf341b78851203066ab421869b783ec42a", + "version" : "1.1.0" + } + }, + { + "identity" : "swift-collections", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-collections", + "state" : { + "revision" : "a0cb0954ecb21e4e31b0070e6ed5674e8556685a", + "version" : "1.6.0" + } + }, + { + "identity" : "swift-composable-architecture", + "kind" : "remoteSourceControl", + "location" : "https://github.com/pointfreeco/swift-composable-architecture", + "state" : { + "revision" : "e2fa1df6cd9eec6fa6314aa20513e47da576f24e", + "version" : "1.26.0" + } + }, + { + "identity" : "swift-concurrency-extras", + "kind" : "remoteSourceControl", + "location" : "https://github.com/pointfreeco/swift-concurrency-extras", + "state" : { + "revision" : "a90e2e40a7a840a853dd29e57cbef5dbb72c9d5b", + "version" : "1.4.0" + } + }, + { + "identity" : "swift-custom-dump", + "kind" : "remoteSourceControl", + "location" : "https://github.com/pointfreeco/swift-custom-dump", + "state" : { + "revision" : "a8cd6c976f335ed361dcecddb0dc39ebda51bc3e", + "version" : "1.6.1" + } + }, + { + "identity" : "swift-dependencies", + "kind" : "remoteSourceControl", + "location" : "https://github.com/pointfreeco/swift-dependencies", + "state" : { + "revision" : "8dc1fbf2f6255a73dec53b4648164884898db4c5", + "version" : "1.14.1" + } + }, + { + "identity" : "swift-identified-collections", + "kind" : "remoteSourceControl", + "location" : "https://github.com/pointfreeco/swift-identified-collections", + "state" : { + "revision" : "322d9ffeeba85c9f7c4984b39422ec7cc3c56597", + "version" : "1.1.1" + } + }, + { + "identity" : "swift-navigation", + "kind" : "remoteSourceControl", + "location" : "https://github.com/pointfreeco/swift-navigation", + "state" : { + "revision" : "6cff006b3607b700029dc44f06b41a6cc7384ac7", + "version" : "2.10.2" + } + }, + { + "identity" : "swift-perception", + "kind" : "remoteSourceControl", + "location" : "https://github.com/pointfreeco/swift-perception", + "state" : { + "revision" : "25ac73741c3436605d61eceb5207e896973918e7", + "version" : "2.0.10" + } + }, + { + "identity" : "swift-sharing", + "kind" : "remoteSourceControl", + "location" : "https://github.com/pointfreeco/swift-sharing", + "state" : { + "revision" : "c8dd3627eb92cef1bcb44ac5a93d558ab32b82ce", + "version" : "2.9.0" + } + }, + { + "identity" : "swift-syntax", + "kind" : "remoteSourceControl", + "location" : "https://github.com/swiftlang/swift-syntax", + "state" : { + "revision" : "79e4b74a295b6eb74a8b585e3a39d29e70c1dbd1", + "version" : "603.0.2" + } + }, + { + "identity" : "swiftcommonmark", + "kind" : "remoteSourceControl", + "location" : "https://github.com/gonzalezreal/SwiftCommonMark", + "state" : { + "revision" : "ed252beaddecce28ea6363f800c773d6169011b8", + "version" : "1.0.0" + } + }, + { + "identity" : "swiftlintplugins", + "kind" : "remoteSourceControl", + "location" : "https://github.com/SimplyDanny/SwiftLintPlugins", + "state" : { + "revision" : "ecb6907e0db6f79ed2c7a569df4e723a47615208", + "version" : "0.64.1" + } + }, + { + "identity" : "swiftuipager", + "kind" : "remoteSourceControl", + "location" : "https://github.com/fermoya/SwiftUIPager", + "state" : { + "revision" : "4ddc04c801aac143090bb14cf26603a3bf9c74cb", + "version" : "2.5.0" + } + }, + { + "identity" : "swiftyopencc", + "kind" : "remoteSourceControl", + "location" : "https://github.com/ddddxxx/SwiftyOpenCC", + "state" : { + "revision" : "1d8105a0f7199c90af722bff62728050c858e777", + "version" : "2.0.0-beta" + } + }, + { + "identity" : "uiimagecolors", + "kind" : "remoteSourceControl", + "location" : "https://github.com/jathu/UIImageColors", + "state" : { + "revision" : "e49e6c32ea556e9fa0109dc79686bea4a10d41a2", + "version" : "2.2.0" + } + }, + { + "identity" : "waterfallgrid", + "kind" : "remoteSourceControl", + "location" : "https://github.com/paololeonardi/WaterfallGrid", + "state" : { + "revision" : "c7c08652c3540adf8e48409c351879b4caea7e89", + "version" : "1.1.0" + } + }, + { + "identity" : "xctest-dynamic-overlay", + "kind" : "remoteSourceControl", + "location" : "https://github.com/pointfreeco/xctest-dynamic-overlay", + "state" : { + "revision" : "401bf70d95bfe8db2a1dc619f9e175a85c089321", + "version" : "1.10.1" + } + } + ], + "version" : 3 +} diff --git a/AppPackage/Package.swift b/AppPackage/Package.swift new file mode 100644 index 000000000..afbacb348 --- /dev/null +++ b/AppPackage/Package.swift @@ -0,0 +1,955 @@ +// swift-tools-version: 6.3.1 + +import PackageDescription + +// MARK: Dependency +var dependencies: [PackageDescription.Package.Dependency] = [ + // Pinned to match the app's resolved version; 1.1.x deprecates ColorfulView. + .package(url: "https://github.com/Co2333/Colorful", .upToNextMinor(from: "1.0.1")), + .package(url: "https://github.com/EhPanda-Team/DeprecatedAPI", branch: "main"), + .package(url: "https://github.com/SDWebImage/SDWebImageSwiftUI", from: "3.0.0"), + .package(url: "https://github.com/SDWebImage/SDWebImageWebPCoder", from: "0.14.0"), + .package(url: "https://github.com/SFSafeSymbols/SFSafeSymbols", from: "7.0.0"), + .package(url: "https://github.com/SimplyDanny/SwiftLintPlugins", from: "0.63.0"), + .package(url: "https://github.com/ddddxxx/SwiftyOpenCC", exact: "2.0.0-beta"), + .package(url: "https://github.com/fermoya/SwiftUIPager", from: "2.5.0"), + .package(url: "https://github.com/gonzalezreal/SwiftCommonMark", from: "1.0.0"), + .package(url: "https://github.com/jathu/UIImageColors", from: "2.2.0"), + .package(url: "https://github.com/onevcat/Kingfisher", from: "8.0.0"), + .package(url: "https://github.com/paololeonardi/WaterfallGrid", from: "1.0.0"), + .package(url: "https://github.com/pointfreeco/swift-case-paths", from: "1.7.0"), + .package( + url: "https://github.com/pointfreeco/swift-composable-architecture", + from: "1.25.0" + ), + .package(url: "https://github.com/pointfreeco/swift-sharing", from: "2.0.0"), + .package(url: "https://github.com/tid-kijyun/Kanna", from: "6.0.0") +] + +extension PackageDescription.Target.Dependency { + static let casePaths: Self = .product(name: "CasePaths", package: "swift-case-paths") + static let colorful: Self = .product(name: "Colorful", package: "Colorful") + static let commonMark: Self = .product(name: "CommonMark", package: "SwiftCommonMark") + static let composableArchitecture: Self = .product( + name: "ComposableArchitecture", + package: "swift-composable-architecture" + ) + static let deprecatedAPI: Self = .product(name: "DeprecatedAPI", package: "DeprecatedAPI") + static let kanna: Self = .product(name: "Kanna", package: "Kanna") + static let kingfisher: Self = .product(name: "Kingfisher", package: "Kingfisher") + static let openCC: Self = .product(name: "OpenCC", package: "SwiftyOpenCC") + static let sdWebImageSwiftUI: Self = .product(name: "SDWebImageSwiftUI", package: "SDWebImageSwiftUI") + static let sdWebImageWebPCoder: Self = .product(name: "SDWebImageWebPCoder", package: "SDWebImageWebPCoder") + static let sfSafeSymbols: Self = .product(name: "SFSafeSymbols", package: "SFSafeSymbols") + static let sharing: Self = .product(name: "Sharing", package: "swift-sharing") + static let swiftUIPager: Self = .product(name: "SwiftUIPager", package: "SwiftUIPager") + static let uiImageColors: Self = .product(name: "UIImageColors", package: "UIImageColors") + static let waterfallGrid: Self = .product(name: "WaterfallGrid", package: "WaterfallGrid") +} + +let swiftLintPlugins: [PackageDescription.Target.PluginUsage] = [ + .plugin(name: "SwiftLintBuildToolPlugin", package: "SwiftLintPlugins") +] + +// Matches the app target's "Approachable Concurrency" (SWIFT_APPROACHABLE_CONCURRENCY) +// so code keeps compiling under the same concurrency posture after moving into the package. +let sharedSwiftSettings: [PackageDescription.SwiftSetting] = [ + .enableUpcomingFeature("InferIsolatedConformances"), + .enableUpcomingFeature("NonisolatedNonsendingByDefault") +] + +// MARK: Module +enum Module: String { + case animatedImageFeature = "AnimatedImageFeature" + case appComponents = "AppComponents" + case appDelegateClient = "AppDelegateClient" + case appFeature = "AppFeature" + case appLaunchAutomationClient = "AppLaunchAutomationClient" + case appModels = "AppModels" + case appTools = "AppTools" + case applicationClient = "ApplicationClient" + case authorizationClient = "AuthorizationClient" + case backgroundProcessingClient = "BackgroundProcessingClient" + case clipboardClient = "ClipboardClient" + case commonMarkExt = "CommonMarkExt" + case cookieClient = "CookieClient" + case dfClient = "DFClient" + case dateSeekFeature = "DateSeekFeature" + case detailFeature = "DetailFeature" + case deviceClient = "DeviceClient" + case downloadClient = "DownloadClient" + case downloadsFeature = "DownloadsFeature" + case favoritesFeature = "FavoritesFeature" + case fileClient = "FileClient" + case filtersFeature = "FiltersFeature" + case galleryListComponents = "GalleryListComponents" + case hapticsClient = "HapticsClient" + case homeFeature = "HomeFeature" + case imageClient = "ImageClient" + case libraryClient = "LibraryClient" + case logsClient = "LogsClient" + case networkingFeature = "NetworkingFeature" + case openCCExt = "OpenCCExt" + case osLogExt = "OSLogExt" + case parserFeature = "ParserFeature" + case quickSearchFeature = "QuickSearchFeature" + case readingFeature = "ReadingFeature" + case readingSettingFeature = "ReadingSettingFeature" + case resources = "Resources" + case searchFeature = "SearchFeature" + case settingFeature = "SettingFeature" + case sfSafeSymbolsExt = "SFSafeSymbolsExt" + case systemNotificationExt = "SystemNotificationExt" + case tagTranslationFeature = "TagTranslationFeature" + case urlClient = "URLClient" + case userDefaultsClient = "UserDefaultsClient" + + // Test support + case testingSupport = "TestingSupport" + + // Test targets + case parserFeatureTests = "ParserFeatureTests" + case downloadsFeatureTests = "DownloadsFeatureTests" + case fileClientTests = "FileClientTests" + case settingFeatureTests = "SettingFeatureTests" + case detailFeatureTests = "DetailFeatureTests" + case networkingFeatureTests = "NetworkingFeatureTests" + case appModelsTests = "AppModelsTests" +} + +extension Module { + enum Dependency { + case module(Module) + case literal(String) + case targetDependency(PackageDescription.Target.Dependency) + + var targetDependency: PackageDescription.Target.Dependency { + switch self { + case .module(let module): + return .init(stringLiteral: module.rawValue) + + case .literal(let stringLiteral): + return .init(stringLiteral: stringLiteral) + + case .targetDependency(let dependency): + return dependency + } + } + } +} + +// MARK: Exclude +enum Path: String { + case resources = "Resources" +} + +enum Exclude { + case literal(String) + case path(Path) + + var name: String { + switch self { + case .literal(let stringLiteral): + return stringLiteral + + case .path(let path): + return path.rawValue + } + } +} + +// MARK: Resource +enum Resource { + case copy(Path) + case embedInCode(Path) + case process(Path, PackageDescription.Resource.Localization? = nil) + + var value: PackageDescription.Resource { + switch self { + case .copy(let path): + return .copy(path.rawValue) + + case .embedInCode(let path): + return .embedInCode(path.rawValue) + + case .process(let path, let localization): + return .process(path.rawValue, localization: localization) + } + } +} + +// MARK: Helper methods +extension PackageDescription.Target { + static func target( + module: Module, + dependencies: [Module.Dependency] = .init(), + path: String? = nil, + exclude: [Exclude] = .init(), + sources: [String]? = nil, + resources: [Resource]? = nil, + publicHeadersPath: String? = nil, + packageAccess: Bool = true, + cSettings: [PackageDescription.CSetting]? = nil, + cxxSettings: [PackageDescription.CXXSetting]? = nil, + swiftSettings: [PackageDescription.SwiftSetting]? = sharedSwiftSettings, + linkerSettings: [PackageDescription.LinkerSetting]? = nil, + plugins: [PackageDescription.Target.PluginUsage]? = nil + ) -> PackageDescription.Target { + target( + name: module.rawValue, + dependencies: dependencies.map(\.targetDependency), + path: path, + exclude: exclude.map(\.name), + sources: sources, + resources: resources?.map(\.value), + publicHeadersPath: publicHeadersPath, + packageAccess: packageAccess, + cSettings: cSettings, + cxxSettings: cxxSettings, + swiftSettings: swiftSettings, + linkerSettings: linkerSettings, + plugins: plugins + ) + } + + static func testTarget( + module: Module, + dependencies: [Module.Dependency] = .init(), + path: String? = nil, + exclude: [Exclude] = .init(), + sources: [String]? = nil, + resources: [Resource]? = nil, + packageAccess: Bool = true, + cSettings: [PackageDescription.CSetting]? = nil, + cxxSettings: [PackageDescription.CXXSetting]? = nil, + swiftSettings: [PackageDescription.SwiftSetting]? = sharedSwiftSettings, + linkerSettings: [PackageDescription.LinkerSetting]? = nil, + plugins: [PackageDescription.Target.PluginUsage]? = nil + ) -> PackageDescription.Target { + testTarget( + name: module.rawValue, + dependencies: dependencies.map(\.targetDependency), + path: path, + exclude: exclude.map(\.name), + sources: sources, + resources: resources?.map(\.value), + packageAccess: packageAccess, + cSettings: cSettings, + cxxSettings: cxxSettings, + swiftSettings: swiftSettings, + linkerSettings: linkerSettings, + plugins: plugins + ) + } +} + +// MARK: Target +let targets: [PackageDescription.Target] = [ + .target( + module: .appFeature, + dependencies: [ + .module(.appComponents), + .module(.appDelegateClient), + .module(.appLaunchAutomationClient), + .module(.appModels), + .module(.appTools), + .module(.applicationClient), + .module(.authorizationClient), + .module(.backgroundProcessingClient), + .module(.clipboardClient), + .module(.cookieClient), + .module(.dateSeekFeature), + .module(.detailFeature), + .module(.dfClient), + .module(.deviceClient), + .module(.downloadClient), + .module(.downloadsFeature), + .module(.favoritesFeature), + .module(.fileClient), + .module(.filtersFeature), + .module(.hapticsClient), + .module(.homeFeature), + .module(.imageClient), + .module(.libraryClient), + .module(.networkingFeature), + .module(.osLogExt), + .module(.parserFeature), + .module(.quickSearchFeature), + .module(.readingFeature), + .module(.resources), + .module(.searchFeature), + .module(.animatedImageFeature), + .module(.settingFeature), + .module(.systemNotificationExt), + .module(.urlClient), + .module(.userDefaultsClient), + .targetDependency(.colorful), + .targetDependency(.commonMark), + .targetDependency(.composableArchitecture), + .targetDependency(.deprecatedAPI), + .targetDependency(.kanna), + .targetDependency(.kingfisher), + .targetDependency(.openCC), + .targetDependency(.sdWebImageSwiftUI), + .targetDependency(.sdWebImageWebPCoder), + .targetDependency(.sfSafeSymbols), + .targetDependency(.swiftUIPager), + .targetDependency(.uiImageColors), + .targetDependency(.waterfallGrid) + ], + resources: [.process(.resources)], + plugins: swiftLintPlugins + ), + .target( + module: .appModels, + dependencies: [ + .module(.appTools), + .module(.resources), + .module(.osLogExt), + .targetDependency(.casePaths), + .targetDependency(.sharing) + ], + resources: [.process(.resources)], + plugins: swiftLintPlugins + ), + .target( + module: .resources, + resources: [.process(.resources)], + plugins: swiftLintPlugins + ), + .target( + module: .deviceClient, + dependencies: [ + .module(.appTools), + .targetDependency(.composableArchitecture) + ], + plugins: swiftLintPlugins + ), + .target( + module: .downloadClient, + dependencies: [ + .module(.appModels), + .module(.appTools), + .module(.libraryClient), + .module(.networkingFeature), + .module(.osLogExt), + .module(.parserFeature), + .module(.resources), + .module(.animatedImageFeature), + .module(.urlClient), + .targetDependency(.composableArchitecture), + .targetDependency(.kanna), + .targetDependency(.sharing) + ], + resources: [.process(.resources)], + plugins: swiftLintPlugins + ), + .target( + module: .fileClient, + dependencies: [ + .module(.appModels), + .module(.appTools), + .module(.openCCExt), + .targetDependency(.composableArchitecture) + ], + plugins: swiftLintPlugins + ), + .target( + module: .systemNotificationExt, + dependencies: [ + .module(.appComponents), + .targetDependency(.composableArchitecture), + .targetDependency(.sfSafeSymbols) + ], + plugins: swiftLintPlugins + ), + .target( + module: .appTools, + dependencies: [], + plugins: swiftLintPlugins + ), + .target( + module: .appDelegateClient, + dependencies: [ + .module(.appTools), + .targetDependency(.composableArchitecture) + ], + plugins: swiftLintPlugins + ), + .target( + module: .appLaunchAutomationClient, + dependencies: [ + .module(.appModels), + .module(.appTools), + .targetDependency(.composableArchitecture) + ], + plugins: swiftLintPlugins + ), + .target( + module: .authorizationClient, + dependencies: [ + .targetDependency(.composableArchitecture) + ], + plugins: swiftLintPlugins + ), + .target( + module: .backgroundProcessingClient, + dependencies: [ + .module(.appModels), + .module(.osLogExt), + .targetDependency(.composableArchitecture) + ], + plugins: swiftLintPlugins + ), + .target( + module: .clipboardClient, + dependencies: [ + .module(.animatedImageFeature), + .targetDependency(.composableArchitecture) + ], + plugins: swiftLintPlugins + ), + .target( + module: .cookieClient, + dependencies: [ + .module(.appModels), + .module(.appTools), + .targetDependency(.composableArchitecture) + ], + resources: [.process(.resources)], + plugins: swiftLintPlugins + ), + .target( + module: .dfClient, + dependencies: [ + .module(.networkingFeature), + .targetDependency(.composableArchitecture), + .targetDependency(.kingfisher) + ], + plugins: swiftLintPlugins + ), + .target( + module: .networkingFeature, + dependencies: [ + .module(.appModels), + .module(.appTools), + .module(.osLogExt), + .module(.parserFeature), + .targetDependency(.composableArchitecture), + .targetDependency(.deprecatedAPI), + .targetDependency(.kanna) + ], + plugins: swiftLintPlugins + ), + .target( + module: .hapticsClient, + dependencies: [ + .module(.appTools), + .targetDependency(.composableArchitecture) + ], + plugins: swiftLintPlugins + ), + .target( + module: .appComponents, + dependencies: [ + .module(.sfSafeSymbolsExt), + .module(.appModels), + .module(.appTools), + .module(.parserFeature), + .module(.resources), + .module(.tagTranslationFeature), + .targetDependency(.composableArchitecture), + .targetDependency(.kingfisher), + .targetDependency(.sfSafeSymbols), + .targetDependency(.sharing) + ], + resources: [.process(.resources)], + plugins: swiftLintPlugins + ), + .target( + module: .galleryListComponents, + dependencies: [ + .module(.appComponents), + .module(.appModels), + .module(.appTools), + .module(.tagTranslationFeature), + .targetDependency(.kingfisher), + .targetDependency(.sfSafeSymbols), + .targetDependency(.sharing), + .targetDependency(.waterfallGrid) + ], + resources: [.process(.resources)], + plugins: swiftLintPlugins + ), + .target( + module: .animatedImageFeature, + dependencies: [ + .targetDependency(.sdWebImageSwiftUI) + ], + plugins: swiftLintPlugins + ), + .target( + module: .commonMarkExt, + dependencies: [ + .targetDependency(.casePaths), + .targetDependency(.commonMark) + ], + plugins: swiftLintPlugins + ), + .target( + module: .sfSafeSymbolsExt, + dependencies: [ + .targetDependency(.sfSafeSymbols) + ], + plugins: swiftLintPlugins + ), + .target( + module: .openCCExt, + dependencies: [ + .module(.appModels), + .targetDependency(.openCC) + ], + plugins: swiftLintPlugins + ), + .target( + module: .osLogExt, + dependencies: [ + .module(.appTools) + ], + plugins: swiftLintPlugins + ), + .target( + module: .logsClient, + dependencies: [ + .module(.appModels), + .module(.appTools), + .module(.osLogExt), + .targetDependency(.composableArchitecture) + ], + plugins: swiftLintPlugins + ), + .target( + module: .tagTranslationFeature, + dependencies: [ + .module(.appModels), + .module(.appTools), + .module(.commonMarkExt) + ], + plugins: swiftLintPlugins + ), + .target( + module: .filtersFeature, + dependencies: [ + .module(.appComponents), + .module(.appModels), + .module(.resources), + .targetDependency(.composableArchitecture), + .targetDependency(.sharing) + ], + resources: [.process(.resources)], + plugins: swiftLintPlugins + ), + .target( + module: .dateSeekFeature, + dependencies: [ + .module(.appModels), + .module(.appTools), + .module(.hapticsClient), + .module(.resources), + .targetDependency(.composableArchitecture), + .targetDependency(.sfSafeSymbols) + ], + resources: [.process(.resources)], + plugins: swiftLintPlugins + ), + .target( + module: .readingSettingFeature, + dependencies: [ + .module(.appModels), + .module(.appTools), + .module(.resources), + .targetDependency(.composableArchitecture), + .targetDependency(.sharing) + ], + resources: [.process(.resources)], + plugins: swiftLintPlugins + ), + .target( + module: .quickSearchFeature, + dependencies: [ + .module(.appComponents), + .module(.appModels), + .module(.resources), + .targetDependency(.composableArchitecture), + .targetDependency(.sfSafeSymbols), + .targetDependency(.sharing) + ], + resources: [.process(.resources)], + plugins: swiftLintPlugins + ), + .target( + module: .downloadsFeature, + dependencies: [ + .module(.sfSafeSymbolsExt), + .module(.appComponents), + .module(.appModels), + .module(.appTools), + .module(.detailFeature), + .module(.deviceClient), + .module(.downloadClient), + .module(.galleryListComponents), + .module(.readingFeature), + .module(.resources), + .module(.systemNotificationExt), + .module(.tagTranslationFeature), + .targetDependency(.composableArchitecture), + .targetDependency(.sfSafeSymbols) + ], + resources: [.process(.resources)], + plugins: swiftLintPlugins + ), + .target( + module: .favoritesFeature, + dependencies: [ + .module(.appComponents), + .module(.appModels), + .module(.appTools), + .module(.dateSeekFeature), + .module(.detailFeature), + .module(.deviceClient), + .module(.downloadClient), + .module(.galleryListComponents), + .module(.hapticsClient), + .module(.networkingFeature), + .module(.quickSearchFeature), + .module(.resources), + .module(.tagTranslationFeature), + .targetDependency(.composableArchitecture) + ], + plugins: swiftLintPlugins + ), + .target( + module: .settingFeature, + dependencies: [ + .module(.sfSafeSymbolsExt), + .module(.appComponents), + .module(.appModels), + .module(.appTools), + .module(.applicationClient), + .module(.authorizationClient), + .module(.clipboardClient), + .module(.cookieClient), + .module(.dfClient), + .module(.fileClient), + .module(.hapticsClient), + .module(.libraryClient), + .module(.logsClient), + .module(.networkingFeature), + .module(.osLogExt), + .module(.readingSettingFeature), + .module(.resources), + .module(.systemNotificationExt), + .module(.userDefaultsClient), + .targetDependency(.composableArchitecture), + .targetDependency(.sfSafeSymbols), + .targetDependency(.sharing) + ], + resources: [.process(.resources)], + plugins: swiftLintPlugins + ), + .target( + module: .searchFeature, + dependencies: [ + .module(.appComponents), + .module(.appModels), + .module(.appTools), + .module(.dateSeekFeature), + .module(.detailFeature), + .module(.deviceClient), + .module(.downloadClient), + .module(.filtersFeature), + .module(.galleryListComponents), + .module(.hapticsClient), + .module(.networkingFeature), + .module(.quickSearchFeature), + .module(.resources), + .module(.tagTranslationFeature), + .targetDependency(.composableArchitecture), + .targetDependency(.kingfisher), + .targetDependency(.sfSafeSymbols), + .targetDependency(.sharing) + ], + resources: [.process(.resources)], + plugins: swiftLintPlugins + ), + .target( + module: .homeFeature, + dependencies: [ + .module(.appComponents), + .module(.appModels), + .module(.appTools), + .module(.dateSeekFeature), + .module(.detailFeature), + .module(.deviceClient), + .module(.downloadClient), + .module(.filtersFeature), + .module(.galleryListComponents), + .module(.hapticsClient), + .module(.libraryClient), + .module(.networkingFeature), + .module(.quickSearchFeature), + .module(.resources), + .module(.tagTranslationFeature), + .targetDependency(.colorful), + .targetDependency(.composableArchitecture), + .targetDependency(.kingfisher), + .targetDependency(.sfSafeSymbols), + .targetDependency(.swiftUIPager), + .targetDependency(.uiImageColors), + .targetDependency(.sharing) + ], + resources: [.process(.resources)], + plugins: swiftLintPlugins + ), + .target( + module: .detailFeature, + dependencies: [ + .module(.sfSafeSymbolsExt), + .module(.appComponents), + .module(.appLaunchAutomationClient), + .module(.appModels), + .module(.appTools), + .module(.applicationClient), + .module(.clipboardClient), + .module(.cookieClient), + .module(.downloadClient), + .module(.fileClient), + .module(.filtersFeature), + .module(.galleryListComponents), + .module(.hapticsClient), + .module(.networkingFeature), + .module(.quickSearchFeature), + .module(.readingFeature), + .module(.resources), + .module(.systemNotificationExt), + .module(.tagTranslationFeature), + .module(.urlClient), + .targetDependency(.commonMark), + .targetDependency(.composableArchitecture), + .targetDependency(.kingfisher), + .targetDependency(.sfSafeSymbols), + .targetDependency(.sharing) + ], + resources: [.process(.resources)], + plugins: swiftLintPlugins + ), + .target( + module: .readingFeature, + dependencies: [ + .module(.sfSafeSymbolsExt), + .module(.appComponents), + .module(.appDelegateClient), + .module(.appModels), + .module(.appTools), + .module(.clipboardClient), + .module(.cookieClient), + .module(.deviceClient), + .module(.downloadClient), + .module(.hapticsClient), + .module(.imageClient), + .module(.networkingFeature), + .module(.osLogExt), + .module(.readingSettingFeature), + .module(.resources), + .module(.animatedImageFeature), + .module(.systemNotificationExt), + .module(.urlClient), + .targetDependency(.composableArchitecture), + .targetDependency(.kingfisher), + .targetDependency(.sdWebImageSwiftUI), + .targetDependency(.sfSafeSymbols), + .targetDependency(.swiftUIPager) + ], + resources: [.process(.resources)], + plugins: swiftLintPlugins + ), + .target( + module: .imageClient, + dependencies: [ + .module(.appModels), + .module(.animatedImageFeature), + .module(.appTools), + .targetDependency(.composableArchitecture) + ], + plugins: swiftLintPlugins + ), + .target( + module: .libraryClient, + dependencies: [ + .module(.appModels), + .module(.animatedImageFeature), + .module(.appTools), + .targetDependency(.composableArchitecture), + .targetDependency(.kingfisher), + .targetDependency(.sdWebImageSwiftUI), + .targetDependency(.sdWebImageWebPCoder), + .targetDependency(.uiImageColors) + ], + plugins: swiftLintPlugins + ), + .target( + module: .parserFeature, + dependencies: [ + .module(.appModels), + .module(.appTools), + .module(.resources), + .module(.osLogExt), + .targetDependency(.kanna) + ], + plugins: swiftLintPlugins + ), + .target( + module: .applicationClient, + dependencies: [ + .module(.appTools), + .targetDependency(.composableArchitecture) + ], + plugins: swiftLintPlugins + ), + .target( + module: .urlClient, + dependencies: [ + .module(.appModels), + .module(.appTools), + .targetDependency(.composableArchitecture) + ], + plugins: swiftLintPlugins + ), + .target( + module: .userDefaultsClient, + dependencies: [ + .module(.appTools), + .targetDependency(.composableArchitecture) + ], + plugins: swiftLintPlugins + ), + + // MARK: Test Support + .target( + module: .testingSupport, + dependencies: [ + .targetDependency(.kanna) + ], + resources: [.process(.resources)], + plugins: swiftLintPlugins + ), + + // MARK: Tests + .testTarget( + module: .parserFeatureTests, + dependencies: [ + .module(.testingSupport), + .module(.animatedImageFeature), + .module(.appFeature), + .module(.appModels), + .module(.appTools), + .module(.networkingFeature), + .module(.parserFeature), + .module(.urlClient), + .targetDependency(.kanna) + ], + plugins: swiftLintPlugins + ), + .testTarget( + module: .downloadsFeatureTests, + dependencies: [ + .module(.testingSupport), + .module(.appDelegateClient), + .module(.appFeature), + .module(.appLaunchAutomationClient), + .module(.appModels), + .module(.appTools), + .module(.applicationClient), + .module(.backgroundProcessingClient), + .module(.clipboardClient), + .module(.cookieClient), + .module(.detailFeature), + .module(.dfClient), + .module(.deviceClient), + .module(.downloadClient), + .module(.downloadsFeature), + .module(.fileClient), + .module(.hapticsClient), + .module(.imageClient), + .module(.libraryClient), + .module(.networkingFeature), + .module(.readingFeature), + .module(.urlClient), + .module(.userDefaultsClient), + .targetDependency(.composableArchitecture), + .targetDependency(.kingfisher), + .targetDependency(.sfSafeSymbols) + ], + plugins: swiftLintPlugins + ), + .testTarget( + module: .fileClientTests, + dependencies: [ + .module(.appModels), + .module(.fileClient) + ], + plugins: swiftLintPlugins + ), + .testTarget( + module: .settingFeatureTests, + dependencies: [ + .module(.appModels), + .module(.cookieClient), + .module(.fileClient), + .module(.hapticsClient), + .module(.logsClient), + .module(.settingFeature), + .targetDependency(.composableArchitecture), + .targetDependency(.sharing) + ], + plugins: swiftLintPlugins + ), + .testTarget( + module: .detailFeatureTests, + dependencies: [ + .module(.appModels), + .module(.detailFeature), + .module(.hapticsClient), + .targetDependency(.composableArchitecture) + ], + plugins: swiftLintPlugins + ), + .testTarget( + module: .networkingFeatureTests, + dependencies: [ + .module(.appModels), + .module(.networkingFeature) + ], + plugins: swiftLintPlugins + ), + .testTarget( + module: .appModelsTests, + dependencies: [ + .module(.appModels) + ], + plugins: swiftLintPlugins + ) +] + +// MARK: Package +let package = Package( + name: "AppPackage", + defaultLocalization: "en", + platforms: [.iOS(.v26)], + products: targets + .filter({ !$0.isTest && $0.name != Module.testingSupport.rawValue }) + .map(\.name) + .map({ .library(name: $0, targets: [$0]) }), + dependencies: dependencies, + targets: targets +) diff --git a/AppPackage/Sources/AnimatedImageFeature/.swiftlint.yml b/AppPackage/Sources/AnimatedImageFeature/.swiftlint.yml new file mode 100644 index 000000000..1242ffcaa --- /dev/null +++ b/AppPackage/Sources/AnimatedImageFeature/.swiftlint.yml @@ -0,0 +1 @@ +parent_config: ../../../.swiftlint.yml diff --git a/AppPackage/Sources/AnimatedImageFeature/AnimatedImage+.swift b/AppPackage/Sources/AnimatedImageFeature/AnimatedImage+.swift new file mode 100644 index 000000000..895b644ff --- /dev/null +++ b/AppPackage/Sources/AnimatedImageFeature/AnimatedImage+.swift @@ -0,0 +1,244 @@ +import UIKit +import SDWebImage +import UniformTypeIdentifiers + +private enum ImageDataSignature { + public static let jpeg: [UInt8] = [0xFF, 0xD8, 0xFF] + public static let png: [UInt8] = [0x89, 0x50, 0x4E, 0x47] + public static let pngComplete: [UInt8] = [0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A] + public static let gif = Array("GIF".utf8) + public static let riff = Array("RIFF".utf8) + public static let webp = Array("WEBP".utf8) + public static let webPExtended = Array("VP8X".utf8) + public static let webPAnimation = Array("ANIM".utf8) + public static let apngAnimationControl = Array("acTL".utf8) + public static let pngImageData = Array("IDAT".utf8) +} + +extension Data { + public var knownBinaryImageFileExtension: String? { + if isJPEGFormat { + return "jpg" + } + if isPNGFormat { + return "png" + } + if isGIFFormat { + return "gif" + } + if isWebPFormat { + return "webp" + } + return nil + } + + public var isKnownBinaryImageFormat: Bool { + knownBinaryImageFileExtension != nil + } + + public var isJPEGFormat: Bool { + starts(with: ImageDataSignature.jpeg) + } + + public var isPNGFormat: Bool { + starts(with: ImageDataSignature.png) + } + + public var isAPNGFormat: Bool { + guard starts(with: ImageDataSignature.pngComplete) else { return false } + // Walk the chunk headers in place; a still PNG returns at the first `IDAT` + // after reading only a few header bytes, so no full-image copy is needed. + return withUnsafeBytes { bytes in + var offset = ImageDataSignature.pngComplete.count + while offset + 12 <= bytes.count { + let chunkLength = Int(Self.bigEndianUInt32(bytes, offset: offset)) + let chunkTypeOffset = offset + 4 + let chunkDataOffset = offset + 8 + guard chunkDataOffset + chunkLength + 4 <= bytes.count else { return false } + if Self.matches(ImageDataSignature.apngAnimationControl, in: bytes, at: chunkTypeOffset) { + return true + } + if Self.matches(ImageDataSignature.pngImageData, in: bytes, at: chunkTypeOffset) { + return false + } + offset = chunkDataOffset + chunkLength + 4 + } + return false + } + } + + public var isGIFFormat: Bool { + starts(with: ImageDataSignature.gif) + } + + public var isWebPFormat: Bool { + starts(with: ImageDataSignature.riff) + && hasBytes(ImageDataSignature.webp, at: 8) + } + + /// Whether the bytes are an image that *actually animates*. This is the routing key for + /// rendering and export: animated data is rendered by SDWebImage, still data by + /// Kingfisher / `UIImage`. The decision is made from the bytes themselves (GIF frame + /// count, APNG `acTL` before `IDAT`, WebP VP8X animation bit), never the URL's file + /// extension: the extension is known before any bytes exist and is wrong for mislabeled + /// or content-negotiated images. + public var isAnimatedImageData: Bool { + isAnimatedGIFFormat || isAPNGFormat || isAnimatedWebPFormat + } + + public var animatedImagePasteboardType: String? { + if isAnimatedWebPFormat { + return UTType.webP.identifier + } + if isAPNGFormat { + return UTType.png.identifier + } + if isAnimatedGIFFormat { + return UTType.gif.identifier + } + return nil + } + + public var decodedImage: UIImage? { + if isAnimatedImageData, let animatedImage = SDAnimatedImage(data: self) { + return animatedImage + } + return UIImage(data: self) + } + + private func hasBytes(_ bytes: [UInt8], at offset: Int) -> Bool { + guard count >= offset + bytes.count else { return false } + let start = index(startIndex, offsetBy: offset) + let end = index(start, offsetBy: bytes.count) + return self[start..= 13 else { return false } + + var offset = 13 + if bytes[10] & 0x80 != 0 { + offset += Self.colorTableByteCount(packedField: bytes[10]) + } + + var imageCount = 0 + while offset < bytes.count { + switch bytes[offset] { + case 0x2C: + imageCount += 1 + guard imageCount <= 1 else { return true } + guard offset + 10 <= bytes.count else { return false } + let packedField = bytes[offset + 9] + offset += 10 + if packedField & 0x80 != 0 { + offset += Self.colorTableByteCount(packedField: packedField) + } + guard offset < bytes.count else { return false } + offset += 1 + guard Self.skipGIFSubBlocks(bytes, offset: &offset) else { return false } + + case 0x21: + offset += 2 + guard Self.skipGIFSubBlocks(bytes, offset: &offset) else { return false } + + case 0x3B: + return false + + default: + return false + } + } + return false + } + } + + private var isAnimatedWebPFormat: Bool { + guard isWebPFormat else { return false } + return withUnsafeBytes { bytes in + var offset = 12 + while offset + 8 <= bytes.count { + let chunkTypeOffset = offset + let chunkSize = Int(Self.littleEndianUInt32(bytes, offset: offset + 4)) + let chunkDataOffset = offset + 8 + let paddedChunkSize = chunkSize + (chunkSize % 2) + guard chunkDataOffset + paddedChunkSize <= bytes.count else { return false } + + if Self.matches(ImageDataSignature.webPExtended, in: bytes, at: chunkTypeOffset) { + guard chunkSize >= 1 else { return false } + return bytes[chunkDataOffset] & 0x02 != 0 + } + if Self.matches(ImageDataSignature.webPAnimation, in: bytes, at: chunkTypeOffset) { + return true + } + offset = chunkDataOffset + paddedChunkSize + } + return false + } + } + + private static func colorTableByteCount(packedField: UInt8) -> Int { + 3 * (1 << Int((packedField & 0x07) + 1)) + } + + private static func skipGIFSubBlocks(_ bytes: UnsafeRawBufferPointer, offset: inout Int) -> Bool { + while offset < bytes.count { + let blockSize = Int(bytes[offset]) + offset += 1 + guard blockSize > 0 else { return true } + guard offset + blockSize <= bytes.count else { return false } + offset += blockSize + } + return false + } + + private static func matches(_ expected: [UInt8], in bytes: UnsafeRawBufferPointer, at offset: Int) -> Bool { + guard offset >= 0, offset + expected.count <= bytes.count else { return false } + for index in expected.indices where bytes[offset + index] != expected[index] { + return false + } + return true + } + + private static func littleEndianUInt32(_ bytes: UnsafeRawBufferPointer, offset: Int) -> UInt32 { + guard offset + 4 <= bytes.count else { return 0 } + return UInt32(bytes[offset]) + | UInt32(bytes[offset + 1]) << 8 + | UInt32(bytes[offset + 2]) << 16 + | UInt32(bytes[offset + 3]) << 24 + } + + private static func bigEndianUInt32(_ bytes: UnsafeRawBufferPointer, offset: Int) -> UInt32 { + guard offset + 4 <= bytes.count else { return 0 } + return UInt32(bytes[offset]) << 24 + | UInt32(bytes[offset + 1]) << 16 + | UInt32(bytes[offset + 2]) << 8 + | UInt32(bytes[offset + 3]) + } +} + +extension UIImage { + public var hasAnimatedFrames: Bool { + sd_isAnimated + } + + public var animatedSourceData: Data? { + // Prefer the original downloaded bytes so GIF/APNG/WebP keep their source format. + if let data = (self as? SDAnimatedImageProvider)?.animatedImageData { + return data + } + + // `sd_imageData()` can preserve animated formats that SDWebImage knows how to export. + if let data = sd_imageData(), data.animatedImagePasteboardType != nil { + return data + } + + guard hasAnimatedFrames else { + return nil + } + + // Last resort for generated animated UIImages that no longer carry source bytes. + return sd_imageData(as: .webP, compressionQuality: 1, firstFrameOnly: false) + } +} diff --git a/AppPackage/Sources/AppComponents/.swiftlint.yml b/AppPackage/Sources/AppComponents/.swiftlint.yml new file mode 100644 index 000000000..1242ffcaa --- /dev/null +++ b/AppPackage/Sources/AppComponents/.swiftlint.yml @@ -0,0 +1 @@ +parent_config: ../../../.swiftlint.yml diff --git a/AppPackage/Sources/AppComponents/ActivityView.swift b/AppPackage/Sources/AppComponents/ActivityView.swift new file mode 100644 index 000000000..0618747c4 --- /dev/null +++ b/AppPackage/Sources/AppComponents/ActivityView.swift @@ -0,0 +1,14 @@ +import SwiftUI + +public struct ActivityView: UIViewControllerRepresentable { + private var activityItems: [Any] + + public init(activityItems: [Any]) { + self.activityItems = activityItems + } + + public func makeUIViewController(context: Context) -> UIActivityViewController { + UIActivityViewController(activityItems: activityItems, applicationActivities: nil) + } + public func updateUIViewController(_ uiViewController: UIActivityViewController, context: Context) {} +} diff --git a/EhPanda/View/Support/Components/AlertView.swift b/AppPackage/Sources/AppComponents/AlertView.swift similarity index 52% rename from EhPanda/View/Support/Components/AlertView.swift rename to AppPackage/Sources/AppComponents/AlertView.swift index 06481449b..2bcd35c02 100644 --- a/EhPanda/View/Support/Components/AlertView.swift +++ b/AppPackage/Sources/AppComponents/AlertView.swift @@ -1,33 +1,31 @@ -// -// AlertView.swift -// EhPanda -// - import SwiftUI +import AppModels +import Resources import SFSafeSymbols +import AppTools -struct LoadingView: View { - private let title: String +public struct LoadingView: View { + private let title: LocalizedStringResource - init(title: String = L10n.Localizable.LoadingView.Title.loading) { - self.title = title + public init(title: LocalizedStringResource? = nil) { + self.title = title ?? .loading } - var body: some View { + public var body: some View { ProgressView(title) } } -struct FetchMoreFooter: View { +public struct FetchMoreFooter: View { private let loadingState: LoadingState private let retryAction: (() -> Void)? - init(loadingState: LoadingState, retryAction: (() -> Void)?) { + public init(loadingState: LoadingState, retryAction: (() -> Void)?) { self.loadingState = loadingState self.retryAction = retryAction } - var body: some View { + public var body: some View { HStack(alignment: .center) { Spacer() ZStack { @@ -35,7 +33,7 @@ struct FetchMoreFooter: View { Button { retryAction?() } label: { - Image(systemSymbol: .exclamationmarkArrowTriangle2Circlepath) + Image(systemSymbol: .exclamationmarkArrowTrianglehead2ClockwiseRotate90) .foregroundStyle(.red).imageScale(.large) } .opacity(![.idle, .loading].contains(loadingState) ? 1 : 0) @@ -46,31 +44,31 @@ struct FetchMoreFooter: View { } } -struct NotLoginView: View { +public struct NotLoginView: View { private let action: () -> Void - init(action: @escaping () -> Void) { + public init(action: @escaping () -> Void) { self.action = action } - var body: some View { + public var body: some View { AlertView( symbol: .personCropCircleBadgeQuestionmarkFill, - message: L10n.Localizable.NotLoginView.Title.needLogin + message: .needLogin ) { - AlertViewButton(title: L10n.Localizable.NotLoginView.Button.login, action: action) + AlertViewButton(title: .RLocalizable.login, action: action) } } } -struct ErrorView: View { +public struct ErrorView: View { private let error: AppError - private let buttonTitle: String + private let buttonTitle: LocalizedStringResource private let action: (() -> Void)? - init( + public init( error: AppError, - buttonTitle: String = L10n.Localizable.ErrorView.Button.retry, + buttonTitle: LocalizedStringResource = .RLocalizable.retry, action: (() -> Void)? = nil ) { self.error = error @@ -78,7 +76,7 @@ struct ErrorView: View { self.action = action } - var body: some View { + public var body: some View { AlertView(symbol: error.symbol, message: error.alertText) { if let action = action { AlertViewButton(title: buttonTitle, action: action) @@ -87,19 +85,27 @@ struct ErrorView: View { } } -struct AlertView: View { +public struct AlertView: View { @Environment(\.colorScheme) private var colorScheme private let symbol: SFSymbol private let message: String private let actions: Content - init(symbol: SFSymbol, message: String, @ViewBuilder actions: () -> Content) { + public init(symbol: SFSymbol, message: String, @ViewBuilder actions: () -> Content) { self.symbol = symbol self.message = message self.actions = actions() } - var body: some View { + // Resource overload for static localized messages; the `String` init above remains for + // dynamic messages that are already resolved (e.g. `AppError.alertText`). + public init( + symbol: SFSymbol, message: LocalizedStringResource, @ViewBuilder actions: () -> Content + ) { + self.init(symbol: symbol, message: String(localized: message), actions: actions) + } + + public var body: some View { VStack { Image(systemSymbol: symbol).font(.system(size: 50)).padding(.bottom, 15) Text(message).multilineTextAlignment(.center).foregroundStyle(.gray) @@ -110,16 +116,16 @@ struct AlertView: View { } } -struct AlertViewButton: View { - private let title: String +public struct AlertViewButton: View { + private let title: LocalizedStringResource private let action: () -> Void - init(title: String, action: @escaping () -> Void) { + public init(title: LocalizedStringResource, action: @escaping () -> Void) { self.title = title self.action = action } - var body: some View { + public var body: some View { Button(action: action) { Text(title) .foregroundColor(.primary.opacity(0.7)) @@ -129,31 +135,3 @@ struct AlertViewButton: View { .buttonStyle(.glass) } } - -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(L10n.Localizable.JumpPageView.Title.jumpPage).bold() - HStack { - let opacity = colorScheme == .light ? 0.15 : 0.1 - TextField(inputText, text: $inputText).multilineTextAlignment(.center).keyboardType(.numberPad) - .padding(.horizontal, 10).padding(.vertical, 5).background(Color.gray.opacity(opacity)) - .cornerRadius(5).frame(width: 75).focused(isFocused.projectedValue) - Text("-") - Text("\(pageNumber.maximum + 1)") - } - .lineLimit(1) - } - } -} diff --git a/AppPackage/Sources/AppComponents/AppAlertState.swift b/AppPackage/Sources/AppComponents/AppAlertState.swift new file mode 100644 index 000000000..05e1d40b1 --- /dev/null +++ b/AppPackage/Sources/AppComponents/AppAlertState.swift @@ -0,0 +1,253 @@ +import SwiftUI +import Resources +import ComposableArchitecture + +/// The app's single presentation-state type. It backs both button dialogs and toasts, so a +/// feature models any transient presentation with one `@ObservableState` value: +/// +/// - ``Style/alert`` renders through ``SwiftUICore/View/appAlert(_:)`` as a **native** system alert, +/// with `buttons` wired to your reducer's actions (mirroring `AlertState`'s ergonomics). +/// - ``Style/toast(icon:autoHide:)`` renders through `View.toast(_:)` (in `SystemNotificationExt`) +/// as a bottom Liquid Glass toast; the button-less toast factories live on `AppAlertState`. +/// +/// Its alert initializer mirrors `AlertState` exactly — a `title`, a `ButtonStateBuilder` of +/// `actions`, and an optional `message` — so migrating an alert call site is a pure rename. It reuses +/// TCA's `TextState`/`ButtonState`, so localized keys and button roles behave identically. It isn't +/// `AlertState` itself because it is rendered by a consumer view rather than the framework, so it +/// must be `@ObservableState` for the presentation store to expose its content. +@ObservableState +public struct AppAlertState: Identifiable { + /// How a value is presented. See ``AppAlertState`` for which modifier renders each style. + public enum Style: Equatable, Hashable, Sendable { + case alert + case toast(icon: ToastIcon, autoHide: Bool) + } + + /// The glyph a ``Style/toast(icon:autoHide:)`` presentation shows. + public enum ToastIcon: Equatable, Hashable, Sendable { + case loading, success, error + } + + public let id: UUID + public var style: Style + public var title: TextState + public var message: TextState? + public var textField: AppAlertTextFieldState? + public var buttons: [ButtonState] + + public init( + title: () -> TextState, + @ButtonStateBuilder actions: () -> [ButtonState] = { [] }, + message: (() -> TextState)? = nil + ) { + self.id = UUID() + self.style = .alert + self.title = title() + self.buttons = actions() + self.message = message?() + } + + /// Builds an alert that includes a single text field. `textField` sits between `title` and + /// `actions` so the trailing `actions`/`message` closures read exactly like the plain alert init. + public init( + title: () -> TextState, + textField: AppAlertTextFieldState, + @ButtonStateBuilder actions: () -> [ButtonState] = { [] }, + message: (() -> TextState)? = nil + ) { + self.init(title: title, actions: actions, message: message) + self.textField = textField + } + + // Builds a button-less toast presentation; used by the `Action == Never` factories below. + init(style: Style, title: TextState, message: TextState? = nil) { + self.id = UUID() + self.style = style + self.title = title + self.message = message + self.buttons = [] + } +} + +extension AppAlertState: Equatable where Action: Equatable { + // Mirrors `AlertState`: identity is excluded so two states with equal content compare equal, + // which keeps reducer tests asserting on freshly-constructed states straightforward. + public static func == (lhs: Self, rhs: Self) -> Bool { + lhs.style == rhs.style + && lhs.title == rhs.title + && lhs.message == rhs.message + && lhs.textField == rhs.textField + && lhs.buttons == rhs.buttons + } +} + +extension AppAlertState: Hashable where Action: Hashable { + public func hash(into hasher: inout Hasher) { + hasher.combine(style) + hasher.combine(title) + hasher.combine(message) + hasher.combine(textField) + hasher.combine(buttons) + } +} + +extension AppAlertState: Sendable where Action: Sendable {} + +// Marks the dialog as ephemeral, exactly as `AlertState`/`ConfirmationDialogState` do, so a plain +// `.ifLet(_:action:)` (no child reducer) drives it and it auto-dismisses when a button is tapped. +extension AppAlertState: _EphemeralState {} + +/// Describes a single text field shown inside an ``AppAlertState`` alert. The field's *value* is not +/// stored here: it binds to the host reducer's own state, supplied at the call site through +/// ``SwiftUICore/View/appAlert(_:text:)``. That keeps the alert ephemeral — keystrokes never have to +/// round-trip through a reducer — while still letting a feature declare the field declaratively. It's a +/// top-level type (not nested in ``AppAlertState``) because it's independent of the alert's `Action`. +public struct AppAlertTextFieldState: Equatable, Hashable, Sendable { + /// The kind of keyboard the field raises. Kept host-agnostic (no `UIKit` type) so the state stays + /// `Sendable`; ``SwiftUICore/View/appAlert(_:text:)`` maps it to a `UIKeyboardType`. + public enum Keyboard: Equatable, Hashable, Sendable { + case `default`, numberPad + } + + public var placeholder: TextState + public var keyboard: Keyboard + + public init(placeholder: TextState, keyboard: Keyboard = .default) { + self.placeholder = placeholder + self.keyboard = keyboard + } +} + +// MARK: - Toast presentations +// Button-less toast presentations. `SystemNotificationExt` maps `ToastIcon` + `title`/`message` +// onto the rendered Liquid Glass toast content. +extension AppAlertState where Action == Never { + public static func loading(title: LocalizedStringResource? = nil) -> Self { + .init( + style: .toast(icon: .loading, autoHide: false), + title: TextState(localized: title ?? .toastLoading) + ) + } + public static var communicating: Self { + .init( + style: .toast(icon: .loading, autoHide: false), + title: TextState(localized: .communicating) + ) + } + // `caption` has two overloads: a `LocalizedStringResource` one for captions that come from a + // localized key (the applicable end of a chain), and a `String?` one for captions carrying a + // dynamic runtime value — a server response or a pre-formatted error message — that genuinely + // isn't a localization key and must not be looked up as one. + public static func error(caption: LocalizedStringResource) -> Self { + .init( + style: .toast(icon: .error, autoHide: true), + title: TextState(localized: .error), + message: TextState(localized: caption) + ) + } + public static func error(caption: String? = nil) -> Self { + .init( + style: .toast(icon: .error, autoHide: true), + title: TextState(localized: .error), + message: caption.map { TextState($0) } + ) + } + public static func success(caption: LocalizedStringResource) -> Self { + .init( + style: .toast(icon: .success, autoHide: true), + title: TextState(localized: .success), + message: TextState(localized: caption) + ) + } + public static func success(caption: String? = nil) -> Self { + .init( + style: .toast(icon: .success, autoHide: true), + title: TextState(localized: .success), + message: caption.map { TextState($0) } + ) + } + public static var savedToPhotoLibrary: Self { + .success(caption: .savedToPhotoLibrary) + } + public static var copiedToClipboardSucceeded: Self { + .success(caption: .copiedToClipboard) + } +} + +// MARK: - Native alert presentation +extension View { + /// Presents a **native** system alert when presentation state held in a store becomes non-`nil`, + /// mirroring TCA's `.alert(_:)`. Drive it with a presented store scope: + /// + /// ```swift + /// .appAlert($store.scope(state: \.alert, action: \.alert)) + /// ``` + /// + /// Tapping a button sends that button's action through the presentation store and dismisses the + /// alert automatically — ``AppAlertState`` is ephemeral, just like a system alert. With no + /// buttons, SwiftUI adds its own system-localized "OK". + @MainActor + public func appAlert( + _ item: Binding, Action>?> + ) -> some View { + modifier(AppAlertViewModifier(item: item, text: nil)) + } + + /// Same as ``appAlert(_:)`` but also renders the alert's ``AppAlertState/TextFieldState`` (if any), + /// binding it to `text` and auto-focusing it so the keyboard is up the moment the alert appears — + /// native `.alert` no longer focuses its first field on its own. `text` lives in the host reducer's + /// state, e.g. `.appAlert($store.scope(state: \.alert, action: \.alert), text: $store.pageIndex)`. + @MainActor + public func appAlert( + _ item: Binding, Action>?>, + text: Binding + ) -> some View { + modifier(AppAlertViewModifier(item: item, text: text)) + } +} + +private struct AppAlertViewModifier: ViewModifier { + @Binding var item: Store, Action>? + let text: Binding? + @FocusState private var isFieldFocused: Bool + + func body(content: Content) -> some View { + content.alert( + item.map { Text($0.title) } ?? Text(verbatim: ""), + isPresented: Binding( + get: { item != nil }, + set: { isPresented, transaction in + guard !isPresented, item != nil else { return } + $item.transaction(transaction).wrappedValue = nil + } + ), + presenting: item, + actions: { store in + if let textField = store.textField, let text { + TextField(String(state: textField.placeholder), text: text) + .keyboardType(textField.keyboard == .numberPad ? .numberPad : .default) + .focused($isFieldFocused) + .onAppear { + // The field isn't in the responder chain the instant it appears, so a + // synchronous focus is dropped; hop to the next runloop to make it stick. + DispatchQueue.main.async { isFieldFocused = true } + } + } + ForEach(store.buttons) { button in + Button(role: button.role.map(ButtonRole.init)) { + button.withAction { action in + if let action { store.send(action) } + } + } label: { + Text(button.label) + } + } + }, + message: { store in + if let message = store.message { + Text(message) + } + } + ) + } +} diff --git a/AppPackage/Sources/AppComponents/AppError+Symbol.swift b/AppPackage/Sources/AppComponents/AppError+Symbol.swift new file mode 100644 index 000000000..241d139b7 --- /dev/null +++ b/AppPackage/Sources/AppComponents/AppError+Symbol.swift @@ -0,0 +1,25 @@ +import AppModels +import SFSafeSymbols + +extension AppError { + public var symbol: SFSymbol { + switch self { + case .ipBanned: + return .networkBadgeShieldHalfFilled + case .copyrightClaim, .expunged: + return .trashCircleFill + case .networkingFailed: + return .wifiExclamationmark + case .parseFailed: + return .rectangleAndTextMagnifyingglass + case .quotaExceeded: + return .gaugeWithDotsNeedle67percent + case .authenticationRequired: + return .lockCircleFill + case .fileOperationFailed: + return .folderFill + case .notFound, .unknown, .noUpdates, .webImageFailed: + return .questionmarkCircleFill + } + } +} diff --git a/EhPanda/View/Support/Components/CategoryView.swift b/AppPackage/Sources/AppComponents/CategoryView.swift similarity index 71% rename from EhPanda/View/Support/Components/CategoryView.swift rename to AppPackage/Sources/AppComponents/CategoryView.swift index aa70d331c..846a0ff22 100644 --- a/EhPanda/View/Support/Components/CategoryView.swift +++ b/AppPackage/Sources/AppComponents/CategoryView.swift @@ -1,21 +1,18 @@ -// -// CategoryView.swift -// EhPanda -// - import SwiftUI +import AppModels +import AppTools // MARK: CategoryLabel -struct CategoryLabel: View { - private let text: String +public struct CategoryLabel: View { + private let text: LocalizedStringResource private let color: Color private let font: Font private let insets: EdgeInsets private let cornerRadius: CGFloat private let corners: UIRectCorner - init( - text: String, color: Color, font: Font = .footnote, + public init( + text: LocalizedStringResource, color: Color, font: Font = .footnote, insets: EdgeInsets = .init(top: 1, leading: 3, bottom: 1, trailing: 3), cornerRadius: CGFloat = 2, corners: UIRectCorner = .allCorners ) { @@ -27,7 +24,7 @@ struct CategoryLabel: View { self.corners = corners } - var body: some View { + public var body: some View { Text(text).font(font.bold()).lineLimit(1).foregroundStyle(.white) .padding(insets).background( Rectangle().foregroundStyle(color).cornerRadius(cornerRadius, corners: corners) @@ -36,24 +33,24 @@ struct CategoryLabel: View { } // MARK: CategoryView -struct CategoryView: View { +public struct CategoryView: View { private let bindings: [Binding] private let gridItems = [ GridItem(.adaptive(minimum: DeviceUtil.isPadWidth ? 100 : 80, maximum: 100)) ] - private var tuples: [(Binding, Category)] { - Category.allFiltersCases.enumerated().map { value in + private var tuples: [(Binding, AppModels.Category)] { + AppModels.Category.allFiltersCases.enumerated().map { value in (bindings[value.offset], value.element) } } - init?(bindings: [Binding]) { + public init?(bindings: [Binding]) { guard bindings.count == 10 else { return nil } self.bindings = bindings } - var body: some View { + public var body: some View { LazyVGrid(columns: gridItems) { ForEach(tuples, id: \.1) { isFiltered, category in CategoryCell(isFiltered: isFiltered, category: category) @@ -66,17 +63,18 @@ struct CategoryView: View { // MARK: CategoryCell private struct CategoryCell: View { @Binding private var isFiltered: Bool - private let category: Category + private let category: AppModels.Category - init(isFiltered: Binding, category: Category) { + init(isFiltered: Binding, category: AppModels.Category) { _isFiltered = isFiltered self.category = category } var body: some View { + let color = category.color(host: AppUtil.galleryHost) ZStack { Rectangle() - .foregroundColor(isFiltered ? category.color.opacity(0.3) : category.color) + .foregroundColor(isFiltered ? color.opacity(0.3) : color) Text(category.value).bold().foregroundStyle(.white) .padding(.vertical, 5).lineLimit(1) } diff --git a/AppPackage/Sources/AppComponents/DownloadBadge+Symbol.swift b/AppPackage/Sources/AppComponents/DownloadBadge+Symbol.swift new file mode 100644 index 000000000..730eb544a --- /dev/null +++ b/AppPackage/Sources/AppComponents/DownloadBadge+Symbol.swift @@ -0,0 +1,15 @@ +import AppModels +import SFSafeSymbols + +extension DownloadBadge { + public var symbol: SFSymbol { + switch status { + case .active: .playFill + case .queued: .listDash + case .inactive: .pauseFill + case .completed: .checkmarkCircleFill + case .updateAvailable: .arrowUpCircleFill + case .error: .exclamationmarkTriangleFill + } + } +} diff --git a/AppPackage/Sources/AppComponents/ListNoticeView.swift b/AppPackage/Sources/AppComponents/ListNoticeView.swift new file mode 100644 index 000000000..d777fb187 --- /dev/null +++ b/AppPackage/Sources/AppComponents/ListNoticeView.swift @@ -0,0 +1,27 @@ +import SwiftUI +import SFSafeSymbols + +/// A leading, non-interactive footnote notice (e.g. a size-cap explanation) meant to sit as a row +/// inside a `List`. Rendering it in the list, rather than pinning it above the list via +/// `.safeAreaInset`, keeps it scrolling with the content and leaves the navigation title intact. +public struct ListNoticeView: View { + private let notice: LocalizedStringResource + + public init(notice: LocalizedStringResource) { + self.notice = notice + } + + public var body: some View { + Label( + title: { + Text(notice) + .font(.footnote) + }, + icon: { + Image(systemSymbol: .infoCircle) + .imageScale(.small) + } + ) + .foregroundStyle(.secondary) + } +} diff --git a/EhPanda/View/Support/NewDawnView.swift b/AppPackage/Sources/AppComponents/NewDawnView.swift similarity index 87% rename from EhPanda/View/Support/NewDawnView.swift rename to AppPackage/Sources/AppComponents/NewDawnView.swift index 00667b549..debf890b7 100644 --- a/EhPanda/View/Support/NewDawnView.swift +++ b/AppPackage/Sources/AppComponents/NewDawnView.swift @@ -1,11 +1,9 @@ -// -// NewDawnView.swift -// EhPanda -// - import SwiftUI +import AppModels +import Resources +import AppTools -struct NewDawnView: View { +public struct NewDawnView: View { @Environment(\.colorScheme) private var colorScheme private let greeting: Greeting @@ -24,12 +22,12 @@ struct NewDawnView: View { } } - init(greeting: Greeting) { + public init(greeting: Greeting) { self.greeting = greeting } // MARK: NewDawnView - var body: some View { + public var body: some View { ZStack { LinearGradient( gradient: Gradient(colors: gradientColors), @@ -49,8 +47,8 @@ struct NewDawnView: View { } VStack(spacing: 50) { VStack(spacing: 10) { - TextView(text: L10n.Localizable.NewDawnView.Title.first, font: .largeTitle) - TextView(text: L10n.Localizable.NewDawnView.Title.second, font: .title2) + TextView(text: .first, font: .largeTitle) + TextView(text: .second, font: .title2) } TextView(text: greeting.gainContent ?? "", font: .title3, fontWeight: .bold) } @@ -78,6 +76,12 @@ private struct TextView: View { self.fontWeight = fontWeight } + // Resource overload for the static greeting lines; the `String` init above remains for the + // dynamic gain content. + init(text: LocalizedStringResource, font: Font, fontWeight: Font.Weight = .bold) { + self.init(text: String(localized: text), font: font, fontWeight: fontWeight) + } + var body: some View { HStack { Text(text) @@ -158,7 +162,7 @@ private struct SunBeamItem: View { struct NewDawnView_Previews: PreviewProvider { static var previews: some View { - Text("") + Color.clear .sheet(isPresented: .constant(true)) { NewDawnView(greeting: .mock) } diff --git a/AppPackage/Sources/AppComponents/Placeholder.swift b/AppPackage/Sources/AppComponents/Placeholder.swift new file mode 100644 index 000000000..61015c953 --- /dev/null +++ b/AppPackage/Sources/AppComponents/Placeholder.swift @@ -0,0 +1,49 @@ +import SwiftUI +import AppModels +import AppTools + +public struct Placeholder: View { + @Environment(\.inSheet) private var inSheet + private let style: PlaceholderStyle + + public init(style: PlaceholderStyle) { + self.style = style + } + + public var body: some View { + switch style { + case .activity(let ratio, let cornerRadius): + ZStack { + Color(inSheet ? .systemGray4 : .systemGray5) + + ProgressView() + } + .aspectRatio(ratio, contentMode: .fill) + .cornerRadius(cornerRadius) + + case .progress(let pageNumber, let progress, let isDualPage, let backgroundColor): + ZStack { + backgroundColor + VStack { + Text(String(pageNumber)) + .font(.largeTitle.bold()) + .foregroundColor(.gray) + .padding(.bottom, 30) + + if let progress { + ProgressView(progress) + .progressViewStyle(.plainLinear) + .frame(width: DeviceUtil.absWindowW * (isDualPage ? 0.25 : 0.5)) + } else { + ProgressView() + } + } + } + } + } +} + +public enum PlaceholderStyle { + case activity(ratio: CGFloat, cornerRadius: CGFloat = 5) + case progress(pageNumber: Int, progress: Progress?, isDualPage: Bool = false, backgroundColor: Color) +} diff --git a/AppPackage/Sources/AppComponents/PreviewImageView.swift b/AppPackage/Sources/AppComponents/PreviewImageView.swift new file mode 100644 index 000000000..7d6c31363 --- /dev/null +++ b/AppPackage/Sources/AppComponents/PreviewImageView.swift @@ -0,0 +1,159 @@ +import SwiftUI +import AppModels +import ImageIO +import Kingfisher +import AppTools + +public struct PreviewImageView: View { + private let originalURL: URL? + private let maxPixelSize: CGFloat + public static let defaultMaxPixelSize = Defaults.ImageSize.previewMaxW * 3 + + public init( + originalURL: URL?, + maxPixelSize: CGFloat = PreviewImageView.defaultMaxPixelSize + ) { + self.originalURL = originalURL + self.maxPixelSize = maxPixelSize + } + + public var body: some View { + if let originalURL, originalURL.isFileURL { + LocalPreviewImageView(fileURL: originalURL, maxPixelSize: maxPixelSize) { + Placeholder(style: .activity(ratio: Defaults.ImageSize.previewAspect)) + } + } else { + let (url, modifier) = PreviewResolver.getPreviewConfigs(originalURL: originalURL) + if let url { + KFImage.url( + url, + cacheKey: url.stableImageCacheKey + ?? originalURL?.stableImageCacheKey + ?? originalURL?.absoluteString + ) + .placeholder { + Placeholder(style: .activity(ratio: Defaults.ImageSize.previewAspect)) + } + .imageModifier(modifier) + .fade(duration: 0.25) + .resizable() + .scaledToFit() + } else { + Placeholder(style: .activity(ratio: Defaults.ImageSize.previewAspect)) + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + } + } +} + +private struct LocalPreviewImageView: View { + private let fileURL: URL + private let maxPixelSize: CGFloat + private let placeholder: Placeholder + + @State private var thumbnail: UIImage? + + init( + fileURL: URL, + maxPixelSize: CGFloat, + @ViewBuilder placeholder: () -> Placeholder + ) { + self.fileURL = fileURL + self.maxPixelSize = maxPixelSize + self.placeholder = placeholder() + } + + private var cacheKey: String { + let resourceValues = try? fileURL.resourceValues(forKeys: [ + .contentModificationDateKey, + .fileSizeKey + ]) + let modificationStamp = resourceValues?.contentModificationDate? + .timeIntervalSinceReferenceDate ?? .zero + let fileSize = resourceValues?.fileSize ?? 0 + return "\(fileURL.path)#\(Int(maxPixelSize))#\(fileSize)#\(modificationStamp)" + } + + var body: some View { + Group { + if let thumbnail { + Image(uiImage: thumbnail) + .resizable() + .scaledToFit() + .clipShape(.rect(cornerRadius: 5)) + } else { + placeholder + } + } + .task(id: cacheKey) { + await loadThumbnail() + } + } + + @MainActor + private func loadThumbnail() async { + if let cachedThumbnail = LocalPreviewThumbnailCache.shared.image(forKey: cacheKey) { + thumbnail = cachedThumbnail + return + } + + // Decode off the main actor via `@concurrent`; awaiting it directly (instead + // of a detached task) lets the SwiftUI `.task` cancellation propagate, so a + // cell that scrolls off screen sheds its in-flight decode rather than piling + // up un-sheddable work during fast scrolling. + let generatedThumbnail = await LocalPreviewThumbnailGenerator.make( + fileURL: fileURL, + maxPixelSize: maxPixelSize + ) + + guard !Task.isCancelled else { return } + if let generatedThumbnail { + LocalPreviewThumbnailCache.shared.store(generatedThumbnail, forKey: cacheKey) + } + thumbnail = generatedThumbnail + } + +} + +private enum LocalPreviewThumbnailGenerator { + @concurrent + static func make(fileURL: URL, maxPixelSize: CGFloat) async -> UIImage? { + guard !Task.isCancelled, + let imageSource = CGImageSourceCreateWithURL(fileURL as CFURL, nil) else { + return nil + } + + let options: [CFString: Any] = [ + kCGImageSourceCreateThumbnailFromImageAlways: true, + kCGImageSourceShouldCacheImmediately: false, + kCGImageSourceCreateThumbnailWithTransform: true, + kCGImageSourceThumbnailMaxPixelSize: max(Int(maxPixelSize.rounded(.up)), 1) + ] + + guard !Task.isCancelled, + let imageRef = CGImageSourceCreateThumbnailAtIndex( + imageSource, + .zero, + options as CFDictionary + ) else { + return nil + } + + return UIImage(cgImage: imageRef) + } +} + +@MainActor +private final class LocalPreviewThumbnailCache { + static let shared = LocalPreviewThumbnailCache() + + private let cache = NSCache() + + func image(forKey key: String) -> UIImage? { + cache.object(forKey: key as NSString) + } + + func store(_ image: UIImage, forKey key: String) { + cache.setObject(image, forKey: key as NSString) + } +} diff --git a/EhPanda/View/Detail/Components/RatingView.swift b/AppPackage/Sources/AppComponents/RatingView.swift similarity index 95% rename from EhPanda/View/Detail/Components/RatingView.swift rename to AppPackage/Sources/AppComponents/RatingView.swift index 56098966a..c70882316 100644 --- a/EhPanda/View/Detail/Components/RatingView.swift +++ b/AppPackage/Sources/AppComponents/RatingView.swift @@ -1,18 +1,15 @@ -// -// RatingView.swift -// EhPanda -// - import SwiftUI +import SFSafeSymbols +import AppTools -struct RatingView: View { +public struct RatingView: View { private let rawRating: Float - init(rating: Float) { + public init(rating: Float) { self.rawRating = rating } - var body: some View { + public var body: some View { HStack(spacing: 0) { if rating == 0.0 { ForEach(0..<5) { _ in NotFilledStar() } diff --git a/AppPackage/Sources/AppComponents/Resources/Localizable.xcstrings b/AppPackage/Sources/AppComponents/Resources/Localizable.xcstrings new file mode 100644 index 000000000..6c474b5c3 --- /dev/null +++ b/AppPackage/Sources/AppComponents/Resources/Localizable.xcstrings @@ -0,0 +1,606 @@ +{ + "sourceLanguage": "en", + "strings": { + "communicating": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Communicating..." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Verbinde..." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "通信中..." + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "접속 중..." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "通信中..." + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "連線中..." + } + } + } + }, + "copied_to_clipboard": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Copied to clipboard" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "In Zwischenablage kopiert" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "クリップボードにコピーしました" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "클립보드에 복사되었어요" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "已复制到剪切板" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "已複製到剪貼簿" + } + } + } + }, + "error": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Error" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Fehler" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "エラー" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "실패" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "错误" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "錯誤" + } + } + } + }, + "first": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "It is the dawn of a new day!" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Es ist der Beginn eines neuen Tages!" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "新しい一日の夜明けです!" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "새로운 하루가 시작되었어요!" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "又是全新的一天!" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "現在是嶄新的一天!" + } + } + } + }, + "loading": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Loading..." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Wird geladen..." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "読み込み中..." + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "로딩 중..." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "加载中..." + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "載入中..." + } + } + } + }, + "matches_count": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "%#@count@" + }, + "substitutions": { + "count": { + "argNum": 1, + "formatSpecifier": "lld", + "variations": { + "plural": { + "one": { + "stringUnit": { + "state": "translated", + "value": "Found %arg match." + } + }, + "other": { + "stringUnit": { + "state": "translated", + "value": "Found %arg matches." + } + } + } + } + } + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "%#@count@" + }, + "substitutions": { + "count": { + "argNum": 1, + "formatSpecifier": "lld", + "variations": { + "plural": { + "one": { + "stringUnit": { + "state": "translated", + "value": "%arg Treffer gefunden." + } + }, + "other": { + "stringUnit": { + "state": "translated", + "value": "%arg Treffer gefunden." + } + } + } + } + } + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "%#@count@" + }, + "substitutions": { + "count": { + "argNum": 1, + "formatSpecifier": "lld", + "variations": { + "plural": { + "other": { + "stringUnit": { + "state": "translated", + "value": "%arg 件の該当項目" + } + } + } + } + } + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "%#@count@" + }, + "substitutions": { + "count": { + "argNum": 1, + "formatSpecifier": "lld", + "variations": { + "plural": { + "other": { + "stringUnit": { + "state": "translated", + "value": "검색 결과 %arg개" + } + } + } + } + } + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "%#@count@" + }, + "substitutions": { + "count": { + "argNum": 1, + "formatSpecifier": "lld", + "variations": { + "plural": { + "other": { + "stringUnit": { + "state": "translated", + "value": "找到 %arg 项结果" + } + } + } + } + } + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "%#@count@" + }, + "substitutions": { + "count": { + "argNum": 1, + "formatSpecifier": "lld", + "variations": { + "plural": { + "other": { + "stringUnit": { + "state": "translated", + "value": "找到 %arg 項結果" + } + } + } + } + } + } + } + } + }, + "need_login": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "You need to login to access this feature." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Du musst dich einloggen, um diese Funktion nutzen zu können." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "本機能をご利用になるにはログインが必要です" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "이 기능을 사용하려면 로그인이 필요해요." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "你需要登录才能使用该功能" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "你需要登入才能完成這個動作" + } + } + } + }, + "saved_to_photo_library": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Saved to photo library" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "In der Fotomediathek gesichert" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ライブラリに保存しました" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "이미지 저장" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "已保存到图库" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "已儲存到照片" + } + } + } + }, + "second": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Reflecting on your journey so far, you find that you are a little wiser." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Als du auf deine bisherige Reise zurückblickst wirst du ein kleines bisschen weiser." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "今までの歩みを振り返り、少し賢くなった気がする。" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "지금까지의 여정을 돌이켜보면, 당신은 조금 더 현명해진 것 같죠?" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "回顾至今的历程,发觉自己更睿智了一些。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "回顧到目前為止的旅程,你發現自己睿智了一點。" + } + } + } + }, + "show_all": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Show all" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Alle anzeigen" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "すべて表示" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "모두 보기" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "显示全部" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "顯示全部" + } + } + } + }, + "success": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Success" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Erfolg" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "成功" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "성공" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "成功" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "成功" + } + } + } + }, + "toast.loading": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Loading..." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Wird geladen..." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "読み込み中..." + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "로딩 중..." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "加载中..." + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "載入中..." + } + } + } + } + }, + "version": "1.0" +} \ No newline at end of file diff --git a/AppPackage/Sources/AppComponents/SettingTextField.swift b/AppPackage/Sources/AppComponents/SettingTextField.swift new file mode 100644 index 000000000..7cc08a0c2 --- /dev/null +++ b/AppPackage/Sources/AppComponents/SettingTextField.swift @@ -0,0 +1,46 @@ +import SwiftUI + +public struct SettingTextField: View { + @Environment(\.colorScheme) private var colorScheme + + @Binding private var text: String + private let title: LocalizedStringResource + private let promptText: LocalizedStringResource? + private let width: CGFloat? + private let alignment: TextAlignment + private let background: Color? + + private var color: Color { + if let background = background { return background } + return colorScheme == .light ? Color(.systemGray6) : Color(.systemGray3) + } + private var prompt: Text? { + guard let text = promptText else { return nil } + return Text(text) + } + + public init( + text: Binding, title: LocalizedStringResource, + promptText: LocalizedStringResource? = nil, width: CGFloat? = 50, + alignment: TextAlignment = .center, background: Color? = nil + ) { + _text = text + self.title = title + self.promptText = promptText + self.width = width + self.alignment = alignment + self.background = background + } + + public var body: some View { + // A non-empty, localized label keeps VoiceOver informative; `.labelsHidden()` keeps the + // field's appearance unchanged (only the prompt shows). Avoids an empty `""` title literal. + TextField(text: $text, prompt: prompt) { + Text(title) + } + .labelsHidden() + .keyboardType(.numbersAndPunctuation) + .textInputAutocapitalization(.none).multilineTextAlignment(alignment) + .disableAutocorrection(true).background(color).frame(width: width).cornerRadius(5) + } +} diff --git a/EhPanda/View/Support/Components/SubSection.swift b/AppPackage/Sources/AppComponents/SubSection.swift similarity index 85% rename from EhPanda/View/Support/Components/SubSection.swift rename to AppPackage/Sources/AppComponents/SubSection.swift index 4a9ec67c7..a27cf94f4 100644 --- a/EhPanda/View/Support/Components/SubSection.swift +++ b/AppPackage/Sources/AppComponents/SubSection.swift @@ -1,12 +1,9 @@ -// -// SubSection.swift -// EhPanda -// - import SwiftUI +import Resources +import AppTools -struct SubSection: View { - private let title: String +public struct SubSection: View { + private let title: LocalizedStringResource private let showAll: Bool private let tint: Color? private let isLoading: Bool? @@ -14,8 +11,8 @@ struct SubSection: View { private let showAllAction: () -> Void private let content: Content - init( - title: String, showAll: Bool = true, + public init( + title: LocalizedStringResource, showAll: Bool = true, tint: Color? = nil, isLoading: Bool? = nil, reloadAction: (() -> Void)? = nil, showAllAction: @escaping () -> Void = {}, @@ -30,7 +27,7 @@ struct SubSection: View { self.content = content() } - var body: some View { + public var body: some View { VStack(alignment: .leading) { HStack { Button { @@ -48,7 +45,7 @@ struct SubSection: View { .foregroundColor(.primary) Spacer() Button(action: showAllAction) { - Text(L10n.Localizable.SubSection.Button.showAll).font(.subheadline) + Text(.showAll).font(.subheadline) } .tint(tint).opacity(showAll ? 1 : 0) } diff --git a/AppPackage/Sources/AppComponents/TagCloudView.swift b/AppPackage/Sources/AppComponents/TagCloudView.swift new file mode 100644 index 000000000..2d7882450 --- /dev/null +++ b/AppPackage/Sources/AppComponents/TagCloudView.swift @@ -0,0 +1,127 @@ +import SwiftUI +import SFSafeSymbols +import Kingfisher +import AppTools + +public struct TagCloudView: View +where TagCell: View, Element: Equatable & Identifiable, ID == Element.ID { + private let data: [Element] + private let id: KeyPath + private let spacing: Double + private let content: (Element) -> TagCell + + public init( + data: Data, id: KeyPath = \Element.id, spacing: Double = 4, + @ViewBuilder content: @escaping (Element) -> TagCell + ) where Data.Index == Int, Data.Element == Element { + self.data = .init(data) + self.id = id + self.spacing = spacing + self.content = content + } + + public var body: some View { + FlowLayout(spacing: spacing) { + ForEach(data, id: id) { element in + content(element) + } + } + } +} + +private struct FlowLayout: Layout { + let spacing: Double + + func sizeThatFits( + proposal: ProposedViewSize, + subviews: Subviews, + cache: inout () + ) -> CGSize { + let frames = frames( + for: subviews, + maxWidth: proposal.width ?? .infinity + ) + let size = frames.reduce(CGSize.zero) { size, frame in + CGSize( + width: max(size.width, frame.maxX), + height: max(size.height, frame.maxY) + ) + } + return CGSize(width: proposal.width ?? size.width, height: size.height) + } + + func placeSubviews( + in bounds: CGRect, + proposal: ProposedViewSize, + subviews: Subviews, + cache: inout () + ) { + let frames = frames(for: subviews, maxWidth: bounds.width) + for (index, subview) in subviews.enumerated() { + subview.place( + at: CGPoint( + x: bounds.minX + frames[index].minX, + y: bounds.minY + frames[index].minY + ), + proposal: ProposedViewSize(frames[index].size) + ) + } + } + + private func frames(for subviews: Subviews, maxWidth: CGFloat) -> [CGRect] { + var frames = [CGRect]() + var origin = CGPoint.zero + var rowHeight = CGFloat.zero + let maxWidth = maxWidth.isFinite ? maxWidth : .greatestFiniteMagnitude + let spacing = CGFloat(spacing) + + for subview in subviews { + let size = subview.sizeThatFits(.unspecified) + if origin.x > 0, origin.x + size.width > maxWidth { + origin.x = 0 + origin.y += rowHeight + spacing + rowHeight = 0 + } + + frames.append(CGRect(origin: origin, size: size)) + origin.x += size.width + spacing + rowHeight = max(rowHeight, size.height) + } + return frames + } +} + +public struct TagCloudCell: View { + private let text: String + private let imageURL: URL? + private let showsImages: Bool + private let font: Font + private let padding: EdgeInsets + private let textColor: Color + private let backgroundColor: Color + + public init( + text: String, imageURL: URL?, showsImages: Bool, font: Font, + padding: EdgeInsets, textColor: Color, backgroundColor: Color + ) { + self.text = text + self.imageURL = imageURL + self.showsImages = showsImages + self.font = font + self.padding = padding + self.textColor = textColor + self.backgroundColor = backgroundColor + } + + public var body: some View { + HStack(spacing: 2) { + Text(showsImages ? text : text.emojisRipped) + if let imageURL = imageURL, showsImages { + Image(systemSymbol: .photo).opacity(0) + .overlay(KFImage(imageURL).resizable().scaledToFit()) + } + } + .font(font.bold()).lineLimit(1).foregroundColor(textColor) + .padding(padding).background(backgroundColor).cornerRadius(5) + } +} diff --git a/EhPanda/View/Support/Components/TagSuggestionView.swift b/AppPackage/Sources/AppComponents/TagSuggestionView.swift similarity index 72% rename from EhPanda/View/Support/Components/TagSuggestionView.swift rename to AppPackage/Sources/AppComponents/TagSuggestionView.swift index 1d1b1184f..8ef2f519a 100644 --- a/EhPanda/View/Support/Components/TagSuggestionView.swift +++ b/AppPackage/Sources/AppComponents/TagSuggestionView.swift @@ -1,30 +1,31 @@ -// -// TagSuggestionView.swift -// EhPanda -// - import SwiftUI +import SFSafeSymbols +import AppModels +import TagTranslationFeature +import Resources import Kingfisher +import Observation +import AppTools -struct TagSuggestionView: View { +public struct TagSuggestionView: View { @Binding private var keyword: String private let translations: [String: TagTranslation] private let showsImages: Bool private let isEnabled: Bool - @StateObject private var translationHandler = TagTranslationHandler() + @State private var translationHandler = TagTranslationHandler() - init(keyword: Binding, translations: [String: TagTranslation], showsImages: Bool, isEnabled: Bool) { + public init(keyword: Binding, translations: [String: TagTranslation], showsImages: Bool, isEnabled: Bool) { _keyword = keyword self.translations = translations self.showsImages = showsImages self.isEnabled = isEnabled } - var body: some View { + public var body: some View { if isEnabled { if DeviceUtil.isPhone { - Text(L10n.Localizable.Searchable.Title.matchesCount(translationHandler.suggestions.count)) + Text(.matchesCount(count: translationHandler.suggestions.count)) .foregroundColor(.secondary) .font(.subheadline) } @@ -61,6 +62,23 @@ private struct SuggestionCell: View { return showsImages ? value : value.emojisRipped } + // Two-line label for the search-completion suggestion, built as an `AttributedString` rather + // than an interpolated `Text("\(…)\n\(…)")` so Xcode does not extract a "%@\n%@" catalog key. + // Markdown is parsed inline (matching `Text(_ key: LocalizedStringKey)` above) since the tag + // strings are dynamic content, not localization keys. + private var searchCompletionLabel: AttributedString { + var label = Self.markdown(displayValue) + label.append(AttributedString("\n")) + label.append(Self.markdown(suggestion.displayKey)) + return label + } + private static func markdown(_ string: String) -> AttributedString { + (try? AttributedString( + markdown: string, + options: .init(interpretedSyntax: .inlineOnlyPreservingWhitespace) + )) ?? AttributedString(string) + } + var body: some View { if DeviceUtil.isPhone { HStack(spacing: 20) { @@ -92,18 +110,20 @@ private struct SuggestionCell: View { Spacer() } - .contentShape(Rectangle()) + .contentShape(.rect) .onTapGesture(perform: action) } else { - (Text(displayValue.localizedKey) + Text("\n") + Text(suggestion.displayKey.localizedKey)) + Text(searchCompletionLabel) .searchCompletion(suggestion.tag.searchKeyword) } } } // MARK: TagTranslationHandler -final class TagTranslationHandler: ObservableObject { - @Published var suggestions = [TagSuggestion]() +@Observable +@MainActor +final class TagTranslationHandler { + var suggestions = [TagSuggestion]() func analyze(text: inout String, translations: [String: TagTranslation]) { let keyword = text.replacingOccurrences(of: " +", with: " ", options: .regularExpression) @@ -124,8 +144,14 @@ final class TagTranslationHandler: ObservableObject { for index in (lastCompletedTagIndex ?? 0).. [TagSuggestion] { - let originalKeyword = keyword + private func getSuggestions( + translations: [String: TagTranslation], + keyword: String, + originalKeyword: String + ) -> [TagSuggestion] { var keyword = keyword var namespace: String? let namespaceAbbreviations = TagNamespace.abbreviations diff --git a/AppPackage/Sources/AppComponents/TextState+LocalizedStringResource.swift b/AppPackage/Sources/AppComponents/TextState+LocalizedStringResource.swift new file mode 100644 index 000000000..8c53aa96c --- /dev/null +++ b/AppPackage/Sources/AppComponents/TextState+LocalizedStringResource.swift @@ -0,0 +1,12 @@ +import Foundation +import ComposableArchitecture + +public extension TextState { + /// Builds a `TextState` from a `LocalizedStringResource`, resolving it eagerly the same way a + /// `String(localized:)`-wrapped construction would. The `localized:` label keeps this out of + /// unlabeled overload resolution, so it never competes with `TextState`'s string-literal and + /// `LocalizedStringKey` initializers inside `ButtonState`/`ConfirmationDialogState` builders. + init(localized resource: LocalizedStringResource) { + self.init(verbatim: String(localized: resource)) + } +} diff --git a/EhPanda/View/Support/Components/ToolbarItems.swift b/AppPackage/Sources/AppComponents/ToolbarItems.swift similarity index 62% rename from EhPanda/View/Support/Components/ToolbarItems.swift rename to AppPackage/Sources/AppComponents/ToolbarItems.swift index ef4d850e6..d62dd5599 100644 --- a/EhPanda/View/Support/Components/ToolbarItems.swift +++ b/AppPackage/Sources/AppComponents/ToolbarItems.swift @@ -1,19 +1,19 @@ -// -// ToolbarItems.swift -// EhPanda -// - import SwiftUI +import Sharing +import SFSafeSymbols +import AppModels +import Resources +import SFSafeSymbolsExt -struct CustomToolbarItem: ToolbarContent { +public struct CustomToolbarItem: ToolbarContent { private let placement: ToolbarItemPlacement private let tint: Color? private let disabled: Bool private let content: Content - init(placement: ToolbarItemPlacement = .navigationBarTrailing, - tint: Color? = nil, disabled: Bool = false, - @ViewBuilder content: () -> Content + public init(placement: ToolbarItemPlacement = .navigationBarTrailing, + tint: Color? = nil, disabled: Bool = false, + @ViewBuilder content: () -> Content ) { self.placement = placement self.tint = tint @@ -21,7 +21,7 @@ struct CustomToolbarItem: ToolbarContent { self.content = content() } - var body: some ToolbarContent { + public var body: some ToolbarContent { ToolbarItem(placement: placement) { HStack(spacing: 14) { content @@ -32,16 +32,16 @@ struct CustomToolbarItem: ToolbarContent { } } -struct ToolbarFeaturesMenu: View { +public struct ToolbarFeaturesMenu: View { private let content: Content private let symbolRenderingMode: SymbolRenderingMode - init(symbolRenderingMode: SymbolRenderingMode = .monochrome, @ViewBuilder content: () -> Content) { + public init(symbolRenderingMode: SymbolRenderingMode = .monochrome, @ViewBuilder content: () -> Content) { self.content = content() self.symbolRenderingMode = symbolRenderingMode } - var body: some View { + public var body: some View { Menu { content } label: { @@ -51,78 +51,96 @@ struct ToolbarFeaturesMenu: View { } } -struct FiltersButton: View { +public struct FiltersButton: View { private let hideText: Bool private let action: () -> Void - init(hideText: Bool = false, action: @escaping () -> Void) { + public init(hideText: Bool = false, action: @escaping () -> Void) { self.hideText = hideText self.action = action } - var body: some View { + public var body: some View { Button(action: action) { Image(systemSymbol: .line3HorizontalDecrease) if !hideText { - Text(L10n.Localizable.ToolbarItem.Button.filters) + Text(.RLocalizable.filters) } } } } -struct QuickSearchButton: View { +public struct QuickSearchButton: View { private let hideText: Bool private let action: () -> Void - init(hideText: Bool = false, action: @escaping () -> Void) { + public init(hideText: Bool = false, action: @escaping () -> Void) { self.hideText = hideText self.action = action } - var body: some View { + public var body: some View { Button(action: action) { Image(systemSymbol: .magnifyingglass) if !hideText { - Text(L10n.Localizable.ToolbarItem.Button.quickSearch) + Text(.RLocalizable.quickSearch) } } } } -struct JumpPageButton: View { +public struct JumpPageButton: View { private let pageNumber: PageNumber private let hideText: Bool private let action: () -> Void - init(pageNumber: PageNumber, hideText: Bool = false, action: @escaping () -> Void) { + public init(pageNumber: PageNumber, hideText: Bool = false, action: @escaping () -> Void) { self.pageNumber = pageNumber self.hideText = hideText self.action = action } - var body: some View { + public var body: some View { Button(action: action) { Image(systemSymbol: .arrowshapeBounceForward) if !hideText { - Text(L10n.Localizable.ToolbarItem.Button.jumpPage) + Text(.RLocalizable.jumpPage) } } .disabled(pageNumber.isSinglePage) } } -struct FavoritesIndexMenu: View { - private let user: User +public struct DateSeekButton: View { + private let navigation: DateSeekNavigation? + private let action: (DateSeekNavigation) -> Void + + public init(navigation: DateSeekNavigation?, action: @escaping (DateSeekNavigation) -> Void) { + self.navigation = navigation + self.action = action + } + + public var body: some View { + Button { + navigation.map(action) + } label: { + Label(.RLocalizable.dateSeek, systemSymbol: .calendar) + } + .disabled(navigation == nil) + } +} + +public struct FavoritesIndexMenu: View { + @SharedReader(.user) private var user: User private let index: Int private let action: (Int) -> Void - init(user: User, index: Int, action: @escaping (Int) -> Void) { - self.user = user + public init(index: Int, action: @escaping (Int) -> Void) { self.index = index self.action = action } - var body: some View { + public var body: some View { Menu { ForEach(-1..<10) { index in Button { @@ -141,16 +159,16 @@ struct FavoritesIndexMenu: View { } } -struct ToplistsTypeMenu: View { +public struct ToplistsTypeMenu: View { private let type: ToplistsType private let action: (ToplistsType) -> Void - init(type: ToplistsType, action: @escaping (ToplistsType) -> Void) { + public init(type: ToplistsType, action: @escaping (ToplistsType) -> Void) { self.type = type self.action = action } - var body: some View { + public var body: some View { Menu { ForEach(ToplistsType.allCases) { type in Button { @@ -169,16 +187,16 @@ struct ToplistsTypeMenu: View { } } -struct SortOrderMenu: View { +public struct SortOrderMenu: View { private let sortOrder: FavoritesSortOrder? private let action: (FavoritesSortOrder) -> Void - init(sortOrder: FavoritesSortOrder?, action: @escaping (FavoritesSortOrder) -> Void) { + public init(sortOrder: FavoritesSortOrder?, action: @escaping (FavoritesSortOrder) -> Void) { self.sortOrder = sortOrder self.action = action } - var body: some View { + public var body: some View { Menu { ForEach(FavoritesSortOrder.allCases) { order in Button { diff --git a/EhPanda/App/Tools/Extensions/ViewModifiers.swift b/AppPackage/Sources/AppComponents/ViewModifiers.swift similarity index 66% rename from EhPanda/App/Tools/Extensions/ViewModifiers.swift rename to AppPackage/Sources/AppComponents/ViewModifiers.swift index cf2be369e..26a76605a 100644 --- a/EhPanda/App/Tools/Extensions/ViewModifiers.swift +++ b/AppPackage/Sources/AppComponents/ViewModifiers.swift @@ -1,23 +1,21 @@ -// -// ViewModifiers.swift -// EhPanda -// - import SwiftUI import Kingfisher +import SFSafeSymbols +import AppTools +import ParserFeature extension View { - func cornerRadius(_ radius: CGFloat, corners: UIRectCorner) -> some View { + public func cornerRadius(_ radius: CGFloat, corners: UIRectCorner) -> some View { clipShape(RoundedCorner(radius: radius, corners: corners)) } - @ViewBuilder func withHorizontalSpacing(width: CGFloat = 8, height: CGFloat? = nil) -> some View { + @ViewBuilder public func withHorizontalSpacing(width: CGFloat = 8, height: CGFloat? = nil) -> some View { Color.clear.frame(width: width, height: height) self Color.clear.frame(width: width, height: height) } - func withArrow(isVisible: Bool = true) -> some View { + public func withArrow(isVisible: Bool = true) -> some View { HStack { self Spacer() @@ -28,13 +26,13 @@ extension View { } } - func autoBlur(radius: Double) -> some View { + public func autoBlur(radius: Double) -> some View { blur(radius: radius) .allowsHitTesting(radius < 1) .animation(.linear(duration: 0.1), value: radius) } - func synchronize( + public func synchronize( _ first: Binding, _ second: Binding, initial: (first: Bool, second: Bool) = (false, false) @@ -48,7 +46,7 @@ extension View { } } - func synchronize( + public func synchronize( _ first: Binding, _ second: FocusState.Binding, initial: (first: Bool, second: Bool) = (false, false) @@ -63,26 +61,28 @@ extension View { } } -struct PlainLinearProgressViewStyle: ProgressViewStyle { - func makeBody(configuration: ProgressViewStyleConfiguration) -> some View { +public struct PlainLinearProgressViewStyle: ProgressViewStyle { + public init() {} + + public func makeBody(configuration: ProgressViewStyleConfiguration) -> some View { ProgressView(value: CGFloat(configuration.fractionCompleted ?? 0), total: 1) } } extension ProgressViewStyle where Self == PlainLinearProgressViewStyle { - static var plainLinear: PlainLinearProgressViewStyle { + public static var plainLinear: PlainLinearProgressViewStyle { PlainLinearProgressViewStyle() } } // MARK: Image Modifier -struct CornersModifier: ImageModifier { +public struct CornersModifier: ImageModifier { let radius: CGFloat? - init(radius: CGFloat? = nil) { + public init(radius: CGFloat? = nil) { self.radius = radius } - func modify(_ image: KFCrossPlatformImage) -> KFCrossPlatformImage { + public func modify(_ image: KFCrossPlatformImage) -> KFCrossPlatformImage { if let radius = radius { return image.withRoundedCorners(radius: radius) ?? image } else { @@ -91,16 +91,16 @@ struct CornersModifier: ImageModifier { } } -struct OffsetModifier: ImageModifier { +public struct OffsetModifier: ImageModifier { private let size: CGSize? private let offset: CGSize? - init(size: CGSize?, offset: CGSize?) { + public init(size: CGSize?, offset: CGSize?) { self.size = size self.offset = offset } - func modify(_ image: KFCrossPlatformImage) -> KFCrossPlatformImage { + public func modify(_ image: KFCrossPlatformImage) -> KFCrossPlatformImage { guard let size = size, let offset = offset else { return image } @@ -108,16 +108,16 @@ struct OffsetModifier: ImageModifier { } } -struct RoundedOffsetModifier: ImageModifier { +public struct RoundedOffsetModifier: ImageModifier { private let size: CGSize? private let offset: CGSize? - init(size: CGSize?, offset: CGSize?) { + public init(size: CGSize?, offset: CGSize?) { self.size = size self.offset = offset } - func modify(_ image: KFCrossPlatformImage) -> KFCrossPlatformImage { + public func modify(_ image: KFCrossPlatformImage) -> KFCrossPlatformImage { guard let size = size, let offset = offset, let croppedImg = image.cropping(size: size, offset: offset), let roundedCroppedImg = croppedImg.withRoundedCorners(radius: 5) @@ -127,16 +127,16 @@ struct RoundedOffsetModifier: ImageModifier { } } -struct WebtoonModifier: ImageModifier { +public struct WebtoonModifier: ImageModifier { private let minAspect: CGFloat private let idealAspect: CGFloat - init(minAspect: CGFloat, idealAspect: CGFloat) { + public init(minAspect: CGFloat, idealAspect: CGFloat) { self.minAspect = minAspect self.idealAspect = idealAspect } - func modify(_ image: KFCrossPlatformImage) -> KFCrossPlatformImage { + public func modify(_ image: KFCrossPlatformImage) -> KFCrossPlatformImage { let width = image.size.width let height = image.size.height let idealHeight = width / idealAspect @@ -146,7 +146,7 @@ struct WebtoonModifier: ImageModifier { } extension KFImage { - func defaultModifier(withRoundedCorners: Bool = true) -> KFImage { + public func defaultModifier(withRoundedCorners: Bool = true) -> KFImage { self .imageModifier(CornersModifier( radius: withRoundedCorners ? 5 : nil @@ -156,11 +156,16 @@ extension KFImage { } } -struct RoundedCorner: Shape { +public struct RoundedCorner: Shape { var radius: CGFloat = .infinity var corners: UIRectCorner = .allCorners - func path(in rect: CGRect) -> Path { + public init(radius: CGFloat = .infinity, corners: UIRectCorner = .allCorners) { + self.radius = radius + self.corners = corners + } + + public func path(in rect: CGRect) -> Path { let path = UIBezierPath( roundedRect: rect, byRoundingCorners: corners, @@ -173,13 +178,13 @@ struct RoundedCorner: Shape { } } -struct PreviewResolver { - static func getPreviewConfigs(originalURL: URL?) -> (URL?, ImageModifier) { +public struct PreviewResolver { + public static func getPreviewConfigs(originalURL: URL?) -> (URL?, ImageModifier) { guard let url = originalURL, - let (plainURL, size, offset) = Parser.parsePreviewConfigs(url: url) + let info = Parser.parsePreviewConfigs(url: url) else { return (originalURL, RoundedOffsetModifier(size: nil, offset: nil)) } - return (plainURL, RoundedOffsetModifier(size: size, offset: offset)) + return (info.plainURL, RoundedOffsetModifier(size: info.size, offset: info.offset)) } } diff --git a/AppPackage/Sources/AppDelegateClient/.swiftlint.yml b/AppPackage/Sources/AppDelegateClient/.swiftlint.yml new file mode 100644 index 000000000..1242ffcaa --- /dev/null +++ b/AppPackage/Sources/AppDelegateClient/.swiftlint.yml @@ -0,0 +1 @@ +parent_config: ../../../.swiftlint.yml diff --git a/AppPackage/Sources/AppDelegateClient/AppDelegateClient.swift b/AppPackage/Sources/AppDelegateClient/AppDelegateClient.swift new file mode 100644 index 000000000..d9c405e4d --- /dev/null +++ b/AppPackage/Sources/AppDelegateClient/AppDelegateClient.swift @@ -0,0 +1,61 @@ +import SwiftUI +import ComposableArchitecture +import AppTools + +public struct AppDelegateClient: Sendable { + public let setOrientation: @MainActor @Sendable (UIInterfaceOrientationMask) -> Void + public let setOrientationMask: @MainActor @Sendable (UIInterfaceOrientationMask) -> Void +} + +extension AppDelegateClient { + public static let live: Self = .init( + setOrientation: { mask in + DeviceUtil.keyWindow?.windowScene?.requestGeometryUpdate(.iOS(interfaceOrientations: mask)) + }, + setOrientationMask: { mask in + AppOrientationMask.current = mask + } + ) + + @MainActor + public func setPortraitOrientation() { + setOrientation(.portrait) + } + @MainActor + public func setAllOrientationMask() { + setOrientationMask([.all]) + } + @MainActor + public func setPortraitOrientationMask() { + setOrientationMask([.portrait, .portraitUpsideDown]) + } +} + +// MARK: API +public enum AppDelegateClientKey: DependencyKey { + public static let liveValue = AppDelegateClient.live + public static let previewValue = AppDelegateClient.noop + public static let testValue = AppDelegateClient.unimplemented +} + +extension DependencyValues { + public var appDelegateClient: AppDelegateClient { + get { self[AppDelegateClientKey.self] } + set { self[AppDelegateClientKey.self] = newValue } + } +} + +// MARK: Test +extension AppDelegateClient { + public static let noop: Self = .init( + setOrientation: { _ in }, + setOrientationMask: { _ in } + ) + + public static func placeholder() -> Result { fatalError() } + + public static let unimplemented: Self = .init( + setOrientation: IssueReporting.unimplemented(placeholder: placeholder()), + setOrientationMask: IssueReporting.unimplemented(placeholder: placeholder()) + ) +} diff --git a/AppPackage/Sources/AppDelegateClient/AppOrientationMask.swift b/AppPackage/Sources/AppDelegateClient/AppOrientationMask.swift new file mode 100644 index 000000000..352bda4b1 --- /dev/null +++ b/AppPackage/Sources/AppDelegateClient/AppOrientationMask.swift @@ -0,0 +1,11 @@ +import UIKit +import AppTools + +/// The app's current supported-interface-orientation mask. The orientation lock is +/// owned by the orientation-setting client (written through `AppDelegateClient`), and +/// the app delegate reads this back from `application(_:supportedInterfaceOrientationsFor:)`. +@MainActor +public enum AppOrientationMask { + public static var current: UIInterfaceOrientationMask = + DeviceUtil.isPad ? .all : [.portrait, .portraitUpsideDown] +} diff --git a/AppPackage/Sources/AppFeature/.swiftlint.yml b/AppPackage/Sources/AppFeature/.swiftlint.yml new file mode 100644 index 000000000..1242ffcaa --- /dev/null +++ b/AppPackage/Sources/AppFeature/.swiftlint.yml @@ -0,0 +1 @@ +parent_config: ../../../.swiftlint.yml diff --git a/AppPackage/Sources/AppFeature/DataFlow/AppDelegateReducer.swift b/AppPackage/Sources/AppFeature/DataFlow/AppDelegateReducer.swift new file mode 100644 index 000000000..265d643bb --- /dev/null +++ b/AppPackage/Sources/AppFeature/DataFlow/AppDelegateReducer.swift @@ -0,0 +1,113 @@ +import AppModels +import SwiftUI +import Sharing +import BackgroundTasks +import ComposableArchitecture +import AppTools +import AppDelegateClient +import LibraryClient +import DownloadClient +import BackgroundProcessingClient +import CookieClient +import OSLogExt + +private let logger = Logger(category: .init(describing: AppDelegateReducer.self)) + +@Reducer +struct AppDelegateReducer { + @ObservableState + struct State: Equatable {} + + enum Action: Equatable { + case onLaunchFinish + } + + @Dependency(\.libraryClient) private var libraryClient + @Dependency(\.cookieClient) private var cookieClient + + var body: some Reducer { + Reduce { _, action in + switch action { + case .onLaunchFinish: + return .merge( + // Enforce the browsing-history cap once per launch (in-session upserts never trim). + // Runs off the launch path as a background effect — decoding + re-encoding up to + // 1,000 entries shouldn't block `didFinishLaunching`; nothing reads history at start. + .run { _ in + @Shared(.galleryHistory) var galleryHistory + $galleryHistory.withLock { $0.pruneToHistoryCap() } + }, + .run(operation: { _ in libraryClient.initializeWebImage() }), + .run(operation: { _ in cookieClient.removeYay() }), + .run(operation: { _ in cookieClient.syncExCookies() }), + .run(operation: { _ in cookieClient.ignoreOffensive() }), + .run(operation: { _ in cookieClient.fulfillAnotherHostField() }) + ) + } + } + } +} + +// MARK: AppDelegate +public class AppDelegate: UIResponder, UIApplicationDelegate { + let store = Store(initialState: .init(), reducer: AppReducer.init) + + public override init() { + super.init() + } + + public func application( + _ application: UIApplication, supportedInterfaceOrientationsFor window: UIWindow? + ) -> UIInterfaceOrientationMask { AppOrientationMask.current } + + public func application( + _ application: UIApplication, didFinishLaunchingWithOptions + launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil + ) -> Bool { + if !AppUtil.isTesting { + store.send(.appDelegate(.onLaunchFinish)) + // Must register before launch completes so iOS can relaunch us later to + // drain the download queue in a discretionary background window. + BackgroundProcessingClient.live.register { task in + AppDelegate.handleProcessingTask(task) + } + } + return true + } + + /// Drains the download queue in the granted background window. On expiration the + /// in-flight work is cancelled and a fresh request is scheduled so iOS can hand the + /// remaining work back later. + @MainActor + static func handleProcessingTask(_ task: BGProcessingTask) { + @Dependency(\.downloadClient) var downloadClient + @Dependency(\.backgroundProcessingClient) var backgroundProcessingClient + + let work = Task { @MainActor in + logger.notice("Background processing started.") + await downloadClient.runBackgroundProcessing() + // Reschedule only if we stopped on our own with work still pending; an + // expiration cancels this task and reschedules from its own handler. + if !Task.isCancelled, await downloadClient.hasPendingWork() { + backgroundProcessingClient.schedule() + } + task.setTaskCompleted(success: !Task.isCancelled) + logger.notice("Background processing finished, cancelled: \(Task.isCancelled, privacy: .public).") + } + task.expirationHandler = { + work.cancel() + backgroundProcessingClient.schedule() + } + } + + public func application( + _ application: UIApplication, + handleEventsForBackgroundURLSession identifier: String, + completionHandler: @escaping () -> Void + ) { + DownloadBackgroundSessionEvents.setCompletionHandler( + completionHandler, + for: identifier + ) + } +} diff --git a/EhPanda/DataFlow/AppLockReducer.swift b/AppPackage/Sources/AppFeature/DataFlow/AppLockReducer.swift similarity index 94% rename from EhPanda/DataFlow/AppLockReducer.swift rename to AppPackage/Sources/AppFeature/DataFlow/AppLockReducer.swift index 14f67490b..3b7c88061 100644 --- a/EhPanda/DataFlow/AppLockReducer.swift +++ b/AppPackage/Sources/AppFeature/DataFlow/AppLockReducer.swift @@ -1,10 +1,7 @@ -// -// AppLockReducer.swift -// EhPanda -// - import SwiftUI +import Resources import ComposableArchitecture +import AuthorizationClient @Reducer struct AppLockReducer { @@ -36,8 +33,7 @@ struct AppLockReducer { switch action { case .onBecomeActive(let threshold, let blurRadius): if let date = state.becameInactiveDate, threshold >= 0, - Date.now.timeIntervalSince(date) >= Double(threshold) - { + Date.now.timeIntervalSince(date) >= Double(threshold) { return .merge( .send(.authorize), .send(.lockApp(blurRadius)) @@ -64,7 +60,7 @@ struct AppLockReducer { case .authorize: return .run { send in - let success = await authorizationClient.localAuthroize(L10n.Localizable.LocalAuthorization.reason) + let success = await authorizationClient.localAuthroize(String(localized: .autoLockReason)) await send(.authorizeDone(success)) } diff --git a/AppPackage/Sources/AppFeature/DataFlow/AppReducer.swift b/AppPackage/Sources/AppFeature/DataFlow/AppReducer.swift new file mode 100644 index 000000000..4ec8043b4 --- /dev/null +++ b/AppPackage/Sources/AppFeature/DataFlow/AppReducer.swift @@ -0,0 +1,423 @@ +import SwiftUI +import ComposableArchitecture +import URLClient +import HapticsClient +import DownloadClient +import BackgroundProcessingClient +import CookieClient +import AppLaunchAutomationClient +import DeviceClient +import HomeFeature +import SearchFeature +import FavoritesFeature +import DownloadsFeature +import SettingFeature +import OSLogExt +import NetworkingFeature +import AppModels + +private let logger = Logger(category: .init(describing: AppReducer.self)) + +@Reducer +struct AppReducer { + @ObservableState + struct State: Equatable { + var appDelegateState = AppDelegateReducer.State() + var appRouteState = AppRouteReducer.State() + var appLockState = AppLockReducer.State() + var tabBarState = TabBarReducer.State() + var homeState = HomeReducer.State() + var favoritesState = FavoritesReducer.State() + var searchRootState = SearchRootReducer.State() + var downloadsState = DownloadsReducer.State() + var settingState = SettingReducer.State() + var appLogsPumpState = AppActivityLogsPumpReducer.State() + var scenePhase = ScenePhase.active + var hasEnteredBackground = false + var didRunLaunchAutomation = false + var isAwaitingIgneousForLaunchAutomation = false + } + + enum Action: BindableAction { + case binding(BindingAction) + case onScenePhaseChange(ScenePhase) + case runLaunchAutomation + case fetchWatchedTags + case fetchWatchedTagsDone(Result, apiuid: String) + + case appDelegate(AppDelegateReducer.Action) + case appRoute(AppRouteReducer.Action) + case appLock(AppLockReducer.Action) + + case tabBar(TabBarReducer.Action) + + case home(HomeReducer.Action) + case favorites(FavoritesReducer.Action) + case searchRoot(SearchRootReducer.Action) + case downloads(DownloadsReducer.Action) + case setting(SettingReducer.Action) + case appLogsPump(AppActivityLogsPumpReducer.Action) + } + + @Dependency(\.hapticsClient) private var hapticsClient + @Dependency(\.cookieClient) private var cookieClient + @Dependency(\.deviceClient) private var deviceClient + @Dependency(\.downloadClient) private var downloadClient + @Dependency(\.backgroundProcessingClient) private var backgroundProcessingClient + @Dependency(\.appLaunchAutomationClient) private var appLaunchAutomationClient + @Dependency(\.urlClient) private var urlClient + + var body: some Reducer { + BindingReducer() + .onChange(of: \.appRouteState.destination) { oldValue, state in + // iPad presents Setting as a modal sheet; when it's dismissed, reset its navigation + // stack so reopening starts at the root. + if oldValue?.setting != nil, state.appRouteState.destination == nil { + state.settingState.path.removeAll() + } + return .none + } + + Reduce { state, action in + switch action { + case .binding: + return .none + + case .onScenePhaseChange(let scenePhase): + state.scenePhase = scenePhase + guard state.settingState.hasLoadedInitialSetting else { return .none } + + switch scenePhase { + case .active: + let threshold = state.settingState.setting.autoLockPolicy.rawValue + let blurRadius = state.settingState.setting.backgroundBlurRadius + var effects: [Effect] = [ + .send(.appLock(.onBecomeActive(threshold, blurRadius))), + .send(.appLogsPump(.startPump)), + .run { _ in logger.notice("App entered foreground.") } + ] + if !cookieClient.apiuid.isEmpty { + effects.append( + .run { send in + if await WatchedTagsSetting.shared.needsRefresh(apiuid: cookieClient.apiuid) { + await send(.fetchWatchedTags) + } + } + ) + } + // iOS interposes .inactive on a foreground return + // (.background -> .inactive -> .active), so the previous + // phase is never .background here. Latch the background + // entry instead: reconcile once per cycle, never on a + // transient .inactive blip (Control Center, notifications). + if state.hasEnteredBackground { + state.hasEnteredBackground = false + effects.append( + .run { _ in + await downloadClient.reconcileDownloads() + } + ) + } + return .merge(effects) + + case .inactive: + let blurRadius = state.settingState.setting.backgroundBlurRadius + return .send(.appLock(.onBecomeInactive(blurRadius))) + + case .background: + state.hasEnteredBackground = true + // Ask iOS for a later background window to finish the queue; the + // beginBackgroundTask assertion only covers the brief grace + // period right after backgrounding. + var effects: [Effect] = [ + .send(.appLogsPump(.pausePump)), + .run { _ in + logger.notice("App entered background.") + if await downloadClient.hasPendingWork() { + backgroundProcessingClient.schedule() + } + } + ] + // Backgrounding fires no reader `onDisappear`/dismiss, so flush the active reading + // session's last debounced page here — otherwise a force-quit from the background + // drops it. The reader is located from navigation state (see `readingFlushEffects`). + effects.append(contentsOf: readingFlushEffects(state)) + return .merge(effects) + + default: + return .none + } + + case .runLaunchAutomation: + guard !state.didRunLaunchAutomation, + let automation = appLaunchAutomationClient.current() + else { return .none } + + state.didRunLaunchAutomation = true + return .run { send in + if let galleryURL = automation.galleryURL, + urlClient.checkIfHandleable(galleryURL) { + await send(.appRoute(.handleDeepLink(galleryURL))) + } else if let initialTab = automation.initialTab { + await send(.tabBar(.setTabBarItemType(initialTab))) + } + } + + case .fetchWatchedTags: + return .run { send in + let apiuid = cookieClient.apiuid + let result = await MyTagsRequest().response() + await send(.fetchWatchedTagsDone(result, apiuid: apiuid)) + } + + case .fetchWatchedTagsDone(.success(let response), apiuid: let apiuid): + let tagSet = TagSetInfo( + number: 1, + name: "", + enable: true, + backgroundColor: response.tagSetBackgroundColor, + tags: response.tags + ) + return .run { _ in + await WatchedTagsSetting.shared.updateTagSet(tagSet, apiuid: apiuid) + } + case .fetchWatchedTagsDone(.failure(let error), apiuid: _): + return .none + + case .appDelegate(.onLaunchFinish): + // Import any launch-automation cookies and load the persisted settings straight away. + let loginCookies = appLaunchAutomationClient.current()?.loginCookies + return .merge( + .send(.appLogsPump(.startPump)), + .run { send in + if let loginCookies { + cookieClient.importAutomationCookies( + memberID: loginCookies.memberID, + passHash: loginCookies.passHash, + igneous: loginCookies.igneous + ) + } + await send(.setting(.loadUserSettings)) + } + ) + + case .appDelegate: + return .none + + case .appRoute: + return .none + + case .appLock(.unlockApp): + var effects: [Effect] = [ + .send(.setting(.fetchGreeting)) + ] + if state.settingState.setting.detectsLinksFromClipboard { + effects.append(.send(.appRoute(.detectClipboardURL))) + } + return .merge(effects) + + case .appLock: + return .none + + case .tabBar(.setTabBarItemType(let type)): + var effects = [Effect]() + let hapticEffect: Effect = .run { _ in + await hapticsClient.generateFeedback(.soft) + } + if type == state.tabBarState.tabBarItemType { + switch type { + case .home: + if !state.homeState.path.isEmpty { + state.homeState.path.removeAll() + } else { + effects.append(.send(.home(.fetchAllGalleries))) + } + case .favorites: + if !state.favoritesState.path.isEmpty { + state.favoritesState.path.removeAll() + effects.append(hapticEffect) + } else if cookieClient.didLogin { + effects.append(.send(.favorites(.fetchGalleries()))) + effects.append(hapticEffect) + } + case .search: + if !state.searchRootState.path.isEmpty { + state.searchRootState.path.removeAll() + } else { + // Keywords/quick-search words are live via @Shared now; re-tapping the + // Search tab at its root refreshes the recently-viewed galleries instead. + effects.append(.send(.searchRoot(.fetchHistoryGalleries))) + } + case .downloads: + if !state.downloadsState.path.isEmpty { + state.downloadsState.path.removeAll() + } else { + effects.append(.send(.downloads(.fetchDownloads))) + } + effects.append(hapticEffect) + case .setting: + if !state.settingState.path.isEmpty { + state.settingState.path.removeAll() + effects.append(hapticEffect) + } + } + if [.home, .search].contains(type) { + effects.append(hapticEffect) + } + } + return effects.isEmpty ? .none : .merge(effects) + + case .tabBar: + return .none + + case .home(.path(.element(id: _, action: .watched(.onNotLoginViewButtonTapped)))), + .favorites(.onNotLoginViewButtonTapped): + var effects: [Effect] = [ + .run(operation: { _ in await hapticsClient.generateFeedback(.soft) }), + .send(.tabBar(.setTabBarItemType(.setting))) + ] + effects.append(.send(.setting(.settingRowTapped(.account)))) + if !cookieClient.didLogin { + effects.append( + .run { send in + let isPad = await deviceClient.isPad() + let delay = UInt64(isPad ? 1200 : 200) + try await Task.sleep(for: .milliseconds(delay)) + await send(.setting(.pushLogin)) + } + ) + } + return .merge(effects) + + // A gallery tapped on iPad presents modally (hosted by AppRoute) instead of pushing + // inline; the tab hosts delegate that presentation up here. + case let .home(.delegate(.presentGalleryDetail(gallery))), + let .searchRoot(.delegate(.presentGalleryDetail(gallery))), + let .favorites(.delegate(.presentGalleryDetail(gallery))): + return .send(.appRoute(.presentGalleryDetail(gallery, nil))) + + case let .downloads(.delegate(.presentGalleryDetail(gallery, download))): + return .send(.appRoute(.presentGalleryDetail(gallery, download))) + + case .home: + return .none + + case .favorites: + return .none + + case .searchRoot: + return .none + + case .downloads: + return .none + + case .setting(.loadUserSettingsDone): + var effects = [Effect]() + let threshold = state.settingState.setting.autoLockPolicy.rawValue + let blurRadius = state.settingState.setting.backgroundBlurRadius + if threshold >= 0 { + state.appLockState.becameInactiveDate = .distantPast + effects.append(.send(.appLock(.onBecomeActive(threshold, blurRadius)))) + } + if state.settingState.setting.detectsLinksFromClipboard { + effects.append(.send(.appRoute(.detectClipboardURL))) + } + state.isAwaitingIgneousForLaunchAutomation = shouldDelayLaunchAutomationUntilIgneous( + state: state + ) + if !state.isAwaitingIgneousForLaunchAutomation { + effects.append(.send(.runLaunchAutomation)) + } + return effects.isEmpty ? .none : .merge(effects) + + case .setting(.igneousRefreshed): + guard state.isAwaitingIgneousForLaunchAutomation, + !shouldDelayLaunchAutomationUntilIgneous(state: state) + else { return .none } + state.isAwaitingIgneousForLaunchAutomation = false + return .send(.runLaunchAutomation) + + case .setting(.fetchGreetingDone(let result)): + return .send(.appRoute(.fetchGreetingDone(result))) + + case .setting: + return .none + + case .appLogsPump: + return .none + } + } + + Scope(state: \.appRouteState, action: \.appRoute, child: AppRouteReducer.init) + Scope(state: \.appLockState, action: \.appLock, child: AppLockReducer.init) + Scope(state: \.appDelegateState, action: \.appDelegate, child: AppDelegateReducer.init) + Scope(state: \.tabBarState, action: \.tabBar, child: TabBarReducer.init) + Scope(state: \.homeState, action: \.home, child: HomeReducer.init) + Scope(state: \.favoritesState, action: \.favorites, child: FavoritesReducer.init) + Scope(state: \.searchRootState, action: \.searchRoot, child: SearchRootReducer.init) + Scope(state: \.downloadsState, action: \.downloads, child: DownloadsReducer.init) + Scope(state: \.settingState, action: \.setting, child: SettingReducer.init) + Scope(state: \.appLogsPumpState, action: \.appLogsPump, child: AppActivityLogsPumpReducer.init) + } +} + +private extension AppReducer { + /// Flush actions for every reading session currently on top of a navigation host, so a background + /// force-quit persists each reader's last debounced page. A reader is presented as a `.reading` + /// destination of `.detail`/`.previews` (elements of the gallery stacks) or, for the Downloads tab + /// and the iPad/deep-link modal, directly as a host destination. Any new host that can present a + /// `.reading` destination must be registered here. + func readingFlushEffects(_ state: State) -> [Effect] { + var effects: [Effect] = [] + + // GalleryPath stacks whose top element presents a reader. + if let (id, action) = state.appRouteState.path.topReadingFlush { + effects.append(.send(.appRoute(.path(.element(id: id, action: action))))) + } + if let (id, action) = state.favoritesState.path.topReadingFlush { + effects.append(.send(.favorites(.path(.element(id: id, action: action))))) + } + if let (id, action) = state.downloadsState.path.topReadingFlush { + effects.append(.send(.downloads(.path(.element(id: id, action: action))))) + } + + // Home / SearchRoot nest the gallery stack under a `.gallery` element. + if let id = state.homeState.path.ids.last, + case .gallery(let gallery)? = state.homeState.path[id: id], + let action = gallery.readingFlushAction { + effects.append(.send(.home(.path(.element(id: id, action: .gallery(action)))))) + } + if let id = state.searchRootState.path.ids.last, + case .gallery(let gallery)? = state.searchRootState.path[id: id], + let action = gallery.readingFlushAction { + effects.append(.send(.searchRoot(.path(.element(id: id, action: .gallery(action)))))) + } + + // The iPad/deep-link modal detail and the Downloads tab present a reader directly. + if state.appRouteState.detail?.destination?.reading != nil { + effects.append(.send(.appRoute(.detail(.presented( + .destination(.presented(.reading(.flushReadingProgress))) + ))))) + } + if state.downloadsState.destination?.reading != nil { + effects.append(.send(.downloads(.destination(.presented(.reading(.flushReadingProgress)))))) + } + + return effects + } + + func shouldDelayLaunchAutomationUntilIgneous(state: State) -> Bool { + guard !state.didRunLaunchAutomation, + cookieClient.shouldFetchIgneous, + let automation = appLaunchAutomationClient.current() + else { return false } + + if let galleryURL = automation.galleryURL, + galleryURL.host?.contains("exhentai.org") == true { + return true + } + + return automation.autoDownloadGID != nil + && state.settingState.setting.galleryHost == .exhentai + } +} diff --git a/AppPackage/Sources/AppFeature/DataFlow/AppRouteReducer.swift b/AppPackage/Sources/AppFeature/DataFlow/AppRouteReducer.swift new file mode 100644 index 000000000..be044ff28 --- /dev/null +++ b/AppPackage/Sources/AppFeature/DataFlow/AppRouteReducer.swift @@ -0,0 +1,225 @@ +import AppTools +import SwiftUI +import AppModels +import Sharing +import ComposableArchitecture +import URLClient +import UserDefaultsClient +import HapticsClient +import NetworkingFeature +import ClipboardClient +import AppComponents +import DetailFeature + +@Reducer +struct AppRouteReducer { + @Reducer + enum Destination { + @ReducerCaseIgnored + case setting(EquatableVoid) + @ReducerCaseIgnored + case newDawn(Greeting) + } + + @ObservableState + struct State: Equatable { + @Presents var toast: AppAlertState? + // The deep-link/clipboard gallery, presented modally as the root of its own gallery stack. + @Presents var detail: DetailReducer.State? + var path = StackState() + @Presents var destination: Destination.State? + + init() {} + } + + enum Action: BindableAction { + case binding(BindingAction) + case toast(PresentationAction) + case destination(PresentationAction) + case detail(PresentationAction) + case path(StackActionOf) + case presentSetting + case presentNewDawn(Greeting) + case presentGalleryDetail(Gallery, DownloadedGallery?) + case setToast(AppAlertState) + + case detectClipboardURL + case handleDeepLink(URL) + case handleGalleryLink(URL, Gallery) + + case updateReadingProgress(gid: String, token: String, progress: Int) + + case fetchGallery(URL, Bool) + case fetchGalleryDone(URL, Result) + case fetchGreetingDone(Result) + } + + @Dependency(\.userDefaultsClient) private var userDefaultsClient + @Dependency(\.clipboardClient) private var clipboardClient + @Dependency(\.hapticsClient) private var hapticsClient + @Dependency(\.urlClient) private var urlClient + @Dependency(\.date) private var date + + var body: some Reducer { + BindingReducer() + + Reduce { state, action in + switch action { + case .binding: + return .none + + case .toast: + return .none + + case .destination: + return .none + + case .detail(.dismiss): + state.path.removeAll() + return .none + + case let .detail(.presented(.delegate(delegate))): + if let next = GalleryNavigation.nextScreen(for: .detail(.delegate(delegate))) { + state.path.appendGuardingDuplicate(next) + } + return .none + + case .detail: + return .none + + case let .path(.element(id: _, action: .comments(.delegate(.performedCommentAction(gid))))): + if state.detail?.gid == gid { + return .send(.detail(.presented(.fetchGalleryDetail))) + } + guard let id = state.path.detailID(forGID: gid) else { return .none } + return .send(.path(.element(id: id, action: .detail(.fetchGalleryDetail)))) + + case let .path(.element(id: _, action: elementAction)): + if let next = GalleryNavigation.nextScreen(for: elementAction) { + state.path.appendGuardingDuplicate(next) + } + return .none + + case .path: + return .none + + case .presentSetting: + state.destination = .setting(.init()) + return .none + + case .presentNewDawn(let greeting): + state.destination = .newDawn(greeting) + return .none + + case .presentGalleryDetail(let gallery, let download): + // A gallery opened from a tab on iPad: modal detail rooting its own gallery stack, + // seeded from the local download when one exists so it renders offline, otherwise + // from the tapped gallery. + state.path.removeAll() + if let download { + state.detail = .init(seededFrom: download) + } else { + state.detail = .init(gallery: gallery) + } + return .none + + case .setToast(let config): + state.toast = config + return .none + + case .detectClipboardURL: + let currentChangeCount = clipboardClient.changeCount() + guard currentChangeCount != userDefaultsClient + .getValue(.clipboardChangeCount) else { return .none } + var effects: [Effect] = [ + .run(operation: { _ in userDefaultsClient.setValue(currentChangeCount, .clipboardChangeCount) }) + ] + if let url = clipboardClient.url() { + effects.append(.send(.handleDeepLink(url))) + } + return .merge(effects) + + case .handleDeepLink(let url): + let url = urlClient.resolveAppSchemeURL(url) ?? url + guard urlClient.checkIfHandleable(url) else { return .none } + var delay = 0 + if state.detail != nil { + delay = 1000 + state.detail = nil + state.path.removeAll() + } + // Always fetch the gallery so the pushed detail is seeded from it. + let analysis = urlClient.analyzeURL(url) + return .run { [delay] send in + try await Task.sleep(for: .milliseconds(delay)) + await send(.fetchGallery(url, analysis.isGalleryImageURL)) + } + + case .handleGalleryLink(let url, let gallery): + let analysis = urlClient.analyzeURL(url) + let deepLink = GalleryDeepLink(pageIndex: analysis.pageIndex, commentID: analysis.commentID) + var effects = [Effect]() + if let pageIndex = analysis.pageIndex { + effects.append(.send(.updateReadingProgress( + gid: gallery.id, token: gallery.token, progress: pageIndex + ))) + } + state.path.removeAll() + state.detail = DetailReducer.State(gallery: gallery, pendingDeepLink: deepLink) + effects.append(.run(operation: { _ in await hapticsClient.generateFeedback(.light) })) + return .merge(effects) + + case let .updateReadingProgress(gid, token, progress): + // The linked gallery is in scope, so persist the real token — the entry resolves + // immediately rather than waiting for the detail screen to backfill it. Invalid + // gid/token records are rejected inside the shared mutator. + @Shared(.galleryHistory) var galleryHistory + $galleryHistory.withLock { + $0.updateReadingProgress(gid: gid, token: token, progress: progress, date: date.now) + } + return .none + + case .fetchGallery(let url, let isGalleryImageURL): + state.toast = .loading() + return .run { send in + let response = await GalleryReverseRequest( + url: url, isGalleryImageURL: isGalleryImageURL + ) + .response() + await send(.fetchGalleryDone(url, response)) + } + + case .fetchGalleryDone(let url, let result): + state.toast = nil + switch result { + case .success(let gallery): + return .send(.handleGalleryLink(url, gallery)) + case .failure: + // Let the loading toast animate out before showing the error toast. + return .run { send in + try await Task.sleep(for: .milliseconds(500)) + await send(.setToast(.error())) + } + } + + case .fetchGreetingDone(let result): + if case .success(let greeting) = result, !greeting.gainedNothing { + return .send(.presentNewDawn(greeting)) + } + return .none + } + } + .haptics( + unwrapping: \.destination, + case: \.newDawn, + hapticsClient: hapticsClient + ) + .ifLet(\.$destination, action: \.destination) + .ifLet(\.$detail, action: \.detail) { DetailReducer() } + .ifLet(\.$toast, action: \.toast) + .forEach(\.path, action: \.path) + } +} + +extension AppRouteReducer.Destination.State: Equatable, Sendable {} +extension AppRouteReducer.Destination.Action: Equatable, Sendable {} diff --git a/AppPackage/Sources/AppFeature/Logger+.swift b/AppPackage/Sources/AppFeature/Logger+.swift new file mode 100644 index 000000000..ec1c9186c --- /dev/null +++ b/AppPackage/Sources/AppFeature/Logger+.swift @@ -0,0 +1,7 @@ +import OSLogExt + +extension Logger { + init(category: String) { + self.init(moduleName: "AppFeature", category: category) + } +} diff --git a/AppPackage/Sources/AppFeature/Resources/Localizable.xcstrings b/AppPackage/Sources/AppFeature/Resources/Localizable.xcstrings new file mode 100644 index 000000000..250126972 --- /dev/null +++ b/AppPackage/Sources/AppFeature/Resources/Localizable.xcstrings @@ -0,0 +1,47 @@ +{ + "sourceLanguage": "en", + "strings": { + "auto_lock_reason": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "The App has been locked due to the Auto-Lock expiration." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Die App hat sich selbst gesperrt, da der auto-lock Zeitraum abgelaufen ist." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "自動ロック期限が切れたため、アプリがロックされています" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "자동 잠금으로 앱이 잠겼어요." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "因超过设置的自动锁定期限,App 已被锁定" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "由於超過 APP 自動鎖定期限,APP 已被鎖定,請重新解鎖" + } + } + } + } + }, + "version": "1.0" +} \ No newline at end of file diff --git a/AppPackage/Sources/AppFeature/RootView.swift b/AppPackage/Sources/AppFeature/RootView.swift new file mode 100644 index 000000000..ab262a0c3 --- /dev/null +++ b/AppPackage/Sources/AppFeature/RootView.swift @@ -0,0 +1,25 @@ +import ComposableArchitecture +import SwiftUI +import UIKit +import AppTools + +// MARK: RootView +public struct RootView: View { + private let appDelegate: AppDelegate + + public init(appDelegate: AppDelegate) { + self.appDelegate = appDelegate + } + + public var body: some View { + TabBarView(store: appDelegate.store).onAppear(perform: addTouchHandler).accentColor(.primary) + } + + private func addTouchHandler() { + DispatchQueue.main.asyncAfter(deadline: .now() + 1.5) { + let tapGesture = UITapGestureRecognizer(target: TouchHandler.shared, action: nil) + tapGesture.delegate = TouchHandler.shared + DeviceUtil.keyWindow?.addGestureRecognizer(tapGesture) + } + } +} diff --git a/EhPanda/View/TabBar/TabBarReducer.swift b/AppPackage/Sources/AppFeature/View/TabBar/TabBarReducer.swift similarity index 67% rename from EhPanda/View/TabBar/TabBarReducer.swift rename to AppPackage/Sources/AppFeature/View/TabBar/TabBarReducer.swift index 2fc33e651..faaba4def 100644 --- a/EhPanda/View/TabBar/TabBarReducer.swift +++ b/AppPackage/Sources/AppFeature/View/TabBar/TabBarReducer.swift @@ -1,9 +1,5 @@ -// -// TabBarReducer.swift -// EhPanda -// - import ComposableArchitecture +import AppModels @Reducer struct TabBarReducer { @@ -16,15 +12,11 @@ struct TabBarReducer { case setTabBarItemType(TabBarItemType) } - @Dependency(\.deviceClient) private var deviceClient - var body: some Reducer { Reduce { state, action in switch action { case .setTabBarItemType(let type): - if !deviceClient.isPad() || type != .setting { - state.tabBarItemType = type - } + state.tabBarItemType = type return .none } } diff --git a/EhPanda/View/TabBar/TabBarView.swift b/AppPackage/Sources/AppFeature/View/TabBar/TabBarView.swift similarity index 62% rename from EhPanda/View/TabBar/TabBarView.swift rename to AppPackage/Sources/AppFeature/View/TabBar/TabBarView.swift index ecaba9e50..36eaf4186 100644 --- a/EhPanda/View/TabBar/TabBarView.swift +++ b/AppPackage/Sources/AppFeature/View/TabBar/TabBarView.swift @@ -1,11 +1,17 @@ -// -// TabBarView.swift -// EhPanda -// - import SwiftUI +import AppModels +import Resources import SFSafeSymbols import ComposableArchitecture +import AppTools +import SystemNotificationExt +import AppComponents +import DetailFeature +import HomeFeature +import SearchFeature +import FavoritesFeature +import DownloadsFeature +import SettingFeature struct TabBarView: View { @Environment(\.scenePhase) private var scenePhase @@ -20,7 +26,13 @@ struct TabBarView: View { TabView( selection: .init( get: { store.tabBarState.tabBarItemType }, - set: { store.send(.tabBar(.setTabBarItemType($0))) } + set: { tab in + if tab == .setting, DeviceUtil.isPad { + store.send(.appRoute(.presentSetting)) + } else { + store.send(.tabBar(.setTabBarItemType(tab))) + } + } ) ) { ForEach(TabBarItemType.allCases) { type in @@ -29,26 +41,22 @@ struct TabBarView: View { case .home: HomeView( store: store.scope(state: \.homeState, action: \.home), - user: store.settingState.user, - setting: $store.settingState.setting, - blurRadius: store.appLockState.blurRadius, - tagTranslator: store.settingState.tagTranslator + blurRadius: store.appLockState.blurRadius ) case .favorites: FavoritesView( store: store.scope(state: \.favoritesState, action: \.favorites), - user: store.settingState.user, - setting: $store.settingState.setting, - blurRadius: store.appLockState.blurRadius, - tagTranslator: store.settingState.tagTranslator + blurRadius: store.appLockState.blurRadius ) case .search: SearchRootView( store: store.scope(state: \.searchRootState, action: \.searchRoot), - user: store.settingState.user, - setting: $store.settingState.setting, - blurRadius: store.appLockState.blurRadius, - tagTranslator: store.settingState.tagTranslator + blurRadius: store.appLockState.blurRadius + ) + case .downloads: + DownloadsView( + store: store.scope(state: \.downloadsState, action: \.downloads), + blurRadius: store.appLockState.blurRadius ) case .setting: SettingView( @@ -69,11 +77,11 @@ struct TabBarView: View { } .font(.system(size: 80)).opacity(store.appLockState.isAppLocked ? 1 : 0) } - .sheet(item: $store.appRouteState.route.sending(\.appRoute.setNavigation).newDawn) { greeting in - NewDawnView(greeting: greeting) + .sheet(item: $store.appRouteState.destination.newDawn) { greeting in + NewDawnView(greeting: greeting.wrappedValue) .autoBlur(radius: store.appLockState.blurRadius) } - .sheet(item: $store.appRouteState.route.sending(\.appRoute.setNavigation).setting) { _ in + .sheet(item: $store.appRouteState.destination.setting) { _ in SettingView( store: store.scope(state: \.settingState, action: \.setting), blurRadius: store.appLockState.blurRadius @@ -81,55 +89,45 @@ struct TabBarView: View { .accentColor(store.settingState.setting.accentColor) .autoBlur(radius: store.appLockState.blurRadius) } - .sheet(item: $store.appRouteState.route.sending(\.appRoute.setNavigation).detail, id: \.self) { route in - NavigationView { + .sheet(item: $store.scope(state: \.appRouteState.detail, action: \.appRoute.detail)) { detailStore in + NavigationStack( + path: $store.scope(state: \.appRouteState.path, action: \.appRoute.path) + ) { DetailView( - store: store.scope( - state: \.appRouteState.detailState.wrappedValue!, - action: \.appRoute.detail - ), - gid: route.wrappedValue, user: store.settingState.user, - setting: $store.settingState.setting, - blurRadius: store.appLockState.blurRadius, - tagTranslator: store.settingState.tagTranslator + store: detailStore, + gid: detailStore.gid, + blurRadius: store.appLockState.blurRadius + ) + } destination: { elementStore in + galleryDestination( + elementStore, + blurRadius: store.appLockState.blurRadius ) } .accentColor(store.settingState.setting.accentColor) .autoBlur(radius: store.appLockState.blurRadius) .environment(\.inSheet, true) - .navigationViewStyle(.stack) } - .progressHUD( - config: store.appRouteState.hudConfig, - unwrapping: $store.appRouteState.route, - case: \.hud - ) + .toast($store.scope(state: \.appRouteState.toast, action: \.appRoute.toast)) .onChange(of: scenePhase) { _, newValue in store.send(.onScenePhaseChange(newValue)) } .onOpenURL { store.send(.appRoute(.handleDeepLink($0))) } } } // MARK: TabType -enum TabBarItemType: Int, CaseIterable, Identifiable { - var id: Int { rawValue } - - case home - case favorites - case search - case setting -} - extension TabBarItemType { - var title: String { + var title: LocalizedStringResource { switch self { case .home: - return L10n.Localizable.TabItem.Title.home + return .RLocalizable.home case .favorites: - return L10n.Localizable.TabItem.Title.favorites + return .RLocalizable.favorites case .search: - return L10n.Localizable.TabItem.Title.search + return .RLocalizable.search + case .downloads: + return .RLocalizable.downloads case .setting: - return L10n.Localizable.TabItem.Title.setting + return .RLocalizable.setting } } var symbol: SFSymbol { @@ -140,6 +138,8 @@ extension TabBarItemType { return .heartCircle case .search: return .magnifyingglassCircle + case .downloads: + return .arrowDownCircle case .setting: return .gearshapeCircle } diff --git a/AppPackage/Sources/AppLaunchAutomationClient/.swiftlint.yml b/AppPackage/Sources/AppLaunchAutomationClient/.swiftlint.yml new file mode 100644 index 000000000..1242ffcaa --- /dev/null +++ b/AppPackage/Sources/AppLaunchAutomationClient/.swiftlint.yml @@ -0,0 +1 @@ +parent_config: ../../../.swiftlint.yml diff --git a/AppPackage/Sources/AppLaunchAutomationClient/AppLaunchAutomation.swift b/AppPackage/Sources/AppLaunchAutomationClient/AppLaunchAutomation.swift new file mode 100644 index 000000000..fb9115a76 --- /dev/null +++ b/AppPackage/Sources/AppLaunchAutomationClient/AppLaunchAutomation.swift @@ -0,0 +1,133 @@ +import AppTools +import Foundation +import AppModels + +public struct AppLaunchAutomation: Sendable { + public init( + initialTab: TabBarItemType? = nil, + autoDownloadGID: String? = nil, + downloadFolderName: String? = nil, + loginCookies: LoginCookies? = nil, + galleryURL: URL? = nil + ) { + self.initialTab = initialTab + self.autoDownloadGID = autoDownloadGID + self.downloadFolderName = downloadFolderName + self.loginCookies = loginCookies + self.galleryURL = galleryURL + } + public struct LoginCookies: Sendable { + public let memberID: String + public let passHash: String + public let igneous: String? + public init( + memberID: String, + passHash: String, + igneous: String? = nil + ) { + self.memberID = memberID + self.passHash = passHash + self.igneous = igneous + } + } + + public let initialTab: TabBarItemType? + public let autoDownloadGID: String? + public let downloadFolderName: String? + public let loginCookies: LoginCookies? + public let galleryURL: URL? + + public static var current: Self? { + #if DEBUG + resolve(environment: ProcessInfo.processInfo.environment) + #else + nil + #endif + } + + public static func resolve(environment: [String: String]) -> Self? { + #if DEBUG + let initialTab = environment["EHPANDA_AUTOMATION_TAB"] + .flatMap(parseTab(rawValue:)) + let autoDownloadGID = trimmedValue( + environment: environment, + key: "EHPANDA_AUTOMATION_AUTO_DOWNLOAD_GID" + ) + let downloadFolderName = trimmedValue( + environment: environment, + key: "EHPANDA_AUTOMATION_DOWNLOAD_FOLDER" + ) + let galleryURL = trimmedValue( + environment: environment, + key: "EHPANDA_AUTOMATION_GALLERY_URL" + ) + .flatMap(URL.init(string:)) + let memberID = trimmedValue( + environment: environment, + key: "EHPANDA_AUTOMATION_IPB_MEMBER_ID" + ) + let passHash = trimmedValue( + environment: environment, + key: "EHPANDA_AUTOMATION_IPB_PASS_HASH" + ) + let igneous = trimmedValue( + environment: environment, + key: "EHPANDA_AUTOMATION_IGNEOUS" + ) + let loginCookies: LoginCookies? = if let memberID, let passHash { + LoginCookies( + memberID: memberID, + passHash: passHash, + igneous: igneous + ) + } else { + nil + } + + guard initialTab != nil + || autoDownloadGID != nil + || loginCookies != nil + || galleryURL != nil + else { + return nil + } + return .init( + initialTab: initialTab, + autoDownloadGID: autoDownloadGID, + downloadFolderName: downloadFolderName, + loginCookies: loginCookies, + galleryURL: galleryURL + ) + #else + nil + #endif + } + + private static func parseTab(rawValue: String) -> TabBarItemType? { + switch rawValue + .trimmingCharacters(in: .whitespacesAndNewlines) + .lowercased() { + case "home": + return .home + case "favorites": + return .favorites + case "search": + return .search + case "downloads": + return .downloads + case "setting", "settings": + return .setting + default: + return nil + } + } + + private static func trimmedValue( + environment: [String: String], + key: String + ) -> String? { + environment[key] + .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } + .flatMap(\.nonEmpty) + } +} diff --git a/AppPackage/Sources/AppLaunchAutomationClient/AppLaunchAutomationClient.swift b/AppPackage/Sources/AppLaunchAutomationClient/AppLaunchAutomationClient.swift new file mode 100644 index 000000000..bcfc2e808 --- /dev/null +++ b/AppPackage/Sources/AppLaunchAutomationClient/AppLaunchAutomationClient.swift @@ -0,0 +1,33 @@ +import ComposableArchitecture + +@DependencyClient +public struct AppLaunchAutomationClient: Sendable { + public var current: @Sendable () -> AppLaunchAutomation? +} + +extension AppLaunchAutomationClient { + public static let live: Self = .init( + current: { + AppLaunchAutomation.current + } + ) +} + +public enum AppLaunchAutomationClientKey: DependencyKey { + public static let liveValue = AppLaunchAutomationClient.live + public static let previewValue = AppLaunchAutomationClient.none + public static let testValue = AppLaunchAutomationClient() +} + +extension DependencyValues { + public var appLaunchAutomationClient: AppLaunchAutomationClient { + get { self[AppLaunchAutomationClientKey.self] } + set { self[AppLaunchAutomationClientKey.self] = newValue } + } +} + +extension AppLaunchAutomationClient { + public static let none: Self = .init( + current: { nil } + ) +} diff --git a/AppPackage/Sources/AppModels/.swiftlint.yml b/AppPackage/Sources/AppModels/.swiftlint.yml new file mode 100644 index 000000000..1242ffcaa --- /dev/null +++ b/AppPackage/Sources/AppModels/.swiftlint.yml @@ -0,0 +1 @@ +parent_config: ../../../.swiftlint.yml diff --git a/AppPackage/Sources/AppModels/Download/DownloadBadge.swift b/AppPackage/Sources/AppModels/Download/DownloadBadge.swift new file mode 100644 index 000000000..53f5551dd --- /dev/null +++ b/AppPackage/Sources/AppModels/Download/DownloadBadge.swift @@ -0,0 +1,11 @@ +public struct DownloadBadge: Equatable, Sendable { + public init( + status: DownloadDisplayStatus, + progress: DownloadProgress + ) { + self.status = status + self.progress = progress + } + public let status: DownloadDisplayStatus + public let progress: DownloadProgress +} diff --git a/AppPackage/Sources/AppModels/Download/DownloadDisplayStatus.swift b/AppPackage/Sources/AppModels/Download/DownloadDisplayStatus.swift new file mode 100644 index 000000000..8e2f46e8d --- /dev/null +++ b/AppPackage/Sources/AppModels/Download/DownloadDisplayStatus.swift @@ -0,0 +1,27 @@ +public enum DownloadDisplayStatus: Equatable, CaseIterable, Sendable { + case active + case queued + case updateAvailable + case error + case inactive + case completed +} + +extension DownloadDisplayStatus { + public var sortPriority: Int { + switch self { + case .active: + return 0 + case .queued: + return 1 + case .updateAvailable: + return 2 + case .error: + return 3 + case .inactive: + return 4 + case .completed: + return 5 + } + } +} diff --git a/AppPackage/Sources/AppModels/Download/DownloadFailure.swift b/AppPackage/Sources/AppModels/Download/DownloadFailure.swift new file mode 100644 index 000000000..dfb208bfa --- /dev/null +++ b/AppPackage/Sources/AppModels/Download/DownloadFailure.swift @@ -0,0 +1,62 @@ +public enum DownloadFailureCode: String, Codable, Equatable, Sendable { + case quotaExceeded + case authenticationRequired + case fileOperationFailed + case ipBanned + case networkingFailed + case parseFailed + case notFound + case unknown +} + +public struct DownloadFailure: Codable, Equatable, Sendable { + public var code: DownloadFailureCode + public var message: String + + public init(code: DownloadFailureCode, message: String) { + self.code = code + self.message = message + } + + public init(error: AppError) { + switch error { + case .quotaExceeded: + self = .init(code: .quotaExceeded, message: error.alertText) + case .authenticationRequired: + self = .init(code: .authenticationRequired, message: error.alertText) + case .fileOperationFailed(let reason): + self = .init(code: .fileOperationFailed, message: reason) + case .ipBanned(let interval): + self = .init(code: .ipBanned, message: interval.description) + case .networkingFailed: + self = .init(code: .networkingFailed, message: error.alertText) + case .parseFailed: + self = .init(code: .parseFailed, message: error.alertText) + case .notFound: + self = .init(code: .notFound, message: error.alertText) + default: + self = .init(code: .unknown, message: error.alertText) + } + } + + public var appError: AppError { + switch code { + case .quotaExceeded: + return .quotaExceeded + case .authenticationRequired: + return .authenticationRequired + case .fileOperationFailed: + return .fileOperationFailed(message) + case .ipBanned: + return .ipBanned(.unrecognized(content: message)) + case .networkingFailed: + return .networkingFailed + case .parseFailed: + return .parseFailed + case .notFound: + return .notFound + case .unknown: + return .unknown + } + } +} diff --git a/AppPackage/Sources/AppModels/Download/DownloadFolderFilter.swift b/AppPackage/Sources/AppModels/Download/DownloadFolderFilter.swift new file mode 100644 index 000000000..216d99865 --- /dev/null +++ b/AppPackage/Sources/AppModels/Download/DownloadFolderFilter.swift @@ -0,0 +1,26 @@ +import Resources + +public enum DownloadFolderFilter: Equatable, Sendable { + case all + case folder(String) + + public var title: String { + switch self { + case .all: + return String(localized: .downloadFolderFilterAll) + case .folder(let name): + return name + } + } +} + +extension DownloadedGallery { + public func matches(folderFilter: DownloadFolderFilter) -> Bool { + switch folderFilter { + case .all: + return true + case .folder(let name): + return folderName == name + } + } +} diff --git a/AppPackage/Sources/AppModels/Download/DownloadInspection.swift b/AppPackage/Sources/AppModels/Download/DownloadInspection.swift new file mode 100644 index 000000000..743ea979c --- /dev/null +++ b/AppPackage/Sources/AppModels/Download/DownloadInspection.swift @@ -0,0 +1,49 @@ +import Foundation + +public enum DownloadPageStatus: String, Equatable, CaseIterable, Sendable { + case pending + case downloaded + case failed +} + +public struct DownloadPageInspection: Equatable, Identifiable, Sendable { + public init( + index: Int, + status: DownloadPageStatus, + relativePath: String? = nil, + fileURL: URL? = nil, + failure: DownloadFailure? = nil + ) { + self.index = index + self.status = status + self.relativePath = relativePath + self.fileURL = fileURL + self.failure = failure + } + public var id: Int { index } + + public let index: Int + public let status: DownloadPageStatus + public let relativePath: String? + public let fileURL: URL? + public let failure: DownloadFailure? +} + +public struct DownloadInspection: Equatable, Sendable { + public init( + download: DownloadedGallery, + coverURL: URL? = nil, + pages: [DownloadPageInspection] + ) { + self.download = download + self.coverURL = coverURL + self.pages = pages + } + public let download: DownloadedGallery + public let coverURL: URL? + public let pages: [DownloadPageInspection] + + public var failedPageIndices: [Int] { + pages.filter { $0.status == .failed }.map(\.index) + } +} diff --git a/AppPackage/Sources/AppModels/Download/DownloadProgress.swift b/AppPackage/Sources/AppModels/Download/DownloadProgress.swift new file mode 100644 index 000000000..11e5f1fde --- /dev/null +++ b/AppPackage/Sources/AppModels/Download/DownloadProgress.swift @@ -0,0 +1,22 @@ +public struct DownloadProgress: Equatable, Sendable { + public init( + completedPageCount: Int, + pageCount: Int + ) { + self.completedPageCount = completedPageCount + self.pageCount = pageCount + } + public let completedPageCount: Int + public let pageCount: Int + + public var displayPageCount: Int { + max(pageCount, 1) + } + public var displayCompletedPageCount: Int { + min(max(completedPageCount, 0), displayPageCount) + } + + public var fraction: Double { + Double(displayCompletedPageCount) / Double(displayPageCount) + } +} diff --git a/AppPackage/Sources/AppModels/Download/DownloadRequestOptions.swift b/AppPackage/Sources/AppModels/Download/DownloadRequestOptions.swift new file mode 100644 index 000000000..9beb5d794 --- /dev/null +++ b/AppPackage/Sources/AppModels/Download/DownloadRequestOptions.swift @@ -0,0 +1,24 @@ +/// Execution policy for a download: *how* to fetch (thread limit, cellular, auto-retry), +/// not *what* to fetch. Deliberately separate from `DownloadRequestPayload` and never +/// persisted to a manifest or request: it is resolved fresh from the latest settings once +/// per run (see `downloadOptionsProvider`) and threaded to the workers, so a settings change +/// while a gallery sits queued takes effect when it finally starts. +public struct DownloadRequestOptions: Equatable, Sendable { + public var threadLimit = Setting.downloadThreadLimitDefaultValue + public var allowCellular = Setting.downloadAllowCellularDefaultValue + public var autoRetryFailedPages = Setting.downloadAutoRetryFailedPagesDefaultValue + + public init( + threadLimit: Int = Setting.downloadThreadLimitDefaultValue, + allowCellular: Bool = Setting.downloadAllowCellularDefaultValue, + autoRetryFailedPages: Bool = Setting.downloadAutoRetryFailedPagesDefaultValue + ) { + self.threadLimit = threadLimit + self.allowCellular = allowCellular + self.autoRetryFailedPages = autoRetryFailedPages + } + + public var workerCount: Int { + threadLimit + } +} diff --git a/AppPackage/Sources/AppModels/Download/DownloadStartMode.swift b/AppPackage/Sources/AppModels/Download/DownloadStartMode.swift new file mode 100644 index 000000000..403e70c8f --- /dev/null +++ b/AppPackage/Sources/AppModels/Download/DownloadStartMode.swift @@ -0,0 +1,6 @@ +public enum DownloadStartMode: String, Equatable, Sendable { + case initial + case update + case redownload + case repair +} diff --git a/AppPackage/Sources/AppModels/Download/DownloadedGallery+Extensions.swift b/AppPackage/Sources/AppModels/Download/DownloadedGallery+Extensions.swift new file mode 100644 index 000000000..6553f225c --- /dev/null +++ b/AppPackage/Sources/AppModels/Download/DownloadedGallery+Extensions.swift @@ -0,0 +1,110 @@ +import AppTools +import SwiftUI + +// MARK: - DownloadBadge +extension DownloadBadge { + public var color: Color { + switch status { + case .active, .queued: .green + case .inactive, .completed: .gray + case .updateAvailable: .blue + case .error: .yellow + } + } +} + +// MARK: - DownloadRequestPayload +/// Request *identity*: *what* to download. It intentionally carries no execution options +/// (thread limit, cellular, auto-retry); those are always-latest policy resolved per run, so +/// keeping them out of the payload makes "never persisted, always fresh" a structural +/// guarantee rather than a convention. See `DownloadRequestOptions`. +public struct DownloadRequestPayload: Equatable, Sendable { + public let gallery: Gallery + public let galleryDetail: GalleryDetail + public let previewURLs: [Int: URL] + public let previewConfig: PreviewConfig + public let host: GalleryHost + public let folderName: String + public let versionMetadata: DownloadVersionMetadata? + public let mode: DownloadStartMode + public let pageSelection: Set? + + public init( + gallery: Gallery, + galleryDetail: GalleryDetail, + previewURLs: [Int: URL], + previewConfig: PreviewConfig, + host: GalleryHost, + folderName: String, + versionMetadata: DownloadVersionMetadata? = nil, + mode: DownloadStartMode, + pageSelection: Set? = nil + ) { + self.gallery = gallery + self.galleryDetail = galleryDetail + self.previewURLs = previewURLs + self.previewConfig = previewConfig + self.host = host + self.folderName = folderName + self.versionMetadata = versionMetadata + self.mode = mode + self.pageSelection = pageSelection + } +} + +// MARK: - ReadingContentSource +/// How the reader sources its pages. `.local` is an explicit *offline mode*, not a redundant +/// copy of `.remote`: it is a wholesale network kill-switch (remote mode reads a downloaded +/// file per page when present, so a single missing entry would otherwise trigger a live, +/// quota-burning H@H fetch; the offline gate prevents that for offline reads) and it carries +/// manifest metadata provenance (gallery + language seeded from the manifest, so a downloaded +/// gallery reads entirely from its local files and manifest). When the local files turn up empty +/// it auto-promotes to `.remote`. +public enum ReadingContentSource: Equatable, Sendable { + case remote + case local(DownloadedGallery, DownloadManifest) +} + +// MARK: - DownloadVersionMetadata +public struct DownloadVersionMetadata: Equatable, Codable, Sendable { + public let gid: String + public let token: String + public let currentGID: String? + public let currentKey: String? + public let parentGID: String? + public let parentKey: String? + public let firstGID: String? + public let firstKey: String? + + public init( + gid: String, + token: String, + currentGID: String?, + currentKey: String?, + parentGID: String?, + parentKey: String?, + firstGID: String?, + firstKey: String? + ) { + self.gid = gid + self.token = token + self.currentGID = currentGID + self.currentKey = currentKey + self.parentGID = parentGID + self.parentKey = parentKey + self.firstGID = firstGID + self.firstKey = firstKey + } + + public func hasUpdate(comparedTo download: DownloadedGallery) -> Bool { + (download.gid, download.token) != (resolvedCurrentGID, resolvedCurrentKey) + } + + public var resolvedCurrentGID: String { + currentGID?.nonEmpty ?? gid + } + + public var resolvedCurrentKey: String { + currentKey?.nonEmpty ?? token + } +} diff --git a/AppPackage/Sources/AppModels/Download/DownloadedGallery+Manifest.swift b/AppPackage/Sources/AppModels/Download/DownloadedGallery+Manifest.swift new file mode 100644 index 000000000..53172f76c --- /dev/null +++ b/AppPackage/Sources/AppModels/Download/DownloadedGallery+Manifest.swift @@ -0,0 +1,74 @@ +import Foundation + +/// The identity record for a downloaded gallery, written to `manifest.json` in its folder. +/// Identity lives *here* (`gid` / `token`), not in the folder path: the human-readable +/// `[gid_token] Title` folder name is presentation, and the title in it can change and +/// re-slot the directory without affecting identity. Folder membership follows the file's +/// location on disk, so this manifest is what re-establishes identity after the gallery is +/// moved or renamed via the Files app. +public struct DownloadManifest: Codable, Equatable, Sendable { + public let gid: String + public let host: GalleryHost + public let token: String + public let title: String + public let jpnTitle: String? + public let category: Category + public let language: Language + public let remoteCoverURL: URL? + public let uploader: String? + public let tags: [GalleryTag] + public let postedDate: Date + public let rating: Float + public var pages: [Int: String] + + public init( + gid: String, + host: GalleryHost, + token: String, + title: String, + jpnTitle: String?, + category: Category, + language: Language, + remoteCoverURL: URL?, + uploader: String?, + tags: [GalleryTag], + postedDate: Date, + rating: Float, + pages: [Int: String] + ) { + self.gid = gid + self.host = host + self.token = token + self.title = title + self.jpnTitle = jpnTitle + self.category = category + self.language = language + self.remoteCoverURL = remoteCoverURL + self.uploader = uploader + self.tags = tags + self.postedDate = postedDate + self.rating = rating + self.pages = pages + } +} + +extension DownloadManifest { + public var pageCount: Int { + pages.count + } + + public var galleryURL: URL { + host.url + .appendingPathComponent("g") + .appendingPathComponent(gid) + .appendingPathComponent(token) + } + + public var completedPageCount: Int { + pages.values.filter { !$0.isEmpty }.count + } + + public var isComplete: Bool { + !pages.isEmpty && completedPageCount == pages.count + } +} diff --git a/AppPackage/Sources/AppModels/Download/DownloadedGallery+SupportTypes.swift b/AppPackage/Sources/AppModels/Download/DownloadedGallery+SupportTypes.swift new file mode 100644 index 000000000..1d843681b --- /dev/null +++ b/AppPackage/Sources/AppModels/Download/DownloadedGallery+SupportTypes.swift @@ -0,0 +1,116 @@ +import AppTools +import SwiftUI + +// MARK: DownloadedGallery Computed Properties +extension DownloadedGallery { + public var displayTitle: String { + jpnTitle?.nonEmpty ?? title + } + + public var searchableText: String { + [ + title, + jpnTitle, + uploader, + String(localized: category.value), + tags.flatMap(\.contents).map(\.text).joined(separator: " ") + ] + .compactMap { $0 } + .filter { !$0.isEmpty } + .joined(separator: " ") + } + + public var manifestURL: URL { + folderURL.appendingPathComponent(Defaults.FilePath.downloadManifest) + } + + public var coverURL: URL? { + localCoverURL ?? onlineCoverURL + } + + public var badge: DownloadBadge { + DownloadBadge( + status: displayStatus, + progress: DownloadProgress( + completedPageCount: completedPageCount, + pageCount: pageCount + ) + ) + } + + public var gallery: Gallery { + Gallery( + gid: gid, + token: token, + title: displayTitle, + rating: rating, + tags: tags, + category: category, + uploader: uploader, + pageCount: pageCount, + postedDate: postedDate, + coverURL: coverURL, + galleryURL: manifest.galleryURL + ) + } + + public var canRetry: Bool { + displayStatus == .error + } + + public var canValidateImageData: Bool { + [.completed, .updateAvailable].contains(displayStatus) + || lastError?.code == .fileOperationFailed + } + + public var canPauseOrResume: Bool { + [.active, .inactive].contains(displayStatus) + } + + public var canTogglePause: Bool { + canPauseOrResume || isQueuedWorkItem + } + + public var canCancelFromDetailAction: Bool { + isQueuedWorkItem || canPauseOrResume || displayStatus == .completed + } + + public var canTriggerUpdate: Bool { + guard !isQueuedWorkItem, !canPauseOrResume else { return false } + return displayStatus == .updateAvailable + } + + public var isQueuedWorkItem: Bool { + displayStatus == .queued + } + + public var hasUpdate: Bool { + displayStatus == .updateAvailable + } + + public var isIncomplete: Bool { + completedPageCount < pageCount + } + + public func needsInterruptedDownloadNormalization( + activeGalleryID: String?, + hasActiveTask: Bool + ) -> Bool { + displayStatus == .active && !(hasActiveTask && activeGalleryID == gid) + } + +} + +extension DownloadInspection { + public var hasDownloadedPages: Bool { + pages.contains { $0.status == .downloaded } + } + + public var canRetryFailedPages: Bool { + !failedPageIndices.isEmpty + } + + public var canValidateImageData: Bool { + hasDownloadedPages && download.canValidateImageData + } +} diff --git a/AppPackage/Sources/AppModels/Download/DownloadedGallery.swift b/AppPackage/Sources/AppModels/Download/DownloadedGallery.swift new file mode 100644 index 000000000..7e38b8e1e --- /dev/null +++ b/AppPackage/Sources/AppModels/Download/DownloadedGallery.swift @@ -0,0 +1,48 @@ +import SwiftUI + +public struct DownloadedGallery: Identifiable, Equatable, Sendable { + public var id: String { gid } + + public let manifest: DownloadManifest + public let folderURL: URL + public let folderName: String + public let localCoverURL: URL? + public let localPageURLs: [Int: URL] + public let displayStatus: DownloadDisplayStatus + public let lastDownloadedDate: Date? + public let lastError: DownloadFailure? + + public var gid: String { manifest.gid } + public var host: GalleryHost { manifest.host } + public var token: String { manifest.token } + public var title: String { manifest.title } + public var jpnTitle: String? { manifest.jpnTitle } + public var uploader: String? { manifest.uploader } + public var category: Category { manifest.category } + public var tags: [GalleryTag] { manifest.tags } + public var pageCount: Int { manifest.pageCount } + public var postedDate: Date { manifest.postedDate } + public var rating: Float { manifest.rating } + public var onlineCoverURL: URL? { manifest.remoteCoverURL } + public var completedPageCount: Int { manifest.completedPageCount } + + public init( + manifest: DownloadManifest, + folderURL: URL, + folderName: String, + localCoverURL: URL?, + localPageURLs: [Int: URL], + modificationDate: Date?, + displayStatus: DownloadDisplayStatus, + lastError: DownloadFailure? = nil + ) { + self.manifest = manifest + self.folderURL = folderURL + self.folderName = folderName + self.localCoverURL = localCoverURL + self.localPageURLs = localPageURLs + self.displayStatus = displayStatus + self.lastDownloadedDate = modificationDate + self.lastError = lastError + } +} diff --git a/AppPackage/Sources/AppModels/Gallery/Category.swift b/AppPackage/Sources/AppModels/Gallery/Category.swift new file mode 100644 index 000000000..f9968671f --- /dev/null +++ b/AppPackage/Sources/AppModels/Gallery/Category.swift @@ -0,0 +1,63 @@ +import SwiftUI +import OSLogExt +import Resources + +private let logger = Logger(category: .init(describing: Category.self)) + +public enum Category: String, Codable, CaseIterable, Identifiable, Sendable { + public var id: String { rawValue } + + public static let allFavoritesCases: [Self] = [.misc] + allCases.dropLast(2) + public 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 { + public func color(host: GalleryHost) -> Color { + .init(host.rawValue + "/" + rawValue) + } + public 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 = "`Private` doesn't have a `filterValue`!" + logger.error("\(message, privacy: .public)") + fatalError(message) + } + } + public var value: LocalizedStringResource { + switch self { + case .doujinshi: return .categoryDoujinshi + case .manga: return .categoryManga + case .artistCG: return .categoryArtistCg + case .gameCG: return .categoryGameCg + case .western: return .categoryWestern + case .nonH: return .categoryNonH + case .imageSet: return .categoryImageSet + case .cosplay: return .categoryCosplay + case .asianPorn: return .categoryAsianPorn + case .misc: return .categoryMisc + case .private: return .categoryPrivate + } + } +} diff --git a/AppPackage/Sources/AppModels/Gallery/Gallery.swift b/AppPackage/Sources/AppModels/Gallery/Gallery.swift new file mode 100644 index 000000000..b165a008b --- /dev/null +++ b/AppPackage/Sources/AppModels/Gallery/Gallery.swift @@ -0,0 +1,162 @@ +import AppTools +import SwiftUI + +public struct Gallery: Identifiable, Codable, Equatable, Hashable, Sendable { + public static func == (lhs: Gallery, rhs: Gallery) -> Bool { + lhs.gid == rhs.gid + } + + public static func mockGalleries(count: Int, randomID: Bool = true) -> [Gallery] { + // A blank, fresh-id gallery used only to fill the loading skeleton. `.preview` can't stand in + // because its `gid` is a fixed constant and the skeleton's `ForEach` is keyed by gallery id, + // so every row needs a distinct id. + func blank() -> Gallery { + .init( + gid: UUID().uuidString, + token: "", + title: "", + rating: 0.0, + tags: [], + category: .doujinshi, + uploader: "", + pageCount: 1, + postedDate: .now, + coverURL: nil, + galleryURL: nil, + isExpunged: false, + hasRated: false, + favoriteTagIndex: nil, + favoriteTagName: nil + ) + } + guard randomID, count > 0 else { + return Array(repeating: blank(), count: count) + } + return (0...count).map { _ in blank() } + } + public static let preview = Gallery( + gid: UUID().uuidString, + token: "", + title: "Preview", + rating: 3.5, + tags: [], + category: .doujinshi, + uploader: "Anonymous", + pageCount: 1, + postedDate: .now, + coverURL: URL( + string: "https://github.com/" + + "EhPanda-Team/Imageset/blob/" + + "main/JPGs/2.jpg?raw=true" + ), + galleryURL: nil, + isExpunged: false, + hasRated: false, + favoriteTagIndex: nil, + favoriteTagName: nil + ) + + public var trimmedTitle: String { + var title = title + if let range = title.range(of: "|") { + title = String(title[.. [GalleryTag.Content] { + let tagContents = tags.flatMap(\.contents) + guard maximum > 0 else { return tagContents } + return .init(tagContents.prefix(min(tagContents.count, maximum))) + } + + public var id: String { gid } + public let gid: String + public let token: String + + public var title: String + public var titleJpn: String? + public var rating: Float + public var tags: [GalleryTag] + public let category: Category + public var uploader: String? + public var pageCount: Int + public let postedDate: Date + public let coverURL: URL? + public let galleryURL: URL? + public var lastOpenDate: Date? + public var isExpunged: Bool = false + public var hasRated: Bool = false + public var favoriteTagIndex: Int? + public var favoriteTagName: String? + public var isFavorite: Bool { + favoriteTagIndex != nil || favoriteTagName != nil + } + + public init( + gid: String, + token: String, + title: String, + titleJpn: String? = nil, + rating: Float, + tags: [GalleryTag], + category: Category, + uploader: String? = nil, + pageCount: Int, + postedDate: Date, + coverURL: URL?, + galleryURL: URL?, + lastOpenDate: Date? = nil, + isExpunged: Bool = false, + hasRated: Bool = false, + favoriteTagIndex: Int? = nil, + favoriteTagName: String? = nil + ) { + self.gid = gid + self.token = token + self.title = title + self.titleJpn = titleJpn.flatMap(\.nonEmpty) + self.rating = rating + self.tags = tags + self.category = category + self.uploader = uploader + self.pageCount = pageCount + self.postedDate = postedDate + self.coverURL = coverURL + self.galleryURL = galleryURL + self.lastOpenDate = lastOpenDate + self.isExpunged = isExpunged + self.hasRated = hasRated + self.favoriteTagIndex = favoriteTagIndex + self.favoriteTagName = favoriteTagName + } +} + +extension Gallery: DateFormattable, CustomStringConvertible { + public var description: String { + "Gallery(\(gid))" + } + + public var filledCount: Int { Int(rating) } + public var halfFilledCount: Int { Int(rating - 0.5) == filledCount ? 1 : 0 } + public var notFilledCount: Int { 5 - filledCount - halfFilledCount } + + public func color(host: GalleryHost) -> Color { + category.color(host: host) + } + public var originalDate: Date { + postedDate + } +} diff --git a/AppPackage/Sources/AppModels/Gallery/GalleryArchive.swift b/AppPackage/Sources/AppModels/Gallery/GalleryArchive.swift new file mode 100644 index 000000000..457a76be0 --- /dev/null +++ b/AppPackage/Sources/AppModels/Gallery/GalleryArchive.swift @@ -0,0 +1,65 @@ +import Foundation +import Resources + +public struct GalleryArchive: Codable, Equatable, Sendable { + public init( + hathArchives: [HathArchive] + ) { + self.hathArchives = hathArchives + } + public struct HathArchive: Codable, Identifiable, Equatable, Sendable { + public var id: String { resolution.rawValue } + + public let resolution: ArchiveResolution + public let fileSize: String + private let gpPrice: String + + public init(resolution: ArchiveResolution, fileSize: String, gpPrice: String) { + self.resolution = resolution + self.fileSize = fileSize + self.gpPrice = gpPrice + } + + public var isValid: Bool { + fileSize != "N/A" && gpPrice != "N/A" + } + public var price: String { + switch gpPrice { + case "Free": + return String(localized: .hathArchiveFree) + default: + return gpPrice + } + } + } + + public let hathArchives: [HathArchive] +} + +public enum ArchiveResolution: String, Codable, CaseIterable, Equatable, Sendable { + case x780 = "780x" + case x980 = "980x" + case x1280 = "1280x" + case x1600 = "1600x" + case x2400 = "2400x" + case original = "Original" +} + +extension ArchiveResolution { + public var value: String { + switch self { + case .x780, .x980, .x1280, .x1600, .x2400: + return rawValue + case .original: + return String(localized: .archiveResolutionOriginal) + } + } + public var parameter: String { + switch self { + case .original: + return "org" + default: + return .init(rawValue.dropLast()) + } + } +} diff --git a/AppPackage/Sources/AppModels/Gallery/GalleryComment.swift b/AppPackage/Sources/AppModels/Gallery/GalleryComment.swift new file mode 100644 index 000000000..cfb4ed92d --- /dev/null +++ b/AppPackage/Sources/AppModels/Gallery/GalleryComment.swift @@ -0,0 +1,89 @@ +import Foundation + +public struct GalleryComment: Identifiable, Equatable, Codable, Sendable { + public init( + votedUp: Bool, + votedDown: Bool, + votable: Bool, + editable: Bool, + score: String? = nil, + author: String, + contents: [CommentContent], + commentID: String, + commentDate: Date + ) { + self.votedUp = votedUp + self.votedDown = votedDown + self.votable = votable + self.editable = editable + self.score = score + self.author = author + self.contents = contents + self.commentID = commentID + self.commentDate = commentDate + } + public var id: String { commentID } + + public var votedUp: Bool + public var votedDown: Bool + public let votable: Bool + public let editable: Bool + + public let score: String? + public let author: String + public let contents: [CommentContent] + public let commentID: String + public let commentDate: Date + + public var plainTextContent: String { + contents + .filter { [.plainText, .linkedText, .singleLink].contains($0.type) } + .compactMap { $0.type == .singleLink ? $0.link?.absoluteString : $0.text }.joined() + } +} + +extension GalleryComment: DateFormattable { + public var originalDate: Date { + commentDate + } +} + +public struct CommentContent: Identifiable, Equatable, Codable, Sendable { + public init( + id: UUID = .init(), + type: CommentContentType, + text: String? = nil, + link: URL? = nil, + imgURL: URL? = nil, + secondLink: URL? = nil, + secondImgURL: URL? = nil + ) { + self.id = id + self.type = type + self.text = text + self.link = link + self.imgURL = imgURL + self.secondLink = secondLink + self.secondImgURL = secondImgURL + } + public var id: UUID = .init() + public let type: CommentContentType + public var text: String? + public var link: URL? + public var imgURL: URL? + + public var secondLink: URL? + public var secondImgURL: URL? +} + +public enum CommentContentType: Int, Codable, Sendable { + case singleImg + case doubleImg + case linkedImg + case doubleLinkedImg + + case plainText + case linkedText + + case singleLink +} diff --git a/AppPackage/Sources/AppModels/Gallery/GalleryDetail.swift b/AppPackage/Sources/AppModels/Gallery/GalleryDetail.swift new file mode 100644 index 000000000..f6decdbfa --- /dev/null +++ b/AppPackage/Sources/AppModels/Gallery/GalleryDetail.swift @@ -0,0 +1,149 @@ +import AppTools +import Foundation +import Resources + +public struct GalleryDetail: Codable, Equatable, Sendable { + public init( + gid: String, + title: String, + jpnTitle: String? = nil, + isFavorited: Bool, + visibility: GalleryVisibility, + rating: Float, + userRating: Float, + ratingCount: Int, + category: Category, + language: Language, + uploader: String, + postedDate: Date, + coverURL: URL? = nil, + archiveURL: URL? = nil, + parentURL: URL? = nil, + favoritedCount: Int, + pageCount: Int, + sizeCount: Float, + sizeType: String, + torrentCount: Int, + favoriteTagIndex: Int? = nil, + favoriteTagName: String? = nil + ) { + self.gid = gid + self.title = title + self.jpnTitle = jpnTitle + self.isFavorited = isFavorited + self.visibility = visibility + self.rating = rating + self.userRating = userRating + self.ratingCount = ratingCount + self.category = category + self.language = language + self.uploader = uploader + self.postedDate = postedDate + self.coverURL = coverURL + self.archiveURL = archiveURL + self.parentURL = parentURL + self.favoritedCount = favoritedCount + self.pageCount = pageCount + self.sizeCount = sizeCount + self.sizeType = sizeType + self.torrentCount = torrentCount + self.favoriteTagIndex = favoriteTagIndex + self.favoriteTagName = favoriteTagName + } + public static let empty: Self = .init( + gid: "", title: "", isFavorited: false, + visibility: .yes, rating: 0, userRating: 0, + ratingCount: 0, category: .private, + language: .japanese, uploader: "", + postedDate: .now, coverURL: nil, + favoritedCount: 0, pageCount: 0, + sizeCount: 0, sizeType: "", + torrentCount: 0 + ) + public static let preview = GalleryDetail( + gid: "", + title: "Preview", + jpnTitle: "プレビュー", + isFavorited: true, + visibility: .yes, + rating: 3.5, + userRating: 4.0, + ratingCount: 1919, + category: .doujinshi, + language: .japanese, + uploader: "Anonymous", + postedDate: .distantPast, + coverURL: URL( + string: "https://github.com/" + + "EhPanda-Team/Imageset/blob/" + + "main/JPGs/2.jpg?raw=true" + ), + favoritedCount: 514, + pageCount: 114, + sizeCount: 514, + sizeType: "MB", + torrentCount: 101 + ) + + public var trimmedTitle: String { + var title = title + if let range = title.range(of: "|") { + title = String(title[.. String { + let namespace = tag.namespace?.abbreviation ?? tag.namespace?.rawValue ?? tag.rawNamespace.lowercased() + return tag.namespace == .temp ? text : [namespace, text].joined(separator: ":") + } + public func serachKeyword(tag: GalleryTag) -> String { + let keyword = text.contains(" ") ? "\"\(text)$\"" : "\(text)$" + let namespace = tag.namespace?.abbreviation ?? tag.namespace?.rawValue ?? tag.rawNamespace.lowercased() + return tag.namespace == .temp ? keyword : [namespace, keyword].joined(separator: ":") + } + + public let rawNamespace: String + public let text: String + public let isVotedUp: Bool + public let isVotedDown: Bool + public var textColor: Color? + public var backgroundColor: Color? + } + + public var id: String { rawNamespace } + public var namespace: TagNamespace? { + .init(rawValue: rawNamespace) + } + + public let rawNamespace: String + public var contents: [Content] +} + +public enum PreviewConfig: Codable, Equatable, Sendable { + case normal(rows: Int) + case large(rows: Int) +} + +extension PreviewConfig { + public var batchSize: Int { + switch self { + case .normal(let rows): + return 10 * rows + case .large(let rows): + return 5 * rows + } + } + + public func pageNumber(index: Int) -> Int { + max(index - 1, 0) / batchSize + } + public func batchRange(index: Int) -> ClosedRange { + let lowerBound = pageNumber(index: index) * batchSize + 1 + let upperBound = lowerBound + batchSize - 1 + return lowerBound...upperBound + } +} diff --git a/AppPackage/Sources/AppModels/Gallery/GalleryTorrent.swift b/AppPackage/Sources/AppModels/Gallery/GalleryTorrent.swift new file mode 100644 index 000000000..6fe7a6282 --- /dev/null +++ b/AppPackage/Sources/AppModels/Gallery/GalleryTorrent.swift @@ -0,0 +1,46 @@ +import Foundation + +public struct GalleryTorrent: Identifiable, Codable, Equatable, Sendable { + public init( + id: UUID = .init(), + postedDate: Date, + fileSize: String, + seedCount: Int, + peerCount: Int, + downloadCount: Int, + uploader: String, + fileName: String, + hash: String, + torrentURL: URL + ) { + self.id = id + self.postedDate = postedDate + self.fileSize = fileSize + self.seedCount = seedCount + self.peerCount = peerCount + self.downloadCount = downloadCount + self.uploader = uploader + self.fileName = fileName + self.hash = hash + self.torrentURL = torrentURL + } + public var id: UUID = .init() + public let postedDate: Date + public let fileSize: String + public let seedCount: Int + public let peerCount: Int + public let downloadCount: Int + public let uploader: String + public let fileName: String + public let hash: String + public let torrentURL: URL +} + +extension GalleryTorrent: DateFormattable { + public var originalDate: Date { + postedDate + } + public var magnetURL: String { + "magnet:?xt=urn:btih:\(hash)" + } +} diff --git a/AppPackage/Sources/AppModels/Gallery/Language.swift b/AppPackage/Sources/AppModels/Gallery/Language.swift new file mode 100644 index 000000000..84314916a --- /dev/null +++ b/AppPackage/Sources/AppModels/Gallery/Language.swift @@ -0,0 +1,104 @@ +import Foundation +import Resources + +public enum Language: String, Codable, Sendable { + public static let allExcludedCases: [Self] = [ + .japanese, .english, .chinese, .dutch, .french, .german, .hungarian, .italian, + .korean, .polish, .portuguese, .russian, .spanish, .thai, .vietnamese, .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 { + public var codes: [String]? { + switch self { + case .english: return ["en-US"] + case .french: return ["fr-FR"] + case .italian: return ["it-IT"] + case .german: return ["de-DE"] + case .spanish: return ["es-ES"] + case .portuguese: return ["pt-BR"] + case .chinese: return ["zh-Hans", "zh-Hant"] + default: return nil + } + } + public var abbreviation: String { + switch self { + // swiftlint:disable switch_case_alignment line_length + case .invalid, .other: return "N/A"; case .afrikaans: return "AF"; case .albanian: return "SQ"; case .arabic: return "AR"; case .bengali: return "BN"; case .bosnian: return "BS"; case .bulgarian: return "BG"; case .burmese: return "MY"; case .catalan: return "CA"; case .cebuano: return "CEB"; case .chinese: return "ZH"; case .croatian: return "HR"; case .czech: return "CS"; case .danish: return "DA"; case .dutch: return "NL"; case .english: return "EN"; case .esperanto: return "EO"; case .estonian: return "ET"; case .finnish: return "FI"; case .french: return "FR"; case .georgian: return "KA"; case .german: return "DE"; case .greek: return "EL"; case .hebrew: return "HE"; case .hindi: return "HI"; case .hmong: return "HMN"; case .hungarian: return "HU"; case .indonesian: return "ID"; case .italian: return "IT"; case .japanese: return "JA"; case .kazakh: return "KK"; case .khmer: return "KM"; case .korean: return "KO"; case .kurdish: return "KU"; case .lao: return "LO"; case .latin: return "LA"; case .mongolian: return "MN"; case .ndebele: return "ND"; case .nepali: return "NE"; case .norwegian: return "NO"; case .oromo: return "OM"; case .pashto: return "PS"; case .persian: return "FA"; case .polish: return "PL"; case .portuguese: return "PT"; case .punjabi: return "PA"; case .romanian: return "RO"; case .russian: return "RU"; case .sango: return "SG"; case .serbian: return "SR"; case .shona: return "SN"; case .slovak: return "SK"; case .slovenian: return "SL"; case .somali: return "SO"; case .spanish: return "ES"; case .swahili: return "SW"; case .swedish: return "SV"; case .tagalog: return "TL"; case .thai: return "TH"; case .tigrinya: return "TI"; case .turkish: return "TR"; case .ukrainian: return "UK"; case .urdu: return "UR"; case .vietnamese: return "VI"; case .zulu: return "ZU" + // swiftlint:enable switch_case_alignment line_length + } + } + public var value: LocalizedStringResource { + switch self { + case .invalid: return .languageInvalid + case .other: return .languageOther + case .afrikaans: return .languageAfrikaans + case .albanian: return .languageAlbanian + case .arabic: return .languageArabic + case .bengali: return .languageBengali + case .bosnian: return .languageBosnian + case .bulgarian: return .languageBulgarian + case .burmese: return .languageBurmese + case .catalan: return .languageCatalan + case .cebuano: return .languageCebuano + case .chinese: return .languageChinese + case .croatian: return .languageCroatian + case .czech: return .languageCzech + case .danish: return .languageDanish + case .dutch: return .languageDutch + case .english: return .languageEnglish + case .esperanto: return .languageEsperanto + case .estonian: return .languageEstonian + case .finnish: return .languageFinnish + case .french: return .languageFrench + case .georgian: return .languageGeorgian + case .german: return .languageGerman + case .greek: return .languageGreek + case .hebrew: return .languageHebrew + case .hindi: return .languageHindi + case .hmong: return .languageHmong + case .hungarian: return .languageHungarian + case .indonesian: return .languageIndonesian + case .italian: return .languageItalian + case .japanese: return .languageJapanese + case .kazakh: return .languageKazakh + case .khmer: return .languageKhmer + case .korean: return .languageKorean + case .kurdish: return .languageKurdish + case .lao: return .languageLao + case .latin: return .languageLatin + case .mongolian: return .languageMongolian + case .ndebele: return .languageNdebele + case .nepali: return .languageNepali + case .norwegian: return .languageNorwegian + case .oromo: return .languageOromo + case .pashto: return .languagePashto + case .persian: return .languagePersian + case .polish: return .languagePolish + case .portuguese: return .languagePortuguese + case .punjabi: return .languagePunjabi + case .romanian: return .languageRomanian + case .russian: return .languageRussian + case .sango: return .languageSango + case .serbian: return .languageSerbian + case .shona: return .languageShona + case .slovak: return .languageSlovak + case .slovenian: return .languageSlovenian + case .somali: return .languageSomali + case .spanish: return .languageSpanish + case .swahili: return .languageSwahili + case .swedish: return .languageSwedish + case .tagalog: return .languageTagalog + case .thai: return .languageThai + case .tigrinya: return .languageTigrinya + case .turkish: return .languageTurkish + case .ukrainian: return .languageUkrainian + case .urdu: return .languageUrdu + case .vietnamese: return .languageVietnamese + case .zulu: return .languageZulu + } + } +} diff --git a/AppPackage/Sources/AppModels/Logger+.swift b/AppPackage/Sources/AppModels/Logger+.swift new file mode 100644 index 000000000..2f1d493d5 --- /dev/null +++ b/AppPackage/Sources/AppModels/Logger+.swift @@ -0,0 +1,7 @@ +import OSLogExt + +extension Logger { + init(category: String) { + self.init(moduleName: "AppModels", category: category) + } +} diff --git a/AppPackage/Sources/AppModels/Persistence/AppSharedKeys.swift b/AppPackage/Sources/AppModels/Persistence/AppSharedKeys.swift new file mode 100644 index 000000000..c51d7156b --- /dev/null +++ b/AppPackage/Sources/AppModels/Persistence/AppSharedKeys.swift @@ -0,0 +1,126 @@ +import Foundation +import Sharing + +// MARK: - Persisted app data (@Shared) +// +// EhPanda is a thin browsing client: it parses and delivers website content and deliberately +// keeps no database. Only light, *bounded* app data is persisted, and it is persisted whole — +// each model as a single Codable value in UserDefaults via Sharing's `appStorage` strategy. +// +// Why whole structs in app storage rather than a database or per-field keys: +// • Every value here is either capped (`galleryHistory`, `historyKeywords`, `quickSearchWords`) +// or inherently small (`setting`, `user`, the filters), so the defaults domain comfortably +// holds it. +// • Keeping the structs intact preserves their invariants (e.g. `Setting`/`Filter` `didSet` +// cascades) and makes resets/logout a single atomic assignment. +// • Models decode *strictly* (synthesized Codable, or a hand-written throwing decoder where an +// identity invariant must hold). A corrupt or shape-incompatible blob fails to decode and +// Sharing falls back to the key's default — a clean, coherent reset, never a partially-filled +// Franken-value. Additive evolution stays cheap: a new *optional* field is absent-tolerant +// automatically under synthesized decode, so old blobs stay valid. +// +// Cap policies differ by the *kind* of data, deliberately: +// • `galleryHistory` (auto-recorded browsing) is capped at `GalleryHistoryEntry.historyCap` and +// trimmed only by a launch-time prune, so in-session upserts may briefly exceed the cap. +// • `historyKeywords` (auto-recorded searches) is capped at write time by evicting the oldest, +// keeping the most recent 20. +// • `quickSearchWords` (user-*authored* presets) is capped at `QuickSearchReducer.wordLimit` but is +// never evicted — the add control is simply disabled at the limit, because silently dropping a +// saved word would lose user work. Auto-recorded data is disposable; authored data is not. +// +// Every model carries a self-validating `SchemaVersion` field (default 1) that rejects a +// newer/downgrade value on decode and logs the anomaly via OSLog (see `SchemaVersion`), then fails +// the decode so Sharing resets to the key default rather than half-reading an unknown shape. The +// four whole-struct models (`Setting`, +// `User`, the filters, `TagTranslatorInfo`) keep synthesized strict Codable — the typed field gates +// the version without a hand-written decoder, preserving their `didSet` invariants and optional-field +// tolerance. The two identity-bearing array-element models (`GalleryHistoryEntry`, `QuickSearchWord`) +// still hand-write `init(from:)` for their identity invariants, decoding that same `SchemaVersion` +// field for the version check. Every model also declares an ordered `schemas` history (à la SwiftData's +// `SchemaMigrationPlan.schemas`), starting at its v1 base schema, from which `currentSchemaVersion` is +// derived. When a real breaking change lands, the model appends a `VersionedSchema` whose `migrate` maps +// the previous shape forward and adopts `MigratableModel`; the `SchemaMigrator` engine then walks the +// chain in order (v1→v2→v3…) during decode. See `VersionedSchema` / `SchemaMigrator`. +// +// Nothing here uses the `fileStorage` strategy. The tag-translation table is the only large +// artifact, and it is deliberately NOT persisted through Sharing: only its thin +// `tagTranslatorInfo` metadata lives in app storage, while the multi-megabyte translations are a +// plain, re-downloadable cache file rebuilt at launch (see `TagTranslatorInfo`). Web images keep +// their own caches. `appStorage` keys must not contain `.` or `@`. + +// MARK: Account & preferences + +extension SharedKey where Self == AppStorageKey.Default { + public static var setting: Self { + Self[.appStorage("setting"), default: Setting()] + } +} + +extension SharedKey where Self == AppStorageKey.Default { + public static var user: Self { + Self[.appStorage("user"), default: User()] + } +} + +// The daily "New Dawn" greeting is an ephemeral reward, not durable account identity, so it lives in +// memory only and resets to `nil` on the next launch. Two features write it — the Setting daily fetch +// and the Detail-page parse — through the newer-only `mergeNewer(_:)` rule (see `Greeting`). +extension SharedKey where Self == InMemoryKey.Default { + public static var greeting: Self { + Self[.inMemory("greeting"), default: nil] + } +} + +// MARK: Filters + +extension SharedKey where Self == AppStorageKey.Default { + public static var searchFilter: Self { + Self[.appStorage("searchFilter"), default: Filter()] + } + public static var globalFilter: Self { + Self[.appStorage("globalFilter"), default: Filter()] + } + public static var watchedFilter: Self { + Self[.appStorage("watchedFilter"), default: Filter()] + } +} + +// MARK: Search history & presets + +extension SharedKey where Self == AppStorageKey<[String]>.Default { + public static var historyKeywords: Self { + Self[.appStorage("historyKeywords"), default: []] + } +} + +extension SharedKey where Self == AppStorageKey<[QuickSearchWord]>.Default { + public static var quickSearchWords: Self { + Self[.appStorage("quickSearchWords"), default: []] + } +} + +// MARK: Tag translations (thin metadata only — the table itself is a rebuilt cache file) + +extension SharedKey where Self == AppStorageKey.Default { + public static var tagTranslatorInfo: Self { + Self[.appStorage("tagTranslatorInfo"), default: TagTranslatorInfo()] + } +} + +// The full translation table (multi-megabyte `translations` dictionary) is rebuilt at launch from the +// cache file, so it lives in memory only — never in app storage. `SettingFeature` owns the writes (the +// launch rebuild and language switches); every other feature reads it through `@SharedReader`, so tag +// lookups no longer thread a `TagTranslator` copy down through each view. +extension SharedKey where Self == InMemoryKey.Default { + public static var tagTranslator: Self { + Self[.inMemory("tagTranslator"), default: TagTranslator()] + } +} + +// MARK: Browsing history (merged recency + reading progress; capped, pruned at launch) + +extension SharedKey where Self == AppStorageKey<[GalleryHistoryEntry]>.Default { + public static var galleryHistory: Self { + Self[.appStorage("galleryHistory"), default: []] + } +} diff --git a/AppPackage/Sources/AppModels/Persistence/JSONValue.swift b/AppPackage/Sources/AppModels/Persistence/JSONValue.swift new file mode 100644 index 000000000..9415c1e05 --- /dev/null +++ b/AppPackage/Sources/AppModels/Persistence/JSONValue.swift @@ -0,0 +1,62 @@ +import Foundation + +/// A minimal, `Sendable` JSON tree used as the working representation for schema migrations. +/// +/// A migration map mutates a `[String: JSONValue]` object — "fetch this key, create that key, set this +/// value" — before the migrated data is decoded into the current model shape. `Int` and `Double` are +/// kept distinct so integer fields (and `Int`-raw enums) round-trip without being widened to `Double`. +public enum JSONValue: Hashable, Sendable { + case null + case bool(Bool) + case int(Int) + case double(Double) + case string(String) + case array([JSONValue]) + case object([String: JSONValue]) +} + +extension JSONValue { + public var boolValue: Bool? { if case .bool(let value) = self { return value } else { return nil } } + public var intValue: Int? { if case .int(let value) = self { return value } else { return nil } } + public var doubleValue: Double? { if case .double(let value) = self { return value } else { return nil } } + public var stringValue: String? { if case .string(let value) = self { return value } else { return nil } } +} + +extension JSONValue: Codable { + public init(from decoder: Decoder) throws { + let container = try decoder.singleValueContainer() + if container.decodeNil() { + self = .null + } else if let bool = try? container.decode(Bool.self) { + self = .bool(bool) + } else if let int = try? container.decode(Int.self) { + self = .int(int) + } else if let double = try? container.decode(Double.self) { + self = .double(double) + } else if let string = try? container.decode(String.self) { + self = .string(string) + } else if let array = try? container.decode([JSONValue].self) { + self = .array(array) + } else if let object = try? container.decode([String: JSONValue].self) { + self = .object(object) + } else { + throw DecodingError.dataCorrupted(.init( + codingPath: decoder.codingPath, + debugDescription: "Unsupported JSON value" + )) + } + } + + public func encode(to encoder: Encoder) throws { + var container = encoder.singleValueContainer() + switch self { + case .null: try container.encodeNil() + case .bool(let value): try container.encode(value) + case .int(let value): try container.encode(value) + case .double(let value): try container.encode(value) + case .string(let value): try container.encode(value) + case .array(let value): try container.encode(value) + case .object(let value): try container.encode(value) + } + } +} diff --git a/AppPackage/Sources/AppModels/Persistence/SchemaMigrator.swift b/AppPackage/Sources/AppModels/Persistence/SchemaMigrator.swift new file mode 100644 index 000000000..0972bbfcc --- /dev/null +++ b/AppPackage/Sources/AppModels/Persistence/SchemaMigrator.swift @@ -0,0 +1,54 @@ +import Foundation +import OSLogExt + +private let logger = Logger(category: .init(describing: SchemaMigrator.self)) + +/// A model that migrates older persisted blobs forward *in decode*. +/// +/// Adopt this only once a model gains a real v2 (before that, a v1 model keeps synthesized/identity +/// `Codable` and just lists `schemas = [SchemaV1.self]`). A conformer provides: +/// • `schemas` — the ordered schema history (see `SchemaVersioned` / `VersionedSchema`), +/// • `init(currentFrom:)` — a decoder for the *current* shape only, and +/// • `init(from:)` as the one-line `self = try SchemaMigrator.migrate(Self.self, from: decoder)`. +/// The engine reads the stored version, walks every schema newer than it up to `currentSchemaVersion`, +/// then decodes the current shape through `init(currentFrom:)`. +public protocol MigratableModel: Codable, SchemaVersioned { + /// Decode the *current* schema shape. Called by the engine after the blob has been migrated forward; + /// it must not re-enter migration (do not call `SchemaMigrator.migrate` here). + init(currentFrom decoder: Decoder) throws +} + +/// Walks a model's ordered schema history over a raw blob, then decodes the migrated result. +public enum SchemaMigrator { + /// Wraps `init(currentFrom:)` so the engine can decode the current shape without re-entering + /// `Model.init(from:)` (which would recurse back into migration). + private struct CurrentShape: Decodable { + let value: Model + init(from decoder: Decoder) throws { + value = try Model(currentFrom: decoder) + } + } + + /// Decode `Model` from a possibly-older blob: read its `schemaVersion`, ask every schema newer than + /// it to migrate the object forward in order, then decode the current shape. + public static func migrate( + _ type: Model.Type, from decoder: Decoder + ) throws -> Model { + var object = try [String: JSONValue](from: decoder) + let stored = object["schemaVersion"]?.intValue ?? 1 + let current = Model.currentSchemaVersion + guard (1...current).contains(stored) else { + let message = "\(Model.self) schemaVersion \(stored) is outside the supported range 1...\(current)" + logger.error("\(message, privacy: .public)") + throw DecodingError.dataCorrupted(.init(codingPath: decoder.codingPath, debugDescription: message)) + } + // `schemas` is ordered v1…vN; each newer than `stored` maps the previous version into itself, so + // walking them in order chains stored → current (v1→v2→v3…). + for schema in Model.schemas where schema.version > stored { + try schema.migrate(&object) + } + object["schemaVersion"] = .int(current) + let migratedData = try JSONEncoder().encode(object) + return try JSONDecoder().decode(CurrentShape.self, from: migratedData).value + } +} diff --git a/AppPackage/Sources/AppModels/Persistence/SchemaVersion.swift b/AppPackage/Sources/AppModels/Persistence/SchemaVersion.swift new file mode 100644 index 000000000..2cb8ed340 --- /dev/null +++ b/AppPackage/Sources/AppModels/Persistence/SchemaVersion.swift @@ -0,0 +1,75 @@ +import Foundation +import OSLogExt + +private let logger = Logger(category: "SchemaVersion") + +/// A model whose persisted blob carries a `schemaVersion` and knows the newest version this build can +/// decode. Adopt it together with a `SchemaVersion` field so the version validates itself on +/// decode. +public protocol SchemaVersioned { + /// This model's schema history, oldest → newest (index 0 is version 1). The raw-JSON analog of + /// `SchemaMigrationPlan.schemas`. Never empty: a model that has never changed still declares its v1 + /// base schema. When a breaking change lands, append the next `VersionedSchema`; see `VersionedSchema`. + static var schemas: [any VersionedSchema.Type] { get } +} + +extension SchemaVersioned { + /// The newest `schemaVersion` this build understands — the head of `schemas`. A stored blob carrying + /// a larger value is a downgrade and is rejected on decode. + public static var currentSchemaVersion: Int { schemas.last?.version ?? 1 } + + /// `true` iff `schemas` is non-empty and its versions are `1, 2, … n` — ascending, contiguous, and + /// starting at 1. The engine's ordered walk relies on this; an invariant test enforces it. + public static var hasWellFormedSchemas: Bool { + !schemas.isEmpty && schemas.enumerated().allSatisfy { $0.offset + 1 == $0.element.version } + } +} + +/// A self-validating `schemaVersion` field. +/// +/// On decode it accepts `1...Model.currentSchemaVersion` and throws on anything else — a newer +/// (downgrade) value, or a corrupt `0`/negative — logging the rejected version via OSLog first. The +/// throw fails the whole model decode, so `Sharing` falls back to the key's default rather than +/// half-reading an unknown shape. It encodes as a bare integer, so the persisted JSON is unchanged +/// (`"schemaVersion": 1`). +/// +/// This is the lightweight, in-decode migration seam. It gives every persisted model uniform +/// downgrade rejection *without* a hand-written `init(from:)`, so synthesized `Codable` — and with it +/// each model's `didSet` invariants and optional-field tolerance — stays untouched. When a real +/// breaking change lands for a model, it appends a `VersionedSchema` to its `schemas` and routes +/// `init(from:)` through `SchemaMigrator`, which walks the chain to map the older shape forward. +public struct SchemaVersion: Hashable, Sendable { + public let value: Int + + public init(_ value: Int) { + self.value = value + } +} + +extension SchemaVersion: ExpressibleByIntegerLiteral { + public init(integerLiteral value: Int) { + self.init(value) + } +} + +extension SchemaVersion: Codable { + public init(from decoder: Decoder) throws { + let container = try decoder.singleValueContainer() + let decoded = try container.decode(Int.self) + guard (1...Model.currentSchemaVersion).contains(decoded) else { + let message = "\(Model.self) schemaVersion \(decoded) is outside the " + + "supported range 1...\(Model.currentSchemaVersion)" + logger.error("\(message, privacy: .public)") + throw DecodingError.dataCorrupted(.init( + codingPath: decoder.codingPath, + debugDescription: message + )) + } + value = decoded + } + + public func encode(to encoder: Encoder) throws { + var container = encoder.singleValueContainer() + try container.encode(value) + } +} diff --git a/AppPackage/Sources/AppModels/Persistence/VersionedSchema.swift b/AppPackage/Sources/AppModels/Persistence/VersionedSchema.swift new file mode 100644 index 000000000..6c74cee8c --- /dev/null +++ b/AppPackage/Sources/AppModels/Persistence/VersionedSchema.swift @@ -0,0 +1,21 @@ +import Foundation + +/// One schema version of a persisted model: its version number and how the *previous* version's raw +/// JSON becomes this one. +/// +/// Declared oldest → newest in `SchemaVersioned.schemas`; the `SchemaMigrator` engine walks them to +/// bring an old blob forward one hop at a time. This is the raw-JSON analog of SwiftData's +/// `VersionedSchema` fused with a `.custom` migration stage — we migrate a JSON blob rather than a +/// typed store, so a schema carries just its version and its map (no per-version model snapshot, and +/// no lightweight/auto-inferred stage, because there is no typed store to infer against). +public protocol VersionedSchema { + /// This schema's version number. Across a model's `schemas`, versions run `1, 2, … n` — ascending, + /// contiguous, starting at 1. + static var version: Int { get } + + /// Rewrite the previous version's decoded object into this version's shape — "fetch this key, + /// create that key, set this value". The base schema (version 1) has nothing before it, so its + /// body is empty and the engine never invokes it (it only runs schemas whose `version` exceeds the + /// stored one). + static func migrate(_ object: inout [String: JSONValue]) throws +} diff --git a/AppPackage/Sources/AppModels/Persistent/AppIconType.swift b/AppPackage/Sources/AppModels/Persistent/AppIconType.swift new file mode 100644 index 000000000..81ea634b2 --- /dev/null +++ b/AppPackage/Sources/AppModels/Persistent/AppIconType.swift @@ -0,0 +1,59 @@ +import Foundation +import Resources + +public enum AppIconType: Int, Codable, Identifiable, CaseIterable, Sendable { + public var id: Int { rawValue } + + case `default` + case ukiyoe + case developer + case standWithUkraine2022 + case notMyPresidnet +} + +extension AppIconType { + public var name: LocalizedStringResource { + switch self { + case .default: + return .appIconTypeDefault + + case .ukiyoe: + return .appIconTypeUkiyoe + + case .developer: + return .appIconTypeDeveloper + + case .standWithUkraine2022: + return .appIconTypeStandWithUkraine2022 + + case .notMyPresidnet: + return .appIconTypeNotMyPresident + } + } + + public var filename: String { + switch self { + case .default: + return "AppIcon_Default" + + case .ukiyoe: + return "AppIcon_Ukiyoe" + + case .developer: + return "AppIcon_Developer" + + case .standWithUkraine2022: + return "AppIcon_StandWithUkraine2022" + + case .notMyPresidnet: + return "AppIcon_NotMyPresident" + } + } + + // Resolves the system's current alternate-icon name back to a known type; an unrecognized name falls + // back to `.default`. Callers handle the `nil` (primary-icon) case themselves. Shared by the Setting + // tab's launch reconciliation and the App Icon screen's post-edit sync so both map identically. + public static func matching(alternateIconName: String) -> AppIconType { + allCases.first { alternateIconName.contains($0.filename) } ?? .default + } +} diff --git a/AppPackage/Sources/AppModels/Persistent/Filter.swift b/AppPackage/Sources/AppModels/Persistent/Filter.swift new file mode 100644 index 000000000..d244a1add --- /dev/null +++ b/AppPackage/Sources/AppModels/Persistent/Filter.swift @@ -0,0 +1,149 @@ +import SwiftUI +import Resources + +public struct Filter: Codable, Equatable, Sendable, SchemaVersioned { + public init( + doujinshi: Bool = false, + manga: Bool = false, + artistCG: Bool = false, + gameCG: Bool = false, + western: Bool = false, + nonH: Bool = false, + imageSet: Bool = false, + cosplay: Bool = false, + asianPorn: Bool = false, + misc: Bool = false, + advanced: Bool = false, + galleryName: Bool = true, + galleryTags: Bool = true, + galleryDesc: Bool = false, + torrentFilenames: Bool = false, + onlyWithTorrents: Bool = false, + lowPowerTags: Bool = false, + downvotedTags: Bool = false, + expungedGalleries: Bool = false, + minRatingActivated: Bool = false, + minRating: Int = 2, + pageRangeActivated: Bool = false, + pageLowerBound: String = "", + pageUpperBound: String = "", + disableLanguage: Bool = false, + disableUploader: Bool = false, + disableTags: Bool = false + ) { + self.doujinshi = doujinshi + self.manga = manga + self.artistCG = artistCG + self.gameCG = gameCG + self.western = western + self.nonH = nonH + self.imageSet = imageSet + self.cosplay = cosplay + self.asianPorn = asianPorn + self.misc = misc + self.advanced = advanced + self.galleryName = galleryName + self.galleryTags = galleryTags + self.galleryDesc = galleryDesc + self.torrentFilenames = torrentFilenames + self.onlyWithTorrents = onlyWithTorrents + self.lowPowerTags = lowPowerTags + self.downvotedTags = downvotedTags + self.expungedGalleries = expungedGalleries + self.minRatingActivated = minRatingActivated + self.minRating = minRating + self.pageRangeActivated = pageRangeActivated + self.pageLowerBound = pageLowerBound + self.pageUpperBound = pageUpperBound + self.disableLanguage = disableLanguage + self.disableUploader = disableUploader + self.disableTags = disableTags + } + /// This model's schema history (oldest → newest); see `SchemaVersioned` / `VersionedSchema`. + /// `currentSchemaVersion` derives from the head. Append a `VersionedSchema` and adopt + /// `MigratableModel` when a breaking change lands. + public static var schemas: [any VersionedSchema.Type] { [SchemaV1.self] } + /// The v1 base schema. Its `migrate` is empty — nothing precedes v1, and the engine only runs + /// schemas newer than the stored version, so it exists solely to anchor version 1. + enum SchemaV1: VersionedSchema { + static let version = 1 + static func migrate(_ object: inout [String: JSONValue]) throws {} + } + // A self-validating field: it rejects a newer/downgrade blob on decode (see `SchemaVersion`), which + // fails the whole decode so Sharing resets to the key default. Synthesized Codable is otherwise + // untouched, so the `didSet` couplings below and optional-field tolerance still hold; a field added + // later must stay optional so old blobs keep decoding. + public var schemaVersion: SchemaVersion = 1 + public var doujinshi = false + public var manga = false + public var artistCG = false + public var gameCG = false + public var western = false + public var nonH = false + public var imageSet = false + public var cosplay = false + public var asianPorn = false + public var misc = false + + public var advanced = false + public var galleryName = true + public var galleryTags = true + public var galleryDesc = false + public var torrentFilenames = false + public var onlyWithTorrents = false + public var lowPowerTags = false { + didSet { + if lowPowerTags { + downvotedTags = false + } + } + } + public var downvotedTags = false { + didSet { + if downvotedTags { + lowPowerTags = false + } + } + } + public var expungedGalleries = false + + public var minRatingActivated = false + public var minRating = 2 + + public var pageRangeActivated = false + public var pageLowerBound = "" + public var pageUpperBound = "" + + public var disableLanguage = false + public var disableUploader = false + public var disableTags = false + + public mutating func fixInvalidData() { + if !pageLowerBound.isEmpty && Int(pageLowerBound) == nil { + pageLowerBound = "" + } + if !pageUpperBound.isEmpty && Int(pageUpperBound) == nil { + pageUpperBound = "" + } + } +} + +public enum FilterRange: Int, CaseIterable, Identifiable, Sendable { + public var id: Int { rawValue } + + case search + case global + case watched +} +public extension FilterRange { + var value: LocalizedStringResource { + switch self { + case .search: + return .filterRangeSearch + case .global: + return .filterRangeGlobal + case .watched: + return .filterRangeWatched + } + } +} diff --git a/AppPackage/Sources/AppModels/Persistent/GalleryHistory+Operations.swift b/AppPackage/Sources/AppModels/Persistent/GalleryHistory+Operations.swift new file mode 100644 index 000000000..783a9e912 --- /dev/null +++ b/AppPackage/Sources/AppModels/Persistent/GalleryHistory+Operations.swift @@ -0,0 +1,69 @@ +import Foundation + +// The browsing-history list persisted behind `@Shared(.galleryHistory)`. Kept most-recent-first; +// these are the only mutators, so the invariants live here rather than being re-derived at each call +// site: entries stay most-recent-first and unique per gid, and only a resolvable record is ever +// stored — a non-numeric gid is rejected, and a brand-new entry must carry a token (an existing entry +// may keep or backfill its own). None of them trim — the 1,000-entry cap is enforced solely by a +// launch-time prune, so in-session upserts may temporarily grow the list past the cap. +extension Array where Element == GalleryHistoryEntry { + /// The saved resume page for `gid`, or 0 when the gallery has no history entry yet. + public func readingProgress(gid: String) -> Int { + first { $0.gid == gid }?.readingProgress ?? 0 + } + + /// Records that `gid` was just opened: stamps its recency with `date`, moves it to the front, + /// fills in a previously-missing token, and preserves any saved reading progress. Inserts a + /// fresh entry when the gallery is new to the history. A non-numeric gid, or a new gallery with + /// no token, is rejected so the persisted list never accumulates unresolvable junk. + public mutating func recordGalleryOpen( + gid: String, + token: String, + date: Date, + hasRated: Bool = false, + favoriteTagIndex: Int? = nil, + favoriteTagName: String? = nil + ) { + guard Int(gid) != nil else { return } + let existing = first { $0.gid == gid } + guard existing != nil || !token.isEmpty else { return } + var entry = existing ?? GalleryHistoryEntry( + gid: gid, token: token, lastOpenDate: date, + hasRated: hasRated, favoriteTagIndex: favoriteTagIndex, + favoriteTagName: favoriteTagName + ) + removeAll { $0.gid == gid } + entry.lastOpenDate = date + if entry.token.isEmpty { entry.token = token } + entry.hasRated = hasRated + entry.favoriteTagIndex = favoriteTagIndex + entry.favoriteTagName = favoriteTagName + insert(entry, at: 0) + } + + /// Updates the saved resume page for `gid` in place, leaving its recency and position untouched. + /// Inserts a fresh front entry stamped `date` when the gallery has no history yet — e.g. a deep + /// link that jumps straight to a page before the detail screen records the open. A non-numeric + /// gid, or a new entry with no token, is rejected (an in-place update keeps its stored token). + public mutating func updateReadingProgress(gid: String, token: String, progress: Int, date: Date) { + guard Int(gid) != nil else { return } + if let index = firstIndex(where: { $0.gid == gid }) { + self[index].readingProgress = progress + } else { + guard !token.isEmpty else { return } + insert( + GalleryHistoryEntry(gid: gid, token: token, lastOpenDate: date, readingProgress: progress), + at: 0 + ) + } + } + + /// Trims the list to the `historyCap` most-recent entries by `lastOpenDate`. Called only at + /// launch: it also normalises the order to most-recent-first when it has to drop anything. + public mutating func pruneToHistoryCap() { + guard count > GalleryHistoryEntry.historyCap else { return } + self = Array( + sorted { $0.lastOpenDate > $1.lastOpenDate }.prefix(GalleryHistoryEntry.historyCap) + ) + } +} diff --git a/AppPackage/Sources/AppModels/Persistent/GalleryHistoryEntry.swift b/AppPackage/Sources/AppModels/Persistent/GalleryHistoryEntry.swift new file mode 100644 index 000000000..40e98dae2 --- /dev/null +++ b/AppPackage/Sources/AppModels/Persistent/GalleryHistoryEntry.swift @@ -0,0 +1,77 @@ +import Foundation + +/// A single browsing-history record, pairing a gallery's recency (`lastOpenDate`) with its +/// resume position (`readingProgress`) in one lightweight value. +/// +/// Only the minimal identity (`gid`/`token`), the recency key and the resume page are +/// persisted — never a gallery snapshot. The History screen re-fetches display metadata +/// from the site's `gdata` API on demand, keeping persisted website content at zero. +/// +/// The manual `init(from:)` decodes strictly: a blank identity or an unknown `schemaVersion` +/// throws, failing the whole `[GalleryHistoryEntry]` decode so Sharing resets this disposable list +/// to `[]` rather than surfacing a `""`-id Franken-entry that would collide in an `Identifiable` list. +public struct GalleryHistoryEntry: Codable, Equatable, Identifiable, Sendable, SchemaVersioned { + /// The most entries kept across launches. Enforced only by a launch-time prune (see + /// `Array.pruneToHistoryCap`); in-session upserts may temporarily exceed it. + public static let historyCap = 1000 + + public init( + gid: String, + token: String, + lastOpenDate: Date, + readingProgress: Int = 0, + hasRated: Bool = false, + favoriteTagIndex: Int? = nil, + favoriteTagName: String? = nil + ) { + self.gid = gid + self.token = token + self.lastOpenDate = lastOpenDate + self.readingProgress = readingProgress + self.hasRated = hasRated + self.favoriteTagIndex = favoriteTagIndex + self.favoriteTagName = favoriteTagName + } + public var id: String { gid } + /// This model's schema history (oldest → newest); see `SchemaVersioned` / `VersionedSchema`. + /// `currentSchemaVersion` derives from the head. Append a `VersionedSchema` and adopt + /// `MigratableModel` when a breaking change lands. + public static var schemas: [any VersionedSchema.Type] { [SchemaV1.self] } + /// The v1 base schema. Its `migrate` is empty — nothing precedes v1, and the engine only runs + /// schemas newer than the stored version, so it exists solely to anchor version 1. + enum SchemaV1: VersionedSchema { + static let version = 1 + static func migrate(_ object: inout [String: JSONValue]) throws {} + } + /// Self-validating (see `SchemaVersion`): a newer/downgrade value is rejected on decode. The + /// identity guards in `init(from:)` below stay hand-written. + public var schemaVersion: SchemaVersion = 1 + public var gid: String + public var token: String + public var lastOpenDate: Date + public var readingProgress: Int + public var hasRated: Bool = false + public var favoriteTagIndex: Int? + public var favoriteTagName: String? +} + +// MARK: Manually decode +extension GalleryHistoryEntry { + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + schemaVersion = try container.decode(SchemaVersion.self, forKey: .schemaVersion) + gid = try container.decode(String.self, forKey: .gid) + token = try container.decode(String.self, forKey: .token) + lastOpenDate = try container.decode(Date.self, forKey: .lastOpenDate) + readingProgress = try container.decode(Int.self, forKey: .readingProgress) + hasRated = try container.decodeIfPresent(Bool.self, forKey: .hasRated) ?? false + favoriteTagIndex = try container.decodeIfPresent(Int.self, forKey: .favoriteTagIndex) + favoriteTagName = try container.decodeIfPresent(String.self, forKey: .favoriteTagName) + guard !gid.isEmpty, !token.isEmpty else { + throw DecodingError.dataCorrupted(.init( + codingPath: container.codingPath, + debugDescription: "GalleryHistoryEntry requires a non-empty gid and token" + )) + } + } +} diff --git a/AppPackage/Sources/AppModels/Persistent/Setting.swift b/AppPackage/Sources/AppModels/Persistent/Setting.swift new file mode 100644 index 000000000..c0f99caab --- /dev/null +++ b/AppPackage/Sources/AppModels/Persistent/Setting.swift @@ -0,0 +1,296 @@ +import AppTools +import SwiftUI +import Resources +import Foundation + +public struct Setting: Codable, Equatable, Sendable, SchemaVersioned { + public init( + galleryHost: GalleryHost = .ehentai, + showsNewDawnGreeting: Bool = false, + enablesTagsExtension: Bool = false, + translatesTags: Bool = false, + showsTagsSearchSuggestion: Bool = false, + showsImagesInTags: Bool = false, + redirectsLinksToSelectedHost: Bool = false, + detectsLinksFromClipboard: Bool = false, + backgroundBlurRadius: Double = 10, + autoLockPolicy: AutoLockPolicy = .never, + listDisplayMode: ListDisplayMode = .detail, + accentColor: Color = .blue, + appIconType: AppIconType = .default, + showsTagsInList: Bool = false, + listTagsNumberMaximum: Int = 0, + displaysJapaneseTitle: Bool = true, + readingDirection: ReadingDirection = .vertical, + prefetchLimit: Int = 10, + enablesLandscape: Bool = false, + enablesDualPageMode: Bool = false, + exceptCover: Bool = false, + contentDividerHeight: Double = 0, + maximumScaleFactor: Double = 3, + doubleTapScaleFactor: Double = 2, + bypassesSNIFiltering: Bool = false + ) { + self.galleryHost = galleryHost + self.showsNewDawnGreeting = showsNewDawnGreeting + self.enablesTagsExtension = enablesTagsExtension + self.translatesTags = translatesTags + self.showsTagsSearchSuggestion = showsTagsSearchSuggestion + self.showsImagesInTags = showsImagesInTags + self.redirectsLinksToSelectedHost = redirectsLinksToSelectedHost + self.detectsLinksFromClipboard = detectsLinksFromClipboard + self.backgroundBlurRadius = backgroundBlurRadius + self.autoLockPolicy = autoLockPolicy + self.listDisplayMode = listDisplayMode + self.accentColor = accentColor + self.appIconType = appIconType + self.showsTagsInList = showsTagsInList + self.listTagsNumberMaximum = listTagsNumberMaximum + self.displaysJapaneseTitle = displaysJapaneseTitle + self.readingDirection = readingDirection + self.prefetchLimit = prefetchLimit + self.enablesLandscape = enablesLandscape + self.enablesDualPageMode = enablesDualPageMode + self.exceptCover = exceptCover + self.contentDividerHeight = contentDividerHeight + self.maximumScaleFactor = maximumScaleFactor + self.doubleTapScaleFactor = doubleTapScaleFactor + self.bypassesSNIFiltering = bypassesSNIFiltering + } + /// This model's schema history (oldest → newest); see `SchemaVersioned` / `VersionedSchema`. + /// `currentSchemaVersion` derives from the head. Append a `VersionedSchema` and adopt + /// `MigratableModel` when a breaking change lands. + public static var schemas: [any VersionedSchema.Type] { [SchemaV1.self] } + /// The v1 base schema. Its `migrate` is empty — nothing precedes v1, and the engine only runs + /// schemas newer than the stored version, so it exists solely to anchor version 1. + enum SchemaV1: VersionedSchema { + static let version = 1 + static func migrate(_ object: inout [String: JSONValue]) throws {} + } + // A self-validating field: it rejects a newer/downgrade blob on decode (see `SchemaVersion`), which + // fails the whole decode so Sharing resets to the key default. Synthesized Codable is otherwise + // untouched, so the `didSet` couplings below and optional-field tolerance still hold; a field added + // later must stay optional so old blobs keep decoding. + public var schemaVersion: SchemaVersion = 1 + // Account + public var galleryHost: GalleryHost = .ehentai + public var showsNewDawnGreeting = false + + // General + public var enablesTagsExtension = false { + didSet { + if !enablesTagsExtension { + translatesTags = false + showsTagsSearchSuggestion = false + showsImagesInTags = false + } + } + } + public var translatesTags = false + public var showsTagsSearchSuggestion = false + public var showsImagesInTags = false + public var redirectsLinksToSelectedHost = false + public var detectsLinksFromClipboard = false + // `autoLockPolicy` and `backgroundBlurRadius` are coupled: auto-lock relies on a non-zero blur to + // obscure content, so enabling auto-lock while blur is 0 restores a default blur, and dropping blur + // to 0 disables auto-lock. Kept on the model (not a reducer `BindingReducer`) so every write path — + // the Setting editor or any direct `@Shared(.setting)` binding — preserves it without a reducer + // round-trip, mirroring the scale-factor clamp below. + public var backgroundBlurRadius: Double = 10 { + didSet { + if autoLockPolicy != .never && backgroundBlurRadius == 0 { + autoLockPolicy = .never + } + } + } + public var autoLockPolicy: AutoLockPolicy = .never { + didSet { + if autoLockPolicy != .never && backgroundBlurRadius == 0 { + backgroundBlurRadius = 10 + } + } + } + + // Appearance + public var listDisplayMode: ListDisplayMode = .detail + public var preferredColorScheme = PreferredColorScheme.automatic + public var accentColor: Color = .blue + public var appIconType: AppIconType = .default + public var showsTagsInList = false + public var listTagsNumberMaximum = 0 + public var displaysJapaneseTitle = true + + // Reading + public var readingDirection: ReadingDirection = .vertical + public var prefetchLimit = 10 + public var enablesLandscape = false + public var enablesDualPageMode = false + public var exceptCover = false + public var contentDividerHeight: Double = 0 + // The two scale factors are mutually clamped so `doubleTapScaleFactor <= maximumScaleFactor` + // always holds, regardless of the write path — SettingReducer's editor or the reader sheet's + // direct `@Shared(.setting)` binding. Keeping the invariant on the model (not in a reducer's + // `BindingReducer`) is what lets a write skip the reducer and still stay consistent. + public var maximumScaleFactor: Double = 3 { + didSet { + if doubleTapScaleFactor > maximumScaleFactor { + doubleTapScaleFactor = maximumScaleFactor + } + } + } + public var doubleTapScaleFactor: Double = 2 { + didSet { + if maximumScaleFactor < doubleTapScaleFactor { + maximumScaleFactor = doubleTapScaleFactor + } + } + } + + // Downloads + public static let downloadThreadLimitDefaultValue = 1 + public static let downloadAllowCellularDefaultValue = true + public static let downloadAutoRetryFailedPagesDefaultValue = true + + public var downloadThreadLimit = Self.downloadThreadLimitDefaultValue + public var downloadAllowCellular = Self.downloadAllowCellularDefaultValue + public var downloadAutoRetryFailedPages = Self.downloadAutoRetryFailedPagesDefaultValue + + // Laboratory + public var bypassesSNIFiltering = false +} + +extension Setting { + public var downloadRequestOptions: DownloadRequestOptions { + .init( + threadLimit: downloadThreadLimit, + allowCellular: downloadAllowCellular, + autoRetryFailedPages: downloadAutoRetryFailedPages + ) + } +} + +public enum GalleryHost: String, Codable, Equatable, CaseIterable, Identifiable, Sendable { + case ehentai = "E-Hentai" + case exhentai = "ExHentai" + + public var id: Int { hashValue } + public var url: URL { + switch self { + case .ehentai: + return Defaults.URL.ehentai + case .exhentai: + return Defaults.URL.exhentai + } + } + public var cookieURLs: [URL] { + switch self { + case .ehentai: + return [Defaults.URL.ehentai] + + case .exhentai: + return [Defaults.URL.exhentai, Defaults.URL.sexhentai] + } + } + public var abbr: String { + switch self { + case .ehentai: + return "eh" + case .exhentai: + return "ex" + } + } +} + +public enum AutoLockPolicy: Int, Codable, CaseIterable, Identifiable, Sendable { + public 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 { + public var value: LocalizedStringResource { + switch self { + case .never: + return .autoLockPolicyNever + case .instantly: + return .autoLockPolicyInstantly + case .sec15: + return .RLocalizable.seconds(count: rawValue) + case .min1, .min5, .min10, .min30: + return .RLocalizable.minutes(count: rawValue / 60) + } + } +} + +public enum PreferredColorScheme: Int, Codable, CaseIterable, Identifiable, Sendable { + public var id: Int { rawValue } + + case automatic + case light + case dark +} +extension PreferredColorScheme { + public var value: LocalizedStringResource { + switch self { + case .automatic: + return .preferredColorSchemeAutomatic + case .light: + return .preferredColorSchemeLight + case .dark: + return .preferredColorSchemeDark + } + } + public var userInterfaceStyle: UIUserInterfaceStyle { + switch self { + case .automatic: + return .unspecified + case .light: + return .light + case .dark: + return .dark + } + } +} + +public enum ReadingDirection: Int, Codable, CaseIterable, Identifiable, Sendable { + public var id: Int { rawValue } + + case vertical + case rightToLeft + case leftToRight +} +extension ReadingDirection { + public var value: LocalizedStringResource { + switch self { + case .vertical: + return .readingDirectionVertical + case .rightToLeft: + return .readingDirectionRightToLeft + case .leftToRight: + return .readingDirectionLeftToRight + } + } +} + +public enum ListDisplayMode: Int, Codable, CaseIterable, Identifiable, Sendable { + public var id: Int { rawValue } + + case detail + case thumbnail +} +extension ListDisplayMode { + public var value: LocalizedStringResource { + switch self { + case .detail: + return .listDisplayModeDetail + case .thumbnail: + return .listDisplayModeThumbnail + } + } +} diff --git a/AppPackage/Sources/AppModels/Persistent/User.swift b/AppPackage/Sources/AppModels/Persistent/User.swift new file mode 100644 index 000000000..bc4e9be6d --- /dev/null +++ b/AppPackage/Sources/AppModels/Persistent/User.swift @@ -0,0 +1,72 @@ +import Foundation +import Resources + +public struct User: Codable, Equatable, Sendable, SchemaVersioned { + public init( + displayName: String? = nil, + avatarURL: URL? = nil, + credits: String? = nil, + galleryPoints: String? = nil, + favoriteCategories: [Int: String]? = nil + ) { + self.displayName = displayName + self.avatarURL = avatarURL + self.credits = credits + self.galleryPoints = galleryPoints + self.favoriteCategories = favoriteCategories + } + public static let empty = User() + + /// This model's schema history (oldest → newest); see `SchemaVersioned` / `VersionedSchema`. + /// `currentSchemaVersion` derives from the head. Append a `VersionedSchema` and adopt + /// `MigratableModel` when a breaking change lands. + public static var schemas: [any VersionedSchema.Type] { [SchemaV1.self] } + /// The v1 base schema. Its `migrate` is empty — nothing precedes v1, and the engine only runs + /// schemas newer than the stored version, so it exists solely to anchor version 1. + enum SchemaV1: VersionedSchema { + static let version = 1 + static func migrate(_ object: inout [String: JSONValue]) throws {} + } + // A self-validating field: it rejects a newer/downgrade blob on decode (see `SchemaVersion`), which + // fails the whole decode so Sharing resets to the key default. Synthesized Codable is otherwise + // untouched, so optional-field tolerance still holds; a field added later must stay optional so old + // blobs keep decoding. + public var schemaVersion: SchemaVersion = 1 + public var displayName: String? + public var avatarURL: URL? + + public var credits: String? + public var galleryPoints: String? + + public var favoriteCategories: [Int: String]? + + public func getFavoriteCategory(index: Int) -> String { + guard index != -1 else { return String(localized: .favoriteCategoryAll) } + let defaultCategory = String(localized: .favoriteCategoryDefault(index: index)) + let category = favoriteCategories?[index] ?? defaultCategory + let isDefault = category == "Favorites \(index)" + return isDefault ? defaultCategory : category + } +} + +public enum FavoritesType: String, Codable, CaseIterable, Sendable { + public static func getTypeFrom(index: Int) -> FavoritesType { + FavoritesType.allCases.filter({ $0.index == index }).first ?? .all + } + + public var index: Int { + Int(rawValue.replacingOccurrences(of: "favorite_", with: "")) ?? -1 + } + + case all = "all" + case favorite0 = "favorite_0" + case favorite1 = "favorite_1" + case favorite2 = "favorite_2" + case favorite3 = "favorite_3" + case favorite4 = "favorite_4" + case favorite5 = "favorite_5" + case favorite6 = "favorite_6" + case favorite7 = "favorite_7" + case favorite8 = "favorite_8" + case favorite9 = "favorite_9" +} diff --git a/AppPackage/Sources/AppModels/Resources/Localizable.xcstrings b/AppPackage/Sources/AppModels/Resources/Localizable.xcstrings new file mode 100644 index 000000000..828c90a9b --- /dev/null +++ b/AppPackage/Sources/AppModels/Resources/Localizable.xcstrings @@ -0,0 +1,18716 @@ +{ + "sourceLanguage": "en", + "strings": { + "app_activity_log_level.debug": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Debug" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Debug" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "デバッグ" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "디버그" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "调试" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "偵錯" + } + } + } + }, + "app_activity_log_level.error": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Error" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Fehler" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "エラー" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "오류" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "错误" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "錯誤" + } + } + } + }, + "app_activity_log_level.fault": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Fault" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Störung" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "障害" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "결함" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "故障" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "故障" + } + } + } + }, + "app_activity_log_level.info": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Info" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Info" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "情報" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "정보" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "信息" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "資訊" + } + } + } + }, + "app_activity_log_level.notice": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Notice" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Hinweis" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "通知" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "알림" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "通知" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "通知" + } + } + } + }, + "app_activity_log_level.undefined": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Undefined" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Undefiniert" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "未定義" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "정의되지 않음" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "未定义" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "未定義" + } + } + } + }, + "app_activity_log_level.unknown": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Unknown" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Unbekannt" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "不明" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "알 수 없음" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "未知" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "未知" + } + } + } + }, + "app_error.authentication_required": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Authentication Required" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Authentifizierung erforderlich" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "認証が必要です" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "인증 필요" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "需要登录" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "需要登入" + } + } + } + }, + "app_error.authentication_required_description": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Login required to access this download." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Für diesen Download ist eine Anmeldung erforderlich." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "このダウンロードにアクセスするにはログインが必要です。" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "이 다운로드에 접근하려면 로그인해야 합니다." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "访问此下载内容需要登录。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "存取此下載內容需要登入。" + } + } + } + }, + "app_error.copyright_claim": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Copyright Claim" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Urheberrechtsanspruch" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "著作権侵害の申し立て" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "저작권 신고" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "版权声明" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "版權聲明" + } + } + } + }, + "app_error.file_operation_failed": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "File Operation Failed" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Dateivorgang fehlgeschlagen" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ファイル操作に失敗しました" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "파일 작업 실패" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "文件操作失败" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "檔案操作失敗" + } + } + } + }, + "app_error.gallery_expunged": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Gallery Expunged" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Galerie entfernt" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ギャラリー削除済み" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "갤러리 삭제됨" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "画廊已删除" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "畫廊已刪除" + } + } + } + }, + "app_error.ip_banned": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "IP Banned" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "IP-Adresse gesperrt" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "IP アドレスがブロックされました" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "IP 차단됨" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "IP 已封禁" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "IP 已封禁" + } + } + } + }, + "app_error.local_file_operation_failed": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Local file operation failed." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Lokaler Dateivorgang fehlgeschlagen." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ローカルファイルの操作に失敗しました。" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "로컬 파일 작업에 실패했습니다." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "本地文件操作失败。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "本機檔案操作失敗。" + } + } + } + }, + "app_error.network_error": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Network Error" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Netzwerkfehler" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ネットワークエラー" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "네트워크 오류" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "网络错误" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "網路錯誤" + } + } + } + }, + "app_error.no_updates_available": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "No updates available" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Keine Updates verfügbar" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "利用可能な更新はありません" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "사용 가능한 업데이트 없음" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "没有可用更新" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "沒有可用更新" + } + } + } + }, + "app_error.not_found": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Not found" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Nicht gefunden" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "見つかりません" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "찾을 수 없음" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "未找到" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "未找到" + } + } + } + }, + "app_error.parse_error": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Parse Error" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Parserfehler" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "解析エラー" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "파싱 오류" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "解析错误" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "解析錯誤" + } + } + } + }, + "app_error.quota_exceeded": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Quota Exceeded" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Kontingent überschritten" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "画像割り当て超過" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "할당량 초과" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "流量额度已用尽" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "流量額度已用盡" + } + } + } + }, + "app_error.quota_exceeded_description": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Image quota exceeded.\nPlease wait and try again later." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Bildkontingent überschritten.\nBitte warte einen Moment und versuche es dann erneut." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "画像の帯域割り当てを使い切りました。\nしばらく待ってからもう一度お試しください。" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "이미지 할당량을 모두 사용했습니다.\n잠시 후 다시 시도해 주세요." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "图片流量额度已用尽。\n请稍后再试。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "圖片流量額度已用盡。\n請稍後再試。" + } + } + } + }, + "app_error.unknown_error": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Unknown Error" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Unbekannter Fehler" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "不明なエラー" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "알 수 없는 오류" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "未知错误" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "未知錯誤" + } + } + } + }, + "app_error.web_image_loading_error": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Web image loading error" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Fehler beim Laden des Webbilds" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "Web 画像の読み込みエラー" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "웹 이미지 로드 오류" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "网页图片加载错误" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "網頁圖片載入錯誤" + } + } + } + }, + "app_icon_type.default": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Default" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Standard" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "デフォルト" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "기본" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "默认" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "預設" + } + } + } + }, + "app_icon_type.developer": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Developer" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Entwickler" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "デベロッパー" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "개발자" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "开发者" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "Developer" + } + } + } + }, + "app_icon_type.not_my_president": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "NOT MY PRESIDENT" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "NICHT MEIN PRÄSIDENT" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "私の大統領ではない" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "내 대통령이 아니다" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "他不是我的主席" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "NOT MY PRESIDENT" + } + } + } + }, + "app_icon_type.stand_with_ukraine_2022": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Stand With Ukraine (2022)" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Solidarität mit der Ukraine (2022)" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ウクライナと共に (2022)" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "우크라이나와 함께 (2022)" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "与乌克兰同在 (2022)" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "Stand With Ukraine (2022)" + } + } + } + }, + "app_icon_type.ukiyoe": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Ukiyo-e" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Ukiyo-e" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "浮世絵" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "우키요에" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "浮世绘" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "Ukiyo-e" + } + } + } + }, + "archive_resolution.original": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Original" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Original" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "オリジナル" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "원본" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "原始分辨率" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "原始畫質" + } + } + } + }, + "auto_lock_policy.instantly": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Instantly" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Sofort" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "すぐに" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "즉시" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "立即" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "立刻" + } + } + } + }, + "auto_lock_policy.never": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Never" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Nie" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "なし" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "안 함" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "不锁定" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "永不自動鎖定" + } + } + } + }, + "ban_interval.and": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "and" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "und" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "" + } + } + } + }, + "browsing_country.afghanistan": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Afghanistan" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Afghanistan" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "アフガニスタン" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "아프가니스탄" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "阿富汗" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "阿富汗" + } + } + } + }, + "browsing_country.aland_islands": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Aland Islands" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Aland Islands" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "オーランド諸島" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "알란드 제도" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "奥兰群岛" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "奧蘭群島" + } + } + } + }, + "browsing_country.albania": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Albania" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Albania" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "アルバニア" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "알바니아" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "阿尔巴尼亚" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "阿爾巴尼亞" + } + } + } + }, + "browsing_country.algeria": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Algeria" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Algeria" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "アルジェリア" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "알제리아" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "阿尔及利亚" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "阿爾及利亞" + } + } + } + }, + "browsing_country.american_samoa": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "American Samoa" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "American Samoa" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "アメリカ領サモア" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "아메리칸 사모아" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "美属萨摩亚" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "美屬薩摩亞" + } + } + } + }, + "browsing_country.andorra": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Andorra" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Andorra" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "アンドラ" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "안도라" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "安道尔" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "安道爾" + } + } + } + }, + "browsing_country.angola": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Angola" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Angola" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "アンゴラ" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "앙골라" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "安哥拉" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "安哥拉" + } + } + } + }, + "browsing_country.anguilla": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Anguilla" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Anguilla" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "アンギラ" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "안젤라" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "安圭拉" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "安圭拉" + } + } + } + }, + "browsing_country.antarctica": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Antarctica" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Antarctica" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "南極大陸" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "남극" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "南极洲" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "南極洲" + } + } + } + }, + "browsing_country.antigua_and_barbuda": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Antigua and Barbuda" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Antigua and Barbuda" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "アンティグア・バーブーダ" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "앤티가 바부다" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "安提瓜和巴布达" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "安地卡及巴布達" + } + } + } + }, + "browsing_country.argentina": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Argentina" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Argentina" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "アルゼンチン" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "아르헨티나" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "阿根廷" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "阿根廷" + } + } + } + }, + "browsing_country.armenia": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Armenia" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Armenia" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "アルメニア" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "아르메니아" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "亚美尼亚" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "亞美尼亞" + } + } + } + }, + "browsing_country.aruba": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Aruba" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Aruba" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "アルバ" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "아루바 섬" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "阿鲁巴" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "阿魯巴" + } + } + } + }, + "browsing_country.asia_pacific_region": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Asia-Pacific Region" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Asia-Pacific Region" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "アジア太平洋地域" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "아시아 태평양 영역" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "亚太地区" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "亞太地區" + } + } + } + }, + "browsing_country.australia": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Australia" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Australia" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "オーストラリア" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "호주" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "澳大利亚" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "澳洲" + } + } + } + }, + "browsing_country.austria": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Austria" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Austria" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "オーストリア" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "오스트리아" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "奥地利" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "奧地利" + } + } + } + }, + "browsing_country.auto_detect": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Auto-Detect" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Auto-Detect" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "自動検出" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "자동으로 설정" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "自动检测" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "自動偵測" + } + } + } + }, + "browsing_country.azerbaijan": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Azerbaijan" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Azerbaijan" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "アゼルバイジャン" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "아제르바이잔" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "阿塞拜疆" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "亞塞拜然" + } + } + } + }, + "browsing_country.bahamas": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Bahamas" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Bahamas" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "バハマ" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "바하마스" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "巴哈马" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "巴哈馬" + } + } + } + }, + "browsing_country.bahrain": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Bahrain" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Bahrain" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "バーレーン" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "바레인" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "巴林" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "巴林" + } + } + } + }, + "browsing_country.bangladesh": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Bangladesh" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Bangladesh" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "バングラデシュ" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "방글라데시" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "孟加拉国" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "孟加拉" + } + } + } + }, + "browsing_country.barbados": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Barbados" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Barbados" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "バルバドス" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "바베이도스" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "巴巴多斯" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "巴貝多" + } + } + } + }, + "browsing_country.belarus": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Belarus" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Belarus" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ベラルーシ" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "벨라루스" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "白俄罗斯" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "白俄羅斯" + } + } + } + }, + "browsing_country.belgium": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Belgium" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Belgium" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ベルギー" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "벨기에" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "比利时" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "比利時" + } + } + } + }, + "browsing_country.belize": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Belize" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Belize" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ベリーズ" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "벨리즈" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "伯利兹" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "貝里斯" + } + } + } + }, + "browsing_country.benin": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Benin" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Benin" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ベナン" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "베냉" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "贝宁" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "貝南" + } + } + } + }, + "browsing_country.bermuda": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Bermuda" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Bermuda" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "バミューダ諸島" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "버뮤다" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "百慕大" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "百慕達" + } + } + } + }, + "browsing_country.bhutan": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Bhutan" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Bhutan" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ブータン" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "부탄" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "不丹" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "不丹" + } + } + } + }, + "browsing_country.bolivia": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Bolivia" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Bolivia" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ボリビア" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "볼리비아" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "玻利维亚" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "玻利維亞" + } + } + } + }, + "browsing_country.bonaire_saint_eustatius_and_saba": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Bonaire Saint Eustatius and Saba" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Bonaire Saint Eustatius and Saba" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ボネール、シント・ユースタティウスおよびサバ" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "보네르 성 유스타티우스와 사바" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "博奈尔、圣尤斯特歇斯与萨巴" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "博奈爾、聖尤斯特歇斯和薩巴" + } + } + } + }, + "browsing_country.bosnia_and_herzegovina": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Bosnia and Herzegovina" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Bosnia and Herzegovina" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ボスニア・ヘルツェゴビナ" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "보스니아 헤르체코비나 " + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "波斯尼亚和黑塞哥维那" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "波士尼亞" + } + } + } + }, + "browsing_country.botswana": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Botswana" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Botswana" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ボツワナ" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "보츠와나" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "博茨瓦纳" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "波札那" + } + } + } + }, + "browsing_country.bouvet_island": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Bouvet Island" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Bouvet Island" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ブーベ島" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "부베섬" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "布韦岛" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "布威島" + } + } + } + }, + "browsing_country.brazil": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Brazil" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Brazil" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ブラジル" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "브라질" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "巴西" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "巴西" + } + } + } + }, + "browsing_country.british_indian_ocean_territory": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "British Indian Ocean Territory" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "British Indian Ocean Territory" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "イギリス領インド洋地域" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "영국령 인도양 식민지" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "英属印度洋领地" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "英屬印度洋領地" + } + } + } + }, + "browsing_country.brunei_darussalam": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Brunei Darussalam" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Brunei Darussalam" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ブルネイ・ダルサラーム" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "브루나이 다루살람" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "文莱" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "汶萊" + } + } + } + }, + "browsing_country.bulgaria": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Bulgaria" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Bulgaria" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ブルガリア" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "불가리아" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "保加利亚" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "保加利亞" + } + } + } + }, + "browsing_country.burkina_faso": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Burkina Faso" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Burkina Faso" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ブルキナファソ" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "부르키나 파소" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "布基纳法索" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "布吉納法索" + } + } + } + }, + "browsing_country.burundi": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Burundi" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Burundi" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ブルンジ" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "부룬디" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "蒲隆地" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "蒲隆地" + } + } + } + }, + "browsing_country.cambodia": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Cambodia" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Cambodia" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "カンボジア" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "캄보디아" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "柬埔寨" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "柬埔寨" + } + } + } + }, + "browsing_country.cameroon": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Cameroon" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Cameroon" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "カメルーン" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "카메룬" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "喀麦隆" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "喀麥隆" + } + } + } + }, + "browsing_country.canada": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Canada" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Canada" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "カナダ" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "캐나다" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "加拿大" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "加拿大" + } + } + } + }, + "browsing_country.cape_verde": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Cape Verde" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Cape Verde" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "カーボベルデ" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "포르투갈어" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "佛得角" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "維德角" + } + } + } + }, + "browsing_country.cayman_islands": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Cayman Islands" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Cayman Islands" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ケイマン諸島" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "케이맨 제도" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "开曼群岛" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "開曼群島" + } + } + } + }, + "browsing_country.central_african_republic": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Central African Republic" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Central African Republic" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "中央アフリカ共和国" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "중앙아프리카 공화국" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "中非" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "中非" + } + } + } + }, + "browsing_country.chad": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Chad" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Chad" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "チャド" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "차드" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "乍得" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "查德" + } + } + } + }, + "browsing_country.chile": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Chile" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Chile" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "チリ" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "칠레" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "智利" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "智利" + } + } + } + }, + "browsing_country.china": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "China" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "China" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "中華人民共和国" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "중국" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "中华人民共和国" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "中國" + } + } + } + }, + "browsing_country.christmas_island": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Christmas Island" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Christmas Island" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "クリスマス島" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "크리스마스 섬" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "圣诞岛" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "聖誕島" + } + } + } + }, + "browsing_country.cocos_islands": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Cocos Islands" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Cocos Islands" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ココス諸島" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "코코스 제도" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "科科斯岛" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "科科斯(基林)群島" + } + } + } + }, + "browsing_country.colombia": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Colombia" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Colombia" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "コロンビア" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "콜롬비아" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "哥伦比亚" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "哥倫比亞" + } + } + } + }, + "browsing_country.comoros": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Comoros" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Comoros" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "コモロ" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "코모로" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "科摩罗" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "葛摩" + } + } + } + }, + "browsing_country.congo": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Congo" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Congo" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "コンゴ共和国" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "콩고" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "刚果共和国" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "剛果共和國" + } + } + } + }, + "browsing_country.cook_islands": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Cook Islands" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Cook Islands" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "クック諸島" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "쿡제도" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "库克群岛" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "庫克群島" + } + } + } + }, + "browsing_country.costa_rica": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Costa Rica" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Costa Rica" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "コスタリカ" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "코스타리카" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "哥斯达黎加" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "哥斯大黎加" + } + } + } + }, + "browsing_country.cote_d_ivoire": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Cote D'Ivoire" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Cote D'Ivoire" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "コートジボワール" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "코트디부아르" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "科特迪瓦" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "象牙海岸" + } + } + } + }, + "browsing_country.croatia": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Croatia" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Croatia" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "クロアチア" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "크로아티아" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "克罗地亚" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "克羅埃西亞" + } + } + } + }, + "browsing_country.cuba": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Cuba" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Cuba" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "キューバ" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "쿠바" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "古巴" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "古巴" + } + } + } + }, + "browsing_country.curacao": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Curacao" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Curacao" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "キュラソー島" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "큐라소" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "库拉索" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "古拉索" + } + } + } + }, + "browsing_country.cyprus": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Cyprus" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Cyprus" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "キプロス" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "키프로스" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "塞浦路斯" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "賽普勒斯" + } + } + } + }, + "browsing_country.czech_republic": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Czech Republic" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Czech Republic" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "チェコ" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "체코 공화국" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "捷克" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "捷克共和國" + } + } + } + }, + "browsing_country.denmark": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Denmark" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Denmark" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "デンマーク" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "덴마크" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "丹麦" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "丹麥" + } + } + } + }, + "browsing_country.djibouti": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Djibouti" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Djibouti" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ジブチ" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "지부티" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "吉布提" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "吉布地" + } + } + } + }, + "browsing_country.dominica": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Dominica" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Dominica" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ドミニカ" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "도미니카" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "多米尼克" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "多米尼克" + } + } + } + }, + "browsing_country.dominican_republic": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Dominican Republic" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Dominican Republic" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ドミニカ共和国" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "도미니카 공화국" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "多米尼加" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "多明尼加" + } + } + } + }, + "browsing_country.ecuador": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Ecuador" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Ecuador" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "エクアドル" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "에콰도르" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "厄瓜多尔" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "厄瓜多" + } + } + } + }, + "browsing_country.egypt": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Egypt" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Egypt" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "エジプト" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "이집트" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "埃及" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "埃及" + } + } + } + }, + "browsing_country.el_salvador": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "El Salvador" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "El Salvador" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "エルサルバドル" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "엘살바도르" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "萨尔瓦多" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "薩爾瓦多" + } + } + } + }, + "browsing_country.equatorial_guinea": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Equatorial Guinea" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Equatorial Guinea" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "赤道ギニア" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "적도 기니" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "赤道几内亚" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "赤道幾內亞" + } + } + } + }, + "browsing_country.eritrea": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Eritrea" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Eritrea" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "エリトリア" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "에리트레아" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "厄立特里亚" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "厄利垂亞" + } + } + } + }, + "browsing_country.estonia": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Estonia" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Estonia" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "エストニア" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "에스토니아" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "爱沙尼亚" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "愛沙尼亞" + } + } + } + }, + "browsing_country.ethiopia": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Ethiopia" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Ethiopia" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "エチオピア" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "에티오피아" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "埃塞俄比亚" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "衣索比亞" + } + } + } + }, + "browsing_country.europe": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Europe" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Europe" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ヨーロッパ" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "유럽" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "欧洲" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "歐洲" + } + } + } + }, + "browsing_country.falkland_islands": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Falkland Islands" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Falkland Islands" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "フォークランド諸島" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "포클랜드 제도" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "福克兰群岛" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "福克蘭群島" + } + } + } + }, + "browsing_country.faroe_islands": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Faroe Islands" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Faroe Islands" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "フェロー諸島" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "페로스 제도" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "法罗群岛" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "法羅群島" + } + } + } + }, + "browsing_country.fiji": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Fiji" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Fiji" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "フィジー" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "피지" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "斐济" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "斐濟" + } + } + } + }, + "browsing_country.finland": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Finland" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Finland" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "フィンランド" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "핀란드" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "芬兰" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "芬蘭" + } + } + } + }, + "browsing_country.france": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "France" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "France" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "フランス" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "프랑스" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "法国" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "法國" + } + } + } + }, + "browsing_country.french_guiana": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "French Guiana" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "French Guiana" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "フランス領ギアナ" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "프랑스령 기아나" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "法属圭亚那" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "法屬圭亞那" + } + } + } + }, + "browsing_country.french_polynesia": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "French Polynesia" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "French Polynesia" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "フランス領ポリネシア" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "프랑스령 폴리네시아" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "法属波利尼西亚" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "法屬玻里尼西亞" + } + } + } + }, + "browsing_country.french_southern_territories": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "French Southern Territories" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "French Southern Territories" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "フランス領南方・南極地域" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "프랑스령 남부와 남극지역" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "法属南部和南极领地" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "法屬南部領土" + } + } + } + }, + "browsing_country.gabon": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Gabon" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Gabon" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ガボン" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "가봉" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "加蓬" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "加彭" + } + } + } + }, + "browsing_country.gambia": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Gambia" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Gambia" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ガンビア" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "감비아" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "冈比亚" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "甘比亞" + } + } + } + }, + "browsing_country.georgia": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Georgia" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Georgia" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ジョージア" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "그루지야" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "格鲁吉亚" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "喬治亞" + } + } + } + }, + "browsing_country.germany": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Germany" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Germany" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ドイツ" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "독일" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "德国" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "德國" + } + } + } + }, + "browsing_country.ghana": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Ghana" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Ghana" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ガーナ" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "가나" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "加纳" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "迦納" + } + } + } + }, + "browsing_country.gibraltar": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Gibraltar" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Gibraltar" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ジブラルタル" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "지브롤터" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "直布罗陀" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "直布羅陀" + } + } + } + }, + "browsing_country.greece": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Greece" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Greece" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ギリシャ" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "희랍" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "希腊" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "希臘" + } + } + } + }, + "browsing_country.greenland": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Greenland" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Greenland" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "グリーンランド" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "그린란드" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "格陵兰" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "格陵蘭" + } + } + } + }, + "browsing_country.grenada": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Grenada" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Grenada" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "グレナダ" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "그레나다" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "格林纳达" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "格瑞那達" + } + } + } + }, + "browsing_country.guadeloupe": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Guadeloupe" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Guadeloupe" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "グアドループ" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "과들루프 섬" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "瓜德罗普" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "瓜地洛普" + } + } + } + }, + "browsing_country.guam": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Guam" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Guam" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "グアム" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "괌" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "关岛" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "關島" + } + } + } + }, + "browsing_country.guatemala": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Guatemala" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Guatemala" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "グアテマラ" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "과테말라" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "危地马拉" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "瓜地馬拉" + } + } + } + }, + "browsing_country.guernsey": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Guernsey" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Guernsey" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ガーンジー" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "건지종 젖소" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "根西" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "耿西" + } + } + } + }, + "browsing_country.guinea": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Guinea" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Guinea" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ギニア" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "기니" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "几内亚" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "幾內亞" + } + } + } + }, + "browsing_country.guinea_bissau": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Guinea-Bissau" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Guinea-Bissau" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ギニアビサウ" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "기니비사우" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "几内亚比绍" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "幾內亞比索" + } + } + } + }, + "browsing_country.guyana": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Guyana" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Guyana" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ガイアナ" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "가이아나" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "圭亚那" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "蓋亞那" + } + } + } + }, + "browsing_country.haiti": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Haiti" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Haiti" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ハイチ" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "아이티" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "海地" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "海地" + } + } + } + }, + "browsing_country.heard_island_and_mc_donald_islands": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Heard Island and McDonald Islands" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Heard Island and McDonald Islands" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ハード島とマクドナルド諸島" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "허드 맥도널드 제도" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "赫德岛和麦克唐纳群岛" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "赫德島和麥克唐納群島" + } + } + } + }, + "browsing_country.honduras": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Honduras" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Honduras" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ホンジュラス" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "온두라스" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "洪都拉斯" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "宏都拉斯" + } + } + } + }, + "browsing_country.hong_kong": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Hong Kong" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Hong Kong" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "香港" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "홍콩" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "香港" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "香港" + } + } + } + }, + "browsing_country.hungary": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Hungary" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Hungary" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ハンガリー" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "헝가리" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "匈牙利" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "匈牙利" + } + } + } + }, + "browsing_country.iceland": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Iceland" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Iceland" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "アイスランド" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "Iceland" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "冰岛" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "冰島" + } + } + } + }, + "browsing_country.india": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "India" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "India" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "インド" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "인도" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "印度" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "印度" + } + } + } + }, + "browsing_country.indonesia": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Indonesia" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Indonesia" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "インドネシア" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "인도네시아" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "印度尼西亚" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "印度尼西亞(印尼)" + } + } + } + }, + "browsing_country.iran": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Iran" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Iran" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "イラン" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "이란" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "伊朗" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "伊朗" + } + } + } + }, + "browsing_country.iraq": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Iraq" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Iraq" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "イラク" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "이라크" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "伊拉克" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "伊拉克" + } + } + } + }, + "browsing_country.ireland": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Ireland" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Ireland" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "アイルランド" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "아일랜드" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "爱尔兰" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "愛爾蘭" + } + } + } + }, + "browsing_country.isle_of_man": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Isle of Man" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Isle of Man" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "マン島" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "맨 섬" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "曼岛" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "曼島" + } + } + } + }, + "browsing_country.israel": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Israel" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Israel" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "イスラエル" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "이스라엘" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "以色列" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "以色列" + } + } + } + }, + "browsing_country.italy": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Italy" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Italy" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "イタリア" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "이탈리아" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "意大利" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "義大利" + } + } + } + }, + "browsing_country.jamaica": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Jamaica" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Jamaica" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ジャマイカ" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "자마이카" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "牙买加" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "牙買加" + } + } + } + }, + "browsing_country.japan": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Japan" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Japan" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "日本" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "일본" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "日本" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "日本" + } + } + } + }, + "browsing_country.jersey": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Jersey" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Jersey" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ジャージー" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "저시" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "泽西" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "澤西島" + } + } + } + }, + "browsing_country.jordan": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Jordan" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Jordan" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ヨルダン" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "요단" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "约旦" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "約旦" + } + } + } + }, + "browsing_country.kazakhstan": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Kazakhstan" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Kazakhstan" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "カザフスタン" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "카자흐스탄" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "哈萨克斯坦" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "哈薩克共和國" + } + } + } + }, + "browsing_country.kenya": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Kenya" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Kenya" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ケニア" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "케냐" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "肯尼亚" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "肯亞" + } + } + } + }, + "browsing_country.kiribati": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Kiribati" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Kiribati" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "キリバス" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "키리바시" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "基里巴斯" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "吉里巴斯" + } + } + } + }, + "browsing_country.kuwait": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Kuwait" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Kuwait" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "クウェート" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "쿠웨이트" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "科威特" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "科威特" + } + } + } + }, + "browsing_country.kyrgyzstan": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Kyrgyzstan" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Kyrgyzstan" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "キルギス" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "키르기스스탄" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "吉尔吉斯斯坦" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "吉爾吉斯" + } + } + } + }, + "browsing_country.lao_peoples_democratic_republic": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Lao People's Democratic Republic" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Lao People's Democratic Republic" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ラオス" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "라오 인민민주공화국" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "老挝" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "寮國" + } + } + } + }, + "browsing_country.latvia": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Latvia" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Latvia" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ラトビア" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "라트비아" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "拉脱维亚" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "拉脫維亞" + } + } + } + }, + "browsing_country.lebanon": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Lebanon" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Lebanon" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "レバノン" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "레바논" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "黎巴嫩" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "黎巴嫩" + } + } + } + }, + "browsing_country.lesotho": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Lesotho" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Lesotho" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "レソト" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "레소토" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "莱索托" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "賴索托" + } + } + } + }, + "browsing_country.liberia": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Liberia" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Liberia" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "リベリア" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "리베리아" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "利比里亚" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "賴比瑞亞" + } + } + } + }, + "browsing_country.libya": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Libya" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Libya" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "リビア" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "리비아" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "利比亚" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "利比亞" + } + } + } + }, + "browsing_country.liechtenstein": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Liechtenstein" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Liechtenstein" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "リヒテンシュタイン" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "리히텐슈타인" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "列支敦士登" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "列支敦斯登" + } + } + } + }, + "browsing_country.lithuania": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Lithuania" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Lithuania" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "リトアニア" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "리투아니아" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "立陶宛" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "立陶宛" + } + } + } + }, + "browsing_country.luxembourg": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Luxembourg" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Luxembourg" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ルクセンブルク" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "룩셈부르크" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "卢森堡" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "盧森堡" + } + } + } + }, + "browsing_country.macau": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Macau" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Macau" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "マカオ" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "마카오" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "澳门" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "澳門" + } + } + } + }, + "browsing_country.macedonia": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Macedonia" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Macedonia" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "マケドニア" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "마케도니아" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "马其顿" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "北馬其頓" + } + } + } + }, + "browsing_country.madagascar": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Madagascar" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Madagascar" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "マダガスカル" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "마다스카르" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "马达加斯加" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "馬達加斯加" + } + } + } + }, + "browsing_country.malawi": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Malawi" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Malawi" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "マラウイ" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "말라위" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "马拉维" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "馬拉威" + } + } + } + }, + "browsing_country.malaysia": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Malaysia" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Malaysia" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "マレーシア" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "말레이시아" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "马来西亚" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "馬來西亞" + } + } + } + }, + "browsing_country.maldives": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Maldives" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Maldives" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "モルディブ" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "말디브" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "马尔代夫" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "馬爾地夫" + } + } + } + }, + "browsing_country.mali": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Mali" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Mali" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "マリ" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "말리" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "马里" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "馬利" + } + } + } + }, + "browsing_country.malta": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Malta" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Malta" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "マルタ" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "말타" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "马耳他" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "馬爾他" + } + } + } + }, + "browsing_country.marshall_islands": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Marshall Islands" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Marshall Islands" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "マーシャル諸島" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "마샬군도" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "马绍尔群岛" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "馬紹爾群島" + } + } + } + }, + "browsing_country.martinique": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Martinique" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Martinique" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "マルティニーク" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "마르티니크" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "马提尼克" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "馬丁尼克" + } + } + } + }, + "browsing_country.mauritania": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Mauritania" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Mauritania" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "モーリタニア" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "모리타니아" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "毛里塔尼亚" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "茅利塔尼亞" + } + } + } + }, + "browsing_country.mauritius": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Mauritius" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Mauritius" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "モーリシャス" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "모리셔스" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "模里西斯" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "模里西斯" + } + } + } + }, + "browsing_country.mayotte": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Mayotte" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Mayotte" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "マヨット" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "마요트 섬" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "马约特" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "馬約特" + } + } + } + }, + "browsing_country.mexico": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Mexico" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Mexico" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "メキシコ" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "맥시코" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "墨西哥" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "墨西哥" + } + } + } + }, + "browsing_country.micronesia": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Micronesia" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Micronesia" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ミクロネシア" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "마크로네시아" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "密克罗尼西亚" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "密克羅尼西亞" + } + } + } + }, + "browsing_country.moldova": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Moldova" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Moldova" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "モルドバ" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "몰도바" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "摩尔多瓦" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "摩爾多瓦" + } + } + } + }, + "browsing_country.monaco": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Monaco" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Monaco" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "モナコ" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "모나코" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "摩纳哥" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "摩納哥" + } + } + } + }, + "browsing_country.mongolia": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Mongolia" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Mongolia" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "モンゴル" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "몽콜" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "蒙古" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "蒙古" + } + } + } + }, + "browsing_country.montenegro": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Montenegro" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Montenegro" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "モンテネグロ" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "몬테네그로" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "黑山" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "蒙特內哥羅" + } + } + } + }, + "browsing_country.montserrat": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Montserrat" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Montserrat" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "モントセラト" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "몬트세라트섬" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "蒙塞拉特岛" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "蒙特塞拉特" + } + } + } + }, + "browsing_country.morocco": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Morocco" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Morocco" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "モロッコ" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "모로코가족" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "摩洛哥" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "摩洛哥" + } + } + } + }, + "browsing_country.mozambique": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Mozambique" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Mozambique" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "モザンビーク" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "모잠비크" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "莫桑比克" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "莫三比克" + } + } + } + }, + "browsing_country.myanmar": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Myanmar" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Myanmar" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ミャンマー" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "미얀마" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "缅甸" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "緬甸" + } + } + } + }, + "browsing_country.namibia": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Namibia" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Namibia" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ナミビア" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "나미비아" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "纳米比亚" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "納米比亞" + } + } + } + }, + "browsing_country.nauru": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Nauru" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Nauru" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ナウル" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "나우루" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "诺鲁" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "諾魯" + } + } + } + }, + "browsing_country.nepal": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Nepal" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Nepal" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ネパール" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "네팔" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "尼泊尔" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "尼泊爾" + } + } + } + }, + "browsing_country.netherlands": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Netherlands" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Netherlands" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "オランダ" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "네덜란드" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "荷兰" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "荷蘭" + } + } + } + }, + "browsing_country.new_caledonia": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "New Caledonia" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "New Caledonia" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ニューカレドニア" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "뉴칼레도니아" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "新喀里多尼亚" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "新喀里多尼亞" + } + } + } + }, + "browsing_country.new_zealand": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "New Zealand" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "New Zealand" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ニュージーランド" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "뉴질랜드" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "新西兰" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "紐西蘭" + } + } + } + }, + "browsing_country.nicaragua": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Nicaragua" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Nicaragua" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ニカラグア" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "나카라과" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "尼加拉瓜" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "尼加拉瓜" + } + } + } + }, + "browsing_country.niger": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Niger" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Niger" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ニジェール" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "니제르" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "尼日尔" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "尼日" + } + } + } + }, + "browsing_country.nigeria": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Nigeria" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Nigeria" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ナイジェリア" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "나이지리아" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "尼日利亚" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "奈及利亞" + } + } + } + }, + "browsing_country.niue": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Niue" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Niue" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ニウエ" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "니우에 섬" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "纽埃" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "紐埃" + } + } + } + }, + "browsing_country.norfolk_island": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Norfolk Island" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Norfolk Island" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ノーフォーク島" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "노퍽섬" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "诺福克岛" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "諾福克島" + } + } + } + }, + "browsing_country.north_korea": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "North Korea" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "North Korea" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "朝鮮" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "북한" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "朝鲜" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "朝鮮" + } + } + } + }, + "browsing_country.northern_mariana_islands": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Northern Mariana Islands" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Northern Mariana Islands" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "北マリアナ諸島" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "북마리아나제도" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "北马里亚纳群岛" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "北馬里亞納群島" + } + } + } + }, + "browsing_country.norway": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Norway" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Norway" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ノルウェー" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "노르웨이" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "挪威" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "挪威" + } + } + } + }, + "browsing_country.oman": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Oman" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Oman" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "オマーン" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "오만" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "阿曼" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "阿曼" + } + } + } + }, + "browsing_country.pakistan": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Pakistan" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Pakistan" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "パキスタン" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "파키스탄" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "巴基斯坦" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "巴基斯坦" + } + } + } + }, + "browsing_country.palau": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Palau" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Palau" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "パラオ" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "팔라우" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "帛琉" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "帛琉" + } + } + } + }, + "browsing_country.palestinian_territory": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Palestinian Territory" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Palestinian Territory" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "パレスチナ" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "팔레스타인의 지역" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "巴勒斯坦" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "巴勒斯坦領土" + } + } + } + }, + "browsing_country.panama": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Panama" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Panama" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "パナマ" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "파나마모자" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "巴拿马" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "巴拿馬" + } + } + } + }, + "browsing_country.papua_new_guinea": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Papua New Guinea" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Papua New Guinea" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "パプアニューギニア" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "파푸아뉴기니" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "巴布亚新几内亚" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "巴布亞紐幾內亞" + } + } + } + }, + "browsing_country.paraguay": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Paraguay" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Paraguay" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "パラグアイ" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "파라과이" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "巴拉圭" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "巴拉圭" + } + } + } + }, + "browsing_country.peru": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Peru" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Peru" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ペルー" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "페루" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "秘鲁" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "秘魯" + } + } + } + }, + "browsing_country.philippines": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Philippines" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Philippines" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "フィリピン" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "필리핀" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "菲律宾" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "菲律賓" + } + } + } + }, + "browsing_country.pitcairn_islands": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Pitcairn Islands" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Pitcairn Islands" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ピトケアン諸島" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "핏케언 제도" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "皮特凯恩群岛" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "皮特凱恩群島" + } + } + } + }, + "browsing_country.poland": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Poland" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Poland" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ポーランド" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "폴란드" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "波兰" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "波蘭" + } + } + } + }, + "browsing_country.portugal": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Portugal" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Portugal" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ポルトガル" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "포르투갈" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "葡萄牙" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "葡萄牙" + } + } + } + }, + "browsing_country.puerto_rico": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Puerto Rico" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Puerto Rico" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "プエルトリコ" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "푸에르토리코" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "波多黎各" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "波多黎各" + } + } + } + }, + "browsing_country.qatar": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Qatar" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Qatar" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "カタール" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "카타로" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "卡塔尔" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "卡達" + } + } + } + }, + "browsing_country.reunion": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Reunion" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Reunion" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ユニオン" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "레워니옹" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "留尼汪" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "留尼旺" + } + } + } + }, + "browsing_country.romania": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Romania" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Romania" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ルーマニア" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "루마니아" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "羅馬尼亞" + } + } + } + }, + "browsing_country.russian_federation": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Russian Federation" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Russian Federation" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ロシア" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "러시아 연방" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "俄罗斯" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "俄羅斯" + } + } + } + }, + "browsing_country.rwanda": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Rwanda" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Rwanda" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ルワンダ" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "르완다" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "卢旺达" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "盧安達" + } + } + } + }, + "browsing_country.saint_barthelemy": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Saint Barthelemy" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Saint Barthelemy" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "サン・バルテルミー島" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "생바르텔레미" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "圣巴泰勒米" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "聖巴瑟米" + } + } + } + }, + "browsing_country.saint_helena": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Saint Helena" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Saint Helena" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "セントヘレナ" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "세인츠헬레나 섬" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "圣赫勒拿" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "聖赫勒拿" + } + } + } + }, + "browsing_country.saint_kitts_and_nevis": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Saint Kitts and Nevis" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Saint Kitts and Nevis" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "セントクリストファー・ネービス" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "세인트키츠네비스" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "圣基茨岛" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "聖克里斯多福及尼維斯" + } + } + } + }, + "browsing_country.saint_lucia": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Saint Lucia" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Saint Lucia" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "セントルシア" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "세인트루시아" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "圣卢西亚" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "聖露西亞" + } + } + } + }, + "browsing_country.saint_martin": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Saint Martin" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Saint Martin" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "サン・マルタン島" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "세인트 마틴" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "圣马丁岛" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "聖馬丁" + } + } + } + }, + "browsing_country.saint_pierre_and_miquelon": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Saint Pierre and Miquelon" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Saint Pierre and Miquelon" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "サンピエール島・ミクロン島" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "생피에르 미글롱" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "圣皮埃尔和密克隆" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "聖皮耶與密克隆" + } + } + } + }, + "browsing_country.saint_vincent_and_the_grenadines": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Saint Vincent and the Grenadines" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Saint Vincent and the Grenadines" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "セントビンセントおよびグレナディーン諸島" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "세인트빈센트 그레나딘" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "圣文森特和格林纳丁斯" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "聖文森及格瑞那丁" + } + } + } + }, + "browsing_country.samoa": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Samoa" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Samoa" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "サモア" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "사모아" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "萨摩亚" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "薩摩亞" + } + } + } + }, + "browsing_country.san_marino": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "San Marino" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "San Marino" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "サンマリノ" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "산마리노" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "圣马力诺" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "聖馬利諾" + } + } + } + }, + "browsing_country.sao_tome_and_principe": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Sao Tome and Principe" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Sao Tome and Principe" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "サントメ・プリンシペ" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "상투메 프린시페 도브라" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "圣多美和普林西比" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "聖多美普林西比" + } + } + } + }, + "browsing_country.saudi_arabia": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Saudi Arabia" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Saudi Arabia" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "サウジアラビア" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "사우디 아라비아" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "沙地阿拉伯" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "沙烏地阿拉伯" + } + } + } + }, + "browsing_country.senegal": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Senegal" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Senegal" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "セネガル" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "세네갈" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "塞内加尔" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "塞內加爾" + } + } + } + }, + "browsing_country.serbia": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Serbia" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Serbia" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "セルビア" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "세르비아" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "塞尔维亚" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "塞爾維亞" + } + } + } + }, + "browsing_country.seychelles": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Seychelles" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Seychelles" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "セーシェル" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "세이셸" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "塞舌尔" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "塞席爾" + } + } + } + }, + "browsing_country.sierra_leone": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Sierra Leone" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Sierra Leone" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "シエラレオネ" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "시에라리온" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "塞拉利昂" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "獅子山" + } + } + } + }, + "browsing_country.singapore": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Singapore" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Singapore" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "シンガポール" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "싱가포르" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "新加坡" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "新加坡" + } + } + } + }, + "browsing_country.sint_maarten": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Sint Maarten" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Sint Maarten" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "シント・マールテン" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "신트마르턴" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "圣马丁岛" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "聖馬丁" + } + } + } + }, + "browsing_country.slovakia": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Slovakia" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Slovakia" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "スロバキア" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "슬로바키아" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "斯洛伐克" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "斯洛伐克" + } + } + } + }, + "browsing_country.slovenia": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Slovenia" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Slovenia" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "スロベニア" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "슬로베니아" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "斯洛文尼亚" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "斯洛維尼亞" + } + } + } + }, + "browsing_country.solomon_islands": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Solomon Islands" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Solomon Islands" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ソロモン諸島" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "솔로몬 제도" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "所罗门群岛" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "索羅門群島" + } + } + } + }, + "browsing_country.somalia": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Somalia" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Somalia" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ソマリア" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "소말리아" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "索马里" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "索馬利亞" + } + } + } + }, + "browsing_country.south_africa": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "South Africa" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "South Africa" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "南アフリカ" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "남아프리카" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "南非" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "南非" + } + } + } + }, + "browsing_country.south_georgia_and_the_south_sandwich_islands": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "South Georgia and the South Sandwich Islands" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "South Georgia and the South Sandwich Islands" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "サウスジョージア・サウスサンドウィッチ諸島" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "사우스조지아 사우스샌드위치 제도" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "南乔治亚和南桑威奇群岛" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "南喬治亞和南桑威奇群島" + } + } + } + }, + "browsing_country.south_korea": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "South Korea" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "South Korea" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "韓国" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "한국" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "韩国" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "韓國" + } + } + } + }, + "browsing_country.south_sudan": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "South Sudan" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "South Sudan" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "南スーダン" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "남수단" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "南苏丹" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "南蘇丹" + } + } + } + }, + "browsing_country.spain": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Spain" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Spain" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "スペイン" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "스페인" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "西班牙" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "西班牙" + } + } + } + }, + "browsing_country.sri_lanka": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Sri Lanka" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Sri Lanka" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "スリランカ" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "스리랑카" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "斯里兰卡" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "斯里蘭卡" + } + } + } + }, + "browsing_country.sudan": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Sudan" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Sudan" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "スーダン" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "수단" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "苏丹" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "蘇丹" + } + } + } + }, + "browsing_country.suriname": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Suriname" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Suriname" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "スリナム" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "수리남" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "苏里南" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "蘇利南" + } + } + } + }, + "browsing_country.svalbard_and_jan_mayen": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Svalbard and Jan Mayen" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Svalbard and Jan Mayen" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "スヴァールバル諸島およびヤンマイエン島" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "스발바르 얀마옌 제도" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "斯瓦尔巴和扬马延" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "斯瓦巴和揚馬延" + } + } + } + }, + "browsing_country.swaziland": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Swaziland" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Swaziland" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "エスワティニ" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "스와질란드" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "史瓦帝尼" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "史瓦帝尼" + } + } + } + }, + "browsing_country.sweden": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Sweden" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Sweden" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "スウェーデン" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "스웨덴" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "瑞典" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "瑞典" + } + } + } + }, + "browsing_country.switzerland": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Switzerland" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Switzerland" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "スイス" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "스위스" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "瑞士" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "瑞士" + } + } + } + }, + "browsing_country.syrian_arab_republic": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Syrian Arab Republic" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Syrian Arab Republic" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "シリア" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "시리아" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "叙利亚" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "敘利亞" + } + } + } + }, + "browsing_country.taiwan": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Taiwan" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Taiwan" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "台湾" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "대만" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "台湾" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "臺灣" + } + } + } + }, + "browsing_country.tajikistan": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Tajikistan" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Tajikistan" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "タジキスタン" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "타지키스탄" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "塔吉克斯坦" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "塔吉克" + } + } + } + }, + "browsing_country.tanzania": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Tanzania" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Tanzania" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "タンザニア" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "탄지니아" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "坦桑尼亚" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "坦尚尼亞" + } + } + } + }, + "browsing_country.thailand": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Thailand" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Thailand" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "タイ" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "태국" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "泰国" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "泰國" + } + } + } + }, + "browsing_country.the_democratic_republic_of_the_congo": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "The Democratic Republic of the Congo" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "The Democratic Republic of the Congo" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "コンゴ民主共和国" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "콩고민주공화국" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "刚果民主共和国" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "剛果民主共和國" + } + } + } + }, + "browsing_country.timor_leste": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Timor-Leste" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Timor-Leste" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "東ティモール" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "동티모르" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "东帝汶" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "東帝汶" + } + } + } + }, + "browsing_country.togo": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Togo" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Togo" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "トーゴ" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "토고" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "多哥" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "多哥" + } + } + } + }, + "browsing_country.tokelau": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Tokelau" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Tokelau" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "トケラウ" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "토켈라우" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "托克劳" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "托克勞" + } + } + } + }, + "browsing_country.tonga": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Tonga" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Tonga" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "トンガ" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "통가" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "汤加" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "東加" + } + } + } + }, + "browsing_country.trinidad_and_tobago": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Trinidad and Tobago" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Trinidad and Tobago" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "トリニダード・トバゴ" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "트리니다드토바고" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "特立尼达和多巴哥" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "千里達和托巴哥" + } + } + } + }, + "browsing_country.tunisia": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Tunisia" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Tunisia" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "チュニジア" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "튀니지" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "突尼斯" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "突尼西亞" + } + } + } + }, + "browsing_country.turkey": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Turkey" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Turkey" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "トルコ" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "터키" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "土耳其" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "土耳其" + } + } + } + }, + "browsing_country.turkmenistan": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Turkmenistan" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Turkmenistan" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "トルクメニスタン" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "투르크메니스탄" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "土库曼斯坦" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "土庫曼" + } + } + } + }, + "browsing_country.turks_and_caicos_islands": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Turks and Caicos Islands" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Turks and Caicos Islands" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "タークス・カイコス諸島" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "터크스카이코스 제도" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "特克斯和凯科斯群岛" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "土克斯及開科斯群島" + } + } + } + }, + "browsing_country.tuvalu": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Tuvalu" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Tuvalu" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ツバル" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "투발루" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "图瓦卢" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "吐瓦魯" + } + } + } + }, + "browsing_country.uganda": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Uganda" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Uganda" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ウガンダ" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "우간다" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "乌干达" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "烏干達" + } + } + } + }, + "browsing_country.ukraine": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Ukraine" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Ukraine" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ウクライナ" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "우크라이나" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "乌克兰" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "烏克蘭" + } + } + } + }, + "browsing_country.united_arab_emirates": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "United Arab Emirates" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "United Arab Emirates" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "アラブ首長国連邦" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "아랍 에미리트 연합국" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "阿拉伯联合酋长国" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "阿拉伯聯合大公國" + } + } + } + }, + "browsing_country.united_kingdom": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "United Kingdom" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "United Kingdom" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "イギリス" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "영국" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "英国" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "英國" + } + } + } + }, + "browsing_country.united_states": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "United States" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "United States" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "アメリカ" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "미국" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "美国" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "美國" + } + } + } + }, + "browsing_country.united_states_minor_outlying_islands": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "United States Minor Outlying Islands" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "United States Minor Outlying Islands" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "合衆国領有小離島" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "미국령 군소 제도" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "美国本土外小岛屿" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "美國外圍小島嶼" + } + } + } + }, + "browsing_country.uruguay": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Uruguay" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Uruguay" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ウルグアイ" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "우루과이" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "乌拉圭" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "烏拉圭" + } + } + } + }, + "browsing_country.uzbekistan": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Uzbekistan" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Uzbekistan" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ウズベキスタン" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "우즈베키스탄" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "乌兹别克斯坦" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "烏茲別克" + } + } + } + }, + "browsing_country.vanuatu": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Vanuatu" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Vanuatu" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "バヌアツ" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "바누어투" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "瓦努阿图" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "萬那杜" + } + } + } + }, + "browsing_country.vatican_city_state": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Vatican City State" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Vatican City State" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "バチカン市国" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "바티칸 시국" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "梵蒂冈城国" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "梵蒂岡" + } + } + } + }, + "browsing_country.venezuela": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Venezuela" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Venezuela" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ベネズエラ" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "베네수엘라" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "委內瑞拉" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "委內瑞拉" + } + } + } + }, + "browsing_country.vietnam": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Vietnam" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Vietnam" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ベトナム" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "베트남" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "越南" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "越南" + } + } + } + }, + "browsing_country.virgin_islands_US": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "U.S. Virgin Islands" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "U.S. Virgin Islands" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "アメリカ領ヴァージン諸島" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "세인트존 섬" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "美属维尔京群岛" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "美屬維京群島" + } + } + } + }, + "browsing_country.virgin_islands_british": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "British Virgin Islands" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "British Virgin Islands" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "イギリス領バージン諸島" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "영국령 버진 제도" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "英属维尔京群岛" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "英屬維京群島" + } + } + } + }, + "browsing_country.wallis_and_futuna": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Wallis and Futuna" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Wallis and Futuna" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ウォリス・フツナ" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "월리스 푸투나제도" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "瓦利斯和富图纳" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "瓦利斯和富圖納" + } + } + } + }, + "browsing_country.western_sahara": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Western Sahara" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Western Sahara" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "西サハラ" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "서사하라" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "西撒哈拉" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "西撒哈拉" + } + } + } + }, + "browsing_country.yemen": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Yemen" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Yemen" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "イエメン" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "예멘" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "也门" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "葉門" + } + } + } + }, + "browsing_country.zambia": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Zambia" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Zambia" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ザンビア" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "잠비아" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "赞比亚" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "尚比亞" + } + } + } + }, + "browsing_country.zimbabwe": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Zimbabwe" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Zimbabwe" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ジンバブエ" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "짐바브웨" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "津巴布韦" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "辛巴威" + } + } + } + }, + "category.artist_CG": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Artist CG" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Artist CG" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "イラスト" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "일러스트" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "插画" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "插畫" + } + } + } + }, + "category.asian_porn": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Asian Porn" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Asian Porn" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "アジア" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "Asian Porn" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "亚洲" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "亞洲" + } + } + } + }, + "category.cosplay": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Cosplay" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Cosplay" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "コスプレ" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "코스프레" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "角色扮演" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "角色扮演" + } + } + } + }, + "category.doujinshi": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Doujinshi" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Doujinshi" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "同人誌" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "동인지" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "同人志" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "同人誌" + } + } + } + }, + "category.game_CG": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Game CG" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Game CG" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ゲーム CG" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "게임 CG" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "游戏 CG" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "遊戲 CG" + } + } + } + }, + "category.image_set": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Image Set" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Image Set" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "画像集" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "포토북" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "照片集" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "圖片集" + } + } + } + }, + "category.manga": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Manga" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Manga" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "漫画" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "만화" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "漫画" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "漫畫" + } + } + } + }, + "category.misc": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Misc" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Misc" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "その他" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "기타" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "其它" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "其它" + } + } + } + }, + "category.non_h": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Non-H" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Non-H" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "健全" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "Non-H" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "健康" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "健康" + } + } + } + }, + "category.private": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Private" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Private" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "プライベート" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "Private" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "非公开" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "私人" + } + } + } + }, + "category.western": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Western" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Western" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "西洋" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "서양" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "西方" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "西方" + } + } + } + }, + "comments_sort_order.highest_score": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "By highest score" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Nach höchster Punktzahl" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "スコアの高い順" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "평가가 가장 높은 순서" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "按最高分的评论" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "最相關留言優先" + } + } + } + }, + "comments_sort_order.oldest": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Oldest comments first" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Älteste Kommentare zuerst" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "コメントの古い順" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "가장 이른 순서" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "按最早的评论" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "最舊留言優先" + } + } + } + }, + "comments_sort_order.recent": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Recent comments first" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Neueste Kommentare zuerst" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "コメントの新しい順" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "최신순" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "按最新的评论" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "最新留言優先" + } + } + } + }, + "comments_votes_show_timing.always": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Always" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Immer" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "常時" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "항상" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "始终显示" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "總是顯示" + } + } + } + }, + "comments_votes_show_timing.on_hover_or_click": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "On score hover or click" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Beim Überfahren oder Anklicken der Punktzahl" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "スコアに経過・クリック時" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "점수를 가리키커나 클리하기" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "悬停或点击时" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "滑鼠在分數上停留或點擊時" + } + } + } + }, + "copyright_claim": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "This gallery is unavailable due to a copyright claim by %@. Sorry about that." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Diese Galerie ist wegen eines Urheberrechtsanspruchs von %@ nicht verfügbar. Das tut uns leid." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "申し訳ありませんが、このギャラリーは %@ の著作権主張によってアクセス不可になっています。" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "%@의 저작권 요청으로 인하여 이 갤러리를 사용할 수 없어요." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "抱歉,该画廊因 %@ 的版权主张已无法访问。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "非常抱歉,這個畫廊已因為 %@ 提出的版權聲索而不再提供存取" + } + } + } + }, + "display_mode.compact": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Compact" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Kompakt" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "コンパクト" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "컴팩트" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "紧凑" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "緊湊(Compact)" + } + } + } + }, + "display_mode.extended": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Extended" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Erweitert" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "拡張" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "확장" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "扩展" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "放大(Extended)" + } + } + } + }, + "display_mode.minimal": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Minimal" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Minimal" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "最小化" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "미니멀" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "最小化" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "最小(Minimal)" + } + } + } + }, + "display_mode.minimalPlus": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Minimal+" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Minimal+" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "最小化+" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "미니멀+" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "最小化 +" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "Minimal+" + } + } + } + }, + "display_mode.thumbnail": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Thumbnail" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Vorschaubilder" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "サムネイル" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "썸네일" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "缩略图" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "縮圖(Thumbnail)" + } + } + } + }, + "download_folder_filter.all": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "All" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Alle" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "すべて" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "전체" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "全部" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "全部" + } + } + } + }, + "eh_setting.archiver_behavior.auto_select_original_auto_start": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Auto Select Original, Auto Start" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Original automatisch wählen, automatisch starten" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "自動でオリジナルを選択、自動で開始" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "자동으로 원본을 선택, 자동 시작" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "自动选择原始画质,自动下载" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "自動選擇原始畫質並開始下載" + } + } + } + }, + "eh_setting.archiver_behavior.auto_select_original_manual_start": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Auto Select Original, Manual Start" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Original automatisch wählen, manuell starten" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "自動でオリジナルを選択、手動で開始" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "자동으로 원본을 선택, 수동 시작" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "自动选择原始画质,手动下载" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "自動選擇原始畫質,,手動開始下載" + } + } + } + }, + "eh_setting.archiver_behavior.auto_select_resample_auto_start": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Auto Select Resample, Auto Start" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Neu berechnete Version automatisch wählen, automatisch starten" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "自動でリサンプルを選択、自動で開始" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "자동으로 저화질을 선택, 자동 시작" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "自动选择压缩画质,自动下载" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "自動選擇重新採樣並開始下載" + } + } + } + }, + "eh_setting.archiver_behavior.auto_select_resample_manual_start": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Auto Select Resample, Manual Start" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Neu berechnete Version automatisch wählen, manuell starten" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "自動でリサンプルを選択、手動で開始" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "자동으로 저화질을 선택, 수동 시작" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "自动选择压缩画质,手动下载" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "自動選擇重新採樣,手動開始下載" + } + } + } + }, + "eh_setting.archiver_behavior.manual_select_auto_start": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Manual Select, Auto Start" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Manuell wählen, automatisch starten" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "手動で選択、自動で開始" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "수동 선택, 자동 시작" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "手动选择,自动下载" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "手動選擇,自動開始下載" + } + } + } + }, + "eh_setting.archiver_behavior.manual_select_manual_start": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Manual Select, Manual Start (Default)" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Manuell wählen, manuell starten (Standard)" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "手動で選択、手動で開始(デフォルト)" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "수동 선택, 수동 시작 (기본)" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "手动选择,手动下载(默认)" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "手動選擇,手動開始下載(預設)" + } + } + } + }, + "excluded_languages_category.original": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Original" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Original" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "オリジナル" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "원본" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "原始版本" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "原始語言" + } + } + } + }, + "excluded_languages_category.rewrite": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Rewrite" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Umgeschrieben" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "書き換え版" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "다시 쓰기" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "改编版本" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "覆寫" + } + } + } + }, + "excluded_languages_category.translated": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Translated" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Übersetzt" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "翻訳版" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "번역됨" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "翻译版本" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "翻譯語言" + } + } + } + }, + "favorite_category.all": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "All" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Alle" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "すべて" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "모두" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "全部" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "全部" + } + } + } + }, + "favorite_category.default": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Favorites %#@index@" + }, + "substitutions": { + "index": { + "argNum": 1, + "formatSpecifier": "lld", + "variations": { + "plural": { + "other": { + "stringUnit": { + "state": "translated", + "value": "%arg" + } + } + } + } + } + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Favoriten %#@index@" + }, + "substitutions": { + "index": { + "argNum": 1, + "formatSpecifier": "lld", + "variations": { + "plural": { + "other": { + "stringUnit": { + "state": "translated", + "value": "%arg" + } + } + } + } + } + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "お気に入り %#@index@" + }, + "substitutions": { + "index": { + "argNum": 1, + "formatSpecifier": "lld", + "variations": { + "plural": { + "other": { + "stringUnit": { + "state": "translated", + "value": "%arg" + } + } + } + } + } + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "즐겨찾기 %#@index@" + }, + "substitutions": { + "index": { + "argNum": 1, + "formatSpecifier": "lld", + "variations": { + "plural": { + "other": { + "stringUnit": { + "state": "translated", + "value": "%arg" + } + } + } + } + } + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "收藏夹 %#@index@" + }, + "substitutions": { + "index": { + "argNum": 1, + "formatSpecifier": "lld", + "variations": { + "plural": { + "other": { + "stringUnit": { + "state": "translated", + "value": "%arg" + } + } + } + } + } + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "收藏匣 %#@index@" + }, + "substitutions": { + "index": { + "argNum": 1, + "formatSpecifier": "lld", + "variations": { + "plural": { + "other": { + "stringUnit": { + "state": "translated", + "value": "%arg" + } + } + } + } + } + } + } + } + }, + "favorites_sort_order.favorited_time": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "By favorited time" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Nach Zeitpunkt des Favorisierens" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "気に入った時間の新しい順" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "별점 시간으로" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "按收藏时间" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "透過收藏順序排序" + } + } + } + }, + "favorites_sort_order.last_update_time": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "By last gallery update time" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Nach letzter Aktualisierung der Galerie" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "更新時間の新しい順" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "마지막 업데이트 시간으로" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "按更新时间" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "透過最後更新時間排序" + } + } + } + }, + "filter_range.global": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Global" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Global" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "全般" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "전체" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "全局" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "全域" + } + } + } + }, + "filter_range.search": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Search" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Suche" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "検索" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "검색" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "搜索" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "搜尋" + } + } + } + }, + "filter_range.watched": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Watched" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Meine Tags" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "タグの購読" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "주시 태그" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "标签" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "標籤" + } + } + } + }, + "gallery_name.default": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Default Title" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Standardtitel" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "デフォルトタイトル" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "영어 제목" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "默认标题" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "預設標題" + } + } + } + }, + "gallery_name.japanese": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Japanese Title (if available)" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Japanischer Titel (falls vorhanden)" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "日本語タイトル(可能なら)" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "일본어 제목(가능하면)" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "日文标题(如果有)" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "日文標題(若該畫廊支援)" + } + } + } + }, + "gallery_page_numbering.none": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "None" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Keine" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "表示しない" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "표시 안 함" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "不显示" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "不顯示" + } + } + } + }, + "gallery_page_numbering.page_number_and_name": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Page Number + Name" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Seitenzahl + Name" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ページ番号と名前を表示" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "페이지 번호와 이름" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "显示页码和名称" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "顯示頁碼和名稱" + } + } + } + }, + "gallery_page_numbering.page_number_only": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Page Number Only" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Nur Seitenzahl" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ページ番号のみ表示" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "페이지 번호만" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "仅显示页码" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "只顯示頁碼" + } + } + } + }, + "gallery_unavailable": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "This gallery has been removed or is unavailable." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Diese Galerie wurde entfernt oder ist nicht verfügbar." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "このギャラリーはすでに削除済みまたは無効です。" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "이 갤러리는 제거되었거나 사용할 수 없어요." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "该画廊已被移除或不可用。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "此畫廊已被刪除或你沒有權限存取" + } + } + } + }, + "gallery_visibility.expunged": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Expunged" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Entfernt" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "削除済み" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "삭제됨" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "已删除" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "已被刪除" + } + } + } + }, + "gallery_visibility.no": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "No (%@)" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Nein (%@)" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "いいえ (%@)" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "아니요 (%@)" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "否 (%@)" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "No (%@)" + } + } + } + }, + "gallery_visibility.yes": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Yes" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Ja" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "はい" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "네" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "是" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "Yes" + } + } + } + }, + "greeting.and": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": " and " + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": " und " + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": " と " + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": " 과 " + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": " 和 " + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": " 和 " + } + } + } + }, + "greeting.end": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "!" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "!" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "を手に入れた!" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "획득했어요!" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "!" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "!" + } + } + } + }, + "greeting.separator": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": ", " + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": ", " + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "、" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": ", " + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "、" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "、" + } + } + } + }, + "greeting.start": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "You gain " + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Du erhälst " + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "你获得了 " + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "你獲得了 " + } + } + } + }, + "hath_archive.free": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Free" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Frei" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "無料" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "무료" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "免费" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "免費" + } + } + } + }, + "image_resolution.auto": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Auto" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Automatisch" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "自動" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "자동" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "自动" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "自動" + } + } + } + }, + "ip_banned": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Your IP address has been temporarily banned for excessive pageloads which indicates that you are using automated mirroring / harvesting software. The ban expires in %@." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Deine IP-Adresse wurde wegen übermäßig vieler Seitenaufrufe vorübergehend gesperrt. Das deutet auf automatische Mirroring- oder Harvesting-Software hin. Die Sperre läuft in %@ ab." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "この IP アドレスを経由して過剰なページロードが行われました。クローラの疑いがあるため、この IP アドレスは一時的にブロックされました。ブロックは %@後に解除されます。" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "자동화된 미러링/수집 소프트웨어 사용이 의심되는 과도한 페이지 로드로 인해 IP 주소가 일시적으로 차단되었어요. 차단은 %@ 후에 해제돼요." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "当前的 IP 地址发生了过量的页面加载,因有使用爬虫程序的嫌疑已被暂时封禁。封禁将在 %@后解除。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "你的 IP 因為頁面載入次數過於頻繁而被暫時禁止存取,這可能是因為你正在使用爬蟲/鏡像軟體,禁止存取將在 %@ 後解除" + } + } + } + }, + "language.afrikaans": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Afrikaans" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Afrikaan" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "アフリカーンス語" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "아프리칸스어" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "南非语" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "南非語" + } + } + } + }, + "language.albanian": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Albanian" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Albanisch" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "アルバニア語" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "알바니아어" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "阿尔巴尼亚语" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "阿爾巴尼亞語" + } + } + } + }, + "language.arabic": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Arabic" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Arabisch" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "アラビア語" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "아랍어" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "阿拉伯语" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "阿拉伯語" + } + } + } + }, + "language.bengali": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Bengali" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Bengali" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ベンガル語" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "벵갈어" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "孟加拉语" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "孟加拉語" + } + } + } + }, + "language.bosnian": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Bosnian" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Bosnisch" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ボスニア語" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "보스니아어" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "波斯尼亚语" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "波士尼亞語" + } + } + } + }, + "language.bulgarian": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Bulgarian" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Bulgarisch" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ブルガリア語" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "불가리아어" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "保加利亚语" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "保加利亞語" + } + } + } + }, + "language.burmese": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Burmese" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Birmanisch" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ビルマ語" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "버마어" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "缅甸语" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "緬甸語" + } + } + } + }, + "language.catalan": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Catalan" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Katalanisch" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "カタルーニャ語" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "카탈루냐어" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "加泰罗尼亚语" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "加泰隆尼亞語" + } + } + } + }, + "language.cebuano": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Cebuano" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Cebuano" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "セブアノ語" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "세부어" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "宿雾語" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "宿霧語" + } + } + } + }, + "language.chinese": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Chinese" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Chinesisch" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "中国語" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "중국어" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "汉语" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "漢語" + } + } + } + }, + "language.croatian": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Croatian" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Kroatisch" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "クロアチア語" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "크로아티아어" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "克罗地亚语" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "克羅埃西亞語" + } + } + } + }, + "language.czech": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Czech" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Tschechisch" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "チェコ語" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "체코어" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "捷克语" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "捷克語" + } + } + } + }, + "language.danish": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Danish" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Dänisch" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "デンマーク語" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "덴마크어" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "丹麦语" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "丹麥語" + } + } + } + }, + "language.dutch": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Dutch" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Niederländisch" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "オランダ語" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "네덜란드어" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "荷兰语" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "荷蘭語" + } + } + } + }, + "language.english": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "English" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Englisch" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "英語" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "영어" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "英语" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "英語" + } + } + } + }, + "language.esperanto": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Esperanto" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Esperanto" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "国際語" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "국제어" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "国际语" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "國際語" + } + } + } + }, + "language.estonian": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Estonian" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Estländisch" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "エストニア語" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "에스토니아어" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "爱沙尼亚语" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "愛沙尼亞語" + } + } + } + }, + "language.finnish": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Finnish" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Finnisch" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "フィンランド語" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "핀란드어" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "芬兰语" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "芬蘭語" + } + } + } + }, + "language.french": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "French" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Französisch" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "フランス語" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "프랑스어" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "法语" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "法語" + } + } + } + }, + "language.georgian": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Georgian" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Georgisch" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "グルジア語" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "그루지야어" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "格鲁吉亚语" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "喬治亞語" + } + } + } + }, + "language.german": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "German" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Deutsch" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ドイツ語" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "독일어" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "德语" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "德語" + } + } + } + }, + "language.greek": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Greek" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Griechisch" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ギリシア語" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "그리스어" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "希腊语" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "希臘語" + } + } + } + }, + "language.hebrew": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Hebrew" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Hebräisch" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ヘブライ語" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "히브리어" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "希伯来语" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "希伯來語" + } + } + } + }, + "language.hindi": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Hindi" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Hindi" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ヒンディー語" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "힌디어" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "印地语" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "印度語" + } + } + } + }, + "language.hmong": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Hmong" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Hmong" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ミャオ語" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "묘어" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "苗语" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "苗語" + } + } + } + }, + "language.hungarian": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Hungarian" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Ungarisch" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ハンガリー語" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "헝가리어" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "匈牙利语" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "匈牙利語" + } + } + } + }, + "language.indonesian": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Indonesian" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Indonesisch" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "インドネシア語" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "인도네시아어" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "印度尼西亚语" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "印尼語" + } + } + } + }, + "language.invalid": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "N/A" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "./." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "無効" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "무효" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "无效" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "無效" + } + } + } + }, + "language.italian": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Italian" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Italian" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "イタリア語" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "이탈리아어" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "意大利语" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "義大利語" + } + } + } + }, + "language.japanese": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Japanese" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Japanisch" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "日本語" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "일본어" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "日语" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "日語" + } + } + } + }, + "language.kazakh": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Kazakh" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Kazakhstanisch" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "カザフ語" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "카자흐어" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "哈萨克语" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "哈薩克語" + } + } + } + }, + "language.khmer": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Khmer" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Khmer" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "クメール語" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "크메르원" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "高棉文" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "高棉語" + } + } + } + }, + "language.korean": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Korean" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Koreanisch" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "韓国語" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "한국어" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "韩语" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "韓語" + } + } + } + }, + "language.kurdish": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Kurdish" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Kurdisch" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "クルド語" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "쿠르드어" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "库尔德语" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "庫德語" + } + } + } + }, + "language.lao": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Lao" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Lao" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ラーオ語" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "라오스어" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "老挝语" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "寮語" + } + } + } + }, + "language.latin": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Latin" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Latein" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ラテン語" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "라틴어" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "拉丁语" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "拉丁語" + } + } + } + }, + "language.mongolian": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Mongolian" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Mongolisch" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "モンゴル語" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "몽골어" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "蒙古语" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "蒙古語" + } + } + } + }, + "language.ndebele": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Ndebele" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Ndebele" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ンデベレ語" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "은데벨리어" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "恩德贝莱语" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "恩德貝萊語" + } + } + } + }, + "language.nepali": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Nepali" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Nepali" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ネパール語" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "네팔어" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "尼泊尔语" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "尼泊爾語" + } + } + } + }, + "language.norwegian": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Norwegian" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Norwegisch" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ノルウェー語" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "노르웨이어로" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "挪威语" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "挪威語" + } + } + } + }, + "language.oromo": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Oromo" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Oromo" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "オロモ語" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "오로모어" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "奥罗莫语" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "奧羅莫語" + } + } + } + }, + "language.other": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Other" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Other" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "その他" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "Other" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "其它" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "其它" + } + } + } + }, + "language.pashto": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Pashto" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Pashto" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "パシュトー語" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "파슈토어" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "普什图语" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "普什圖語" + } + } + } + }, + "language.persian": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Persian" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Persisch" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ペルシア語" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "페르시아어" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "波斯语" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "波斯語" + } + } + } + }, + "language.polish": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Polish" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Polnisch" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ポーランド語" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "폴란드어" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "波兰语" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "波蘭語" + } + } + } + }, + "language.portuguese": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Portuguese" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Portugiesisch" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ポルトガル語" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "포르투갈어" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "葡萄牙语" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "葡萄牙語" + } + } + } + }, + "language.punjabi": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Punjabi" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Punjabi" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "パンジャーブ語" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "펀자브어" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "旁遮普语" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "旁遮普語" + } + } + } + }, + "language.romanian": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Romanian" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Rumänisch" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ルーマニア語" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "루마니아어" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "罗马尼亚语" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "羅馬尼亞語" + } + } + } + }, + "language.russian": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Russian" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Russisch" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ロシア語" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "러시아어" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "俄语" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "俄語" + } + } + } + }, + "language.sango": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Sango" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Sango" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "サンゴ語" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "쌍고어" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "桑戈语" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "桑戈語" + } + } + } + }, + "language.serbian": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Serbian" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Serbisch" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "セルビア語" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "세르비아어" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "塞尔维亚语" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "塞爾維亞語" + } + } + } + }, + "language.shona": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Shona" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Shona" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ショナ語" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "쇼나어" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "绍纳语" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "紹納語" + } + } + } + }, + "language.slovak": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Slovak" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Slovakisch" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "スロバキア語" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "슬로바키아어" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "斯洛伐克语" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "斯洛伐克語" + } + } + } + }, + "language.slovenian": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Slovenian" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Slovenisch" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "スロベニア語" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "슬로베니아어" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "斯洛文尼亚语" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "斯洛維尼亞語" + } + } + } + }, + "language.somali": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Somali" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Somali" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ソマリ語" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "소말리아어" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "索马里语" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "索馬利語" + } + } + } + }, + "language.spanish": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Spanish" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Spanisch" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "スペイン語" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "스페인어" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "西班牙语" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "西班牙語" + } + } + } + }, + "language.swahili": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Swahili" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Swahili" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "スワヒリ語" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "스와히리어로" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "斯瓦希里语" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "斯瓦希里語" + } + } + } + }, + "language.swedish": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Swedish" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Schwedisch" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "スウェーデン語" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "스웨덴어" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "瑞典语" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "瑞典語" + } + } + } + }, + "language.tagalog": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Tagalog" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Tagalog" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "タガログ語" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "타갈로어" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "他加洛语" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "他加祿語" + } + } + } + }, + "language.thai": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Thai" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Thai" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "タイ語" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "타이어" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "泰语" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "泰語" + } + } + } + }, + "language.tigrinya": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Tigrinya" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Tigrinya" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ティグリニャ語" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "티글리니아어" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "提格利尼亚语" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "提格利尼亞語" + } + } + } + }, + "language.turkish": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Turkish" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Türkisch" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "トルコ語" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "터키어" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "土耳其语" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "土耳其語" + } + } + } + }, + "language.ukrainian": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Ukrainian" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Ukrainisch" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ウクライナ語" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "우크라이나어" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "乌克兰语" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "烏克蘭語" + } + } + } + }, + "language.urdu": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Urdu" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Urdu" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ウルドゥー語" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "우르두어" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "乌尔都语" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "烏爾都語" + } + } + } + }, + "language.vietnamese": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Vietnamese" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Vietnamesisch" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ベトナム語" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "베트남어" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "越南语" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "越南語" + } + } + } + }, + "language.zulu": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Zulu" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Zulu" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ズールー語" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "줄루어" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "祖鲁语" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "祖魯語" + } + } + } + }, + "list_display_mode.detail": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Detail" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Details" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "詳細" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "자세히" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "详情" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "詳細" + } + } + } + }, + "list_display_mode.thumbnail": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Thumbnail" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Vorschaubilder" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "サムネイル" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "썸네일" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "缩略图" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "縮圖" + } + } + } + }, + "load_through_hath_setting.any_client": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Any client" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Jeder Client" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "任意のクライアント" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "어떤 클라이언트에서든" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "所有客户端" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "任何用戶端" + } + } + } + }, + "load_through_hath_setting.any_client_description": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Recommended." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Empfohlen." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "推奨。" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "추천." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "推荐。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "建議選項(預設)" + } + } + } + }, + "load_through_hath_setting.default_port_only": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Default port clients only" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Nur Clients mit Standardport" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "デフォルトポートのクライアントのみ" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "기본 포트 클라이언트만" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "仅使用默认端口的客户端" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "只使用預設連接埠的用戶端" + } + } + } + }, + "load_through_hath_setting.default_port_only_description": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Can be slower. Enable if behind firewall/proxy that blocks outgoing non-standard ports." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Kann langsamer sein. Aktiviere dies nur, wenn eine Firewall oder ein Proxy ausgehende Nicht-Standard-Ports blockiert." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "遅くなることがあります。非標準発信ポートがファイヤーウォール・プロキシにブロックされた場合のみ有効にしてください。" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "더 느려질 수 있어요. 나가는 비표준 포트를 차단하는 방화벽/프록시가 있는 경우 사용하세요." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "可能稍慢。当防火墙或代理阻止非标准接口的流量时启用此项。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "如果網路防火牆會阻擋任何非預設傳出連接埠則使用這個選項(可能較慢)" + } + } + } + }, + "load_through_hath_setting.legacy_no": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "No [Legacy/HTTP]" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Nein [Legacy/HTTP]" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "使わない [レガシー / HTTP]" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "아닙니다 [Legacy/HTTP]" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "不通过 [旧式 / HTTP]" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "No [傳統/Legacy/HTTP]" + } + } + } + }, + "load_through_hath_setting.legacy_no_description": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Donator only. May not work by default in modern browsers. Recommended for legacy/outdated browsers only." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Nur für Spender. Funktioniert in modernen Browsern unter Umständen nicht. Nur für alte oder veraltete Browser empfohlen." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "寄付者独占オプション。モダンブラウザでは機能しないこともあります。レガシー・旧型ブラウザの場合以外おすすめしません。" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "기부자 전용 기능이에요. 최신 브라우저에서는 제대로 작동하지 않을 수 있어요. 오래된 브라우저에서만 사용해주세요." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "仅限赞助者。在现代浏览器可能不可用。只建议在旧式 / 过时的浏览器使用。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "E-Hentai 贊助者功能: 在現代瀏覽器上預設設定可能無法正常工作,僅推薦用於傳統瀏覽器" + } + } + } + }, + "load_through_hath_setting.modern_no": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "No [Modern/HTTPS]" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Nein [Modern/HTTPS]" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "使わない [モダン / HTTPS]" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "아닙니다 [Modern/HTTPS]" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "不通过 [现代 / HTTPS]" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "No [現代/Modern/HTTPS]" + } + } + } + }, + "load_through_hath_setting.modern_no_description": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Donator only. You will not be able to browse as many pages. Recommended only if having severe problems." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Nur für Spender. Du kannst damit weniger Seiten aufrufen. Nur bei schwerwiegenden Problemen empfohlen." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "寄付者独占オプション。閲覧による割当額の消耗は激しくなります。厳重な問題が起こった場合以外おすすめしません。" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "기부자 전용 기능이에요. 심각한 문제가 있는 경우를 제외하고는 사용하지 말아주세요." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "仅限赞助者。配额消耗会加快。只建议在遇到严重问题时使用。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "E-Hentai 贊助者功能: 你將無法同時瀏覽多個頁面,僅在出現重大錯誤時才啟用這個選項" + } + } + } + }, + "multiple_page_viewer_style.align_center_always_scale": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Align center, always scale" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Zentriert, immer skalieren" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "中央揃え、常時スケール" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "가운데 정렬, 항상 크기 맞추기" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "居中对齐,图像始终缩放" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "置中對齊且總是縮放" + } + } + } + }, + "multiple_page_viewer_style.align_center_scale_if_over_width": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Align center, scale if overwidth" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Zentriert, bei Überbreite skalieren" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "中央揃え、幅によってスケール" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "가운데 정렬, 너비 초과할 때 크기 맞추기" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "居中对齐,图像过宽时缩放" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "置中對齊,若寬度超出頁面則進行縮放" + } + } + } + }, + "multiple_page_viewer_style.align_left_scale_if_over_width": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Align left, scale if overwidth" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Linksbündig, bei Überbreite skalieren" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "左寄せ、幅によってスケール" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "왼쪽 정렬, 너비 초과할 때 크기 맞추기" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "左对齐,图像过宽时缩放" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "向左對齊,若寬度超出頁面則進行縮放" + } + } + } + }, + "network_error": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "A network error occurred." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Ein Netzwerkfehler ist aufgetreten." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ネットワーク障害が発生しました" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "인터넷 접속 오류가 발생했어요." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "发生了网络故障" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "網路發生故障,請檢查網路狀態" + } + } + } + }, + "not_found": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "There seems to be nothing here." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Hier scheint es nichts zu geben." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ここには何もないようです" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "여기가 아무도 없는 것 같습니다." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "这里似乎什么也没有" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "這邊看起來空空如也" + } + } + } + }, + "parsing": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "A parsing error occurred." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Ein Parserfehler ist aufgetreten." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "解析中に問題が発生しました" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "구분 분석 오류가 발생했어요." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "发生了解析错误" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "網頁解析器發生故障" + } + } + } + }, + "preferred_color_scheme.automatic": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Automatic" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Automatisch" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "自動" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "자동" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "自动" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "自動" + } + } + } + }, + "preferred_color_scheme.dark": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Dark" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Dunkel" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ダーク" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "다크" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "深色" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "深色" + } + } + } + }, + "preferred_color_scheme.light": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Light" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Hell" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ライト" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "라이트" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "浅色" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "淺色" + } + } + } + }, + "reading_direction.left_to_right": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Left-to-right" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Von links nach rechts" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "左開き" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "왼쪽에서 오른쪽으로" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "左至右" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "由左至右滑" + } + } + } + }, + "reading_direction.right_to_left": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Right-to-left" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Von rechts nach links" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "右開き" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "오른쪽에서 왼쪽으로" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "右至左" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "由右至左滑" + } + } + } + }, + "reading_direction.vertical": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Vertical" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Vertikal" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "縦読み" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "위에서 아래로" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "垂直" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "垂直" + } + } + } + }, + "tag_namespace.artist": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Artist" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Künstler" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "作者" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "작가" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "作者" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "作者" + } + } + } + }, + "tag_namespace.character": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Character" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Charakter" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "キャラ" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "캐릭터" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "角色" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "角色" + } + } + } + }, + "tag_namespace.cosplayer": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Cosplayer" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Cosplayer" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "レイヤー" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "Cosplayer" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "扮装者" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "Cosplayer" + } + } + } + }, + "tag_namespace.female": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Female" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Weiblich" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "女性" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "여성" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "女性" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "女性" + } + } + } + }, + "tag_namespace.group": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Group" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Gruppe" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "団体" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "그룹" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "团体" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "團體" + } + } + } + }, + "tag_namespace.language": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Language" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Sprache" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "言語" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "언어" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "语言" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "語言" + } + } + } + }, + "tag_namespace.male": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Male" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Männlich" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "男性" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "남성" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "男性" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "男性" + } + } + } + }, + "tag_namespace.mixed": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Mixed" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Mixed" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "混在性別" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "Mixed" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "混合性别" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "Mixed" + } + } + } + }, + "tag_namespace.other": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Other" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Other" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "その他" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "Other" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "其它" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "其他" + } + } + } + }, + "tag_namespace.parody": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Parody" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Parodie" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "原作" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "원작" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "原作" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "原作" + } + } + } + }, + "tag_namespace.reclass": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Reclass" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Reclass" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "再分類" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "재분류" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "重归类" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "重新分類" + } + } + } + }, + "tag_namespace.temp": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Temp" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Temp" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "一時的" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "Temp" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "临时" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "Temp" + } + } + } + }, + "tags_sort_order.alphabetical": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Alphabetical" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Alphabetisch" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "アルファベット順" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "알파벳순으로" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "按字母排序" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "字母順序" + } + } + } + }, + "tags_sort_order.tag_power": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "By tag power" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Nach Tag-Gewicht" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "タグパワーの高い順" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "태크 가중치로" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "按标签权重" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "標籤權重" + } + } + } + }, + "thumbnail_load_timing.on_mouse_over": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "On mouse-over" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Bei Mouseover" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "マウス経過時" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "마우스를 올릴 때" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "鼠标悬停时" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "滑鼠位置" + } + } + } + }, + "thumbnail_load_timing.on_mouse_over_description": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Pages load faster, but there may be a slight delay before a thumb appears." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Seiten laden schneller, Vorschaubilder erscheinen aber unter Umständen leicht verzögert." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ページの読み込みが速くなりますが、サムネイルの表示はちょっぴり遅れてきます。" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "페이지가 더 빨리 로드되지만 엄지손가락이 나타나기 전까지 약간의 지연이 있을 수 있어요." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "页面加载快,缩略图加载有延迟。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "網頁的載入速度更快,但縮圖出現的時間可能稍有延遲" + } + } + } + }, + "thumbnail_load_timing.on_page_load": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "On page load" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Beim Laden der Seite" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ページ読み込み時" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "페이지 로드될 때" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "页面加载时" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "網頁載入位置" + } + } + } + }, + "thumbnail_load_timing.on_page_load_description": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Pages take longer to load, but there is no delay for loading a thumb after the page has loaded." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Seiten brauchen länger zum Laden, dafür erscheinen die Vorschaubilder danach ohne Verzögerung." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ページの読み込み時間が増えますが、サムネイルはすぐに表示できます。" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "페이지 로드에 시간이 더 오래 걸리지만, 페이지가 로드된 후 썸네일을 로드하는데 지연이 없어요." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "页面加载时间更长,显示缩略图无需等待。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "網頁需要更多的載入時間,但是在網頁完全載入後縮圖顯示不會有任何延遲" + } + } + } + }, + "thumbnail_size.auto": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Auto" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Automatisch" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "自動" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "자동" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "自动" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "自動" + } + } + } + }, + "thumbnail_size.large": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Large" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Groß" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "大きめ" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "크게" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "较大" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "大型" + } + } + } + }, + "thumbnail_size.normal": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Normal" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Normal" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "普通" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "보통" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "普通" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "正常" + } + } + } + }, + "thumbnail_size.small": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Small" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Klein" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "小さめ" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "작게" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "较小" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "小型" + } + } + } + }, + "toplists_type.all_time": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "All time" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Gesamt" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "すべて" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "전체" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "全部" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "所有時間" + } + } + } + }, + "toplists_type.past_month": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Past month" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Letzter Monat" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "先月" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "지난 달" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "上月" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "上個月" + } + } + } + }, + "toplists_type.past_year": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Past year" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Letztes Jahr" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "去年" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "지난 해" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "去年" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "去年" + } + } + } + }, + "toplists_type.yesterday": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Yesterday" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Gestern" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "昨日" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "어제" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "昨日" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "昨天" + } + } + } + }, + "try_later": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Please try again later." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Bitte versuche es später erneut." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "しばらくしてからもう一度お試しください" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "잠시 후 다시 시도해 주세요." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "请稍后再试" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "請稍後再試" + } + } + } + }, + "unknown": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "An unknown error occurred." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Ein unbekannter Fehler ist aufgetreten." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "不明なエラーが発生しました" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "알 수 없는 오류가 발생했어요." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "发生了未知错误" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "發生不明錯誤" + } + } + } + } + }, + "version": "1.0" +} \ No newline at end of file diff --git a/AppPackage/Sources/AppModels/Support/AppActivityLog.swift b/AppPackage/Sources/AppModels/Support/AppActivityLog.swift new file mode 100644 index 000000000..e8ad0c0ce --- /dev/null +++ b/AppPackage/Sources/AppModels/Support/AppActivityLog.swift @@ -0,0 +1,96 @@ +import OSLog +import SwiftUI +import Resources +import Foundation + +public struct AppActivityLog: Sendable, Equatable, Identifiable, Codable { + public let date: Date + public let category: String + public let level: OSLogEntryLog.Level + public let message: String + + public var id: String { + dateDescription + message + } + + public var dateDescription: String { + Self.logDateFormatter.string(from: date) + } + + private static let logDateFormatter: DateFormatter = { + let formatter = DateFormatter() + formatter.dateFormat = "MM-dd HH:mm:ss.SSS" + formatter.calendar = Calendar(identifier: .gregorian) + formatter.locale = Locale(identifier: "en_US") + return formatter + }() + + public init( + date: Date, + category: String, + level: OSLogEntryLog.Level, + message: String + ) { + self.date = date + self.category = category + self.level = level + self.message = message + } + + public init(osLog: OSLogEntryLog) { + self.init( + date: osLog.date, + category: osLog.category, + level: osLog.level, + message: osLog.composedMessage + ) + } + + // `OSLogEntryLog.Level` is an `Int`-backed enum, so it is persisted as its raw value. + private enum CodingKeys: String, CodingKey { + case date, category, level, message + } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + date = try container.decode(Date.self, forKey: .date) + category = try container.decode(String.self, forKey: .category) + let levelRawValue = try container.decode(Int.self, forKey: .level) + level = OSLogEntryLog.Level(rawValue: levelRawValue) ?? .undefined + message = try container.decode(String.self, forKey: .message) + } + + public func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(date, forKey: .date) + try container.encode(category, forKey: .category) + try container.encode(level.rawValue, forKey: .level) + try container.encode(message, forKey: .message) + } +} + +public extension OSLogEntryLog.Level { + var color: Color { + switch self { + case .debug: .indigo + case .info: .blue + case .notice: .gray + case .error: .orange + case .fault: .red + case .undefined: .primary + @unknown default: .primary + } + } + + var title: LocalizedStringResource { + switch self { + case .undefined: .appActivityLogLevelUndefined + case .debug: .appActivityLogLevelDebug + case .info: .appActivityLogLevelInfo + case .notice: .appActivityLogLevelNotice + case .error: .appActivityLogLevelError + case .fault: .appActivityLogLevelFault + @unknown default: .appActivityLogLevelUnknown + } + } +} diff --git a/AppPackage/Sources/AppModels/Support/AppError.swift b/AppPackage/Sources/AppModels/Support/AppError.swift new file mode 100644 index 000000000..0889151dd --- /dev/null +++ b/AppPackage/Sources/AppModels/Support/AppError.swift @@ -0,0 +1,146 @@ +import Foundation +import Resources + +public enum AppError: Error, Identifiable, Equatable, Hashable, Sendable { + public var id: String { localizedDescription } + + public init(_ error: any Error) { + self = error as? AppError ?? .unknown + } + + case copyrightClaim(String) + case ipBanned(BanInterval) + case expunged(String) + case networkingFailed + case webImageFailed + case parseFailed + case quotaExceeded + case authenticationRequired + case fileOperationFailed(String) + case noUpdates + case notFound + case unknown +} + +extension AppError { + public var isRetryable: Bool { + switch self { + case .networkingFailed, .parseFailed, + .fileOperationFailed, .noUpdates, .unknown, .webImageFailed: + return true + case .copyrightClaim, .expunged, .quotaExceeded, .authenticationRequired, .notFound, + .ipBanned: + return false + } + } + public var localizedDescription: String { + switch self { + case .copyrightClaim: + return String(localized: .appErrorCopyrightClaim) + case .ipBanned: + return String(localized: .appErrorIpBanned) + case .expunged: + return String(localized: .appErrorGalleryExpunged) + case .networkingFailed: + return String(localized: .appErrorNetworkError) + case .webImageFailed: + return String(localized: .appErrorWebImageLoadingError) + case .parseFailed: + return String(localized: .appErrorParseError) + case .quotaExceeded: + return String(localized: .appErrorQuotaExceeded) + case .authenticationRequired: + return String(localized: .appErrorAuthenticationRequired) + case .fileOperationFailed: + return String(localized: .appErrorFileOperationFailed) + case .noUpdates: + return String(localized: .appErrorNoUpdatesAvailable) + case .notFound: + return String(localized: .appErrorNotFound) + case .unknown: + return String(localized: .appErrorUnknownError) + } + } + public var alertText: String { + let tryLater = String(localized: .tryLater) + switch self { + case .copyrightClaim(let owner): + return String(localized: .copyrightClaim(owner)) + case .ipBanned(let interval): + return String(localized: .ipBanned(interval.description)) + case .expunged(let reason): + switch reason { + case String(localized: .RConstant.responseGalleryUnavailable): + return String(localized: .galleryUnavailable) + default: + return reason + } + case .networkingFailed: + return [String(localized: .networkError), tryLater].joined(separator: "\n") + case .parseFailed: + return [String(localized: .parsing), tryLater].joined(separator: "\n") + case .quotaExceeded: + return String(localized: .appErrorQuotaExceededDescription) + case .authenticationRequired: + return String(localized: .appErrorAuthenticationRequiredDescription) + case .fileOperationFailed(let reason): + return [String(localized: .appErrorLocalFileOperationFailed), reason] + .filter { !$0.isEmpty } + .joined(separator: "\n") + case .noUpdates, .webImageFailed: + return "" + case .notFound: + return String(localized: .notFound) + case .unknown: + return [String(localized: .unknown), tryLater].joined(separator: "\n") + } + } +} + +public enum BanInterval: Equatable, Hashable, Sendable { + case days(_: Int, hours: Int?) + case hours(_: Int, minutes: Int?) + case minutes(_: Int, seconds: Int?) + case unrecognized(content: String) +} + +extension BanInterval { + public var description: String { + var params: [String] + let and = String(localized: .banIntervalAnd) + + 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 { !$0.isEmpty }.joined(separator: " ") + } + + private func daysWithUnit(_ days: Int) -> String { + String(localized: .RLocalizable.days(count: days)) + } + private func hoursWithUnit(_ hours: Int) -> String { + String(localized: .RLocalizable.hours(count: hours)) + } + private func minutesWithUnit(_ minutes: Int) -> String { + String(localized: .RLocalizable.minutes(count: minutes)) + } + private func secondsWithUnit(_ seconds: Int) -> String { + String(localized: .RLocalizable.seconds(count: seconds)) + } +} diff --git a/AppPackage/Sources/AppModels/Support/BrowsingCountry+EnglishName.swift b/AppPackage/Sources/AppModels/Support/BrowsingCountry+EnglishName.swift new file mode 100644 index 000000000..a5527f9e2 --- /dev/null +++ b/AppPackage/Sources/AppModels/Support/BrowsingCountry+EnglishName.swift @@ -0,0 +1,258 @@ +extension EhSetting.BrowsingCountry { + public var englishName: String { + switch self { + case .autoDetect: return "Auto-Detect" + case .afghanistan: return "Afghanistan" + case .alandIslands: return "Aland Islands" + case .albania: return "Albania" + case .algeria: return "Algeria" + case .americanSamoa: return "American Samoa" + case .andorra: return "Andorra" + case .angola: return "Angola" + case .anguilla: return "Anguilla" + case .antarctica: return "Antarctica" + case .antiguaAndBarbuda: return "Antigua and Barbuda" + case .argentina: return "Argentina" + case .armenia: return "Armenia" + case .aruba: return "Aruba" + case .asiaPacificRegion: return "Asia-Pacific Region" + case .australia: return "Australia" + case .austria: return "Austria" + case .azerbaijan: return "Azerbaijan" + case .bahamas: return "Bahamas" + case .bahrain: return "Bahrain" + case .bangladesh: return "Bangladesh" + case .barbados: return "Barbados" + case .belarus: return "Belarus" + case .belgium: return "Belgium" + case .belize: return "Belize" + case .benin: return "Benin" + case .bermuda: return "Bermuda" + case .bhutan: return "Bhutan" + case .bolivia: return "Bolivia" + case .bonaireSaintEustatiusAndSaba: return "Bonaire Saint Eustatius and Saba" + case .bosniaAndHerzegovina: return "Bosnia and Herzegovina" + case .botswana: return "Botswana" + case .bouvetIsland: return "Bouvet Island" + case .brazil: return "Brazil" + case .britishIndianOceanTerritory: return "British Indian Ocean Territory" + case .bruneiDarussalam: return "Brunei Darussalam" + case .bulgaria: return "Bulgaria" + case .burkinaFaso: return "Burkina Faso" + case .burundi: return "Burundi" + case .cambodia: return "Cambodia" + case .cameroon: return "Cameroon" + case .canada: return "Canada" + case .capeVerde: return "Cape Verde" + case .caymanIslands: return "Cayman Islands" + case .centralAfricanRepublic: return "Central African Republic" + case .chad: return "Chad" + case .chile: return "Chile" + case .china: return "China" + case .christmasIsland: return "Christmas Island" + case .cocosIslands: return "Cocos Islands" + case .colombia: return "Colombia" + case .comoros: return "Comoros" + case .congo: return "Congo" + case .theDemocraticRepublicOfTheCongo: return "The Democratic Republic of the Congo" + case .cookIslands: return "Cook Islands" + case .costaRica: return "Costa Rica" + case .coteDIvoire: return "Cote D'Ivoire" + case .croatia: return "Croatia" + case .cuba: return "Cuba" + case .curacao: return "Curacao" + case .cyprus: return "Cyprus" + case .czechRepublic: return "Czech Republic" + case .denmark: return "Denmark" + case .djibouti: return "Djibouti" + case .dominica: return "Dominica" + case .dominicanRepublic: return "Dominican Republic" + case .ecuador: return "Ecuador" + case .egypt: return "Egypt" + case .elSalvador: return "El Salvador" + case .equatorialGuinea: return "Equatorial Guinea" + case .eritrea: return "Eritrea" + case .estonia: return "Estonia" + case .ethiopia: return "Ethiopia" + case .europe: return "Europe" + case .falklandIslands: return "Falkland Islands" + case .faroeIslands: return "Faroe Islands" + case .fiji: return "Fiji" + case .finland: return "Finland" + case .france: return "France" + case .frenchGuiana: return "French Guiana" + case .frenchPolynesia: return "French Polynesia" + case .frenchSouthernTerritories: return "French Southern Territories" + case .gabon: return "Gabon" + case .gambia: return "Gambia" + case .georgia: return "Georgia" + case .germany: return "Germany" + case .ghana: return "Ghana" + case .gibraltar: return "Gibraltar" + case .greece: return "Greece" + case .greenland: return "Greenland" + case .grenada: return "Grenada" + case .guadeloupe: return "Guadeloupe" + case .guam: return "Guam" + case .guatemala: return "Guatemala" + case .guernsey: return "Guernsey" + case .guinea: return "Guinea" + case .guineaBissau: return "Guinea-Bissau" + case .guyana: return "Guyana" + case .haiti: return "Haiti" + case .heardIslandAndMcDonaldIslands: return "Heard Island and McDonald Islands" + case .vaticanCityState: return "Vatican City State" + case .honduras: return "Honduras" + case .hongKong: return "Hong Kong" + case .hungary: return "Hungary" + case .iceland: return "Iceland" + case .india: return "India" + case .indonesia: return "Indonesia" + case .iran: return "Iran" + case .iraq: return "Iraq" + case .ireland: return "Ireland" + case .isleOfMan: return "Isle of Man" + case .israel: return "Israel" + case .italy: return "Italy" + case .jamaica: return "Jamaica" + case .japan: return "Japan" + case .jersey: return "Jersey" + case .jordan: return "Jordan" + case .kazakhstan: return "Kazakhstan" + case .kenya: return "Kenya" + case .kiribati: return "Kiribati" + case .kuwait: return "Kuwait" + case .kyrgyzstan: return "Kyrgyzstan" + case .laoPeoplesDemocraticRepublic: return "Lao People's Democratic Republic" + case .latvia: return "Latvia" + case .lebanon: return "Lebanon" + case .lesotho: return "Lesotho" + case .liberia: return "Liberia" + case .libya: return "Libya" + case .liechtenstein: return "Liechtenstein" + case .lithuania: return "Lithuania" + case .luxembourg: return "Luxembourg" + case .macau: return "Macau" + case .macedonia: return "Macedonia" + case .madagascar: return "Madagascar" + case .malawi: return "Malawi" + case .malaysia: return "Malaysia" + case .maldives: return "Maldives" + case .mali: return "Mali" + case .malta: return "Malta" + case .marshallIslands: return "Marshall Islands" + case .martinique: return "Martinique" + case .mauritania: return "Mauritania" + case .mauritius: return "Mauritius" + case .mayotte: return "Mayotte" + case .mexico: return "Mexico" + case .micronesia: return "Micronesia" + case .moldova: return "Moldova" + case .monaco: return "Monaco" + case .mongolia: return "Mongolia" + case .montenegro: return "Montenegro" + case .montserrat: return "Montserrat" + case .morocco: return "Morocco" + case .mozambique: return "Mozambique" + case .myanmar: return "Myanmar" + case .namibia: return "Namibia" + case .nauru: return "Nauru" + case .nepal: return "Nepal" + case .netherlands: return "Netherlands" + case .newCaledonia: return "New Caledonia" + case .newZealand: return "New Zealand" + case .nicaragua: return "Nicaragua" + case .niger: return "Niger" + case .nigeria: return "Nigeria" + case .niue: return "Niue" + case .norfolkIsland: return "Norfolk Island" + case .northKorea: return "North Korea" + case .northernMarianaIslands: return "Northern Mariana Islands" + case .norway: return "Norway" + case .oman: return "Oman" + case .pakistan: return "Pakistan" + case .palau: return "Palau" + case .palestinianTerritory: return "Palestinian Territory" + case .panama: return "Panama" + case .papuaNewGuinea: return "Papua New Guinea" + case .paraguay: return "Paraguay" + case .peru: return "Peru" + case .philippines: return "Philippines" + case .pitcairnIslands: return "Pitcairn Islands" + case .poland: return "Poland" + case .portugal: return "Portugal" + case .puertoRico: return "Puerto Rico" + case .qatar: return "Qatar" + case .reunion: return "Reunion" + case .romania: return "Romania" + case .russianFederation: return "Russian Federation" + case .rwanda: return "Rwanda" + case .saintBarthelemy: return "Saint Barthelemy" + case .saintHelena: return "Saint Helena" + case .saintKittsAndNevis: return "Saint Kitts and Nevis" + case .saintLucia: return "Saint Lucia" + case .saintMartin: return "Saint Martin" + case .saintPierreAndMiquelon: return "Saint Pierre and Miquelon" + case .saintVincentAndTheGrenadines: return "Saint Vincent and the Grenadines" + case .samoa: return "Samoa" + case .sanMarino: return "San Marino" + case .saoTomeAndPrincipe: return "Sao Tome and Principe" + case .saudiArabia: return "Saudi Arabia" + case .senegal: return "Senegal" + case .serbia: return "Serbia" + case .seychelles: return "Seychelles" + case .sierraLeone: return "Sierra Leone" + case .singapore: return "Singapore" + case .sintMaarten: return "Sint Maarten" + case .slovakia: return "Slovakia" + case .slovenia: return "Slovenia" + case .solomonIslands: return "Solomon Islands" + case .somalia: return "Somalia" + case .southAfrica: return "South Africa" + case .southGeorgiaAndTheSouthSandwichIslands: return "South Georgia and the South Sandwich Islands" + case .southKorea: return "South Korea" + case .southSudan: return "South Sudan" + case .spain: return "Spain" + case .sriLanka: return "Sri Lanka" + case .sudan: return "Sudan" + case .suriname: return "Suriname" + case .svalbardAndJanMayen: return "Svalbard and Jan Mayen" + case .swaziland: return "Swaziland" + case .sweden: return "Sweden" + case .switzerland: return "Switzerland" + case .syrianArabRepublic: return "Syrian Arab Republic" + case .taiwan: return "Taiwan" + case .tajikistan: return "Tajikistan" + case .tanzania: return "Tanzania" + case .thailand: return "Thailand" + case .timorLeste: return "Timor-Leste" + case .togo: return "Togo" + case .tokelau: return "Tokelau" + case .tonga: return "Tonga" + case .trinidadAndTobago: return "Trinidad and Tobago" + case .tunisia: return "Tunisia" + case .turkey: return "Turkey" + case .turkmenistan: return "Turkmenistan" + case .turksAndCaicosIslands: return "Turks and Caicos Islands" + case .tuvalu: return "Tuvalu" + case .uganda: return "Uganda" + case .ukraine: return "Ukraine" + case .unitedArabEmirates: return "United Arab Emirates" + case .unitedKingdom: return "United Kingdom" + case .unitedStates: return "United States" + case .unitedStatesMinorOutlyingIslands: return "United States Minor Outlying Islands" + case .uruguay: return "Uruguay" + case .uzbekistan: return "Uzbekistan" + case .vanuatu: return "Vanuatu" + case .venezuela: return "Venezuela" + case .vietnam: return "Vietnam" + case .virginIslandsBritish: return "British Virgin Islands" + case .virginIslandsUS: return "U.S. Virgin Islands" + case .wallisAndFutuna: return "Wallis and Futuna" + case .westernSahara: return "Western Sahara" + case .yemen: return "Yemen" + case .zambia: return "Zambia" + case .zimbabwe: return "Zimbabwe" + } + } +} diff --git a/AppPackage/Sources/AppModels/Support/BrowsingCountry.swift b/AppPackage/Sources/AppModels/Support/BrowsingCountry.swift new file mode 100644 index 000000000..f211d4933 --- /dev/null +++ b/AppPackage/Sources/AppModels/Support/BrowsingCountry.swift @@ -0,0 +1,270 @@ +import Foundation +import Resources + +// reason: the exhaustive ISO country list is kept dense, one case per line +// swiftlint:disable line_length +extension EhSetting { + public enum BrowsingCountry: String, CaseIterable, Identifiable, Equatable, Sendable { + case autoDetect = "-"; case afghanistan = "AF"; case alandIslands = "AX"; case albania = "AL"; case algeria = "DZ"; case americanSamoa = "AS"; case andorra = "AD"; case angola = "AO"; case anguilla = "AI"; case antarctica = "AQ"; case antiguaAndBarbuda = "AG"; case argentina = "AR"; case armenia = "AM"; case aruba = "AW"; case asiaPacificRegion = "AP"; case australia = "AU"; case austria = "AT"; case azerbaijan = "AZ"; case bahamas = "BS"; case bahrain = "BH"; case bangladesh = "BD"; case barbados = "BB"; case belarus = "BY"; case belgium = "BE"; case belize = "BZ"; case benin = "BJ"; case bermuda = "BM"; case bhutan = "BT"; case bolivia = "BO"; case bonaireSaintEustatiusAndSaba = "BQ"; case bosniaAndHerzegovina = "BA"; case botswana = "BW"; case bouvetIsland = "BV"; case brazil = "BR"; case britishIndianOceanTerritory = "IO"; case bruneiDarussalam = "BN"; case bulgaria = "BG"; case burkinaFaso = "BF"; case burundi = "BI"; case cambodia = "KH"; case cameroon = "CM"; case canada = "CA"; case capeVerde = "CV"; case caymanIslands = "KY"; case centralAfricanRepublic = "CF"; case chad = "TD"; case chile = "CL"; case china = "CN"; case christmasIsland = "CX"; case cocosIslands = "CC"; case colombia = "CO"; case comoros = "KM"; case congo = "CG"; case theDemocraticRepublicOfTheCongo = "CD"; case cookIslands = "CK"; case costaRica = "CR"; case coteDIvoire = "CI"; case croatia = "HR"; case cuba = "CU"; case curacao = "CW"; case cyprus = "CY"; case czechRepublic = "CZ"; case denmark = "DK"; case djibouti = "DJ"; case dominica = "DM"; case dominicanRepublic = "DO"; case ecuador = "EC"; case egypt = "EG"; case elSalvador = "SV"; case equatorialGuinea = "GQ"; case eritrea = "ER"; case estonia = "EE"; case ethiopia = "ET"; case europe = "EU"; case falklandIslands = "FK"; case faroeIslands = "FO"; case fiji = "FJ"; case finland = "FI"; case france = "FR"; case frenchGuiana = "GF"; case frenchPolynesia = "PF"; case frenchSouthernTerritories = "TF"; case gabon = "GA"; case gambia = "GM"; case georgia = "GE"; case germany = "DE"; case ghana = "GH"; case gibraltar = "GI"; case greece = "GR"; case greenland = "GL"; case grenada = "GD"; case guadeloupe = "GP"; case guam = "GU"; case guatemala = "GT"; case guernsey = "GG"; case guinea = "GN"; case guineaBissau = "GW"; case guyana = "GY"; case haiti = "HT"; case heardIslandAndMcDonaldIslands = "HM"; case vaticanCityState = "VA"; case honduras = "HN"; case hongKong = "HK"; case hungary = "HU"; case iceland = "IS"; case india = "IN"; case indonesia = "ID"; case iran = "IR"; case iraq = "IQ"; case ireland = "IE"; case isleOfMan = "IM"; case israel = "IL"; case italy = "IT"; case jamaica = "JM"; case japan = "JP"; case jersey = "JE"; case jordan = "JO"; case kazakhstan = "KZ"; case kenya = "KE"; case kiribati = "KI"; case kuwait = "KW"; case kyrgyzstan = "KG"; case laoPeoplesDemocraticRepublic = "LA"; case latvia = "LV"; case lebanon = "LB"; case lesotho = "LS"; case liberia = "LR"; case libya = "LY"; case liechtenstein = "LI"; case lithuania = "LT"; case luxembourg = "LU"; case macau = "MO"; case macedonia = "MK"; case madagascar = "MG"; case malawi = "MW"; case malaysia = "MY"; case maldives = "MV"; case mali = "ML"; case malta = "MT"; case marshallIslands = "MH"; case martinique = "MQ"; case mauritania = "MR"; case mauritius = "MU"; case mayotte = "YT"; case mexico = "MX"; case micronesia = "FM"; case moldova = "MD"; case monaco = "MC"; case mongolia = "MN"; case montenegro = "ME"; case montserrat = "MS"; case morocco = "MA"; case mozambique = "MZ"; case myanmar = "MM"; case namibia = "NA"; case nauru = "NR"; case nepal = "NP"; case netherlands = "NL"; case newCaledonia = "NC"; case newZealand = "NZ"; case nicaragua = "NI"; case niger = "NE"; case nigeria = "NG"; case niue = "NU"; case norfolkIsland = "NF"; case northKorea = "KP"; case northernMarianaIslands = "MP"; case norway = "NO"; case oman = "OM"; case pakistan = "PK"; case palau = "PW"; case palestinianTerritory = "PS"; case panama = "PA"; case papuaNewGuinea = "PG"; case paraguay = "PY"; case peru = "PE"; case philippines = "PH"; case pitcairnIslands = "PN"; case poland = "PL"; case portugal = "PT"; case puertoRico = "PR"; case qatar = "QA"; case reunion = "RE"; case romania = "RO"; case russianFederation = "RU"; case rwanda = "RW"; case saintBarthelemy = "BL"; case saintHelena = "SH"; case saintKittsAndNevis = "KN"; case saintLucia = "LC"; case saintMartin = "MF"; case saintPierreAndMiquelon = "PM"; case saintVincentAndTheGrenadines = "VC"; case samoa = "WS"; case sanMarino = "SM"; case saoTomeAndPrincipe = "ST"; case saudiArabia = "SA"; case senegal = "SN"; case serbia = "RS"; case seychelles = "SC"; case sierraLeone = "SL"; case singapore = "SG"; case sintMaarten = "SX"; case slovakia = "SK"; case slovenia = "SI"; case solomonIslands = "SB"; case somalia = "SO"; case southAfrica = "ZA"; case southGeorgiaAndTheSouthSandwichIslands = "GS"; case southKorea = "KR"; case southSudan = "SS"; case spain = "ES"; case sriLanka = "LK"; case sudan = "SD"; case suriname = "SR"; case svalbardAndJanMayen = "SJ"; case swaziland = "SZ"; case sweden = "SE"; case switzerland = "CH"; case syrianArabRepublic = "SY"; case taiwan = "TW"; case tajikistan = "TJ"; case tanzania = "TZ"; case thailand = "TH"; case timorLeste = "TL"; case togo = "TG"; case tokelau = "TK"; case tonga = "TO"; case trinidadAndTobago = "TT"; case tunisia = "TN"; case turkey = "TR"; case turkmenistan = "TM"; case turksAndCaicosIslands = "TC"; case tuvalu = "TV"; case uganda = "UG"; case ukraine = "UA"; case unitedArabEmirates = "AE"; case unitedKingdom = "GB"; case unitedStates = "US"; case unitedStatesMinorOutlyingIslands = "UM"; case uruguay = "UY"; case uzbekistan = "UZ"; case vanuatu = "VU"; case venezuela = "VE"; case vietnam = "VN"; case virginIslandsBritish = "VG"; case virginIslandsUS = "VI"; case wallisAndFutuna = "WF"; case westernSahara = "EH"; case yemen = "YE"; case zambia = "ZM"; case zimbabwe = "ZW" + } +} +extension EhSetting.BrowsingCountry { + public var id: Int { hashValue } + public var name: LocalizedStringResource { + switch self { + case .autoDetect: return .browsingCountryAutoDetect + case .afghanistan: return .browsingCountryAfghanistan + case .alandIslands: return .browsingCountryAlandIslands + case .albania: return .browsingCountryAlbania + case .algeria: return .browsingCountryAlgeria + case .americanSamoa: return .browsingCountryAmericanSamoa + case .andorra: return .browsingCountryAndorra + case .angola: return .browsingCountryAngola + case .anguilla: return .browsingCountryAnguilla + case .antarctica: return .browsingCountryAntarctica + case .antiguaAndBarbuda: return .browsingCountryAntiguaAndBarbuda + case .argentina: return .browsingCountryArgentina + case .armenia: return .browsingCountryArmenia + case .aruba: return .browsingCountryAruba + case .asiaPacificRegion: return .browsingCountryAsiaPacificRegion + case .australia: return .browsingCountryAustralia + case .austria: return .browsingCountryAustria + case .azerbaijan: return .browsingCountryAzerbaijan + case .bahamas: return .browsingCountryBahamas + case .bahrain: return .browsingCountryBahrain + case .bangladesh: return .browsingCountryBangladesh + case .barbados: return .browsingCountryBarbados + case .belarus: return .browsingCountryBelarus + case .belgium: return .browsingCountryBelgium + case .belize: return .browsingCountryBelize + case .benin: return .browsingCountryBenin + case .bermuda: return .browsingCountryBermuda + case .bhutan: return .browsingCountryBhutan + case .bolivia: return .browsingCountryBolivia + case .bonaireSaintEustatiusAndSaba: return .browsingCountryBonaireSaintEustatiusAndSaba + case .bosniaAndHerzegovina: return .browsingCountryBosniaAndHerzegovina + case .botswana: return .browsingCountryBotswana + case .bouvetIsland: return .browsingCountryBouvetIsland + case .brazil: return .browsingCountryBrazil + case .britishIndianOceanTerritory: return .browsingCountryBritishIndianOceanTerritory + case .bruneiDarussalam: return .browsingCountryBruneiDarussalam + case .bulgaria: return .browsingCountryBulgaria + case .burkinaFaso: return .browsingCountryBurkinaFaso + case .burundi: return .browsingCountryBurundi + case .cambodia: return .browsingCountryCambodia + case .cameroon: return .browsingCountryCameroon + case .canada: return .browsingCountryCanada + case .capeVerde: return .browsingCountryCapeVerde + case .caymanIslands: return .browsingCountryCaymanIslands + case .centralAfricanRepublic: return .browsingCountryCentralAfricanRepublic + case .chad: return .browsingCountryChad + case .chile: return .browsingCountryChile + case .china: return .browsingCountryChina + case .christmasIsland: return .browsingCountryChristmasIsland + case .cocosIslands: return .browsingCountryCocosIslands + case .colombia: return .browsingCountryColombia + case .comoros: return .browsingCountryComoros + case .congo: return .browsingCountryCongo + case .theDemocraticRepublicOfTheCongo: return .browsingCountryTheDemocraticRepublicOfTheCongo + case .cookIslands: return .browsingCountryCookIslands + case .costaRica: return .browsingCountryCostaRica + case .coteDIvoire: return .browsingCountryCoteDIvoire + case .croatia: return .browsingCountryCroatia + case .cuba: return .browsingCountryCuba + case .curacao: return .browsingCountryCuracao + case .cyprus: return .browsingCountryCyprus + case .czechRepublic: return .browsingCountryCzechRepublic + case .denmark: return .browsingCountryDenmark + case .djibouti: return .browsingCountryDjibouti + case .dominica: return .browsingCountryDominica + case .dominicanRepublic: return .browsingCountryDominicanRepublic + case .ecuador: return .browsingCountryEcuador + case .egypt: return .browsingCountryEgypt + case .elSalvador: return .browsingCountryElSalvador + case .equatorialGuinea: return .browsingCountryEquatorialGuinea + case .eritrea: return .browsingCountryEritrea + case .estonia: return .browsingCountryEstonia + case .ethiopia: return .browsingCountryEthiopia + case .europe: return .browsingCountryEurope + case .falklandIslands: return .browsingCountryFalklandIslands + case .faroeIslands: return .browsingCountryFaroeIslands + case .fiji: return .browsingCountryFiji + case .finland: return .browsingCountryFinland + case .france: return .browsingCountryFrance + case .frenchGuiana: return .browsingCountryFrenchGuiana + case .frenchPolynesia: return .browsingCountryFrenchPolynesia + case .frenchSouthernTerritories: return .browsingCountryFrenchSouthernTerritories + case .gabon: return .browsingCountryGabon + case .gambia: return .browsingCountryGambia + case .georgia: return .browsingCountryGeorgia + case .germany: return .browsingCountryGermany + case .ghana: return .browsingCountryGhana + case .gibraltar: return .browsingCountryGibraltar + case .greece: return .browsingCountryGreece + case .greenland: return .browsingCountryGreenland + case .grenada: return .browsingCountryGrenada + case .guadeloupe: return .browsingCountryGuadeloupe + case .guam: return .browsingCountryGuam + case .guatemala: return .browsingCountryGuatemala + case .guernsey: return .browsingCountryGuernsey + case .guinea: return .browsingCountryGuinea + case .guineaBissau: return .browsingCountryGuineaBissau + case .guyana: return .browsingCountryGuyana + case .haiti: return .browsingCountryHaiti + case .heardIslandAndMcDonaldIslands: return .browsingCountryHeardIslandAndMcDonaldIslands + case .vaticanCityState: return .browsingCountryVaticanCityState + case .honduras: return .browsingCountryHonduras + case .hongKong: return .browsingCountryHongKong + case .hungary: return .browsingCountryHungary + case .iceland: return .browsingCountryIceland + case .india: return .browsingCountryIndia + case .indonesia: return .browsingCountryIndonesia + case .iran: return .browsingCountryIran + case .iraq: return .browsingCountryIraq + case .ireland: return .browsingCountryIreland + case .isleOfMan: return .browsingCountryIsleOfMan + case .israel: return .browsingCountryIsrael + case .italy: return .browsingCountryItaly + case .jamaica: return .browsingCountryJamaica + case .japan: return .browsingCountryJapan + case .jersey: return .browsingCountryJersey + case .jordan: return .browsingCountryJordan + case .kazakhstan: return .browsingCountryKazakhstan + case .kenya: return .browsingCountryKenya + case .kiribati: return .browsingCountryKiribati + case .kuwait: return .browsingCountryKuwait + case .kyrgyzstan: return .browsingCountryKyrgyzstan + case .laoPeoplesDemocraticRepublic: return .browsingCountryLaoPeoplesDemocraticRepublic + case .latvia: return .browsingCountryLatvia + case .lebanon: return .browsingCountryLebanon + case .lesotho: return .browsingCountryLesotho + case .liberia: return .browsingCountryLiberia + case .libya: return .browsingCountryLibya + case .liechtenstein: return .browsingCountryLiechtenstein + case .lithuania: return .browsingCountryLithuania + case .luxembourg: return .browsingCountryLuxembourg + case .macau: return .browsingCountryMacau + case .macedonia: return .browsingCountryMacedonia + case .madagascar: return .browsingCountryMadagascar + case .malawi: return .browsingCountryMalawi + case .malaysia: return .browsingCountryMalaysia + case .maldives: return .browsingCountryMaldives + case .mali: return .browsingCountryMali + case .malta: return .browsingCountryMalta + case .marshallIslands: return .browsingCountryMarshallIslands + case .martinique: return .browsingCountryMartinique + case .mauritania: return .browsingCountryMauritania + case .mauritius: return .browsingCountryMauritius + case .mayotte: return .browsingCountryMayotte + case .mexico: return .browsingCountryMexico + case .micronesia: return .browsingCountryMicronesia + case .moldova: return .browsingCountryMoldova + case .monaco: return .browsingCountryMonaco + case .mongolia: return .browsingCountryMongolia + case .montenegro: return .browsingCountryMontenegro + case .montserrat: return .browsingCountryMontserrat + case .morocco: return .browsingCountryMorocco + case .mozambique: return .browsingCountryMozambique + case .myanmar: return .browsingCountryMyanmar + case .namibia: return .browsingCountryNamibia + case .nauru: return .browsingCountryNauru + case .nepal: return .browsingCountryNepal + case .netherlands: return .browsingCountryNetherlands + case .newCaledonia: return .browsingCountryNewCaledonia + case .newZealand: return .browsingCountryNewZealand + case .nicaragua: return .browsingCountryNicaragua + case .niger: return .browsingCountryNiger + case .nigeria: return .browsingCountryNigeria + case .niue: return .browsingCountryNiue + case .norfolkIsland: return .browsingCountryNorfolkIsland + case .northKorea: return .browsingCountryNorthKorea + case .northernMarianaIslands: return .browsingCountryNorthernMarianaIslands + case .norway: return .browsingCountryNorway + case .oman: return .browsingCountryOman + case .pakistan: return .browsingCountryPakistan + case .palau: return .browsingCountryPalau + case .palestinianTerritory: return .browsingCountryPalestinianTerritory + case .panama: return .browsingCountryPanama + case .papuaNewGuinea: return .browsingCountryPapuaNewGuinea + case .paraguay: return .browsingCountryParaguay + case .peru: return .browsingCountryPeru + case .philippines: return .browsingCountryPhilippines + case .pitcairnIslands: return .browsingCountryPitcairnIslands + case .poland: return .browsingCountryPoland + case .portugal: return .browsingCountryPortugal + case .puertoRico: return .browsingCountryPuertoRico + case .qatar: return .browsingCountryQatar + case .reunion: return .browsingCountryReunion + case .romania: return .browsingCountryRomania + case .russianFederation: return .browsingCountryRussianFederation + case .rwanda: return .browsingCountryRwanda + case .saintBarthelemy: return .browsingCountrySaintBarthelemy + case .saintHelena: return .browsingCountrySaintHelena + case .saintKittsAndNevis: return .browsingCountrySaintKittsAndNevis + case .saintLucia: return .browsingCountrySaintLucia + case .saintMartin: return .browsingCountrySaintMartin + case .saintPierreAndMiquelon: return .browsingCountrySaintPierreAndMiquelon + case .saintVincentAndTheGrenadines: return .browsingCountrySaintVincentAndTheGrenadines + case .samoa: return .browsingCountrySamoa + case .sanMarino: return .browsingCountrySanMarino + case .saoTomeAndPrincipe: return .browsingCountrySaoTomeAndPrincipe + case .saudiArabia: return .browsingCountrySaudiArabia + case .senegal: return .browsingCountrySenegal + case .serbia: return .browsingCountrySerbia + case .seychelles: return .browsingCountrySeychelles + case .sierraLeone: return .browsingCountrySierraLeone + case .singapore: return .browsingCountrySingapore + case .sintMaarten: return .browsingCountrySintMaarten + case .slovakia: return .browsingCountrySlovakia + case .slovenia: return .browsingCountrySlovenia + case .solomonIslands: return .browsingCountrySolomonIslands + case .somalia: return .browsingCountrySomalia + case .southAfrica: return .browsingCountrySouthAfrica + case .southGeorgiaAndTheSouthSandwichIslands: return .browsingCountrySouthGeorgiaAndTheSouthSandwichIslands + case .southKorea: return .browsingCountrySouthKorea + case .southSudan: return .browsingCountrySouthSudan + case .spain: return .browsingCountrySpain + case .sriLanka: return .browsingCountrySriLanka + case .sudan: return .browsingCountrySudan + case .suriname: return .browsingCountrySuriname + case .svalbardAndJanMayen: return .browsingCountrySvalbardAndJanMayen + case .swaziland: return .browsingCountrySwaziland + case .sweden: return .browsingCountrySweden + case .switzerland: return .browsingCountrySwitzerland + case .syrianArabRepublic: return .browsingCountrySyrianArabRepublic + case .taiwan: return .browsingCountryTaiwan + case .tajikistan: return .browsingCountryTajikistan + case .tanzania: return .browsingCountryTanzania + case .thailand: return .browsingCountryThailand + case .timorLeste: return .browsingCountryTimorLeste + case .togo: return .browsingCountryTogo + case .tokelau: return .browsingCountryTokelau + case .tonga: return .browsingCountryTonga + case .trinidadAndTobago: return .browsingCountryTrinidadAndTobago + case .tunisia: return .browsingCountryTunisia + case .turkey: return .browsingCountryTurkey + case .turkmenistan: return .browsingCountryTurkmenistan + case .turksAndCaicosIslands: return .browsingCountryTurksAndCaicosIslands + case .tuvalu: return .browsingCountryTuvalu + case .uganda: return .browsingCountryUganda + case .ukraine: return .browsingCountryUkraine + case .unitedArabEmirates: return .browsingCountryUnitedArabEmirates + case .unitedKingdom: return .browsingCountryUnitedKingdom + case .unitedStates: return .browsingCountryUnitedStates + case .unitedStatesMinorOutlyingIslands: return .browsingCountryUnitedStatesMinorOutlyingIslands + case .uruguay: return .browsingCountryUruguay + case .uzbekistan: return .browsingCountryUzbekistan + case .vanuatu: return .browsingCountryVanuatu + case .venezuela: return .browsingCountryVenezuela + case .vietnam: return .browsingCountryVietnam + case .virginIslandsBritish: return .browsingCountryVirginIslandsBritish + case .virginIslandsUS: return .browsingCountryVirginIslandsUs + case .wallisAndFutuna: return .browsingCountryWallisAndFutuna + case .westernSahara: return .browsingCountryWesternSahara + case .yemen: return .browsingCountryYemen + case .zambia: return .browsingCountryZambia + case .zimbabwe: return .browsingCountryZimbabwe + } + } +} +// swiftlint:enable line_length diff --git a/AppPackage/Sources/AppModels/Support/CookieState.swift b/AppPackage/Sources/AppModels/Support/CookieState.swift new file mode 100644 index 000000000..667d2d630 --- /dev/null +++ b/AppPackage/Sources/AppModels/Support/CookieState.swift @@ -0,0 +1,48 @@ +public struct CookiesState: Equatable, Sendable { + public static func empty(_ host: GalleryHost) -> Self { + .init( + host: host, + igneous: .empty, + memberID: .empty, + passHash: .empty + ) + } + public var allCases: [CookieState] {[ + igneous, memberID, passHash + ]} + + public let host: GalleryHost + public var igneous: CookieState + public var memberID: CookieState + public var passHash: CookieState + + public init( + host: GalleryHost, + igneous: CookieState, + memberID: CookieState, + passHash: CookieState + ) { + self.host = host + self.igneous = igneous + self.memberID = memberID + self.passHash = passHash + } +} + +public struct CookieState: Equatable, Sendable { + public static let empty: Self = .init( + key: "", value: .init( + rawValue: "", localizedString: "" + ) + ) + + public let key: String + public var value: CookieValue + public var editingText = "" + + public init(key: String, value: CookieValue, editingText: String = "") { + self.key = key + self.value = value + self.editingText = editingText + } +} diff --git a/AppPackage/Sources/AppModels/Support/CookieValue.swift b/AppPackage/Sources/AppModels/Support/CookieValue.swift new file mode 100644 index 000000000..7d881995c --- /dev/null +++ b/AppPackage/Sources/AppModels/Support/CookieValue.swift @@ -0,0 +1,20 @@ +public struct CookieValue: Equatable, Sendable { + public static let empty: Self = .init( + rawValue: .init(), localizedString: .init() + ) + + public let rawValue: String + public let localizedString: String + + public init(rawValue: String, localizedString: String) { + self.rawValue = rawValue + self.localizedString = localizedString + } + + public var isInvalid: Bool { + !localizedString.isEmpty && !rawValue.isEmpty + } + public var placeholder: String { + localizedString.isEmpty ? rawValue : localizedString + } +} diff --git a/AppPackage/Sources/AppModels/Support/EhSetting+Enums.swift b/AppPackage/Sources/AppModels/Support/EhSetting+Enums.swift new file mode 100644 index 000000000..4c8cd2c7c --- /dev/null +++ b/AppPackage/Sources/AppModels/Support/EhSetting+Enums.swift @@ -0,0 +1,108 @@ +import Foundation +import Resources + +// MARK: CommentsSortOrder +extension EhSetting { + public enum CommentsSortOrder: Int, CaseIterable, Identifiable, Sendable { + case oldest + case recent + case highestScore + } +} +extension EhSetting.CommentsSortOrder { + public var id: Int { rawValue } + + public var value: LocalizedStringResource { + switch self { + case .oldest: + return .commentsSortOrderOldest + case .recent: + return .commentsSortOrderRecent + case .highestScore: + return .commentsSortOrderHighestScore + } + } +} + +// MARK: CommentVotesShowTiming +extension EhSetting { + public enum CommentVotesShowTiming: Int, CaseIterable, Identifiable, Sendable { + case onHoverOrClick + case always + } +} +extension EhSetting.CommentVotesShowTiming { + public var id: Int { rawValue } + + public var value: LocalizedStringResource { + switch self { + case .onHoverOrClick: + return .commentsVotesShowTimingOnHoverOrClick + case .always: + return .commentsVotesShowTimingAlways + } + } +} + +// MARK: TagsSortOrder +extension EhSetting { + public enum TagsSortOrder: Int, CaseIterable, Identifiable, Sendable { + case alphabetical + case tagPower + } +} +extension EhSetting.TagsSortOrder { + public var id: Int { rawValue } + + public var value: LocalizedStringResource { + switch self { + case .alphabetical: + return .tagsSortOrderAlphabetical + case .tagPower: + return .tagsSortOrderTagPower + } + } +} + +// MARK: MultiplePageViewerStyle +extension EhSetting { + public enum MultiplePageViewerStyle: Int, CaseIterable, Identifiable, Sendable { + case alignLeftScaleIfOverWidth + case alignCenterScaleIfOverWidth + case alignCenterAlwaysScale + } +} +extension EhSetting.MultiplePageViewerStyle { + public var id: Int { rawValue } + + public var value: LocalizedStringResource { + switch self { + case .alignLeftScaleIfOverWidth: + return .multiplePageViewerStyleAlignLeftScaleIfOverWidth + case .alignCenterScaleIfOverWidth: + return .multiplePageViewerStyleAlignCenterScaleIfOverWidth + case .alignCenterAlwaysScale: + return .multiplePageViewerStyleAlignCenterAlwaysScale + } + } +} + +// MARK: GalleryPageNumbering +extension EhSetting { + public enum GalleryPageNumbering: Int, CaseIterable, Identifiable, Sendable { + case none + case pageNumberOnly + case pageNumberAndName + } +} +extension EhSetting.GalleryPageNumbering { + public var id: Int { rawValue } + + public var value: LocalizedStringResource { + switch self { + case .none: .galleryPageNumberingNone + case .pageNumberOnly: .galleryPageNumberingPageNumberOnly + case .pageNumberAndName: .galleryPageNumberingPageNumberAndName + } + } +} diff --git a/AppPackage/Sources/AppModels/Support/EhSetting+Extensions.swift b/AppPackage/Sources/AppModels/Support/EhSetting+Extensions.swift new file mode 100644 index 000000000..59370d283 --- /dev/null +++ b/AppPackage/Sources/AppModels/Support/EhSetting+Extensions.swift @@ -0,0 +1,85 @@ +import Foundation +import Resources + +// MARK: ThumbnailLoadTiming +extension EhSetting { + public enum ThumbnailLoadTiming: Int, CaseIterable, Identifiable, Sendable { + case onMouseOver + case onPageLoad + } +} +extension EhSetting.ThumbnailLoadTiming { + public var id: Int { rawValue } + + public var value: LocalizedStringResource { + switch self { + case .onMouseOver: + return .thumbnailLoadTimingOnMouseOver + case .onPageLoad: + return .thumbnailLoadTimingOnPageLoad + } + } + public var description: LocalizedStringResource { + switch self { + case .onMouseOver: + return .thumbnailLoadTimingOnMouseOverDescription + case .onPageLoad: + return .thumbnailLoadTimingOnPageLoadDescription + } + } +} + +// MARK: ThumbnailSize +extension EhSetting { + public enum ThumbnailSize: Int, CaseIterable, Identifiable, Comparable, Sendable { + case auto + case small + case normal + /// Deprecated + case large + } +} +extension EhSetting.ThumbnailSize { + public var id: Int { rawValue } + public static func < (lhs: Self, rhs: Self) -> Bool { + lhs.rawValue < rhs.rawValue + } + + public var value: LocalizedStringResource { + switch self { + case .normal: + return .thumbnailSizeNormal + case .large: + return .thumbnailSizeLarge + case .small: + return .thumbnailSizeSmall + case .auto: + return .thumbnailSizeAuto + } + } +} + +// MARK: ThumbnailRowCount +extension EhSetting { + public enum ThumbnailRowCount: Int, CaseIterable, Identifiable, Comparable, Sendable { + case four + case ten + case twenty + case forty + } +} +extension EhSetting.ThumbnailRowCount { + public var id: Int { rawValue } + public static func < (lhs: Self, rhs: Self) -> Bool { + lhs.rawValue < rhs.rawValue + } + + public var value: String { + switch self { + case .four: "4" + case .ten: "8" + case .twenty: "20" + case .forty: "40" + } + } +} diff --git a/AppPackage/Sources/AppModels/Support/EhSetting.swift b/AppPackage/Sources/AppModels/Support/EhSetting.swift new file mode 100644 index 000000000..73f53c611 --- /dev/null +++ b/AppPackage/Sources/AppModels/Support/EhSetting.swift @@ -0,0 +1,448 @@ +import Foundation +import Resources + +// MARK: EhSetting +public struct EhSetting: Equatable, Sendable { + public init( + ehProfiles: [EhProfile], + isCapableOfCreatingNewProfile: Bool, + capableLoadThroughHathSetting: LoadThroughHathSetting, + capableImageResolution: ImageResolution, + capableSearchResultCount: SearchResultCount, + capableThumbnailConfigRowCount: ThumbnailRowCount, + capableThumbnailConfigSizes: [ThumbnailSize], + loadThroughHathSetting: LoadThroughHathSetting, + browsingCountry: BrowsingCountry, + literalBrowsingCountry: String, + imageResolution: ImageResolution, + imageSizeWidth: Float, + imageSizeHeight: Float, + galleryName: GalleryName, + archiverBehavior: ArchiverBehavior, + displayMode: DisplayMode, + showSearchRangeIndicator: Bool, + enableGalleryThumbnailSelector: Bool, + disabledCategories: [Bool], + favoriteCategories: [String], + favoritesSortOrder: FavoritesSortOrder, + ratingsColor: String, + tagFilteringThreshold: Float, + tagWatchingThreshold: Float, + showFilteredRemovalCount: Bool, + excludedLanguages: [Bool], + excludedUploaders: String, + searchResultCount: SearchResultCount, + thumbnailLoadTiming: ThumbnailLoadTiming, + thumbnailConfigSize: ThumbnailSize, + thumbnailConfigRows: ThumbnailRowCount, + coverScaleFactor: Float, + viewportVirtualWidth: Float, + commentsSortOrder: CommentsSortOrder, + commentVotesShowTiming: CommentVotesShowTiming, + tagsSortOrder: TagsSortOrder, + galleryPageNumbering: GalleryPageNumbering, + useOriginalImages: Bool? = nil, + useMultiplePageViewer: Bool? = nil, + multiplePageViewerStyle: MultiplePageViewerStyle? = nil, + multiplePageViewerShowThumbnailPane: Bool? = nil + ) { + self.ehProfiles = ehProfiles + self.isCapableOfCreatingNewProfile = isCapableOfCreatingNewProfile + self.capableLoadThroughHathSetting = capableLoadThroughHathSetting + self.capableImageResolution = capableImageResolution + self.capableSearchResultCount = capableSearchResultCount + self.capableThumbnailConfigRowCount = capableThumbnailConfigRowCount + self.capableThumbnailConfigSizes = capableThumbnailConfigSizes + self.loadThroughHathSetting = loadThroughHathSetting + self.browsingCountry = browsingCountry + self.literalBrowsingCountry = literalBrowsingCountry + self.imageResolution = imageResolution + self.imageSizeWidth = imageSizeWidth + self.imageSizeHeight = imageSizeHeight + self.galleryName = galleryName + self.archiverBehavior = archiverBehavior + self.displayMode = displayMode + self.showSearchRangeIndicator = showSearchRangeIndicator + self.enableGalleryThumbnailSelector = enableGalleryThumbnailSelector + self.disabledCategories = disabledCategories + self.favoriteCategories = favoriteCategories + self.favoritesSortOrder = favoritesSortOrder + self.ratingsColor = ratingsColor + self.tagFilteringThreshold = tagFilteringThreshold + self.tagWatchingThreshold = tagWatchingThreshold + self.showFilteredRemovalCount = showFilteredRemovalCount + self.excludedLanguages = excludedLanguages + self.excludedUploaders = excludedUploaders + self.searchResultCount = searchResultCount + self.thumbnailLoadTiming = thumbnailLoadTiming + self.thumbnailConfigSize = thumbnailConfigSize + self.thumbnailConfigRows = thumbnailConfigRows + self.coverScaleFactor = coverScaleFactor + self.viewportVirtualWidth = viewportVirtualWidth + self.commentsSortOrder = commentsSortOrder + self.commentVotesShowTiming = commentVotesShowTiming + self.tagsSortOrder = tagsSortOrder + self.galleryPageNumbering = galleryPageNumbering + self.useOriginalImages = useOriginalImages + self.useMultiplePageViewer = useMultiplePageViewer + self.multiplePageViewerStyle = multiplePageViewerStyle + self.multiplePageViewerShowThumbnailPane = multiplePageViewerShowThumbnailPane + } + // swiftlint:disable line_length + public static let empty: Self = .init(ehProfiles: [.empty], isCapableOfCreatingNewProfile: true, capableLoadThroughHathSetting: .anyClient, capableImageResolution: .auto, capableSearchResultCount: .fifty, capableThumbnailConfigRowCount: .forty, capableThumbnailConfigSizes: [], loadThroughHathSetting: .anyClient, browsingCountry: .autoDetect, literalBrowsingCountry: "", imageResolution: .auto, imageSizeWidth: 0, imageSizeHeight: 0, galleryName: .default, archiverBehavior: .autoSelectOriginalAutoStart, displayMode: .compact, showSearchRangeIndicator: true, enableGalleryThumbnailSelector: false, disabledCategories: Array(repeating: false, count: categoryNames.count), favoriteCategories: Array(repeating: "", count: favoriteCategoryCount), favoritesSortOrder: .favoritedTime, ratingsColor: "", tagFilteringThreshold: 0, tagWatchingThreshold: 0, showFilteredRemovalCount: true, excludedLanguages: Array(repeating: false, count: languageValues.count), excludedUploaders: "", searchResultCount: .fifty, thumbnailLoadTiming: .onPageLoad, thumbnailConfigSize: .normal, thumbnailConfigRows: .ten, coverScaleFactor: 0, viewportVirtualWidth: 0, commentsSortOrder: .recent, commentVotesShowTiming: .always, tagsSortOrder: .alphabetical, galleryPageNumbering: .none) + // swiftlint:enable line_length + + public static let categoryNames = Category.allFiltersCases.map(\.rawValue).map { value in + value.lowercased().replacingOccurrences(of: " ", with: "") + } + public static let favoriteCategoryCount = 10 + public static let languageValues = [ + + 1024, 2048, 1, 1025, 2049, 10, 1034, 2058, + + 20, 1044, 2068, 30, 1054, 2078, 40, 1064, 2088, + + 50, 1074, 2098, 60, 1084, 2108, 70, 1094, 2118, + + 80, 1104, 2128, 90, 1114, 2138, 100, 1124, 2148, + + 110, 1134, 2158, 120, 1144, 2168, 130, 1154, 2178, + + 255, 1279, 2303 + + ] + + public let ehProfiles: [EhProfile] + public var ehpandaProfile: EhProfile? { + ehProfiles.filter({ EhSetting.verifyEhPandaProfileName(with: $0.name) }).first + } + public static func verifyEhPandaProfileName(with name: String?) -> Bool { + ["EhPanda", "EhPanda (Default)"].contains(name ?? "") + } + + public let isCapableOfCreatingNewProfile: Bool + public let capableLoadThroughHathSetting: LoadThroughHathSetting + public let capableImageResolution: ImageResolution + public let capableSearchResultCount: SearchResultCount + public let capableThumbnailConfigRowCount: ThumbnailRowCount + public let capableThumbnailConfigSizes: [ThumbnailSize] + + public var capableLoadThroughHathSettings: [LoadThroughHathSetting] { + LoadThroughHathSetting.allCases.filter { setting in + setting <= capableLoadThroughHathSetting + } + } + public var capableImageResolutions: [ImageResolution] { + ImageResolution.allCases.filter { resolution in + resolution <= capableImageResolution + } + } + public var capableSearchResultCounts: [SearchResultCount] { + SearchResultCount.allCases.filter { count in + count <= capableSearchResultCount + } + } + public var capableThumbnailConfigRowCounts: [ThumbnailRowCount] { + ThumbnailRowCount.allCases.filter { row in + row <= capableThumbnailConfigRowCount + } + } + public var localizedLiteralBrowsingCountry: String? { + BrowsingCountry.allCases.first(where: { $0.englishName == literalBrowsingCountry }) + .map { String(localized: $0.name) } + } + + public var loadThroughHathSetting: LoadThroughHathSetting + public var browsingCountry: BrowsingCountry + public let literalBrowsingCountry: String + public var imageResolution: ImageResolution + public var imageSizeWidth: Float + public var imageSizeHeight: Float + public var galleryName: GalleryName + public var archiverBehavior: ArchiverBehavior + public var displayMode: DisplayMode + public var showSearchRangeIndicator: Bool + public var enableGalleryThumbnailSelector: Bool + public var disabledCategories: [Bool] + public var favoriteCategories: [String] + public var favoritesSortOrder: FavoritesSortOrder + public var ratingsColor: String + public var tagFilteringThreshold: Float + public var tagWatchingThreshold: Float + public var showFilteredRemovalCount: Bool + public var excludedLanguages: [Bool] + public var excludedUploaders: String + public var searchResultCount: SearchResultCount + public var thumbnailLoadTiming: ThumbnailLoadTiming + public var thumbnailConfigSize: ThumbnailSize + public var thumbnailConfigRows: ThumbnailRowCount + public var coverScaleFactor: Float + public var viewportVirtualWidth: Float + public var commentsSortOrder: CommentsSortOrder + public var commentVotesShowTiming: CommentVotesShowTiming + public var tagsSortOrder: TagsSortOrder + public var galleryPageNumbering: GalleryPageNumbering + public var useOriginalImages: Bool? + public var useMultiplePageViewer: Bool? + public var multiplePageViewerStyle: MultiplePageViewerStyle? + public var multiplePageViewerShowThumbnailPane: Bool? +} + +// MARK: EhProfile +public struct EhProfile: Comparable, Identifiable, Hashable, Sendable { + public init( + value: Int, + name: String, + isSelected: Bool + ) { + self.value = value + self.name = name + self.isSelected = isSelected + } + public static let empty: Self = .init( + value: 0, name: "", isSelected: true + ) + public static func < (lhs: EhProfile, rhs: EhProfile) -> Bool { + lhs.value < rhs.value + } + public var id: Int { value } + + public let value: Int + public let name: String + public let isSelected: Bool + public var isDefault: Bool { + value == 1 + } +} +public enum EhProfileAction: String, Sendable { + case create + case delete + case rename + case `default` +} + +// MARK: LoadThroughHathSetting +extension EhSetting { + public enum LoadThroughHathSetting: Int, CaseIterable, Identifiable, Comparable, Sendable { + case anyClient + case defaultPortOnly + case modernNo + case legacyNo + } +} +extension EhSetting.LoadThroughHathSetting { + public var id: Int { rawValue } + public static func < ( + lhs: EhSetting.LoadThroughHathSetting, + rhs: EhSetting.LoadThroughHathSetting + ) -> Bool { + lhs.rawValue < rhs.rawValue + } + + public var value: LocalizedStringResource { + switch self { + case .anyClient: + return .loadThroughHathSettingAnyClient + case .defaultPortOnly: + return .loadThroughHathSettingDefaultPortOnly + case .modernNo: + return .loadThroughHathSettingModernNo + case .legacyNo: + return .loadThroughHathSettingLegacyNo + } + } + public var description: LocalizedStringResource { + switch self { + case .anyClient: + return .loadThroughHathSettingAnyClientDescription + case .defaultPortOnly: + return .loadThroughHathSettingDefaultPortOnlyDescription + case .modernNo: + return .loadThroughHathSettingModernNoDescription + case .legacyNo: + return .loadThroughHathSettingLegacyNoDescription + } + } +} + +// MARK: ImageResolution +extension EhSetting { + public enum ImageResolution: Int, CaseIterable, Identifiable, Comparable, Codable, Sendable { + case auto = 0 + case x800 = 1 + case x1280 = 3 + case x1920 = 4 + case x2560 = 5 + } +} +extension EhSetting.ImageResolution { + public var id: Int { rawValue } + public static func < (lhs: Self, rhs: Self) -> Bool { + lhs.rawValue < rhs.rawValue + } + + public var value: String { + switch self { + case .auto: + return String(localized: .imageResolutionAuto) + case .x800: + return "800x" + case .x1280: + return "1280x" + case .x1920: + return "1920x" + case .x2560: + return "2560x" + } + } +} + +// MARK: GalleryName +extension EhSetting { + public enum GalleryName: Int, CaseIterable, Identifiable, Sendable { + case `default` + case japanese + } +} +extension EhSetting.GalleryName { + public var id: Int { rawValue } + + public var value: LocalizedStringResource { + switch self { + case .default: + return .galleryNameDefault + case .japanese: + return .galleryNameJapanese + } + } +} + +// MARK: ArchiverBehavior +extension EhSetting { + public enum ArchiverBehavior: Int, CaseIterable, Identifiable, Sendable { + case manualSelectManualStart + case manualSelectAutoStart + case autoSelectOriginalManualStart + case autoSelectOriginalAutoStart + case autoSelectResampleManualStart + case autoSelectResampleAutoStart + } +} +extension EhSetting.ArchiverBehavior { + public var id: Int { rawValue } + + public var value: LocalizedStringResource { + switch self { + case .manualSelectManualStart: + return .ehSettingArchiverBehaviorManualSelectManualStart + case .manualSelectAutoStart: + return .ehSettingArchiverBehaviorManualSelectAutoStart + case .autoSelectOriginalManualStart: + return .ehSettingArchiverBehaviorAutoSelectOriginalManualStart + case .autoSelectOriginalAutoStart: + return .ehSettingArchiverBehaviorAutoSelectOriginalAutoStart + case .autoSelectResampleManualStart: + return .ehSettingArchiverBehaviorAutoSelectResampleManualStart + case .autoSelectResampleAutoStart: + return .ehSettingArchiverBehaviorAutoSelectResampleAutoStart + } + } +} + +// MARK: DisplayMode +extension EhSetting { + public enum DisplayMode: Int, CaseIterable, Identifiable, Sendable { + case compact + case thumbnail + case extended + case minimal + case minimalPlus + } +} +extension EhSetting.DisplayMode { + public var id: Int { rawValue } + + public var value: LocalizedStringResource { + switch self { + case .compact: + return .displayModeCompact + case .thumbnail: + return .displayModeThumbnail + case .extended: + return .displayModeExtended + case .minimal: + return .displayModeMinimal + case .minimalPlus: + return .displayModeMinimalPlus + } + } +} + +// MARK: FavoritesSortOrder +extension EhSetting { + public enum FavoritesSortOrder: Int, CaseIterable, Identifiable, Sendable { + case lastUpdateTime + case favoritedTime + } +} +extension EhSetting.FavoritesSortOrder { + public var id: Int { rawValue } + + public var value: LocalizedStringResource { + switch self { + case .lastUpdateTime: + return .favoritesSortOrderLastUpdateTime + case .favoritedTime: + return .favoritesSortOrderFavoritedTime + } + } +} + +// MARK: ExcludedLanguagesCategory +extension EhSetting { + public enum ExcludedLanguagesCategory: Int, Identifiable, CaseIterable, Sendable { + case original + case translated + case rewrite + } +} +extension EhSetting.ExcludedLanguagesCategory { + public var id: Int { rawValue } + + public var value: LocalizedStringResource { + switch self { + case .original: + return .excludedLanguagesCategoryOriginal + case .translated: + return .excludedLanguagesCategoryTranslated + case .rewrite: + return .excludedLanguagesCategoryRewrite + } + } +} + +// MARK: SearchResultCount +extension EhSetting { + public enum SearchResultCount: Int, CaseIterable, Identifiable, Comparable, Sendable { + case twentyFive + case fifty + case oneHundred + } +} +extension EhSetting.SearchResultCount { + public var id: Int { rawValue } + public static func < (lhs: Self, rhs: Self) -> Bool { + lhs.rawValue < rhs.rawValue + } + + public var value: String { + switch self { + case .twentyFive: + return "25" + case .fifty: + return "50" + case .oneHundred: + return "100" + } + } +} diff --git a/AppPackage/Sources/AppModels/Support/Greeting.swift b/AppPackage/Sources/AppModels/Support/Greeting.swift new file mode 100644 index 000000000..51e33989b --- /dev/null +++ b/AppPackage/Sources/AppModels/Support/Greeting.swift @@ -0,0 +1,102 @@ +import Foundation +import Resources + +public struct Greeting: Codable, Equatable, Hashable, Identifiable, Sendable { + public init( + id: UUID = UUID(), + gainedEXP: Int? = nil, + gainedCredits: Int? = nil, + gainedGP: Int? = nil, + gainedHath: Int? = nil, + updateTime: Date? = nil + ) { + self.id = id + self.gainedEXP = gainedEXP + self.gainedCredits = gainedCredits + self.gainedGP = gainedGP + self.gainedHath = gainedHath + self.updateTime = updateTime + } + public static let mock: Self = { + var greeting = Greeting() + greeting.gainedEXP = 10 + greeting.gainedCredits = 10000 + greeting.gainedGP = 10000 + greeting.gainedHath = 10 + return greeting + }() + + public var id = UUID() + + public var gainedEXP: Int? + public var gainedCredits: Int? + public var gainedGP: Int? + public var gainedHath: Int? + public var updateTime: Date? + + public var rewards: [String] { + func formatNumber(_ number: Int?) -> String? { + guard let number = number else { return nil } + let formatter = NumberFormatter() + formatter.numberStyle = .decimal + return formatter.string(from: .init(value: number)) + } + + var rewards = [String]() + if let exp = formatNumber(gainedEXP) { + rewards.append("\(exp) EXP") + } + if let credits = formatNumber(gainedCredits) { + rewards.append("\(credits) Credits") + } + if let galleryPoint = formatNumber(gainedGP) { + rewards.append("\(galleryPoint) GP") + } + if let hath = formatNumber(gainedHath) { + rewards.append("\(hath) Hath") + } + return rewards + } + + public var gainContent: String? { + let rewards = rewards + guard !rewards.isEmpty else { return nil } + let and = String(localized: .greetingAnd) + let end = String(localized: .greetingEnd) + let start = String(localized: .greetingStart) + let separator = String(localized: .greetingSeparator) + let rewardDescription = rewards.enumerated().map { (offset, element) in + if offset == 0 { + return element + } 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() + } + + public var gainedNothing: Bool { + [gainedEXP, gainedCredits, gainedGP, gainedHath] + .compactMap({ $0 }).isEmpty + } +} + +extension Optional where Wrapped == Greeting { + /// Adopts `greeting` only when it is newer than the one already held (or none is held). Two + /// features write the session greeting — the Setting daily fetch and the Detail-page parse — so the + /// "keep the newer" rule lives here, next to the model, rather than at either call site, and a stale + /// detail-page greeting can't clobber a fresher one. + public mutating func mergeNewer(_ greeting: Greeting) { + guard let newDate = greeting.updateTime else { return } + if let current = self { + if let currentDate = current.updateTime, currentDate < newDate { + self = greeting + } + } else { + self = greeting + } + } +} diff --git a/AppPackage/Sources/AppModels/Support/ImagePlaceholderFingerprint.swift b/AppPackage/Sources/AppModels/Support/ImagePlaceholderFingerprint.swift new file mode 100644 index 000000000..e1e539b8a --- /dev/null +++ b/AppPackage/Sources/AppModels/Support/ImagePlaceholderFingerprint.swift @@ -0,0 +1,50 @@ +import CryptoKit +import Foundation + +/// A known E-H asset placeholder that decodes as a valid image but is *not* page +/// content: the ExHentai "kokomade" login wall and the H@H `509` bandwidth notice. +/// +/// Both the download pipeline and the reader's owned fetch must reject these +/// identically — a placeholder that slips through poisons the shared image cache +/// for its full expiry window and can be displayed or exported as if it were the +/// real page. Centralising the fingerprints keeps the two paths in lockstep. +public enum ImagePlaceholderFingerprint: Sendable { + case authenticationRequired + case quotaExceeded + + public var error: AppError { + switch self { + case .authenticationRequired: .authenticationRequired + case .quotaExceeded: .quotaExceeded + } + } + + public static let authenticationRequiredByteCount = 144_844 + public static let authenticationRequiredSHA1 = "e48ed350e902a51581246d2a764fa7827e8e6988" + public static let quotaExceededByteCount = 28_658 + public static let quotaExceededSHA1 = "f54b887b017694dc25eb1a1404f71981885f8ed9" + + /// Returns the matching placeholder when `data` is byte-for-byte one of the + /// known fixtures (exact length plus SHA-1), otherwise `nil`. The length gate + /// makes the common, non-placeholder case cost one comparison. + public static func match(_ data: Data) -> Self? { + if matches(data, byteCount: authenticationRequiredByteCount, sha1: authenticationRequiredSHA1) { + return .authenticationRequired + } + if matches(data, byteCount: quotaExceededByteCount, sha1: quotaExceededSHA1) { + return .quotaExceeded + } + return nil + } + + private static func matches(_ data: Data, byteCount: Int, sha1: String) -> Bool { + guard data.count == byteCount else { return false } + return sha1Hex(for: data) == sha1 + } + + public static func sha1Hex(for data: Data) -> String { + Insecure.SHA1.hash(data: data) + .map { String(format: "%02x", $0) } + .joined() + } +} diff --git a/EhPanda/Models/Support/LiveText.swift b/AppPackage/Sources/AppModels/Support/LiveText.swift similarity index 74% rename from EhPanda/Models/Support/LiveText.swift rename to AppPackage/Sources/AppModels/Support/LiveText.swift index db6271fb2..7a4c7d75e 100644 --- a/EhPanda/Models/Support/LiveText.swift +++ b/AppPackage/Sources/AppModels/Support/LiveText.swift @@ -1,49 +1,44 @@ -// -// LiveText.swift -// EhPanda -// - import SwiftUI import Foundation // MARK: LiveTextBounds -struct LiveTextBounds: Equatable { - let topLeft: CGPoint - let topRight: CGPoint - let bottomLeft: CGPoint - let bottomRight: CGPoint +public struct LiveTextBounds: Equatable, Sendable { + public let topLeft: CGPoint + public let topRight: CGPoint + public let bottomLeft: CGPoint + public let bottomRight: CGPoint - var edges: [CGPoint] { + public var edges: [CGPoint] { [topLeft, topRight, bottomRight, bottomLeft] } - init(topLeft: CGPoint, topRight: CGPoint, bottomLeft: CGPoint, bottomRight: CGPoint) { + public init(topLeft: CGPoint, topRight: CGPoint, bottomLeft: CGPoint, bottomRight: CGPoint) { self.topLeft = topLeft self.topRight = topRight self.bottomLeft = bottomLeft self.bottomRight = bottomRight } - func expandingHalfHeight(_ size: CGSize) -> Self { + public func expandingHalfHeight(_ size: CGSize) -> Self { expanding(size: size, width: 0, height: getHeight(size) / 2) } - func getHeight(_ size: CGSize) -> Double { + public func getHeight(_ size: CGSize) -> Double { let topLeft = topLeft * size let bottomLeft = bottomLeft * size return abs(sqrt(pow(topLeft.x - bottomLeft.x, 2) + pow(topLeft.y - bottomLeft.y, 2))) } - func getWidth(_ size: CGSize) -> Double { + public func getWidth(_ size: CGSize) -> Double { let topLeft = topLeft * size let topRight = topRight * size return abs(sqrt(pow(topLeft.x - topRight.x, 2) + pow(topLeft.y - topRight.y, 2))) } - func getRadian(_ size: CGSize) -> Double { + public func getRadian(_ size: CGSize) -> Double { let topLeft = topLeft * size let topRight = topRight * size let radian = atan2(topRight.y - topLeft.y, topRight.x - topLeft.x) return radian < 0 ? radian + .pi * 2 : radian } - func getAngle(_ size: CGSize) -> Double { + public func getAngle(_ size: CGSize) -> Double { 180.0 / .pi * getRadian(size) } @@ -88,19 +83,19 @@ struct LiveTextBounds: Equatable { } // MARK: LiveTextGroup -struct LiveTextGroup: Equatable, Identifiable { - var id: UUID = .init() - let blocks: [LiveTextBlock] - let text: String +public struct LiveTextGroup: Equatable, Identifiable, Sendable { + public var id: UUID = .init() + public let blocks: [LiveTextBlock] + public let text: String - var minX: Double - var maxX: Double - var minY: Double - var maxY: Double - var width: Double! - var height: Double! + public var minX: Double + public var maxX: Double + public var minY: Double + public var maxY: Double + public var width: Double! + public var height: Double! - init?(blocks: [LiveTextBlock]) { + public init?(blocks: [LiveTextBlock]) { guard let firstBlock = blocks.first else { return nil } self.blocks = blocks text = blocks.map(\.text).joined(separator: " ") @@ -119,7 +114,7 @@ struct LiveTextGroup: Equatable, Identifiable { } // Returns the rect of a rectangle area which contains all live text blocks - func getRect(width: Double, height: Double, extendSize: Double) -> CGRect { + public func getRect(width: Double, height: Double, extendSize: Double) -> CGRect { .init( x: minX * width - extendSize, y: minY * height - extendSize, @@ -130,11 +125,20 @@ struct LiveTextGroup: Equatable, Identifiable { } // MARK: LiveTextBlock -struct LiveTextBlock: Equatable, Identifiable { - var id: UUID = .init() +public struct LiveTextBlock: Equatable, Identifiable, Sendable { + public init( + id: UUID = .init(), + text: String, + bounds: LiveTextBounds + ) { + self.id = id + self.text = text + self.bounds = bounds + } + public var id: UUID = .init() - let text: String - let bounds: LiveTextBounds + public let text: String + public let bounds: LiveTextBounds } // MARK: Definition diff --git a/AppPackage/Sources/AppModels/Support/Misc.swift b/AppPackage/Sources/AppModels/Support/Misc.swift new file mode 100644 index 000000000..5d6f783c6 --- /dev/null +++ b/AppPackage/Sources/AppModels/Support/Misc.swift @@ -0,0 +1,192 @@ +import AppTools +import CasePaths +import Foundation + +public typealias FavoritesSortOrder = EhSetting.FavoritesSortOrder + +public enum DateSeekDirection: Equatable, Sendable { + case newer + case older +} + +public struct DateSeekNavigation: Hashable, Sendable { + public init( + directions: Directions, + minimumDate: Date, + maximumDate: Date + ) { + self.directions = directions + self.minimumDate = minimumDate + self.maximumDate = maximumDate + } + /// The seekable directions available from the current page. Non-optional: a navigation only + /// exists when at least one direction is, so the "neither" state is unrepresentable here — it + /// is the `nil` of a `DateSeekNavigation?` instead. + public enum Directions: Hashable, Sendable { + case newer(URL) + case older(URL) + case both(newer: URL, older: URL) + + /// `nil` when neither URL is present — i.e. the page offers no date seek. + public init?(newer: URL?, older: URL?) { + switch (newer, older) { + case let (newer?, older?): + self = .both(newer: newer, older: older) + case let (newer?, nil): + self = .newer(newer) + case let (nil, older?): + self = .older(older) + case (nil, nil): + return nil + } + } + + public var newerURL: URL? { + switch self { + case .newer(let url), .both(newer: let url, older: _): + return url + case .older: + return nil + } + } + public var olderURL: URL? { + switch self { + case .older(let url), .both(newer: _, older: let url): + return url + case .newer: + return nil + } + } + } + + public var directions: Directions + public var minimumDate: Date + public var maximumDate: Date + + public var newerURL: URL? { directions.newerURL } + public var olderURL: URL? { directions.olderURL } + public var dateRange: ClosedRange { minimumDate...maximumDate } + + public func clampedDate(_ date: Date = Date()) -> Date { + min(max(date, minimumDate), maximumDate) + } + + public func seekURL(date: Date, direction: DateSeekDirection) -> URL? { + let baseURL: URL? + switch direction { + case .newer: + baseURL = newerURL + case .older: + baseURL = olderURL + } + return baseURL?.appending(queryItems: ["seek": Self.dateFormatter.string(from: date)]) + } + + /// Formatter for the `seek` query parameter: fixed `yyyy-MM-dd`, UTC, POSIX locale. + public static let dateFormatter: DateFormatter = { + let formatter = DateFormatter() + formatter.dateFormat = "yyyy-MM-dd" + formatter.timeZone = TimeZone(secondsFromGMT: 0) + formatter.locale = Locale(identifier: "en_US_POSIX") + return formatter + }() +} + +public protocol DateFormattable { + var originalDate: Date { get } +} +extension DateFormattable { + public 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) + } +} + +public struct PageNumber: Equatable, Sendable { + public init( + current: Int = 0, + maximum: Int = 0, + lastItemTimestamp: String? = nil, + isNextButtonEnabled: Bool = false + ) { + self.current = current + self.maximum = maximum + self.lastItemTimestamp = lastItemTimestamp + self.isNextButtonEnabled = isNextButtonEnabled + } + public var current = 0 + public var maximum = 0 + public var lastItemTimestamp: String? + public var isNextButtonEnabled = false + + public var isSinglePage: Bool { + current == 0 && maximum == 0 + } + public func hasNextPage(isNumericBased: Bool = false) -> Bool { + isNumericBased ? current < maximum : isNextButtonEnabled + } + public mutating func resetPages() { + self = Self() + } +} + +public struct QuickSearchWord: Codable, Equatable, Identifiable, Sendable, SchemaVersioned { + public init( + id: UUID = .init(), + name: String, + content: String + ) { + self.id = id + self.name = name + self.content = content + } + public static var empty: Self { .init(name: "", content: "") } + + /// This model's schema history (oldest → newest); see `SchemaVersioned` / `VersionedSchema`. + /// `currentSchemaVersion` derives from the head. Append a `VersionedSchema` and adopt + /// `MigratableModel` when a breaking change lands. + public static var schemas: [any VersionedSchema.Type] { [SchemaV1.self] } + /// The v1 base schema. Its `migrate` is empty — nothing precedes v1, and the engine only runs + /// schemas newer than the stored version, so it exists solely to anchor version 1. + enum SchemaV1: VersionedSchema { + static let version = 1 + static func migrate(_ object: inout [String: JSONValue]) throws {} + } + // Self-validating (see `SchemaVersion`): a newer/downgrade value is rejected on decode; the + // identity guards in `init(from:)` below stay hand-written. + public var schemaVersion: SchemaVersion = 1 + public var id: UUID = .init() + public var name: String + public var content: String + + public var effectiveSearchText: String { + !content.isEmpty ? content : name + } +} + +// MARK: Manually decode +extension QuickSearchWord { + /// Strict, throwing decode. `id` is decoded, never fabricated — a tolerant `UUID()` fallback + /// would hand a corrupt entry a fresh identity on every decode. A blob missing `id`/`name`/ + /// `content`, or carrying an unknown `schemaVersion`, throws, failing the whole + /// `[QuickSearchWord]` decode so Sharing resets the key to `[]` instead of surfacing an entry + /// with a random, unstable identity. + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + schemaVersion = try container.decode(SchemaVersion.self, forKey: .schemaVersion) + id = try container.decode(UUID.self, forKey: .id) + name = try container.decode(String.self, forKey: .name) + content = try container.decode(String.self, forKey: .content) + } +} + +@dynamicMemberLookup @CasePathable +public enum LoadingState: Equatable, Hashable, Sendable { + case idle + case loading + case failed(AppError) +} diff --git a/AppPackage/Sources/AppModels/Support/RunLogFile.swift b/AppPackage/Sources/AppModels/Support/RunLogFile.swift new file mode 100644 index 000000000..f8c2ad33d --- /dev/null +++ b/AppPackage/Sources/AppModels/Support/RunLogFile.swift @@ -0,0 +1,76 @@ +import AppTools +import Foundation + +/// A persisted per-run activity-log file, named `ehpanda---.jsonl`. +public struct RunLogFile: Identifiable, Equatable, Sendable { + public let url: URL + /// The run's start time, decoded from the `-` file-name components. + public let date: Date + public let runCount: Int + + // The file URL is the run's canonical unique identity: run counts reset daily, so they + // repeat across days, whereas the name (day + time + count) is unique per file. + public var id: URL { url } + + public init(url: URL, date: Date, runCount: Int) { + self.url = url + self.date = date + self.runCount = runCount + } + + /// Parses a `RunLogFile` from a log-file URL, returning `nil` when the file name + /// does not match the `ehpanda---.jsonl` format. + public init?(fileURL: URL) { + let nameComponents = fileURL.lastPathComponent.split(separator: ".") + guard nameComponents.count == 2, + String(nameComponents[1]) == Defaults.FilePath.activityLogExtension + else { return nil } + + let components = nameComponents[0].split(separator: "-") + guard components.count == 4, + String(components[0]) == Defaults.FilePath.activityLogPrefix, + components[1].count == 8, + components[2].count == 6, + let date = Self.dateTimeFormatter.date(from: String(components[1]) + String(components[2])), + let runCount = Int(components[3]) + else { return nil } + + self.init(url: fileURL, date: date, runCount: runCount) + } + + /// The canonical `ehpanda---.jsonl` file name for a run. + public static func fileName(date: Date, runCount: Int) -> String { + [ + [ + Defaults.FilePath.activityLogPrefix, + dayString(for: date), + timeFormatter.string(from: date), + String(runCount) + ] + .joined(separator: "-"), + + Defaults.FilePath.activityLogExtension + ] + .joined(separator: ".") + } + + /// The `yyyyMMdd` day component used in log file names, in the device's local time zone. + /// Two runs share a day (and thus the same run-count sequence) iff these match. + public static func dayString(for date: Date) -> String { + dayFormatter.string(from: date) + } + + // No explicit time zone: the day rolls over at the device's local midnight, matching how + // the picker groups runs and the user's notion of "a different day". + private static let dayFormatter = fileNameFormatter(dateFormat: "yyyyMMdd") + private static let timeFormatter = fileNameFormatter(dateFormat: "HHmmss") + private static let dateTimeFormatter = fileNameFormatter(dateFormat: "yyyyMMddHHmmss") + + private static func fileNameFormatter(dateFormat: String) -> DateFormatter { + let formatter = DateFormatter() + formatter.dateFormat = dateFormat + formatter.calendar = Calendar(identifier: .gregorian) + formatter.locale = Locale(identifier: "en_US_POSIX") + return formatter + } +} diff --git a/AppPackage/Sources/AppModels/Support/TabBarItemType.swift b/AppPackage/Sources/AppModels/Support/TabBarItemType.swift new file mode 100644 index 000000000..cec85edba --- /dev/null +++ b/AppPackage/Sources/AppModels/Support/TabBarItemType.swift @@ -0,0 +1,9 @@ +public enum TabBarItemType: Int, CaseIterable, Identifiable, Sendable { + public var id: Int { rawValue } + + case home + case favorites + case search + case downloads + case setting +} diff --git a/AppPackage/Sources/AppModels/Support/ToplistsType.swift b/AppPackage/Sources/AppModels/Support/ToplistsType.swift new file mode 100644 index 000000000..b17e84903 --- /dev/null +++ b/AppPackage/Sources/AppModels/Support/ToplistsType.swift @@ -0,0 +1,38 @@ +import Foundation +import Resources + +public enum ToplistsType: Int, Codable, CaseIterable, Identifiable, Sendable { + public var id: Int { rawValue } + + case yesterday + case pastMonth + case pastYear + case allTime +} + +extension ToplistsType { + public var value: LocalizedStringResource { + switch self { + case .yesterday: + return .toplistsTypeYesterday + case .pastMonth: + return .toplistsTypePastMonth + case .pastYear: + return .toplistsTypePastYear + case .allTime: + return .toplistsTypeAllTime + } + } + public var categoryIndex: Int { + switch self { + case .yesterday: + return 15 + case .pastMonth: + return 13 + case .pastYear: + return 12 + case .allTime: + return 11 + } + } +} diff --git a/AppPackage/Sources/AppModels/Support/URL+Mock.swift b/AppPackage/Sources/AppModels/Support/URL+Mock.swift new file mode 100644 index 000000000..e1293450f --- /dev/null +++ b/AppPackage/Sources/AppModels/Support/URL+Mock.swift @@ -0,0 +1,6 @@ +import AppTools +import Foundation + +extension URL { + public static let mock = Defaults.URL.ehentai +} diff --git a/AppPackage/Sources/AppModels/Support/VerifyEhProfileResponse.swift b/AppPackage/Sources/AppModels/Support/VerifyEhProfileResponse.swift new file mode 100644 index 000000000..4a3cc4de1 --- /dev/null +++ b/AppPackage/Sources/AppModels/Support/VerifyEhProfileResponse.swift @@ -0,0 +1,9 @@ +public struct VerifyEhProfileResponse: Equatable, Sendable { + public let profileValue: Int? + public let isProfileNotFound: Bool + + public init(profileValue: Int?, isProfileNotFound: Bool) { + self.profileValue = profileValue + self.isProfileNotFound = isProfileNotFound + } +} diff --git a/AppPackage/Sources/AppModels/Support/WatchedTagsSetting.swift b/AppPackage/Sources/AppModels/Support/WatchedTagsSetting.swift new file mode 100644 index 000000000..3342e99ae --- /dev/null +++ b/AppPackage/Sources/AppModels/Support/WatchedTagsSetting.swift @@ -0,0 +1,129 @@ +import Foundation +import SwiftUI +import AppTools +import Synchronization + +final class TagLookupCache: Sendable { + private let cache: Mutex<[String: (tagSetBackgroundColor: Color?, tag: WatchedTag)]> + + init() { + cache = Mutex([:]) + } + + var isEmpty: Bool { + cache.withLock { $0.isEmpty } + } + + var allEntries: [String: (tagSetBackgroundColor: Color?, tag: WatchedTag)] { + cache.withLock { $0 } + } + + func update(_ newCache: [String: (tagSetBackgroundColor: Color?, tag: WatchedTag)]) { + cache.withLock { $0 = newCache } + } + + func removeAll() { + cache.withLock { $0.removeAll() } + } +} + +public actor WatchedTagsSetting { + public static let shared = WatchedTagsSetting() + + private var onlineTags: [Int: TagSetInfo] = [:] + private var lastFetchedApuid: String = "" + private var lastRefreshTimestamp: Date = .distantPast + private let lookupCache = TagLookupCache() + + private init() {} + + public func updateTagSet(_ tagSet: TagSetInfo, apiuid: String) { + onlineTags[tagSet.number] = tagSet + lastFetchedApuid = apiuid + lastRefreshTimestamp = Date() + rebuildLookupCache() + } + + public func updateTagSet(_ tagSet: TagSetInfo) { + onlineTags[tagSet.number] = tagSet + rebuildLookupCache() + } + + private func rebuildLookupCache() { + var lookup: [String: (Color?, WatchedTag)] = [:] + for tagSetInfo in onlineTags.values { + for tag in tagSetInfo.tags { + let key = "\(tag.namespace):\(tag.key)" + lookup[key] = (tagSetInfo.backgroundColor, tag) + } + } + lookupCache.update(lookup) + } + + public func buildTagLookup() -> [String: (tagSetBackgroundColor: Color?, tag: WatchedTag)] { + if !lookupCache.isEmpty { return lookupCache.allEntries } + var lookup: [String: (Color?, WatchedTag)] = [:] + for tagSetInfo in onlineTags.values { + for tag in tagSetInfo.tags { + let key = "\(tag.namespace):\(tag.key)" + lookup[key] = (tagSetInfo.backgroundColor, tag) + } + } + lookupCache.update(lookup) + return lookup + } + + public nonisolated var cachedTagLookup: [String: (tagSetBackgroundColor: Color?, tag: WatchedTag)] { + lookupCache.allEntries + } + + public func clearOnlineTagSets() { + onlineTags.removeAll() + lookupCache.removeAll() + lastFetchedApuid = "" + lastRefreshTimestamp = .distantPast + } + + public func needsRefresh(apiuid: String, maxAge: TimeInterval = 3600) -> Bool { + return onlineTags.isEmpty || lastFetchedApuid != apiuid || isStale(maxAge: maxAge) + } + + public func isStale(maxAge: TimeInterval = 3600) -> Bool { + return Date().timeIntervalSince(lastRefreshTimestamp) > maxAge + } + + public func allTagSets() -> [TagSetInfo] { + Array(onlineTags.values).sorted { $0.number < $1.number } + } + + public static func applyWatchedTagColors(to tags: [GalleryTag]) -> [GalleryTag] { + let lookup = shared.cachedTagLookup + var recolored = tags + for tagIndex in recolored.indices { + var tag = recolored[tagIndex] + var contents = tag.contents + for contentIndex in contents.indices { + var content = contents[contentIndex] + if content.textColor != nil || content.backgroundColor != nil { + contents[contentIndex] = content + continue + } + let lookupKey = "\(content.rawNamespace):\(content.text)" + if let result = lookup[lookupKey] { + let backgroundColor = result.tag.backgroundColor ?? result.tagSetBackgroundColor + content.backgroundColor = backgroundColor ?? Color(hex: "3377FF") + let resolvedBackground = content.backgroundColor + content.textColor = backgroundColor == nil + ? Color(hex: "F1F1F1") + : (resolvedBackground?.isLightColor ?? false + ? Color(red: 0.035, green: 0.035, blue: 0.035) + : Color(hex: "F1F1F1")) + } + contents[contentIndex] = content + } + tag.contents = contents + recolored[tagIndex] = tag + } + return recolored + } +} diff --git a/AppPackage/Sources/AppModels/Tags/EhTagTranslationDatabaseModel.swift b/AppPackage/Sources/AppModels/Tags/EhTagTranslationDatabaseModel.swift new file mode 100644 index 000000000..8f39c2a5d --- /dev/null +++ b/AppPackage/Sources/AppModels/Tags/EhTagTranslationDatabaseModel.swift @@ -0,0 +1,53 @@ +import Foundation + +public struct EhTagTranslationDatabaseResponse: Codable, Sendable { + public init( + data: [Model] + ) { + self.data = data + } + public struct Item: Codable, Sendable { + public init( + name: String, + intro: String? = nil, + links: String? = nil + ) { + self.name = name + self.intro = intro + self.links = links + } + public let name: String + public var intro: String? + public var links: String? + } + + public struct Model: Codable, Sendable { + public init( + namespace: String, + data: [String: Item] + ) { + self.namespace = namespace + self.data = data + } + public let namespace: String + public let data: [String: Item] + + public var tagTranslations: [TagTranslation] { + guard let namespace = TagNamespace(rawValue: namespace) else { return .init() } + return data.map { + .init( + namespace: namespace, key: $0, value: $1.name, + description: $1.intro, linksString: $1.links + ) + } + } + } + + public let data: [Model] + + public var tagTranslations: [String: TagTranslation] { + .init(uniqueKeysWithValues: data.flatMap(\.tagTranslations).map({ + ($0.namespace.rawValue + $0.key, $0) + })) + } +} diff --git a/AppPackage/Sources/AppModels/Tags/TagDetail.swift b/AppPackage/Sources/AppModels/Tags/TagDetail.swift new file mode 100644 index 000000000..7d2d441db --- /dev/null +++ b/AppPackage/Sources/AppModels/Tags/TagDetail.swift @@ -0,0 +1,19 @@ +import Foundation + +public struct TagDetail: Equatable, Sendable { + public init( + title: String, + description: String, + imageURLs: [URL], + links: [URL] + ) { + self.title = title + self.description = description + self.imageURLs = imageURLs + self.links = links + } + public let title: String + public let description: String + public let imageURLs: [URL] + public let links: [URL] +} diff --git a/EhPanda/Models/Tags/TagNamespace.swift b/AppPackage/Sources/AppModels/Tags/TagNamespace.swift similarity index 55% rename from EhPanda/Models/Tags/TagNamespace.swift rename to AppPackage/Sources/AppModels/Tags/TagNamespace.swift index b423b1d88..1ae007068 100644 --- a/EhPanda/Models/Tags/TagNamespace.swift +++ b/AppPackage/Sources/AppModels/Tags/TagNamespace.swift @@ -1,9 +1,7 @@ -// -// TagCategory.swift -// EhPanda -// +import Foundation +import Resources -enum TagNamespace: String, Codable, CaseIterable { +public enum TagNamespace: String, Codable, CaseIterable, Sendable { case reclass case language case parody @@ -17,7 +15,7 @@ enum TagNamespace: String, Codable, CaseIterable { case other case temp - static let abbreviations: [String: String] = { + public static let abbreviations: [String: String] = { let tuples: [(String, String)] = allCases.compactMap { if let abbreviation = $0.abbreviation { return ($0.rawValue, abbreviation) @@ -30,7 +28,7 @@ enum TagNamespace: String, Codable, CaseIterable { } extension TagNamespace { - var weight: Float { + public var weight: Float { switch self { case .reclass: return 1 case .language: return 2 @@ -46,7 +44,7 @@ extension TagNamespace { case .temp: return 0.1 } } - var abbreviation: String? { + public var abbreviation: String? { switch self { case .reclass: return "r" case .language: return "l" @@ -62,20 +60,20 @@ extension TagNamespace { case .temp: return nil } } - var value: String { + public var value: LocalizedStringResource { switch self { - case .reclass: return L10n.Localizable.Enum.TagNamespace.Value.reclass - case .language: return L10n.Localizable.Enum.TagNamespace.Value.language - case .parody: return L10n.Localizable.Enum.TagNamespace.Value.parody - case .character: return L10n.Localizable.Enum.TagNamespace.Value.character - case .group: return L10n.Localizable.Enum.TagNamespace.Value.group - case .artist: return L10n.Localizable.Enum.TagNamespace.Value.artist - case .male: return L10n.Localizable.Enum.TagNamespace.Value.male - case .female: return L10n.Localizable.Enum.TagNamespace.Value.female - case .mixed: return L10n.Localizable.Enum.TagNamespace.Value.mixed - case .cosplayer: return L10n.Localizable.Enum.TagNamespace.Value.cosplayer - case .other: return L10n.Localizable.Enum.TagNamespace.Value.other - case .temp: return L10n.Localizable.Enum.TagNamespace.Value.temp + case .reclass: return .tagNamespaceReclass + case .language: return .tagNamespaceLanguage + case .parody: return .tagNamespaceParody + case .character: return .tagNamespaceCharacter + case .group: return .tagNamespaceGroup + case .artist: return .tagNamespaceArtist + case .male: return .tagNamespaceMale + case .female: return .tagNamespaceFemale + case .mixed: return .tagNamespaceMixed + case .cosplayer: return .tagNamespaceCosplayer + case .other: return .tagNamespaceOther + case .temp: return .tagNamespaceTemp } } } diff --git a/AppPackage/Sources/AppModels/Tags/TagSuggestion.swift b/AppPackage/Sources/AppModels/Tags/TagSuggestion.swift new file mode 100644 index 000000000..2b4682953 --- /dev/null +++ b/AppPackage/Sources/AppModels/Tags/TagSuggestion.swift @@ -0,0 +1,26 @@ +import Foundation + +public struct TagSuggestion: Equatable, Hashable, Identifiable, Sendable { + public init( + tag: TagTranslation, + weight: Float, + keyRange: Range? = nil, + valueRange: Range? = nil, + originalKeyword: String, + matchesNamespace: Bool + ) { + self.tag = tag + self.weight = weight + self.keyRange = keyRange + self.valueRange = valueRange + self.originalKeyword = originalKeyword + self.matchesNamespace = matchesNamespace + } + public let id: UUID = .init() + public let tag: TagTranslation + public let weight: Float + public let keyRange: Range? + public let valueRange: Range? + public let originalKeyword: String + public let matchesNamespace: Bool +} diff --git a/AppPackage/Sources/AppModels/Tags/TagTranslation.swift b/AppPackage/Sources/AppModels/Tags/TagTranslation.swift new file mode 100644 index 000000000..44929ad23 --- /dev/null +++ b/AppPackage/Sources/AppModels/Tags/TagTranslation.swift @@ -0,0 +1,44 @@ +import Foundation + +public struct TagTranslation: Codable, Equatable, Hashable, Sendable { + public init( + namespace: TagNamespace, + key: String, + value: String, + description: String? = nil, + linksString: String? = nil + ) { + self.namespace = namespace + self.key = key + self.value = value + self.description = description + self.linksString = linksString + } + public let namespace: TagNamespace + public let key: String + public let value: String + public var description: String? + public var linksString: String? + + public var searchKeyword: String { + [namespace.abbreviation ?? namespace.rawValue, ":", + key.contains(" ") ? "\"\(key)$\"" : "\(key)$"].joined() + } + + public func getSuggestion(keyword: String, originalKeyword: String, matchesNamespace: Bool) -> TagSuggestion { + func getWeight(value: String, range: Range) -> Float { + namespace.weight * .init(keyword.count + 1) / .init(value.count) + * (range.lowerBound == value.startIndex ? 2.0 : 1.0) + } + + var weight: Float = .zero + let keyRange = key.range(of: keyword, options: .caseInsensitive) + let valueRange = value.range(of: keyword, options: .caseInsensitive) + if let range = keyRange { weight += getWeight(value: key, range: range) } + if let range = valueRange { weight += getWeight(value: value, range: range) } + return .init( + tag: self, weight: weight, keyRange: keyRange, valueRange: valueRange, + originalKeyword: originalKeyword, matchesNamespace: matchesNamespace + ) + } +} diff --git a/AppPackage/Sources/AppModels/Tags/TagTranslator.swift b/AppPackage/Sources/AppModels/Tags/TagTranslator.swift new file mode 100644 index 000000000..10dcda026 --- /dev/null +++ b/AppPackage/Sources/AppModels/Tags/TagTranslator.swift @@ -0,0 +1,31 @@ +import Foundation + +public struct TagTranslator: Codable, Equatable, Sendable { + public init( + language: TranslatableLanguage? = nil, + hasCustomTranslations: Bool = false, + updatedDate: Date = .distantPast, + translations: [String: TagTranslation] = [String: TagTranslation]() + ) { + self.language = language + self.hasCustomTranslations = hasCustomTranslations + self.updatedDate = updatedDate + self.translations = translations + } + public var language: TranslatableLanguage? + public var hasCustomTranslations = false + public var updatedDate: Date = .distantPast + public var translations = [String: TagTranslation]() +} + +extension TagTranslator: CustomStringConvertible { + public var description: String { + let params = String(describing: [ + "language": language as Any, + "updatedDate": updatedDate, + "translationsCount": translations.count, + "hasCustomTranslations": hasCustomTranslations + ]) + return "TagTranslator(\(params))" + } +} diff --git a/AppPackage/Sources/AppModels/Tags/TagTranslatorInfo.swift b/AppPackage/Sources/AppModels/Tags/TagTranslatorInfo.swift new file mode 100644 index 000000000..6c85974f6 --- /dev/null +++ b/AppPackage/Sources/AppModels/Tags/TagTranslatorInfo.swift @@ -0,0 +1,40 @@ +import Foundation + +/// Thin, `appStorage`-persisted metadata for the tag-translation table. +/// +/// The translations dictionary itself is NOT persisted — it is large, derived, and +/// re-downloadable, so it lives only in memory and is rebuilt at launch from the cached raw JSON +/// (Caches for a remote download, Application Support for a user import). This record remembers just +/// enough to rebuild the right file and to let the update check know what it already has: which +/// language/version is cached and whether a custom import is active. +public struct TagTranslatorInfo: Codable, Equatable, Sendable, SchemaVersioned { + public init( + schemaVersion: SchemaVersion = 1, + language: TranslatableLanguage? = nil, + updatedDate: Date = .distantPast, + hasCustomTranslations: Bool = false + ) { + self.schemaVersion = schemaVersion + self.language = language + self.updatedDate = updatedDate + self.hasCustomTranslations = hasCustomTranslations + } + /// This model's schema history (oldest → newest); see `SchemaVersioned` / `VersionedSchema`. + /// `currentSchemaVersion` derives from the head. Append a `VersionedSchema` and adopt + /// `MigratableModel` when a breaking change lands. + public static var schemas: [any VersionedSchema.Type] { [SchemaV1.self] } + /// The v1 base schema. Its `migrate` is empty — nothing precedes v1, and the engine only runs + /// schemas newer than the stored version, so it exists solely to anchor version 1. + enum SchemaV1: VersionedSchema { + static let version = 1 + static func migrate(_ object: inout [String: JSONValue]) throws {} + } + // A self-validating field: it rejects a newer/downgrade blob on decode (see `SchemaVersion`), which + // fails the whole decode so Sharing resets to the key default. Synthesized Codable is otherwise + // untouched, so optional-field tolerance still holds; a field added later must stay optional so old + // blobs keep decoding. + public var schemaVersion: SchemaVersion + public var language: TranslatableLanguage? + public var updatedDate: Date + public var hasCustomTranslations: Bool +} diff --git a/AppPackage/Sources/AppModels/Tags/TagTranslatorPayload.swift b/AppPackage/Sources/AppModels/Tags/TagTranslatorPayload.swift new file mode 100644 index 000000000..2070d4d1b --- /dev/null +++ b/AppPackage/Sources/AppModels/Tags/TagTranslatorPayload.swift @@ -0,0 +1,13 @@ +import Foundation + +/// The raw result of a tag-translation download: the untouched DB JSON bytes plus the release date +/// resolved from the GitHub metadata. Decoding, OpenCC conversion, and caching happen downstream +/// (see `FileClient.cacheAndBuildRemoteTagTranslator`) so the request layer stays purely network. +public struct TagTranslatorPayload: Sendable, Equatable { + public init(data: Data, updatedDate: Date) { + self.data = data + self.updatedDate = updatedDate + } + public let data: Data + public let updatedDate: Date +} diff --git a/EhPanda/Models/Tags/TranslatableLanguage.swift b/AppPackage/Sources/AppModels/Tags/TranslatableLanguage.swift similarity index 60% rename from EhPanda/Models/Tags/TranslatableLanguage.swift rename to AppPackage/Sources/AppModels/Tags/TranslatableLanguage.swift index bfae94a83..9b7f340fb 100644 --- a/EhPanda/Models/Tags/TranslatableLanguage.swift +++ b/AppPackage/Sources/AppModels/Tags/TranslatableLanguage.swift @@ -1,11 +1,6 @@ -// -// TranslatableLanguage.swift -// EhPanda -// - import Foundation -enum TranslatableLanguage: Codable, CaseIterable { +public enum TranslatableLanguage: Codable, CaseIterable, Sendable { case english case japanese case simplifiedChinese @@ -13,14 +8,14 @@ enum TranslatableLanguage: Codable, CaseIterable { } extension TranslatableLanguage { - static var current: TranslatableLanguage? { + public static var current: TranslatableLanguage? { guard let preferredLanguage = Locale.preferredLanguages.first, let translatableLanguage = TranslatableLanguage.allCases.compactMap({ lang in - preferredLanguage.contains(lang.languageCode) ? lang : nil + preferredLanguage.contains(lang.languageCode) ? lang : nil }).first else { return nil } return translatableLanguage } - var languageCode: String { + public var languageCode: String { switch self { case .english: return "en" @@ -32,7 +27,7 @@ extension TranslatableLanguage { return "zh-Hant" } } - var repoName: String { + public var repoName: String { switch self { case .english: return "EhPanda-Team/EhTagTranslation_Database_EN" @@ -42,16 +37,17 @@ extension TranslatableLanguage { return "EhTagTranslation/Database" } } - var remoteFilename: String { + public var remoteFilename: String { switch self { case .english, .japanese, .simplifiedChinese, .traditionalChinese: return "db.raw.json" } } - var checkUpdateURL: URL { - URLUtil.githubAPI(repoName: repoName) - } - var downloadURL: URL { - URLUtil.githubDownload(repoName: repoName, fileName: remoteFilename) + /// Filesystem-safe name for this language's cached raw translation JSON. Keyed on + /// `languageCode` (not `repoName`, which contains `/`), so each language gets its own file — + /// `simplifiedChinese`/`traditionalChinese` share a repo but need distinct caches (the latter + /// is OpenCC-converted). + public var cachedTranslationsFilename: String { + "tagTranslations-\(languageCode).json" } } diff --git a/AppPackage/Sources/AppModels/Tags/WatchedTag.swift b/AppPackage/Sources/AppModels/Tags/WatchedTag.swift new file mode 100644 index 000000000..6b2f6cee4 --- /dev/null +++ b/AppPackage/Sources/AppModels/Tags/WatchedTag.swift @@ -0,0 +1,74 @@ +import SwiftUI +import Foundation + +public struct MyTagsResponse: Sendable { + public let tagSets: [(number: Int, name: String)] + public let tagSetEnable: Bool + public let tagSetBackgroundColor: Color? + public let tags: [WatchedTag] + public let apikey: String + + public init( + tagSets: [(number: Int, name: String)], + tagSetEnable: Bool, + tagSetBackgroundColor: Color?, + tags: [WatchedTag], + apikey: String + ) { + self.tagSets = tagSets + self.tagSetEnable = tagSetEnable + self.tagSetBackgroundColor = tagSetBackgroundColor + self.tags = tags + self.apikey = apikey + } +} + +public struct WatchedTag: Identifiable, Equatable, Hashable, Sendable, Codable { + public init( + namespace: String, + key: String, + watched: Bool = true, + hidden: Bool = false, + backgroundColor: Color? = nil, + weight: Int = 0 + ) { + self.namespace = namespace + self.key = key + self.watched = watched + self.hidden = hidden + self.backgroundColor = backgroundColor + self.weight = weight + } + public let id: String = UUID().uuidString + public var namespace: String + public var key: String + public var watched: Bool + public var hidden: Bool + public var backgroundColor: Color? + public var weight: Int + + enum CodingKeys: String, CodingKey { + case namespace, key, watched, hidden, backgroundColor, weight + } +} + +public struct TagSetInfo: Equatable, Sendable, Codable { + public init( + number: Int, + name: String, + enable: Bool, + backgroundColor: Color? = nil, + tags: [WatchedTag] = [] + ) { + self.number = number + self.name = name + self.enable = enable + self.backgroundColor = backgroundColor + self.tags = tags + } + public let number: Int + public let name: String + public var enable: Bool + public var backgroundColor: Color? + public var tags: [WatchedTag] +} diff --git a/EhPanda/App/Tools/Utilities/AppUtil.swift b/AppPackage/Sources/AppModels/Utilities/AppUtil.swift similarity index 76% rename from EhPanda/App/Tools/Utilities/AppUtil.swift rename to AppPackage/Sources/AppModels/Utilities/AppUtil.swift index a73c66018..f2d4a6014 100644 --- a/EhPanda/App/Tools/Utilities/AppUtil.swift +++ b/AppPackage/Sources/AppModels/Utilities/AppUtil.swift @@ -1,15 +1,11 @@ -// -// AppUtil.swift -// EhPanda -// - +import AppTools import Foundation -struct AppUtil { - static var version: String { +public struct AppUtil { + public static var version: String { Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String ?? "null" } - static var build: String { + public static var build: String { Bundle.main.object(forInfoDictionaryKey: "CFBundleVersion") as? String ?? "null" } @@ -22,12 +18,12 @@ struct AppUtil { #endif } - static var galleryHost: GalleryHost { + public static var galleryHost: GalleryHost { let rawValue: String? = UserDefaultsUtil.value(forKey: .galleryHost) return GalleryHost(rawValue: rawValue ?? "") ?? .ehentai } - static func dispatchMainSync(execute work: () -> Void) { + public static func dispatchMainSync(execute work: () -> Void) { if Thread.isMainThread { work() } else { diff --git a/AppPackage/Sources/AppModels/Utilities/Defaults+Runtime.swift b/AppPackage/Sources/AppModels/Utilities/Defaults+Runtime.swift new file mode 100644 index 000000000..0d7e7ffb2 --- /dev/null +++ b/AppPackage/Sources/AppModels/Utilities/Defaults+Runtime.swift @@ -0,0 +1,43 @@ +import AppTools +import CoreGraphics +import Foundation + +// Runtime-derived defaults that depend on app utilities (device metrics, the active gallery host). +// They live in the utilities layer so AppModels.Defaults stays free of those dependencies. +extension Defaults { + @MainActor + public struct FrameSize { + public static var archiveGridWidth: CGFloat { + DeviceUtil.isPadWidth ? 175 : DeviceUtil.isSEWidth ? 125 : 150 + } + public static var cardCellWidth: CGFloat { DeviceUtil.windowW * 0.8 } + public static let cardCellHeight: CGFloat = Defaults.ImageSize.headerH + 20 * 2 + public static var cardCellSize: CGSize { + .init(width: cardCellWidth, height: cardCellHeight) + } + public static var rankingCellWidth: CGFloat { + (DeviceUtil.isPadWidth ? 0.4 : 0.7) * DeviceUtil.windowW + } + public static var alertWidthFactor: Double { + DeviceUtil.isPadWidth ? 0.5 : 1.0 + } + } +} + +extension Defaults.ImageSize { + @MainActor public static var previewMinW: CGFloat { DeviceUtil.isPadWidth ? 180 : 100 } + @MainActor public static var previewMaxW: CGFloat { DeviceUtil.isPadWidth ? 220 : 120 } + @MainActor public static var previewAvgW: CGFloat { (previewMinW + previewMaxW) / 2 } +} + +extension Defaults.URL { + public static var host: Foundation.URL { AppUtil.galleryHost == .exhentai ? exhentai : ehentai } + public static var api: Foundation.URL { host.appendingPathComponent("api.php") } + public static var myTags: Foundation.URL { host.appendingPathComponent("mytags") } + public static var uConfig: Foundation.URL { host.appendingPathComponent("uconfig.php") } + public static var galleryPopups: Foundation.URL { host.appendingPathComponent("gallerypopups.php") } + public static var galleryTorrents: Foundation.URL { host.appendingPathComponent("gallerytorrents.php") } + public static var popular: Foundation.URL { host.appendingPathComponent("popular") } + public static var watched: Foundation.URL { host.appendingPathComponent("watched") } + public static var favorites: Foundation.URL { host.appendingPathComponent("favorites.php") } +} diff --git a/EhPanda/App/Tools/Utilities/URLUtil.swift b/AppPackage/Sources/AppModels/Utilities/URLUtil.swift similarity index 52% rename from EhPanda/App/Tools/Utilities/URLUtil.swift rename to AppPackage/Sources/AppModels/Utilities/URLUtil.swift index 0fae2053f..f7e21fa74 100644 --- a/EhPanda/App/Tools/Utilities/URLUtil.swift +++ b/AppPackage/Sources/AppModels/Utilities/URLUtil.swift @@ -1,33 +1,29 @@ -// -// URLUtil.swift -// EhPanda -// - +import AppTools import Foundation -struct URLUtil { +public struct URLUtil { // Fetch - static func searchList(keyword: String, filter: Filter) -> URL { + public static func searchList(keyword: String, filter: Filter) -> URL { Defaults.URL.host.appending(queryItems: [.fSearch: keyword]).applyingFilter(filter) } - static func moreSearchList(keyword: String, filter: Filter, lastID: String) -> URL { + public static func moreSearchList(keyword: String, filter: Filter, lastID: String) -> URL { Defaults.URL.host.appending(queryItems: [.fSearch: keyword, .next: lastID]).applyingFilter(filter) } - static func frontpageList(filter: Filter) -> URL { + public static func frontpageList(filter: Filter) -> URL { Defaults.URL.host.applyingFilter(filter) } - static func moreFrontpageList(filter: Filter, lastID: String) -> URL { + public static func moreFrontpageList(filter: Filter, lastID: String) -> URL { Defaults.URL.host.appending(queryItems: [.next: lastID]).applyingFilter(filter) } - static func popularList(filter: Filter) -> URL { + public static func popularList(filter: Filter) -> URL { Defaults.URL.popular.applyingFilter(filter) } - static func watchedList(filter: Filter, keyword: String = "") -> URL { + public static func watchedList(filter: Filter, keyword: String = "") -> URL { var url = Defaults.URL.watched if !keyword.isEmpty { url.append(queryItems: [.fSearch: keyword]) @@ -35,7 +31,7 @@ struct URLUtil { return url.applyingFilter(filter) } - static func moreWatchedList(filter: Filter, lastID: String, keyword: String = "") -> URL { + public static func moreWatchedList(filter: Filter, lastID: String, keyword: String = "") -> URL { var url = Defaults.URL.watched.appending(queryItems: [.next: lastID]) if !keyword.isEmpty { url.append(queryItems: [.fSearch: keyword]) @@ -43,7 +39,7 @@ struct URLUtil { return url.applyingFilter(filter) } - static func favoritesList( + public static func favoritesList( favIndex: Int, keyword: String = "", sortOrder: FavoritesSortOrder? = nil @@ -61,13 +57,13 @@ struct URLUtil { if let sortOrder = sortOrder { url.append(queryItems: [ .inlineSet: sortOrder == .favoritedTime - ? .sortOrderByFavoritedTime : .sortOrderByUpdateTime + ? .sortOrderByFavoritedTime : .sortOrderByUpdateTime ]) } return url } - static func moreFavoritesList( + public static func moreFavoritesList( favIndex: Int, lastID: String, lastTimestamp: String, @@ -86,7 +82,7 @@ struct URLUtil { return url } - static func toplistsList(catIndex: Int, pageNum: Int? = nil) -> URL { + public static func toplistsList(catIndex: Int, pageNum: Int? = nil) -> URL { var url = Defaults.URL.toplist.appending(queryItems: [.topcat: String(catIndex)]) if let pageNum = pageNum { url.append(queryItems: [.letterP: String(pageNum)]) @@ -94,44 +90,47 @@ struct URLUtil { return url } - static func moreToplistsList(catIndex: Int, pageNum: Int) -> URL { + public static func moreToplistsList(catIndex: Int, pageNum: Int) -> URL { Defaults.URL.toplist.appending(queryItems: [.topcat: String(catIndex), .letterP: String(pageNum)]) } - static func galleryDetail(url: URL) -> URL { + public static func galleryDetail(url: URL) -> URL { url.appending(queryItems: [.showComments: .one]) } - static func galleryTorrents(gid: String, token: String) -> URL { + public static func galleryTorrents(gid: String, token: String) -> URL { Defaults.URL.galleryTorrents.appending(queryItems: [.gid: gid, .token: token]) } // Account Associated Operations - static func addFavorite(gid: String, token: String) -> URL { + public static func addFavorite(gid: String, token: String) -> URL { Defaults.URL.galleryPopups .appending(queryItems: [.gid: gid, .token: token]) .appending(queryItems: [.act: .addFavAct]) } - static func userInfo(uid: String) -> URL { + public static func userInfo(uid: String) -> URL { Defaults.URL.forum.appending(queryItems: [.showUser: uid]) } // Misc - static func detailPage(url: URL, pageNum: Int) -> URL { + public static func detailPage(url: URL, pageNum: Int) -> URL { url.appending(queryItems: [.letterP: String(pageNum)]) } - static func combinedPreviewURL(plainURL: URL, width: String, height: String, offset: String) -> URL { - plainURL.appending(queryItems: [.ehpandaWidth: width, .ehpandaHeight: height, .ehpandaOffset: offset]) + public static func combinedPreviewURL(plainURL: URL, width: String, height: String, offset: String) -> URL { + plainURL + .appending(queryItems: [.ehpandaWidth: width]) + .appending(queryItems: [.ehpandaHeight: height]) + .appending(queryItems: [.ehpandaOffset: offset]) } // GitHub - static func githubAPI(repoName: String) -> URL { + public static func githubAPI(repoName: String) -> URL { Defaults.URL.githubAPI.appendingPathComponent("\(repoName)/releases/latest") } - static func githubDownload(repoName: String, fileName: String) -> URL { + public static func githubDownload(repoName: String, fileName: String) -> URL { Defaults.URL.github.appendingPathComponent("\(repoName)/releases/latest/download/\(fileName)") } } @@ -142,25 +141,43 @@ private extension URL { var queryItems1 = [Defaults.URL.Component.Key: String]() var queryItems2 = [Defaults.URL.Component.Key: Defaults.URL.Component.Value]() - 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 + applyingCategoryFilter(filter, queryItems1: &queryItems1) + + if !filter.advanced { return appending(queryItems: queryItems1).appending(queryItems: queryItems2) } + queryItems2[.advSearch] = .one + applyingBasicAdvancedFilter(filter, queryItems2: &queryItems2) + applyingMinRatingFilter(filter, queryItems1: &queryItems1, queryItems2: &queryItems2) + applyingPageRangeFilter(filter, queryItems1: &queryItems1, queryItems2: &queryItems2) + applyingDisableFilter(filter, queryItems2: &queryItems2) + + return appending(queryItems: queryItems1).appending(queryItems: queryItems2) + } + + func applyingCategoryFilter( + _ filter: Filter, + queryItems1: inout [Defaults.URL.Component.Key: String] + ) { + var categoryValue = 0 + categoryValue += filter.doujinshi ? AppModels.Category.doujinshi.filterValue : 0 + categoryValue += filter.manga ? AppModels.Category.manga.filterValue : 0 + categoryValue += filter.artistCG ? AppModels.Category.artistCG.filterValue : 0 + categoryValue += filter.gameCG ? AppModels.Category.gameCG.filterValue : 0 + categoryValue += filter.western ? AppModels.Category.western.filterValue : 0 + categoryValue += filter.nonH ? AppModels.Category.nonH.filterValue : 0 + categoryValue += filter.imageSet ? AppModels.Category.imageSet.filterValue : 0 + categoryValue += filter.cosplay ? AppModels.Category.cosplay.filterValue : 0 + categoryValue += filter.asianPorn ? AppModels.Category.asianPorn.filterValue : 0 + categoryValue += filter.misc ? AppModels.Category.misc.filterValue : 0 if ![0, 1023].contains(categoryValue) { queryItems1[.fCats] = String(categoryValue) } + } - if !filter.advanced { return appending(queryItems: queryItems1).appending(queryItems: queryItems2) } - queryItems2[.advSearch] = .one - + func applyingBasicAdvancedFilter( + _ filter: Filter, + queryItems2: inout [Defaults.URL.Component.Key: Defaults.URL.Component.Value] + ) { if filter.galleryName { queryItems2[.fSname] = .filterOn } if filter.galleryTags { queryItems2[.fStags] = .filterOn } if filter.galleryDesc { queryItems2[.fSdesc] = .filterOn } @@ -169,41 +186,45 @@ private extension URL { if filter.lowPowerTags { queryItems2[.fSdt1] = .filterOn } if filter.downvotedTags { queryItems2[.fSdt2] = .filterOn } if filter.expungedGalleries { queryItems2[.fSh] = .filterOn } + } + func applyingMinRatingFilter( + _ filter: Filter, + queryItems1: inout [Defaults.URL.Component.Key: String], + queryItems2: inout [Defaults.URL.Component.Key: Defaults.URL.Component.Value] + ) { if filter.minRatingActivated, [2, 3, 4, 5].contains(filter.minRating) { queryItems2[.fSr] = .filterOn queryItems1[.fSrdd] = String(filter.minRating) } + } - if filter.pageRangeActivated { - queryItems2[.fSp] = .filterOn - - switch (Int(filter.pageLowerBound), Int(filter.pageUpperBound)) { - case let (.some(minPages), .some(maxPages)): - if minPages > 0 && maxPages > 0 && minPages <= maxPages { - queryItems1[.fSpf] = String(minPages) - queryItems1[.fSpt] = String(maxPages) - } - - case let (.some(minPages), _): - if minPages > 0 { - queryItems1[.fSpf] = String(minPages) - } - - case let (_, .some(maxPages)): - if maxPages > 0 { - queryItems1[.fSpt] = String(maxPages) - } - - case (.none, .none): - break - } + func applyingPageRangeFilter( + _ filter: Filter, + queryItems1: inout [Defaults.URL.Component.Key: String], + queryItems2: inout [Defaults.URL.Component.Key: Defaults.URL.Component.Value] + ) { + guard filter.pageRangeActivated else { return } + queryItems2[.fSp] = .filterOn + let minPages = Int(filter.pageLowerBound) + let maxPages = Int(filter.pageUpperBound) + if let minPages, let maxPages { + guard minPages > 0, maxPages > 0, minPages <= maxPages else { return } + queryItems1[.fSpf] = String(minPages) + queryItems1[.fSpt] = String(maxPages) + } else if let minPages, minPages > 0 { + queryItems1[.fSpf] = String(minPages) + } else if let maxPages, maxPages > 0 { + queryItems1[.fSpt] = String(maxPages) } + } + func applyingDisableFilter( + _ filter: Filter, + queryItems2: inout [Defaults.URL.Component.Key: Defaults.URL.Component.Value] + ) { if filter.disableLanguage { queryItems2[.fSfl] = .filterOn } if filter.disableUploader { queryItems2[.fSfu] = .filterOn } if filter.disableTags { queryItems2[.fSft] = .filterOn } - - return appending(queryItems: queryItems1).appending(queryItems: queryItems2) } } diff --git a/AppPackage/Sources/AppTools/.swiftlint.yml b/AppPackage/Sources/AppTools/.swiftlint.yml new file mode 100644 index 000000000..1242ffcaa --- /dev/null +++ b/AppPackage/Sources/AppTools/.swiftlint.yml @@ -0,0 +1 @@ +parent_config: ../../../.swiftlint.yml diff --git a/EhPanda/App/Tools/ColorCodable.swift b/AppPackage/Sources/AppTools/ColorCodable.swift similarity index 91% rename from EhPanda/App/Tools/ColorCodable.swift rename to AppPackage/Sources/AppTools/ColorCodable.swift index 4e50b0bc2..9ef6b0334 100644 --- a/EhPanda/App/Tools/ColorCodable.swift +++ b/AppPackage/Sources/AppTools/ColorCodable.swift @@ -1,10 +1,3 @@ -// -// ColorCodable.swift -// EhPanda -// -// Copied from https://brunowernimont.me/howtos/make-swiftui-color-codable -// - import SwiftUI #if os(iOS) import UIKit @@ -45,7 +38,7 @@ private extension Color { } extension Color: @retroactive Codable { - enum CodingKeys: String, CodingKey { + public enum CodingKeys: String, CodingKey, Sendable { case red, green, blue } diff --git a/EhPanda/App/Tools/Utilities/CookieUtil.swift b/AppPackage/Sources/AppTools/CookieUtil.swift similarity index 70% rename from EhPanda/App/Tools/Utilities/CookieUtil.swift rename to AppPackage/Sources/AppTools/CookieUtil.swift index c7a0f58f3..a47b305bc 100644 --- a/EhPanda/App/Tools/Utilities/CookieUtil.swift +++ b/AppPackage/Sources/AppTools/CookieUtil.swift @@ -1,23 +1,19 @@ -// -// CookieUtil.swift -// EhPanda -// - import Foundation // MARK: Cookie -struct CookieUtil { - static var didLogin: Bool { +public struct CookieUtil { + public static var didLogin: Bool { CookieUtil.verify(for: Defaults.URL.ehentai, isEx: false) - || CookieUtil.verify(for: Defaults.URL.exhentai, isEx: true) + || CookieUtil.verify(for: Defaults.URL.exhentai, isEx: true) } - static func verify(for url: URL, isEx: Bool) -> Bool { + public static func verify(for url: URL, isEx: Bool) -> Bool { guard let cookies = HTTPCookieStorage.shared.cookies(for: url), !cookies.isEmpty else { return false } var igneous, memberID, passHash: String? cookies.forEach { cookie in - guard let expiresDate = cookie.expiresDate, expiresDate > .now, !cookie.value.isEmpty else { return } + guard !cookie.value.isEmpty, + cookie.expiresDate.map({ $0 > .now }) != false else { return } if cookie.name == Defaults.Cookie.igneous && cookie.value != Defaults.Cookie.mystery { igneous = cookie.value } diff --git a/AppPackage/Sources/AppTools/DataCache.swift b/AppPackage/Sources/AppTools/DataCache.swift new file mode 100644 index 000000000..a25f01889 --- /dev/null +++ b/AppPackage/Sources/AppTools/DataCache.swift @@ -0,0 +1,330 @@ +import CryptoKit +import Foundation +import UIKit + +/// Owned byte-level cache for the reader page pipeline and its export actions +/// (copy / save / share). It is the one place the app downloads and stores reading-page +/// image bytes itself instead of letting an image library own the transfer. +/// +/// Two render engines are kept on purpose: Kingfisher is primary, and SDWebImage stays +/// only because it renders animated images (WebP / APNG / GIF) that KingfisherWebP +/// renders poorly. Letting each engine fetch and cache on its own split one logical cache +/// in two and forced animated-vs-still routing to be decided from the URL before any bytes +/// existed. Owning the bytes here collapses that into a single fetch + cache; the decoded +/// data is then handed to whichever engine renders it (routed by `Data.isAnimatedImageData`). +/// Scope is deliberately the reader and its exports only. Covers, previews, and cells stay +/// on plain Kingfisher URL-mode caching. +public actor DataCache { + public struct Configuration: Equatable, Sendable { + public var rootURL: URL + public var memoryCostLimit: Int + public var maxDiskAge: TimeInterval + public var diskSizeLimit: UInt64 + public var sweepByteInterval: UInt64 + + public init( + rootURL: URL = URL.cachesDirectory + .appendingPathComponent("DataCache.reading", isDirectory: true), + memoryCostLimit: Int = Int(ProcessInfo.processInfo.physicalMemory / 4), + maxDiskAge: TimeInterval = 7 * 24 * 60 * 60, + diskSizeLimit: UInt64 = 2 * 1024 * 1024 * 1024 + ) { + self.rootURL = rootURL + self.memoryCostLimit = memoryCostLimit + self.maxDiskAge = maxDiskAge + self.diskSizeLimit = diskSizeLimit + self.sweepByteInterval = diskSizeLimit == 0 ? 0 : max(diskSizeLimit / 8, 1) + } + } + + public static let shared = DataCache() + + private let configuration: Configuration + private let fileManager: FileManager + private let memoryCache = NSCache() + private var bytesWrittenSinceSweep: UInt64 = 0 + + public init( + configuration: Configuration = .init(), + fileManager: sending FileManager = FileManager() + ) { + self.configuration = configuration + self.fileManager = fileManager + memoryCache.totalCostLimit = configuration.memoryCostLimit + } + + public nonisolated static func installSystemPurgeObservers() { + Task { @MainActor in + _ = dataCacheSystemPurgeObserver + } + } + + public func data(forKey key: String) -> Data? { + let filename = Self.filename(forKey: key) + if let data = memoryCache.object(forKey: filename as NSString) { + return Data(referencing: data) + } + + let fileURL = configuration.rootURL.appendingPathComponent(filename) + guard fileManager.fileExists(atPath: fileURL.path) else { return nil } + if isExpired(fileURL) { + try? fileManager.removeItem(at: fileURL) + return nil + } + + // A file that exists but can't be read — corrupt, truncated, or purged + // between the existence check and the read — is treated as a miss and + // removed, so the caller re-downloads instead of sticking on the broken + // entry until it expires. + guard let data = try? Data(contentsOf: fileURL) else { + try? fileManager.removeItem(at: fileURL) + return nil + } + memoryCache.setObject(data as NSData, forKey: filename as NSString, cost: data.count) + // A failed access-date bump must not fail an otherwise-successful read. + try? touchAccessDate(for: fileURL) + return data + } + + public func data(forKeys keys: [String]) -> Data? { + for key in Self.uniqued(keys) { + if let data = data(forKey: key) { + return data + } + } + return nil + } + + public func store(_ data: Data, forKey key: String) throws { + let filename = Self.filename(forKey: key) + memoryCache.setObject(data as NSData, forKey: filename as NSString, cost: data.count) + let fileURL = configuration.rootURL.appendingPathComponent(filename) + try write(data, to: fileURL, canRetryDirectoryCreation: true) + bytesWrittenSinceSweep += UInt64(data.count) + if configuration.sweepByteInterval > 0, + bytesWrittenSinceSweep >= configuration.sweepByteInterval { + bytesWrittenSinceSweep = 0 + try sweepDisk() + } + } + + public func store(_ data: Data, forKeys keys: [String]) throws { + for key in Self.uniqued(keys) { + try store(data, forKey: key) + } + } + + public func removeData(forKey key: String) throws { + let filename = Self.filename(forKey: key) + memoryCache.removeObject(forKey: filename as NSString) + let fileURL = configuration.rootURL.appendingPathComponent(filename) + guard fileManager.fileExists(atPath: fileURL.path) else { return } + try fileManager.removeItem(at: fileURL) + } + + public func removeData(forKeys keys: [String]) throws { + for key in Self.uniqued(keys) { + try removeData(forKey: key) + } + } + + public func removeAll() throws { + memoryCache.removeAllObjects() + if fileManager.fileExists(atPath: configuration.rootURL.path) { + try fileManager.removeItem(at: configuration.rootURL) + } + try ensureDirectory() + bytesWrittenSinceSweep = 0 + } + + public func removeAllMemory() { + memoryCache.removeAllObjects() + } + + public func totalSize() throws -> UInt64 { + guard fileManager.fileExists(atPath: configuration.rootURL.path) else { return 0 } + guard let enumerator = fileManager.enumerator( + at: configuration.rootURL, + includingPropertiesForKeys: [.isRegularFileKey, .fileSizeKey], + options: [.skipsHiddenFiles] + ) else { + return 0 + } + + var total: UInt64 = 0 + for case let fileURL as URL in enumerator { + autoreleasepool { + let values = try? fileURL.resourceValues(forKeys: [.isRegularFileKey, .fileSizeKey]) + guard values?.isRegularFile == true else { return } + total += UInt64(values?.fileSize ?? 0) + } + } + return total + } + + public func sweepDisk() throws { + guard fileManager.fileExists(atPath: configuration.rootURL.path) else { return } + var entries = try diskEntries() + let now = Date() + if configuration.maxDiskAge > 0 { + for entry in entries where now.timeIntervalSince(entry.accessDate) > configuration.maxDiskAge { + evictDiskEntry(entry) + } + entries.removeAll { now.timeIntervalSince($0.accessDate) > configuration.maxDiskAge } + } + + guard configuration.diskSizeLimit > 0 else { return } + var totalSize = entries.reduce(UInt64(0)) { $0 + $1.size } + guard totalSize > configuration.diskSizeLimit else { return } + let targetSize = configuration.diskSizeLimit / 2 + for entry in entries.sorted(by: { $0.accessDate < $1.accessDate }) { + evictDiskEntry(entry) + totalSize = totalSize > entry.size ? totalSize - entry.size : 0 + guard totalSize > targetSize else { break } + } + } + + // Evicts a single entry from disk and drops only its matching memory object. + // The memory cache is keyed by the on-disk hashed filename, so eviction stays + // scoped to the swept keys instead of purging the whole memory front. + private func evictDiskEntry(_ entry: DiskEntry) { + try? fileManager.removeItem(at: entry.url) + memoryCache.removeObject(forKey: entry.url.lastPathComponent as NSString) + } + + private func write( + _ data: Data, + to fileURL: URL, + canRetryDirectoryCreation: Bool + ) throws { + do { + try ensureDirectory() + try data.write(to: fileURL, options: .atomic) + // A failed access-date bump must not fail an otherwise-successful write. + try? touchAccessDate(for: fileURL) + } catch { + guard canRetryDirectoryCreation else { throw error } + try? fileManager.removeItem(at: configuration.rootURL) + try ensureDirectory() + try write(data, to: fileURL, canRetryDirectoryCreation: false) + } + } + + private func ensureDirectory() throws { + try fileManager.createDirectory( + at: configuration.rootURL, + withIntermediateDirectories: true + ) + var resourceValues = URLResourceValues() + resourceValues.isExcludedFromBackup = true + var directoryURL = configuration.rootURL + try? directoryURL.setResourceValues(resourceValues) + } + + private static func filename(forKey key: String) -> String { + SHA256.hash(data: Data(key.utf8)) + .map { String(format: "%02x", $0) } + .joined() + } + + private nonisolated static func uniqued(_ keys: [String]) -> [String] { + var seen = Set() + return keys.filter { seen.insert($0).inserted } + } + + private func isExpired(_ fileURL: URL) -> Bool { + guard configuration.maxDiskAge > 0 else { return false } + let accessDate = accessDate(for: fileURL) + return Date().timeIntervalSince(accessDate) > configuration.maxDiskAge + } + + private func touchAccessDate(for fileURL: URL) throws { + try fileManager.setAttributes( + [.creationDate: Date(), .modificationDate: Date()], + ofItemAtPath: fileURL.path + ) + var resourceValues = URLResourceValues() + resourceValues.contentAccessDate = Date() + var mutableURL = fileURL + try? mutableURL.setResourceValues(resourceValues) + } + + private func accessDate(for fileURL: URL) -> Date { + if let date = try? fileURL.resourceValues(forKeys: [.contentAccessDateKey]).contentAccessDate { + return date + } + let attributes = try? fileManager.attributesOfItem(atPath: fileURL.path) + return attributes?[.modificationDate] as? Date ?? .distantPast + } + + private func diskEntries() throws -> [DiskEntry] { + guard let enumerator = fileManager.enumerator( + at: configuration.rootURL, + includingPropertiesForKeys: [ + .contentAccessDateKey, + .fileSizeKey, + .isRegularFileKey + ], + options: [.skipsHiddenFiles] + ) else { + return [] + } + + var entries = [DiskEntry]() + for case let fileURL as URL in enumerator { + let values = try fileURL.resourceValues(forKeys: [ + .contentAccessDateKey, + .fileSizeKey, + .isRegularFileKey + ]) + guard values.isRegularFile == true else { continue } + entries.append( + DiskEntry( + url: fileURL, + size: UInt64(values.fileSize ?? 0), + accessDate: values.contentAccessDate ?? accessDate(for: fileURL) + ) + ) + } + return entries + } +} + +@MainActor +private let dataCacheSystemPurgeObserver = DataCacheSystemPurgeObserver(cache: .shared) + +@MainActor +private final class DataCacheSystemPurgeObserver { + private let tokens: [NSObjectProtocol] + + public init(cache: DataCache) { + let center = NotificationCenter.default + tokens = [ + center.addObserver( + forName: UIApplication.didReceiveMemoryWarningNotification, + object: nil, + queue: .main + ) { [weak cache] _ in + Task { + await cache?.removeAllMemory() + } + }, + center.addObserver( + forName: UIApplication.didEnterBackgroundNotification, + object: nil, + queue: .main + ) { [weak cache] _ in + Task { + await cache?.removeAllMemory() + try? await cache?.sweepDisk() + } + } + ] + } +} + +private struct DiskEntry { + public let url: URL + public let size: UInt64 + public let accessDate: Date +} diff --git a/AppPackage/Sources/AppTools/Defaults.swift b/AppPackage/Sources/AppTools/Defaults.swift new file mode 100644 index 000000000..4f1f38243 --- /dev/null +++ b/AppPackage/Sources/AppTools/Defaults.swift @@ -0,0 +1,157 @@ +import CoreGraphics +import Foundation +import SwiftUI + +public struct Defaults: Sendable { + public struct App: Sendable { + public static let identifier = "app.ehpanda" + } + public struct ImageSize: Sendable { + public static let rowAspect: CGFloat = 8/11 + public static let headerAspect: CGFloat = 8/11 + public static let previewAspect: CGFloat = 8/11 + public static let contentAspect: CGFloat = 7/10 + public static let webtoonMinAspect: CGFloat = 1/4 + public static let webtoonIdealAspect: CGFloat = 2/3 + + public static let rowW: CGFloat = rowH * rowAspect + public static let rowH: CGFloat = 120 + public static let headerW: CGFloat = headerH * headerAspect + public static let headerH: CGFloat = 150 + } + public struct Cookie: Sendable { + public static let yay = "yay" + public static let null = "null" + public static let expired = "expired" + public static let mystery = "mystery" + public static let ignoreOffensive = "nw" + public static let selectedProfile = "sp" + public static let skipServer = "skipserver" + + public static let igneous = "igneous" + public static let ipbMemberId = "ipb_member_id" + public static let ipbPassHash = "ipb_pass_hash" + } + public struct DateFormat: Sendable { + public static let greeting = "dd MMMM yyyy" + public static let publish = "yyyy-MM-dd HH:mm" + public static let torrent = "yyyy-MM-dd HH:mm" + public static let comment = "dd MMMM yyyy, HH:mm" + public static let github = "yyyy-MM-dd'T'HH:mm:ss'Z'" + } + public struct FilePath: Sendable { + public static let logs = "logs" + public static let activityLogPrefix = "ehpanda" + public static let activityLogExtension = "jsonl" + public static let downloads = "Downloads" + public static let downloadPages = "pages" + public static let downloadManifest = "manifest.json" + public static let automationDownloadFolder = "Automation" + public static let defaultDownloadFolder = "Default" + } + public struct Regex: Sendable { + public static let tagSuggestion: NSRegularExpression? = try? .init( + pattern: "(\\S+:\".+?\"|\".+?\"|\\S+:\\S+|\\S+)" + ) + } + public struct FavoriteColor: Sendable { + public static let colors: [Color] = [ + Color(hex: "9e9e9e"), + Color(hex: "fc4e4e"), + Color(hex: "fcb417"), + Color(hex: "dde500"), + Color(hex: "17b91b"), + Color(hex: "36b940"), + Color(hex: "68c9de"), + Color(hex: "5050d7"), + Color(hex: "9755f5"), + Color(hex: "fe93ff"), + ] + } + public struct URL: Sendable { + public static let ehentai: Foundation.URL = .init(string: "https://e-hentai.org/").forceUnwrapped + public static let exhentai: Foundation.URL = .init(string: "https://exhentai.org/").forceUnwrapped + public static let sexhentai: Foundation.URL = .init(string: "https://s.exhentai.org/").forceUnwrapped + + public static let torrentDownload: Foundation.URL = .init(string: "https://ehgt.org/g/t.png").forceUnwrapped + public static let torrentDownloadInvalid: Foundation.URL = .init( + string: "https://ehgt.org/g/td.png" + ).forceUnwrapped + + public static let forum: Foundation.URL = .init(string: "https://forums.e-hentai.org/index.php").forceUnwrapped + public static let login = forum.appending(queryItems: [.act: .loginAct, .code: .zeroOne]) + public static let webLogin = forum.appending(queryItems: [.act: .loginAct]) + + public static let news = ehentai.appendingPathComponent("news.php") + + public static let toplist = ehentai.appendingPathComponent("toplist.php") + + // GitHub + public static let github: Foundation.URL = .init(string: "https://github.com/").forceUnwrapped + public static let githubAPI: Foundation.URL = .init(string: "https://api.github.com/repos/").forceUnwrapped + + // swiftlint:disable nesting identifier_name + public enum Component: Sendable { + public enum Key: String, Sendable { + // Functional Pages + case token = "t" + case gid = "gid" + case letterP = "p" + case page = "page" + case from = "from" + case next = "next" + case favcat = "favcat" + case topcat = "tl" + case showUser = "showuser" + case fSearch = "f_search" + + case code = "CODE" + case act = "act" + case showComments = "hc" + case inlineSet = "inline_set" + case skipServerIdentifier = "nl" + + // Search favorites + case sn = "sn" + case st = "st" + case sf = "sf" + + // Filter + case fCats = "f_cats" + case advSearch = "advsearch" + case fSname = "f_sname" + case fStags = "f_stags" + case fSdesc = "f_sdesc" + case fStorr = "f_storr" + case fSto = "f_sto" + case fSdt1 = "f_sdt1" + case fSdt2 = "f_sdt2" + case fSh = "f_sh" + case fSr = "f_sr" + case fSrdd = "f_srdd" + case fSp = "f_sp" + case fSpf = "f_spf" + case fSpt = "f_spt" + case fSfl = "f_sfl" + case fSfu = "f_sfu" + case fSft = "f_sft" + + // Custom + case ehpandaWidth = "ehpandaWidth" + case ehpandaHeight = "ehpandaHeight" + case ehpandaOffset = "ehpandaOffset" + } + public enum Value: String, Sendable { + case one = "1" + case all = "all" + case zeroOne = "01" + case filterOn = "on" + case loginAct = "Login" + case addFavAct = "addfav" + case sortOrderByUpdateTime = "fs_p" + case sortOrderByFavoritedTime = "fs_f" + } + } + // swiftlint:enable nesting identifier_name + } +} diff --git a/EhPanda/App/Tools/Utilities/DeviceUtil.swift b/AppPackage/Sources/AppTools/DeviceUtil.swift similarity index 55% rename from EhPanda/App/Tools/Utilities/DeviceUtil.swift rename to AppPackage/Sources/AppTools/DeviceUtil.swift index 3a64e266f..f21159102 100644 --- a/EhPanda/App/Tools/Utilities/DeviceUtil.swift +++ b/AppPackage/Sources/AppTools/DeviceUtil.swift @@ -1,78 +1,78 @@ -// -// DeviceUtil.swift -// EhPanda -// - import SwiftUI import Foundation -struct DeviceUtil { - static var isPad: Bool { +@MainActor +public struct DeviceUtil { + public static var isPad: Bool { UIDevice.current.userInterfaceIdiom == .pad } - static var isPhone: Bool { + public static var isPhone: Bool { UIDevice.current.userInterfaceIdiom == .phone } - static var isPadWidth: Bool { + public static var isPadWidth: Bool { windowW >= 744 } - static var isSEWidth: Bool { + public static var isSEWidth: Bool { windowW <= 320 } - static var keyWindow: UIWindow? { + public static var keyWindow: UIWindow? { UIApplication.shared.connectedScenes .filter({ $0.activationState == .foregroundActive }) .compactMap({ $0 as? UIWindowScene }).last? .windows.filter({ $0.isKeyWindow }).last } - static var anyWindow: UIWindow? { + public static var anyWindow: UIWindow? { UIApplication.shared.connectedScenes .compactMap({ $0 as? UIWindowScene }).last? .windows.last } - static var isLandscape: Bool { + private static var currentScreen: UIScreen? { + keyWindow?.windowScene?.screen ?? anyWindow?.windowScene?.screen + } + + public static var isLandscape: Bool { [.landscapeLeft, .landscapeRight] .contains(keyWindow?.windowScene?.effectiveGeometry.interfaceOrientation) } - static var isPortrait: Bool { + public static var isPortrait: Bool { [.portrait, .portraitUpsideDown] .contains(keyWindow?.windowScene?.effectiveGeometry.interfaceOrientation) } - static var windowW: CGFloat { + public static var windowW: CGFloat { min(absWindowW, absWindowH) } - static var windowH: CGFloat { + public static var windowH: CGFloat { max(absWindowW, absWindowH) } - static var screenW: CGFloat { + public static var screenW: CGFloat { min(absScreenW, absScreenH) } - static var screenH: CGFloat { + public static var screenH: CGFloat { max(absScreenW, absScreenH) } - static var absWindowW: CGFloat { + public static var absWindowW: CGFloat { keyWindow?.frame.size.width ?? absScreenW } - static var absWindowH: CGFloat { + public static var absWindowH: CGFloat { keyWindow?.frame.size.height ?? absScreenH } - static var absScreenW: CGFloat { - UIScreen.main.bounds.size.width + public static var absScreenW: CGFloat { + currentScreen?.bounds.size.width ?? 0 } - static var absScreenH: CGFloat { - UIScreen.main.bounds.size.height + public static var absScreenH: CGFloat { + currentScreen?.bounds.size.height ?? 0 } } diff --git a/EhPanda/App/Tools/EnvironmentKeys.swift b/AppPackage/Sources/AppTools/EnvironmentKeys.swift similarity index 50% rename from EhPanda/App/Tools/EnvironmentKeys.swift rename to AppPackage/Sources/AppTools/EnvironmentKeys.swift index 583f6cc33..ab1f51d28 100644 --- a/EhPanda/App/Tools/EnvironmentKeys.swift +++ b/AppPackage/Sources/AppTools/EnvironmentKeys.swift @@ -1,16 +1,11 @@ -// -// EnvironmentKeys.swift -// EhPanda -// - import SwiftUI -struct InSheetKey: EnvironmentKey { - static let defaultValue = false +public struct InSheetKey: EnvironmentKey, Sendable { + public static let defaultValue = false } extension EnvironmentValues { - var inSheet: Bool { + public var inSheet: Bool { get { self[InSheetKey.self] } set { self[InSheetKey.self] = newValue } } diff --git a/EhPanda/App/Tools/EquatableVoid.swift b/AppPackage/Sources/AppTools/EquatableVoid.swift similarity index 86% rename from EhPanda/App/Tools/EquatableVoid.swift rename to AppPackage/Sources/AppTools/EquatableVoid.swift index d2d3dc744..ff7172376 100644 --- a/EhPanda/App/Tools/EquatableVoid.swift +++ b/AppPackage/Sources/AppTools/EquatableVoid.swift @@ -1,8 +1,3 @@ -// -// EquatableVoid.swift -// EhPanda -// - import Foundation public struct EquatableVoid: Hashable, Sendable, Identifiable { diff --git a/AppPackage/Sources/AppTools/Extensions.swift b/AppPackage/Sources/AppTools/Extensions.swift new file mode 100644 index 000000000..40e8c9ee3 --- /dev/null +++ b/AppPackage/Sources/AppTools/Extensions.swift @@ -0,0 +1,207 @@ +import SwiftUI +import UIKit +import Foundation + +// MARK: Encodable +extension Encodable { + public func toData() -> Data? { + try? JSONEncoder().encode(self) + } +} + +// MARK: UIApplication +extension UIApplication { + @MainActor + public func endEditing() { + sendAction(#selector(UIResponder.resignFirstResponder), to: nil, from: nil, for: nil) + } +} + +// MARK: Data +extension Data { + public func toObject() -> O? { + try? JSONDecoder().decode(O.self, from: self) + } + public var utf8InvalidCharactersRipped: Data { + var data = self + data.append(0) + + let str = Array(data).withUnsafeBufferPointer { ptr -> String? in + guard let address = ptr.baseAddress else { return nil } + return String(cString: address) + } + guard let string = str else { return data } + data = string.data(using: .utf8) ?? self + return data + } +} + +// MARK: Float +extension Float { + public var halfRounded: Float { + let lowerbound = Int(self) + let upperbound = lowerbound + 1 + let decimal: Float = self - Float(lowerbound) + + if decimal < 0.25 { + return Float(lowerbound) + } else if decimal >= 0.25 && decimal < 0.75 { + return Float(lowerbound) + 0.5 + } else { + return Float(upperbound) + } + } +} + +// MARK: String +extension String { + public var isInteger: Bool { + Int(self) != nil + } + public var isValidGID: Bool { + !isEmpty && isInteger + } + public var localizedKey: LocalizedStringKey { + .init(self) + } + public var emojisRipped: String { + unicodeScalars + .filter { !$0.properties.isEmojiPresentation } + .reduce("") { $0 + .init($1) } + } + + public var urlEncoded: String { + addingPercentEncoding( + withAllowedCharacters: .urlQueryAllowed + ) ?? "" + } + + public 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 } + } + + public func caseInsensitiveContains(_ other: String) -> Bool { + range(of: other, options: .caseInsensitive) != nil + } + public func caseInsensitiveEqualsTo(_ other: String) -> Bool { + caseInsensitiveContains(other) && count == other.count + } +} + +// MARK: UIImage +extension UIImage { + public func cropping(to rect: CGRect) -> UIImage? { + let scaledRect = CGRect( + x: rect.origin.x * scale, + y: rect.origin.y * scale, + width: rect.size.width * scale, + height: rect.size.height * scale + ) + + guard let cgImage = cgImage?.cropping(to: scaledRect) else { return nil } + return UIImage(cgImage: cgImage, scale: scale, orientation: imageOrientation) + } + + public func cropping(size: CGSize, offset: CGSize) -> UIImage? { + let origin = CGPoint(x: offset.width, y: offset.height) + let rect = CGRect(origin: origin, size: size) + return cropping(to: rect) + } + + public func withRoundedCorners(radius: CGFloat) -> UIImage? { + let maxRadius = min(size.width, size.height) / 2 + + let cornerRadius: CGFloat + if radius > 0 && radius <= maxRadius { + cornerRadius = radius + } else { + cornerRadius = maxRadius + } + + UIGraphicsBeginImageContextWithOptions(size, false, scale) + + let rect = CGRect(origin: .zero, size: size) + UIBezierPath(roundedRect: rect, cornerRadius: cornerRadius).addClip() + draw(in: rect) + + let image = UIGraphicsGetImageFromCurrentImageContext() + UIGraphicsEndImageContext() + return image + } +} + +// MARK: Color +extension Color { + public init(hex: String) { + let hex = hex.trimmingCharacters( + in: CharacterSet.alphanumerics.inverted + ) + var int: UInt64 = 0 + Scanner(string: hex).scanHexInt64(&int) + + let alpha, red, green, blue: UInt64 + switch hex.count { + case 3: + (alpha, red, green, blue) = (255, (int >> 8) * 17, (int >> 4 & 0xF) * 17, (int & 0xF) * 17) + case 6: + (alpha, red, green, blue) = (255, int >> 16, int >> 8 & 0xFF, int & 0xFF) + case 8: + (alpha, red, green, blue) = (int >> 24, int >> 16 & 0xFF, int >> 8 & 0xFF, int & 0xFF) + default: + (alpha, red, green, blue) = (1, 1, 1, 0) + } + + self.init( + .sRGB, red: Double(red) / 255.0, green: Double(green) / 255.0, + blue: Double(blue) / 255.0, opacity: Double(alpha) / 255.0 + ) + } + + public var isLightColor: Bool { + var red: CGFloat = 0 + var green: CGFloat = 0 + var blue: CGFloat = 0 + var alpha: CGFloat = 0 + guard UIColor(self).getRed(&red, green: &green, blue: &blue, alpha: &alpha) else { return true } + let luminance = 0.299 * Double(red) + 0.587 * Double(green) + 0.114 * Double(blue) + return luminance > 0.55 + } +} + +// MARK: Array +extension Array { + public func removeDuplicates(by predicate: (Element, Element) -> Bool) -> Self { + var result = [Element]() + for value in self where result.filter({ predicate($0, value) }).isEmpty { + result.append(value) + } + return result + } + public func removeDuplicates(by keyPath: KeyPath) -> Self { + removeDuplicates(by: { $0[keyPath: keyPath] == $1[keyPath: keyPath] }) + } + public func removeDuplicates() -> Self where Element: Equatable { + removeDuplicates(by: ==) + } +} + +// MARK: Dictionary +extension Dictionary { + public var tuples: [(Key, Value)] { + map({ ($0.key, $0.value) }) + } +} + +// MARK: TimeInterval +extension TimeInterval { + public static let oneYear: Self = .init(60 * 60 * 24 * 365) + public static let oneWeek: Self = .init(60 * 60 * 24 * 7) +} diff --git a/AppPackage/Sources/AppTools/Extensions/Logger+.swift b/AppPackage/Sources/AppTools/Extensions/Logger+.swift new file mode 100644 index 000000000..2d21eb1e0 --- /dev/null +++ b/AppPackage/Sources/AppTools/Extensions/Logger+.swift @@ -0,0 +1,12 @@ +import OSLog + +// AppTools cannot import OSLogExt (OSLogExt depends on AppTools), so it composes +// the subsystem locally instead of using OSLogExt's `Logger(moduleName:category:)`. +extension Logger { + init(category: String) { + self.init( + subsystem: [Defaults.App.identifier, "AppTools"].joined(separator: "."), + category: category + ) + } +} diff --git a/AppPackage/Sources/AppTools/Extensions/Optional+ForceUnwrapped.swift b/AppPackage/Sources/AppTools/Extensions/Optional+ForceUnwrapped.swift new file mode 100644 index 000000000..8bd4b87a4 --- /dev/null +++ b/AppPackage/Sources/AppTools/Extensions/Optional+ForceUnwrapped.swift @@ -0,0 +1,14 @@ +import OSLog +import Foundation + +private let logger = Logger(category: "ForceUnwrap") + +extension Optional { + public var forceUnwrapped: Wrapped! { + if let value = self { + return value + } + logger.error("Failed in force unwrapping type: \(String(describing: Wrapped.self), privacy: .public)") + return nil + } +} diff --git a/AppPackage/Sources/AppTools/Extensions/String+Helpers.swift b/AppPackage/Sources/AppTools/Extensions/String+Helpers.swift new file mode 100644 index 000000000..83aa58eea --- /dev/null +++ b/AppPackage/Sources/AppTools/Extensions/String+Helpers.swift @@ -0,0 +1,43 @@ +import Foundation + +extension String { + public var nonEmpty: String? { + isEmpty ? nil : self + } + public var linkStyled: String { + "[\(self)](\(Defaults.URL.ehentai.absoluteString))" + } + public var firstLetterCapitalized: String { + prefix(1).capitalized + dropFirst() + } + public var stringsBesideColon: (String?, String) { + let strings = split(separator: ":").map(String.init) + if strings.count == 2, !strings[0].isEmpty { + return (strings[0], strings[1]) + } + return (nil, self) + } + public var barcesAndSpacesRemoved: String { + replacingOccurrences(from: "(", to: ")", with: "") + .replacingOccurrences(from: "[", to: "]", with: "") + .replacingOccurrences(from: "{", to: "}", with: "") + .replacingOccurrences(from: "【", to: "】", with: "") + .replacingOccurrences(from: "「", to: "」", with: "") + .trimmingCharacters(in: .whitespacesAndNewlines) + } + + public func replacingOccurrences( + from subString1: String, to subString2: String, with replacement: String + ) -> String { + var result = self + + while let rangeA = result.range(of: subString1), + let rangeB = result.range(of: subString2), + rangeA.lowerBound < rangeB.upperBound { + let unwanted = result[rangeA.lowerBound.. Void) -> URL? { + guard var components = URLComponents( + url: self, resolvingAgainstBaseURL: false + ) + else { return nil } + commitChanges(&components) + return components.url + } + func replaceHost(to newHost: String?) -> URL? { + modifyComponent(for: self) { components in + components.host = newHost + } + } + func replaceScheme(to newScheme: String?) -> URL? { + modifyComponent(for: self) { components in + components.scheme = newScheme + } + } +} diff --git a/AppPackage/Sources/AppTools/Extensions/URL+ImageCacheKey.swift b/AppPackage/Sources/AppTools/Extensions/URL+ImageCacheKey.swift new file mode 100644 index 000000000..097f42522 --- /dev/null +++ b/AppPackage/Sources/AppTools/Extensions/URL+ImageCacheKey.swift @@ -0,0 +1,69 @@ +import Foundation + +extension URL { + private static let ignoredStableCacheQueryNames: Set = [ + "dl", "download", "source", "from", "view" + ] + private static let preferredStableCacheQueryNames: Set = [ + "gid", "page", "imgkey", "fileindex", "xres", "p", "key" + ] + + public var stableImageCacheKey: String? { + let normalizedPath = pathComponents + .filter { $0 != "/" && !$0.isEmpty } + .joined(separator: "/") + guard !normalizedPath.isEmpty else { return nil } + + let queryItems = normalizedStableCacheQueryItems + guard !queryItems.isEmpty else { + return "download::\(normalizedPath)" + } + + let normalizedQuery = queryItems + .map { "\($0.name)=\($0.value ?? "")" } + .joined(separator: "&") + return "download::\(normalizedPath)?\(normalizedQuery)" + } + + /// The keys an image is cached under, primary first: the stable alias (when the + /// path yields one) so differing query/host variants of the same page collide, + /// then the absolute URL as an exact-match fallback. Writers store under the + /// primary key; readers check them in order. + public var imageCacheKeys: [String] { + var keys = [String]() + if let stableImageCacheKey { + keys.append(stableImageCacheKey) + } + keys.append(absoluteString) + return keys + } + + private var normalizedStableCacheQueryItems: [URLQueryItem] { + guard let components = URLComponents( + url: self, + resolvingAgainstBaseURL: false + ), + let queryItems = components.queryItems? + .filter({ !($0.value ?? "").isEmpty }) + else { + return [] + } + + let preferredQueryItems = queryItems.filter { + Self.preferredStableCacheQueryNames.contains($0.name.lowercased()) + } + let filteredQueryItems = preferredQueryItems.isEmpty + ? queryItems.filter { + !Self.ignoredStableCacheQueryNames + .contains($0.name.lowercased()) + } + : preferredQueryItems + + return filteredQueryItems.sorted { lhs, rhs in + if lhs.name == rhs.name { + return (lhs.value ?? "") < (rhs.value ?? "") + } + return lhs.name < rhs.name + } + } +} diff --git a/AppPackage/Sources/AppTools/Extensions/URL+QueryItems.swift b/AppPackage/Sources/AppTools/Extensions/URL+QueryItems.swift new file mode 100644 index 000000000..ea9da934e --- /dev/null +++ b/AppPackage/Sources/AppTools/Extensions/URL+QueryItems.swift @@ -0,0 +1,37 @@ +import Foundation + +extension URL { + public func appending(queryItems: [URLQueryItem]) -> URL { + guard !queryItems.isEmpty else { return self } + var components: URLComponents = .init( + url: self, resolvingAgainstBaseURL: false + ) + .forceUnwrapped + if components.queryItems == nil { + components.queryItems = [] + } + components.queryItems?.append(contentsOf: queryItems) + return components.url.forceUnwrapped + } + public func appending(queryItems: [String: String]) -> URL { + appending(queryItems: queryItems.map(URLQueryItem.init)) + } + public func appending(queryItems: [Defaults.URL.Component.Key: Defaults.URL.Component.Value]) -> URL { + appending(queryItems: queryItems.map({ URLQueryItem(name: $0.rawValue, value: $1.rawValue) })) + } + public func appending(queryItems: [Defaults.URL.Component.Key: String]) -> URL { + appending(queryItems: queryItems.map({ URLQueryItem(name: $0.rawValue, value: $1) })) + } + public mutating func append(queryItems: [URLQueryItem]) { + self = appending(queryItems: queryItems) + } + public mutating func append(queryItems: [String: String]) { + self = appending(queryItems: queryItems) + } + public mutating func append(queryItems: [Defaults.URL.Component.Key: Defaults.URL.Component.Value]) { + self = appending(queryItems: queryItems) + } + public mutating func append(queryItems: [Defaults.URL.Component.Key: String]) { + self = appending(queryItems: queryItems) + } +} diff --git a/AppPackage/Sources/AppTools/FileUtil.swift b/AppPackage/Sources/AppTools/FileUtil.swift new file mode 100644 index 000000000..eca5c9aa2 --- /dev/null +++ b/AppPackage/Sources/AppTools/FileUtil.swift @@ -0,0 +1,13 @@ +import Foundation + +public struct FileUtil { + public static var logsDirectoryURL: URL { + .documentsDirectory.appendingPathComponent(Defaults.FilePath.logs) + } + public static var downloadsDirectoryURL: URL { + .documentsDirectory.appendingPathComponent( + Defaults.FilePath.downloads, + isDirectory: true + ) + } +} diff --git a/EhPanda/App/Tools/Utilities/HapticsUtil.swift b/AppPackage/Sources/AppTools/HapticsUtil.swift similarity index 82% rename from EhPanda/App/Tools/Utilities/HapticsUtil.swift rename to AppPackage/Sources/AppTools/HapticsUtil.swift index b0a3f3dfd..e757d32de 100644 --- a/EhPanda/App/Tools/Utilities/HapticsUtil.swift +++ b/AppPackage/Sources/AppTools/HapticsUtil.swift @@ -1,13 +1,9 @@ -// -// HapticsUtil.swift -// EhPanda -// - import SwiftUI import AudioToolbox -struct HapticsUtil { - static func generateFeedback(style: UIImpactFeedbackGenerator.FeedbackStyle) { +@MainActor +public struct HapticsUtil { + public static func generateFeedback(style: UIImpactFeedbackGenerator.FeedbackStyle) { guard !isLegacyTapticEngine else { generateLegacyFeedback() return @@ -15,7 +11,7 @@ struct HapticsUtil { UIImpactFeedbackGenerator(style: style).impactOccurred() } - static func generateNotificationFeedback(style: UINotificationFeedbackGenerator.FeedbackType) { + public static func generateNotificationFeedback(style: UINotificationFeedbackGenerator.FeedbackType) { guard !isLegacyTapticEngine else { generateLegacyFeedback() return diff --git a/EhPanda/App/Tools/IdentifiableBox.swift b/AppPackage/Sources/AppTools/IdentifiableBox.swift similarity index 89% rename from EhPanda/App/Tools/IdentifiableBox.swift rename to AppPackage/Sources/AppTools/IdentifiableBox.swift index 91d12811e..6c72026c9 100644 --- a/EhPanda/App/Tools/IdentifiableBox.swift +++ b/AppPackage/Sources/AppTools/IdentifiableBox.swift @@ -1,8 +1,3 @@ -// -// IdentifiableBox.swift -// EhPanda -// - import Foundation public struct IdentifiableBox: Identifiable { diff --git a/AppPackage/Sources/AppTools/TouchHandler.swift b/AppPackage/Sources/AppTools/TouchHandler.swift new file mode 100644 index 000000000..15f25d3a2 --- /dev/null +++ b/AppPackage/Sources/AppTools/TouchHandler.swift @@ -0,0 +1,14 @@ +import UIKit + +public final class TouchHandler: NSObject, UIGestureRecognizerDelegate { + public static let shared = TouchHandler() + public var currentPoint: CGPoint? + + public func gestureRecognizer( + _ gestureRecognizer: UIGestureRecognizer, + shouldReceive touch: UITouch + ) -> Bool { + currentPoint = touch.location(in: touch.window) + return false + } +} diff --git a/AppPackage/Sources/AppTools/UserDefaultsUtil.swift b/AppPackage/Sources/AppTools/UserDefaultsUtil.swift new file mode 100644 index 000000000..235f494d6 --- /dev/null +++ b/AppPackage/Sources/AppTools/UserDefaultsUtil.swift @@ -0,0 +1,12 @@ +import Foundation + +public struct UserDefaultsUtil { + public static func value(forKey key: AppUserDefaults) -> T? { + UserDefaults.standard.value(forKey: key.rawValue) as? T + } +} + +public enum AppUserDefaults: String { + case galleryHost + case clipboardChangeCount +} diff --git a/AppPackage/Sources/ApplicationClient/.swiftlint.yml b/AppPackage/Sources/ApplicationClient/.swiftlint.yml new file mode 100644 index 000000000..1242ffcaa --- /dev/null +++ b/AppPackage/Sources/ApplicationClient/.swiftlint.yml @@ -0,0 +1 @@ +parent_config: ../../../.swiftlint.yml diff --git a/EhPanda/App/Tools/Clients/UIApplicationClient.swift b/AppPackage/Sources/ApplicationClient/ApplicationClient.swift similarity index 59% rename from EhPanda/App/Tools/Clients/UIApplicationClient.swift rename to AppPackage/Sources/ApplicationClient/ApplicationClient.swift index f7b163bfe..02f62463e 100644 --- a/EhPanda/App/Tools/Clients/UIApplicationClient.swift +++ b/AppPackage/Sources/ApplicationClient/ApplicationClient.swift @@ -1,22 +1,18 @@ -// -// UIApplicationClient.swift -// EhPanda -// - import SwiftUI import Combine import ComposableArchitecture +import AppTools -struct UIApplicationClient { - let openURL: @MainActor (URL) -> Void - let hideKeyboard: @Sendable () async -> Void - let alternateIconName: () -> String? - let setAlternateIconName: @MainActor (String?) async -> Bool - let setUserInterfaceStyle: @MainActor (UIUserInterfaceStyle) -> Void +public struct ApplicationClient: Sendable { + public let openURL: @MainActor @Sendable (URL) -> Void + public let hideKeyboard: @Sendable () async -> Void + public let alternateIconName: @MainActor @Sendable () -> String? + public let setAlternateIconName: @MainActor @Sendable (String?) async -> Bool + public let setUserInterfaceStyle: @MainActor @Sendable (UIUserInterfaceStyle) -> Void } -extension UIApplicationClient { - static let live: Self = .init( +extension ApplicationClient { + public static let live: Self = .init( openURL: { url in UIApplication.shared.open(url, options: [:]) }, @@ -44,38 +40,37 @@ extension UIApplicationClient { } ) @MainActor - func openSettings() { + public func openSettings() { if let url = URL(string: UIApplication.openSettingsURLString) { return openURL(url) } } @MainActor - func openFileApp() { - if let dirPath = FileUtil.logsDirectoryURL?.path, - let dirURL = URL(string: "shareddocuments://" + dirPath) - { + public func openFileApp() { + let dirPath = FileUtil.logsDirectoryURL.path + if let dirURL = URL(string: "shareddocuments://" + dirPath) { return openURL(dirURL) } } } // MARK: API -enum UIApplicationClientKey: DependencyKey { - static let liveValue = UIApplicationClient.live - static let previewValue = UIApplicationClient.noop - static let testValue = UIApplicationClient.unimplemented +public enum ApplicationClientKey: DependencyKey { + public static let liveValue = ApplicationClient.live + public static let previewValue = ApplicationClient.noop + public static let testValue = ApplicationClient.unimplemented } extension DependencyValues { - var uiApplicationClient: UIApplicationClient { - get { self[UIApplicationClientKey.self] } - set { self[UIApplicationClientKey.self] = newValue } + public var applicationClient: ApplicationClient { + get { self[ApplicationClientKey.self] } + set { self[ApplicationClientKey.self] = newValue } } } // MARK: Test -extension UIApplicationClient { - static let noop: Self = .init( +extension ApplicationClient { + public static let noop: Self = .init( openURL: { _ in}, hideKeyboard: {}, alternateIconName: { nil }, @@ -83,9 +78,9 @@ extension UIApplicationClient { setUserInterfaceStyle: { _ in } ) - static func placeholder() -> Result { fatalError() } + public static func placeholder() -> Result { fatalError() } - static let unimplemented: Self = .init( + public static let unimplemented: Self = .init( openURL: IssueReporting.unimplemented(placeholder: placeholder()), hideKeyboard: IssueReporting.unimplemented(placeholder: placeholder()), alternateIconName: IssueReporting.unimplemented(placeholder: placeholder()), diff --git a/AppPackage/Sources/AuthorizationClient/.swiftlint.yml b/AppPackage/Sources/AuthorizationClient/.swiftlint.yml new file mode 100644 index 000000000..1242ffcaa --- /dev/null +++ b/AppPackage/Sources/AuthorizationClient/.swiftlint.yml @@ -0,0 +1 @@ +parent_config: ../../../.swiftlint.yml diff --git a/EhPanda/App/Tools/Clients/AuthorizationClient.swift b/AppPackage/Sources/AuthorizationClient/AuthorizationClient.swift similarity index 66% rename from EhPanda/App/Tools/Clients/AuthorizationClient.swift rename to AppPackage/Sources/AuthorizationClient/AuthorizationClient.swift index 18a929406..677a6dd1b 100644 --- a/EhPanda/App/Tools/Clients/AuthorizationClient.swift +++ b/AppPackage/Sources/AuthorizationClient/AuthorizationClient.swift @@ -1,19 +1,14 @@ -// -// AuthorizationClient.swift -// EhPanda -// - import Combine import LocalAuthentication import ComposableArchitecture -struct AuthorizationClient { - let passcodeNotSet: () -> Bool - let localAuthroize: (String) async -> Bool +public struct AuthorizationClient: Sendable { + public let passcodeNotSet: @Sendable () -> Bool + public let localAuthroize: @Sendable (String) async -> Bool } extension AuthorizationClient { - static let live: Self = .init( + public static let live: Self = .init( passcodeNotSet: { var error: NSError? return !LAContext().canEvaluatePolicy(.deviceOwnerAuthentication, error: &error) @@ -36,14 +31,14 @@ extension AuthorizationClient { } // MARK: API -enum AuthorizationClientKey: DependencyKey { - static let liveValue = AuthorizationClient.live - static let previewValue = AuthorizationClient.noop - static let testValue = AuthorizationClient.unimplemented +public enum AuthorizationClientKey: DependencyKey { + public static let liveValue = AuthorizationClient.live + public static let previewValue = AuthorizationClient.noop + public static let testValue = AuthorizationClient.unimplemented } extension DependencyValues { - var authorizationClient: AuthorizationClient { + public var authorizationClient: AuthorizationClient { get { self[AuthorizationClientKey.self] } set { self[AuthorizationClientKey.self] = newValue } } @@ -51,14 +46,14 @@ extension DependencyValues { // MARK: Test extension AuthorizationClient { - static let noop: Self = .init( + public static let noop: Self = .init( passcodeNotSet: { false }, localAuthroize: { _ in false } ) - static func placeholder() -> Result { fatalError() } + public static func placeholder() -> Result { fatalError() } - static let unimplemented: Self = .init( + public static let unimplemented: Self = .init( passcodeNotSet: IssueReporting.unimplemented(placeholder: placeholder()), localAuthroize: IssueReporting.unimplemented(placeholder: placeholder()) ) diff --git a/AppPackage/Sources/BackgroundProcessingClient/.swiftlint.yml b/AppPackage/Sources/BackgroundProcessingClient/.swiftlint.yml new file mode 100644 index 000000000..1242ffcaa --- /dev/null +++ b/AppPackage/Sources/BackgroundProcessingClient/.swiftlint.yml @@ -0,0 +1 @@ +parent_config: ../../../.swiftlint.yml diff --git a/AppPackage/Sources/BackgroundProcessingClient/BackgroundProcessingClient.swift b/AppPackage/Sources/BackgroundProcessingClient/BackgroundProcessingClient.swift new file mode 100644 index 000000000..2d53f4e7f --- /dev/null +++ b/AppPackage/Sources/BackgroundProcessingClient/BackgroundProcessingClient.swift @@ -0,0 +1,89 @@ +import OSLogExt +import BackgroundTasks +import AppModels +import ComposableArchitecture + +private let logger = Logger(category: .init(describing: BackgroundProcessingClient.self)) + +public enum BackgroundProcessing { + /// Fixed task identifier, independent of the bundle id. Must stay in sync with the + /// `BGTaskSchedulerPermittedIdentifiers` entry in Info.plist. + public static let downloadTaskIdentifier = "app.ehpanda.downloads.processing" +} + +/// Wraps `BGTaskScheduler` so the app can ask iOS to relaunch it in a discretionary, +/// multi-minute background window to drain the download queue after the foreground +/// grace period ends. Unlike `BackgroundTaskClient`, this is resolved through +/// `DependencyValues` because both the AppDelegate (registration) and `AppReducer` +/// (scheduling) need it. +@DependencyClient +public struct BackgroundProcessingClient: Sendable { + /// Registers the launch handler for the download processing task. Must be called + /// before the app finishes launching. + public var register: @MainActor @Sendable (@escaping @MainActor @Sendable (BGProcessingTask) -> Void) -> Void + /// Submits a processing-task request. Best-effort and fire-and-forget: the system may + /// refuse it (Background App Refresh disabled, identifier not permitted), which the + /// live implementation logs and tolerates. + public var schedule: @Sendable () -> Void + /// Cancels any pending download processing-task request. + public var cancel: @Sendable () -> Void +} + +extension BackgroundProcessingClient { + public static let live = Self( + register: { handler in + _ = BGTaskScheduler.shared.register( + forTaskWithIdentifier: BackgroundProcessing.downloadTaskIdentifier, + using: .main + ) { task in + guard let processingTask = task as? BGProcessingTask else { + task.setTaskCompleted(success: false) + return + } + handler(processingTask) + } + }, + schedule: { + let request = BGProcessingTaskRequest( + identifier: BackgroundProcessing.downloadTaskIdentifier + ) + request.requiresNetworkConnectivity = true + request.requiresExternalPower = false + request.earliestBeginDate = nil + do { + try BGTaskScheduler.shared.submit(request) + logger.notice("Scheduled background processing task.") + } catch { + logger.error("\(error, privacy: .public)") + } + }, + cancel: { + BGTaskScheduler.shared.cancel( + taskRequestWithIdentifier: BackgroundProcessing.downloadTaskIdentifier + ) + } + ) +} + +// MARK: API +public enum BackgroundProcessingClientKey: DependencyKey { + public static let liveValue = BackgroundProcessingClient.live + public static let previewValue = BackgroundProcessingClient.noop + public static let testValue = BackgroundProcessingClient() +} + +extension DependencyValues { + public var backgroundProcessingClient: BackgroundProcessingClient { + get { self[BackgroundProcessingClientKey.self] } + set { self[BackgroundProcessingClientKey.self] = newValue } + } +} + +// MARK: Test +extension BackgroundProcessingClient { + public static let noop = Self( + register: { _ in }, + schedule: {}, + cancel: {} + ) +} diff --git a/AppPackage/Sources/BackgroundProcessingClient/Logger+.swift b/AppPackage/Sources/BackgroundProcessingClient/Logger+.swift new file mode 100644 index 000000000..1941618f1 --- /dev/null +++ b/AppPackage/Sources/BackgroundProcessingClient/Logger+.swift @@ -0,0 +1,7 @@ +import OSLogExt + +extension Logger { + init(category: String) { + self.init(moduleName: "BackgroundProcessingClient", category: category) + } +} diff --git a/AppPackage/Sources/ClipboardClient/.swiftlint.yml b/AppPackage/Sources/ClipboardClient/.swiftlint.yml new file mode 100644 index 000000000..1242ffcaa --- /dev/null +++ b/AppPackage/Sources/ClipboardClient/.swiftlint.yml @@ -0,0 +1 @@ +parent_config: ../../../.swiftlint.yml diff --git a/AppPackage/Sources/ClipboardClient/ClipboardClient.swift b/AppPackage/Sources/ClipboardClient/ClipboardClient.swift new file mode 100644 index 000000000..420c6a8af --- /dev/null +++ b/AppPackage/Sources/ClipboardClient/ClipboardClient.swift @@ -0,0 +1,89 @@ +import SwiftUI +import ComposableArchitecture +import AnimatedImageFeature + +public struct ClipboardClient: Sendable { + public let url: @Sendable () -> URL? + public let changeCount: @Sendable () -> Int + public let saveText: @Sendable (String) -> Void + public let saveImage: @Sendable (UIImage, Bool) -> Void + public let saveImageData: @Sendable (Data) -> Bool +} + +extension ClipboardClient { + public 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: { text in + UIPasteboard.general.string = text + }, + saveImage: { (image, isAnimated) in + if isAnimated { + DispatchQueue.global(qos: .utility).async { + if let data = image.animatedSourceData, + let pasteboardType = data.animatedImagePasteboardType { + UIPasteboard.general.setData(data, forPasteboardType: pasteboardType) + } else { + UIPasteboard.general.image = image + } + } + } else { + UIPasteboard.general.image = image + } + }, + saveImageData: { data in + if let pasteboardType = data.animatedImagePasteboardType { + UIPasteboard.general.setData(data, forPasteboardType: pasteboardType) + return true + } + guard let image = data.decodedImage else { + return false + } + UIPasteboard.general.image = image + return true + } + ) +} + +// MARK: API +public enum ClipboardClientKey: DependencyKey { + public static let liveValue = ClipboardClient.live + public static let previewValue = ClipboardClient.noop + public static let testValue = ClipboardClient.unimplemented +} + +extension DependencyValues { + public var clipboardClient: ClipboardClient { + get { self[ClipboardClientKey.self] } + set { self[ClipboardClientKey.self] = newValue } + } +} + +// MARK: Test +extension ClipboardClient { + public static let noop: Self = .init( + url: { nil }, + changeCount: { 0 }, + saveText: { _ in }, + saveImage: { _, _ in }, + saveImageData: { _ in false } + ) + + public static func placeholder() -> Result { fatalError() } + + public static let unimplemented: Self = .init( + url: IssueReporting.unimplemented(placeholder: placeholder()), + changeCount: IssueReporting.unimplemented(placeholder: placeholder()), + saveText: IssueReporting.unimplemented(placeholder: placeholder()), + saveImage: IssueReporting.unimplemented(placeholder: placeholder()), + saveImageData: IssueReporting.unimplemented(placeholder: placeholder()) + ) +} diff --git a/AppPackage/Sources/CommonMarkExt/.swiftlint.yml b/AppPackage/Sources/CommonMarkExt/.swiftlint.yml new file mode 100644 index 000000000..1242ffcaa --- /dev/null +++ b/AppPackage/Sources/CommonMarkExt/.swiftlint.yml @@ -0,0 +1 @@ +parent_config: ../../../.swiftlint.yml diff --git a/EhPanda/App/Tools/Utilities/MarkdownUtil.swift b/AppPackage/Sources/CommonMarkExt/MarkdownUtil.swift similarity index 83% rename from EhPanda/App/Tools/Utilities/MarkdownUtil.swift rename to AppPackage/Sources/CommonMarkExt/MarkdownUtil.swift index 03c5e4ddf..2be748bec 100644 --- a/EhPanda/App/Tools/Utilities/MarkdownUtil.swift +++ b/AppPackage/Sources/CommonMarkExt/MarkdownUtil.swift @@ -1,42 +1,46 @@ -// -// MarkdownUtil.swift -// EhPanda -// - import CasePaths import CommonMark import Foundation -struct MarkdownUtil { - static func parseTexts(markdown: String) -> [String] { +public struct MarkdownUtil { + public static func parseTexts(markdown: String) -> [String] { (try? Document(markdown: markdown))?.blocks .compactMap({ $0[case: \.paragraph] }) .flatMap(\.text) .compactMap({ $0[case: \.text] }) - ?? [] + ?? [] } - static func parseLinks(markdown: String) -> [URL] { + public static func parseLinks(markdown: String) -> [URL] { (try? Document(markdown: markdown))?.blocks .compactMap({ $0[case: \.paragraph] }) .flatMap(\.text) .compactMap({ $0[case: \.link] }) .compactMap(\.url) - ?? [] + ?? [] } - static func parseImages(markdown: String) -> [URL] { + public static func parseImages(markdown: String) -> [URL] { (try? Document(markdown: markdown))?.blocks .compactMap({ $0[case: \.paragraph] }) .flatMap(\.text) .compactMap({ $0[case: \.image] }) .compactMap { image in - if image.url?.absoluteString.isValidURL == true { + if let absoluteString = image.url?.absoluteString, isValidURL(absoluteString) { return image.url - } else if let title = image.title, title.isValidURL { + } else if let title = image.title, isValidURL(title) { return .init(string: title) } return nil } - ?? [] + ?? [] + } + + private static func isValidURL(_ string: String) -> Bool { + guard let detector = try? NSDataDetector( + types: NSTextCheckingResult.CheckingType.link.rawValue + ), let match = detector.firstMatch( + in: string, options: [], range: NSRange(location: 0, length: string.utf16.count) + ) else { return false } + return match.range.length == string.utf16.count } } @@ -93,13 +97,13 @@ extension Block.AllCasePaths: Sequence { public func makeIterator() -> some IteratorProtocol> { [ \.blockQuote, - \.bulletList, - \.orderedList, - \.code, - \.html, - \.paragraph, - \.heading, - \.thematicBreak + \.bulletList, + \.orderedList, + \.code, + \.html, + \.paragraph, + \.heading, + \.thematicBreak ] .makeIterator() } @@ -161,14 +165,14 @@ extension Inline.AllCasePaths: Sequence { public func makeIterator() -> some IteratorProtocol> { [ \.text, - \.softBreak, - \.lineBreak, - \.code, - \.html, - \.emphasis, - \.strong, - \.link, - \.image + \.softBreak, + \.lineBreak, + \.code, + \.html, + \.emphasis, + \.strong, + \.link, + \.image ] .makeIterator() } diff --git a/AppPackage/Sources/CookieClient/.swiftlint.yml b/AppPackage/Sources/CookieClient/.swiftlint.yml new file mode 100644 index 000000000..1242ffcaa --- /dev/null +++ b/AppPackage/Sources/CookieClient/.swiftlint.yml @@ -0,0 +1 @@ +parent_config: ../../../.swiftlint.yml diff --git a/AppPackage/Sources/CookieClient/CookieClient.swift b/AppPackage/Sources/CookieClient/CookieClient.swift new file mode 100644 index 000000000..a72d255f1 --- /dev/null +++ b/AppPackage/Sources/CookieClient/CookieClient.swift @@ -0,0 +1,542 @@ +import Foundation +import AppModels +import ComposableArchitecture +import AppTools +#if DEBUG +import Synchronization +#endif + +public struct CookieClient: Sendable { + public let clearAll: @Sendable () -> Void + public let getCookie: @Sendable (URL, String) -> CookieValue + private let cookiesForURL: @Sendable (URL) -> [HTTPCookie] + private let removeCookie: @Sendable (URL, String) -> Void + private let checkExistence: @Sendable (URL, String) -> Bool + private let initializeCookie: @Sendable (HTTPCookie, String) -> HTTPCookie + private let storeCookie: @Sendable (HTTPCookie) -> Void + private let setCookieValue: @Sendable (URL, String, String, String, TimeInterval, Bool) -> Void +} + +extension CookieClient { + public static let live: Self = live(cookieStorage: .shared) + + public static func live(cookieStorage: HTTPCookieStorage) -> Self { + .init( + clearAll: { + if let historyCookies = cookieStorage.cookies { + historyCookies.forEach { + cookieStorage.deleteCookie($0) + } + } + }, + getCookie: { url, key in + var value = CookieValue( + rawValue: "", localizedString: String(localized: .cookieValueNone) + ) + guard let cookies = cookieStorage.cookies(for: url), !cookies.isEmpty else { return value } + + cookies.forEach { cookie in + guard cookie.name == key && !cookie.value.isEmpty else { return } + if let expiresDate = cookie.expiresDate, + expiresDate <= .now { + value = CookieValue( + rawValue: "", + localizedString: String(localized: .cookieValueExpired) + ) + return + } + guard cookie.value != Defaults.Cookie.mystery else { + value = CookieValue( + rawValue: cookie.value, localizedString: + String(localized: .cookieValueMystery) + ) + return + } + value = CookieValue(rawValue: cookie.value, localizedString: "") + } + + return value + }, + cookiesForURL: { url in + cookieStorage.cookies(for: url) ?? [] + }, + removeCookie: { url, key in + if let cookies = cookieStorage.cookies(for: url) { + cookies.forEach { cookie in + guard cookie.name == key else { return } + cookieStorage.deleteCookie(cookie) + } + } + }, + checkExistence: { url, key in + if let cookies = cookieStorage.cookies(for: url) { + var existence: HTTPCookie? + cookies.forEach { cookie in + guard cookie.name == key else { return } + existence = cookie + } + return existence != nil + } else { + return false + } + }, + initializeCookie: { cookie, value in + var properties = cookie.properties + properties?[.value] = value + return HTTPCookie(properties: properties ?? [:]) ?? HTTPCookie() + }, + storeCookie: { cookie in + cookieStorage.setCookie(cookie) + }, + setCookieValue: { url, key, value, path, expiresTime, sessionOnly in + let properties: [HTTPCookiePropertyKey: Any] = [ + .path: path, .name: key, .value: value, + .originURL: url + ] + var mutableProperties = properties + if let host = url.host { + mutableProperties[.domain] = host + } + if sessionOnly { + mutableProperties[.discard] = "TRUE" + } else { + mutableProperties[.expires] = Date(timeIntervalSinceNow: expiresTime) + } + if let cookie = HTTPCookie(properties: mutableProperties) { + cookieStorage.setCookie(cookie) + } + } + ) + } +} + +// MARK: Foundation +extension CookieClient { + public func importAutomationCookies(memberID: String, passHash: String, igneous: String?) { + let urls = [Defaults.URL.ehentai, Defaults.URL.exhentai, Defaults.URL.sexhentai] + let authKeys = [Defaults.Cookie.ipbMemberId, Defaults.Cookie.ipbPassHash] + + urls.forEach { url in + authKeys.forEach { key in + removeCookie(url, key) + } + } + [Defaults.URL.exhentai, Defaults.URL.sexhentai].forEach { url in + removeCookie(url, Defaults.Cookie.igneous) + } + + urls.forEach { url in + setCookie( + for: url, + key: Defaults.Cookie.ipbMemberId, + value: memberID, + sessionOnly: true + ) + setCookie( + for: url, + key: Defaults.Cookie.ipbPassHash, + value: passHash, + sessionOnly: true + ) + } + + if let igneous, !igneous.isEmpty { + [Defaults.URL.exhentai, Defaults.URL.sexhentai].forEach { url in + setCookie( + for: url, + key: Defaults.Cookie.igneous, + value: igneous, + sessionOnly: true + ) + } + } + + ignoreOffensive() + fulfillAnotherHostField() + } + + private func setCookie( + for url: URL, key: String, value: String, path: String = "/", + expiresTime: TimeInterval = .oneYear, + sessionOnly: Bool = false + ) { + setCookieValue(url, key, value, path, expiresTime, sessionOnly) + } + public func editCookie(for url: URL, key: String, value: String) { + var newCookie: HTTPCookie? + cookiesForURL(url).forEach { cookie in + guard cookie.name == key else { return } + newCookie = initializeCookie(cookie, value) + removeCookie(url, key) + } + guard let cookie = newCookie else { return } + storeCookie(cookie) + } + public func setOrEditCookie(for url: URL, key: String, value: String) { + if checkExistence(url, key) { + editCookie(for: url, key: key, value: value) + } else { + setCookie(for: url, key: key, value: value) + } + } + public func cookies(for url: URL) -> [HTTPCookie] { + cookiesForURL(url) + } +} + +// MARK: Accessor +extension CookieClient { + public var didLogin: Bool { + let ehHasAuth = !getCookie(Defaults.URL.ehentai, Defaults.Cookie.ipbMemberId).rawValue.isEmpty + && !getCookie(Defaults.URL.ehentai, Defaults.Cookie.ipbPassHash).rawValue.isEmpty + let exIgneous = getCookie(Defaults.URL.exhentai, Defaults.Cookie.igneous).rawValue + let exHasAuth = !getCookie(Defaults.URL.exhentai, Defaults.Cookie.ipbMemberId).rawValue.isEmpty + && !getCookie(Defaults.URL.exhentai, Defaults.Cookie.ipbPassHash).rawValue.isEmpty + && !exIgneous.isEmpty + && exIgneous != Defaults.Cookie.mystery + return ehHasAuth || exHasAuth + } + public var apiuid: String { + getCookie(Defaults.URL.host, Defaults.Cookie.ipbMemberId).rawValue + } + public var isSameAccount: Bool { + let ehUID = getCookie(Defaults.URL.ehentai, Defaults.Cookie.ipbMemberId).rawValue + let exUID = getCookie(Defaults.URL.exhentai, Defaults.Cookie.ipbMemberId).rawValue + if !ehUID.isEmpty && !exUID.isEmpty { return ehUID == exUID } else { return false } + } + public var shouldFetchIgneous: Bool { + let url = Defaults.URL.exhentai + return !getCookie(url, Defaults.Cookie.ipbMemberId).rawValue.isEmpty + && !getCookie(url, Defaults.Cookie.ipbPassHash).rawValue.isEmpty + && getCookie(url, Defaults.Cookie.igneous).rawValue.isEmpty + } + public func removeYay() { + removeCookie(Defaults.URL.exhentai, Defaults.Cookie.yay) + removeCookie(Defaults.URL.sexhentai, Defaults.Cookie.yay) + } + public func syncExCookies() { + let cookies = [ + Defaults.Cookie.ipbMemberId, + Defaults.Cookie.ipbPassHash, + Defaults.Cookie.igneous + ] + for cookie in cookies { + setOrEditCookie( + for: Defaults.URL.sexhentai, + key: cookie, + value: getCookie(Defaults.URL.exhentai, cookie).rawValue + ) + } + } + public func ignoreOffensive() { + setOrEditCookie(for: Defaults.URL.ehentai, key: Defaults.Cookie.ignoreOffensive, value: "1") + setOrEditCookie(for: Defaults.URL.exhentai, key: Defaults.Cookie.ignoreOffensive, value: "1") + } + public func fulfillAnotherHostField() { + 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) { + setOrEditCookie(for: exURL, key: memberIdKey, value: ehMemberId) + setOrEditCookie(for: exURL, key: passHashKey, value: ehPassHash) + } else if !exMemberId.isEmpty && !exPassHash.isEmpty && (ehMemberId.isEmpty || ehPassHash.isEmpty) { + setOrEditCookie(for: ehURL, key: memberIdKey, value: exMemberId) + setOrEditCookie(for: ehURL, key: passHashKey, value: exPassHash) + } + } + public 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) + ) + } + public func getCookiesDescription(host: GalleryHost) -> String { + var dictionary = [String: String]() + [Defaults.Cookie.igneous, Defaults.Cookie.ipbMemberId, Defaults.Cookie.ipbPassHash].forEach { key in + let cookieValue = getCookie(host.url, key) + if !cookieValue.rawValue.isEmpty { + dictionary[key] = cookieValue.rawValue + } + } + return dictionary.description + } +} + +// MARK: SetCookies +extension CookieClient { + public func setCookies(state: CookiesState, trimsSpaces: Bool = true) { + for subState in state.allCases { + for cookie in state.host.cookieURLs { + setOrEditCookie( + for: cookie, + key: subState.key, + value: trimsSpaces + ? subState.editingText .trimmingCharacters(in: .whitespaces) : subState.editingText + ) + } + } + + } + public func setCredentials(response: HTTPURLResponse) { + guard let setString = response.allHeaderFields["Set-Cookie"] as? String else { return } + setString.components(separatedBy: ", ") + .flatMap { $0.components(separatedBy: "; ") }.forEach { value in + [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...])) + } + } + } + } + public func setSkipServer(response: HTTPURLResponse) { + 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/" + ) + } + } + } +} + +// MARK: API +public enum CookieClientKey: DependencyKey { + public static let liveValue = CookieClient.live + public static let previewValue = CookieClient.noop + public static let testValue = CookieClient.unimplemented +} + +extension DependencyValues { + public var cookieClient: CookieClient { + get { self[CookieClientKey.self] } + set { self[CookieClientKey.self] = newValue } + } +} + +// MARK: Test +extension CookieClient { + public static let noop: Self = .init( + clearAll: {}, + getCookie: { _, _ in .empty }, + cookiesForURL: { _ in [] }, + removeCookie: { _, _ in }, + checkExistence: { _, _ in false }, + initializeCookie: { _, _ in .init() }, + storeCookie: { _ in }, + setCookieValue: { _, _, _, _, _, _ in } + ) + + public static func placeholder() -> Result { fatalError() } + + public static let unimplemented: Self = .init( + clearAll: IssueReporting.unimplemented(placeholder: placeholder()), + getCookie: IssueReporting.unimplemented(placeholder: placeholder()), + cookiesForURL: IssueReporting.unimplemented(placeholder: placeholder()), + removeCookie: IssueReporting.unimplemented(placeholder: placeholder()), + checkExistence: IssueReporting.unimplemented(placeholder: placeholder()), + initializeCookie: IssueReporting.unimplemented(placeholder: placeholder()), + storeCookie: IssueReporting.unimplemented(placeholder: placeholder()), + setCookieValue: IssueReporting.unimplemented(placeholder: placeholder()) + ) +} + +#if DEBUG +private struct CookieClientTestingCookie: Sendable { + public var domain: String + public var path: String + public var name: String + public var value: String + public var expiresDate: Date? + public var isSessionOnly: Bool + + public func matches(url: URL, key: String? = nil) -> Bool { + guard let host = url.host?.lowercased() else { return false } + // Host-exact: the store only ever holds concrete-host cookies (never a wildcard `.domain` + // one), so a subdomain query such as `s.exhentai.org` must NOT match an `exhentai.org` + // cookie. A suffix match let a `syncExCookies` write to one host collaterally delete a + // sibling host's login cookies (via `editCookie`'s remove-all-matching), nondeterministically. + let normalizedDomain = domain.lowercased() + .trimmingCharacters(in: CharacterSet(charactersIn: ".")) + let domainMatches = host == normalizedDomain + let keyMatches = key.map { name == $0 } ?? true + return domainMatches && keyMatches + } + + public func httpCookie() -> HTTPCookie? { + var properties: [HTTPCookiePropertyKey: Any] = [ + .domain: domain, + .path: path, + .name: name, + .value: value + ] + if isSessionOnly { + properties[.discard] = "TRUE" + } else if let expiresDate { + properties[.expires] = expiresDate + } + return HTTPCookie(properties: properties) + } +} + +private final class CookieClientTestingStore: Sendable { + private let cookies: Mutex<[String: CookieClientTestingCookie]> + + public init(cookies: [String: CookieClientTestingCookie]) { + self.cookies = Mutex(cookies) + } + + public func value(for url: URL, key: String) -> String { + cookie(for: url, key: key)?.value ?? "" + } + + public func setValue( + _ value: String, + for url: URL, + key: String, + path: String = "/", + expiresTime: TimeInterval = .oneYear, + sessionOnly: Bool = false + ) { + guard let domain = url.host else { return } + let cookie = CookieClientTestingCookie( + domain: domain, + path: path, + name: key, + value: value, + expiresDate: sessionOnly ? nil : Date(timeIntervalSinceNow: expiresTime), + isSessionOnly: sessionOnly + ) + cookies.withLock { $0[storageKey(domain: domain, key: key)] = cookie } + } + + public func removeValue(for url: URL, key: String) { + cookies.withLock { storage in + storage = storage.filter { !$0.value.matches(url: url, key: key) } + } + } + + public func containsValue(for url: URL, key: String) -> Bool { + cookie(for: url, key: key) != nil + } + + public func cookies(for url: URL) -> [HTTPCookie] { + cookies.withLock { storage in + storage.values + .filter { $0.matches(url: url) } + .compactMap { $0.httpCookie() } + } + } + + public func store(_ cookie: HTTPCookie) { + let testingCookie = CookieClientTestingCookie( + domain: cookie.domain, + path: cookie.path, + name: cookie.name, + value: cookie.value, + expiresDate: cookie.expiresDate, + isSessionOnly: cookie.isSessionOnly + ) + cookies.withLock { + $0[storageKey(domain: cookie.domain, key: cookie.name)] = testingCookie + } + } + + public func removeAll() { + cookies.withLock { $0.removeAll() } + } + + private func cookie(for url: URL, key: String) -> CookieClientTestingCookie? { + cookies.withLock { storage in + storage.values.first { $0.matches(url: url, key: key) } + } + } + + private func storageKey(domain: String, key: String) -> String { + "\(domain.lowercased())|\(key)" + } +} + +extension CookieClient { + public static func testing( + memberID: String = "", + passHash: String = "", + igneous: String? = nil + ) -> Self { + let store = CookieClientTestingStore(cookies: [:]) + for url in [Defaults.URL.ehentai, Defaults.URL.exhentai, Defaults.URL.sexhentai] { + if !memberID.isEmpty { + store.setValue(memberID, for: url, key: Defaults.Cookie.ipbMemberId) + } + if !passHash.isEmpty { + store.setValue(passHash, for: url, key: Defaults.Cookie.ipbPassHash) + } + } + if let igneous, !igneous.isEmpty { + for url in [Defaults.URL.exhentai, Defaults.URL.sexhentai] { + store.setValue(igneous, for: url, key: Defaults.Cookie.igneous) + } + } + + return .init( + clearAll: { + store.removeAll() + }, + getCookie: { url, key in + .init(rawValue: store.value(for: url, key: key), localizedString: "") + }, + cookiesForURL: { url in + store.cookies(for: url) + }, + removeCookie: { url, key in + store.removeValue(for: url, key: key) + }, + checkExistence: { url, key in + store.containsValue(for: url, key: key) + }, + initializeCookie: { cookie, value in + var properties = cookie.properties + properties?[.value] = value + return HTTPCookie(properties: properties ?? [:]) ?? HTTPCookie() + }, + storeCookie: { cookie in + store.store(cookie) + }, + setCookieValue: { url, key, value, path, expiresTime, sessionOnly in + store.setValue( + value, + for: url, + key: key, + path: path, + expiresTime: expiresTime, + sessionOnly: sessionOnly + ) + } + ) + } +} +#endif diff --git a/AppPackage/Sources/CookieClient/Resources/Localizable.xcstrings b/AppPackage/Sources/CookieClient/Resources/Localizable.xcstrings new file mode 100644 index 000000000..52e0f4815 --- /dev/null +++ b/AppPackage/Sources/CookieClient/Resources/Localizable.xcstrings @@ -0,0 +1,129 @@ +{ + "sourceLanguage": "en", + "strings": { + "cookie_value.expired": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Expired" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Abgelaufen" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "期限切れ" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "만료됨" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "已过期" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "已過期" + } + } + } + }, + "cookie_value.mystery": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Rejected" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Abgelehnt" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "拒否" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "거절됨" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "被拒绝" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "被拒絕" + } + } + } + }, + "cookie_value.none": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "None" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Nicht vorhanden" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "なし" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "없음" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "无内容" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "None" + } + } + } + } + }, + "version": "1.0" +} \ No newline at end of file diff --git a/AppPackage/Sources/DFClient/.swiftlint.yml b/AppPackage/Sources/DFClient/.swiftlint.yml new file mode 100644 index 000000000..1242ffcaa --- /dev/null +++ b/AppPackage/Sources/DFClient/.swiftlint.yml @@ -0,0 +1 @@ +parent_config: ../../../.swiftlint.yml diff --git a/EhPanda/App/Tools/Clients/DFClient.swift b/AppPackage/Sources/DFClient/DFClient.swift similarity index 61% rename from EhPanda/App/Tools/Clients/DFClient.swift rename to AppPackage/Sources/DFClient/DFClient.swift index c74e2c33d..ff384207a 100644 --- a/EhPanda/App/Tools/Clients/DFClient.swift +++ b/AppPackage/Sources/DFClient/DFClient.swift @@ -1,18 +1,14 @@ -// -// DFClient.swift -// EhPanda -// - import Foundation import Kingfisher import ComposableArchitecture +import NetworkingFeature -struct DFClient { - let setActive: (Bool) -> Void +public struct DFClient: Sendable { + public let setActive: @Sendable (Bool) -> Void } extension DFClient { - static let live: Self = .init( + public static let live: Self = .init( setActive: { newValue in if newValue { URLProtocol.registerClass(DFURLProtocol.self) @@ -28,14 +24,14 @@ extension DFClient { } // MARK: API -enum DFClientKey: DependencyKey { - static let liveValue = DFClient.live - static let previewValue = DFClient.noop - static let testValue = DFClient.unimplemented +public enum DFClientKey: DependencyKey { + public static let liveValue = DFClient.live + public static let previewValue = DFClient.noop + public static let testValue = DFClient.unimplemented } extension DependencyValues { - var dfClient: DFClient { + public var dfClient: DFClient { get { self[DFClientKey.self] } set { self[DFClientKey.self] = newValue } } @@ -43,13 +39,13 @@ extension DependencyValues { // MARK: Test extension DFClient { - static let noop: Self = .init( + public static let noop: Self = .init( setActive: { _ in } ) - static func placeholder() -> Result { fatalError() } + public static func placeholder() -> Result { fatalError() } - static let unimplemented: Self = .init( + public static let unimplemented: Self = .init( setActive: IssueReporting.unimplemented(placeholder: placeholder()) ) } diff --git a/AppPackage/Sources/DateSeekFeature/.swiftlint.yml b/AppPackage/Sources/DateSeekFeature/.swiftlint.yml new file mode 100644 index 000000000..1242ffcaa --- /dev/null +++ b/AppPackage/Sources/DateSeekFeature/.swiftlint.yml @@ -0,0 +1 @@ +parent_config: ../../../.swiftlint.yml diff --git a/AppPackage/Sources/DateSeekFeature/DateSeekPickerView.swift b/AppPackage/Sources/DateSeekFeature/DateSeekPickerView.swift new file mode 100644 index 000000000..64b10ad63 --- /dev/null +++ b/AppPackage/Sources/DateSeekFeature/DateSeekPickerView.swift @@ -0,0 +1,163 @@ +import AppTools +import SFSafeSymbols +import AppModels +import Resources +import SwiftUI + +/// The "Date seek" sheet content: a graphical date picker plus newer/older direction buttons. +/// +/// This is a store-agnostic, reusable component — it is driven entirely by the values passed in, +/// not by a dedicated reducer. Hosts typically wire it to a presented `DateSeekReducer`, but it +/// has no dependency on one. +/// +/// - Precondition: `selectedDate` lies within `navigation.dateRange`. The picker renders the +/// binding as-is and does not clamp it; keeping the date in range is the responsibility of +/// whoever owns the date state (the presented `DateSeekReducer` clamps it in its initializer). +public struct DateSeekPickerView: View { + @Binding var selectedDate: Date + let navigation: DateSeekNavigation + let seekAction: (DateSeekDirection) -> Void + + public init( + selectedDate: Binding, + navigation: DateSeekNavigation, + seekAction: @escaping (DateSeekDirection) -> Void + ) { + _selectedDate = selectedDate + self.navigation = navigation + self.seekAction = seekAction + } + + public var body: some View { + NavigationStack { + Form { + Section { + DatePicker( + .date, + selection: $selectedDate, + in: navigation.dateRange, + displayedComponents: .date + ) + .datePickerStyle(.graphical) + } footer: { + Text(.seekAroundDate) + } + + Section { + let seekOlderButton = + SeekButton( + symbol: .chevronLeftChevronLeftDotted, + title: .seekOlder, + reversedIconTitlePosition: false, + action: { seekAction(.older) } + ) + .disabled(navigation.olderURL == nil) + + let seekNewerButton = + SeekButton( + symbol: .chevronRightDottedChevronRight, + title: .seekNewer, + reversedIconTitlePosition: true, + action: { seekAction(.newer) } + ) + .disabled(navigation.newerURL == nil) + + ViewThatFits(in: .horizontal) { + HStack { + seekOlderButton + Spacer(minLength: 8) + seekNewerButton + } + VStack { + seekOlderButton + .frame(maxWidth: .infinity, alignment: .leading) + + seekNewerButton + .frame(maxWidth: .infinity, alignment: .trailing) + } + } + .listRowBackground(Color.clear) + .listRowInsets(.init()) + } + } + .navigationTitle(.RLocalizable.dateSeek) + .navigationBarTitleDisplayMode(.large) + } + } +} + +private struct SeekButton: View { + let symbol: SFSymbol + let title: LocalizedStringResource + let reversedIconTitlePosition: Bool + let action: () -> Void + + var symbolImage: some View { + Image(systemSymbol: symbol) + } + + var titleLabel: some View { + Text(title) + .font(.subheadline.bold()) + } + + var body: some View { + Button(action: action) { + HStack(spacing: 6) { + if reversedIconTitlePosition { + titleLabel + symbolImage + } else { + symbolImage + titleLabel + } + } + .lineLimit(1) + .padding(.vertical, 4) + .padding(.horizontal, 8) + } + .buttonBorderShape(.buttonBorder) + .buttonStyle(.glass(.clear)) + } +} + +private extension DateSeekNavigation { + /// A navigation spanning a fixed sample range, used only to drive the previews below. + static func preview(_ directions: Directions) -> Self { + .init( + directions: directions, + minimumDate: dateFormatter.date(from: "2007-03-20").forceUnwrapped, + maximumDate: dateFormatter.date(from: "2023-09-08").forceUnwrapped + ) + } +} + +private let previewNewerURL: URL = .init(string: "https://e-hentai.org/?prev=2563984").forceUnwrapped +private let previewOlderURL: URL = .init(string: "https://e-hentai.org/?next=2668517").forceUnwrapped + +#Preview("Both directions") { + @Previewable @State var date: Date = DateSeekNavigation.dateFormatter.date(from: "2015-06-01").forceUnwrapped + DateSeekPickerView( + selectedDate: $date, + navigation: .preview(.both(newer: previewNewerURL, older: previewOlderURL)), + seekAction: { _ in } + ) +} + +#Preview("Newer only") { + @Previewable @State var date: Date = DateSeekNavigation.dateFormatter.date(from: "2015-06-01").forceUnwrapped + DateSeekPickerView( + selectedDate: $date, + navigation: .preview(.newer(previewNewerURL)), + seekAction: { _ in } + ) +} + +#Preview("Older only") { + @Previewable @State var date: Date = DateSeekNavigation.dateFormatter.date(from: "2015-06-01").forceUnwrapped + DateSeekPickerView( + selectedDate: $date, + navigation: .preview(.older(previewOlderURL)), + seekAction: { _ in } + ) +} diff --git a/AppPackage/Sources/DateSeekFeature/DateSeekReducer.swift b/AppPackage/Sources/DateSeekFeature/DateSeekReducer.swift new file mode 100644 index 000000000..c6bdfc32f --- /dev/null +++ b/AppPackage/Sources/DateSeekFeature/DateSeekReducer.swift @@ -0,0 +1,70 @@ +import ComposableArchitecture +import AppModels +import Foundation +import HapticsClient + +/// A headless, reusable feature for the "Date seek" control. +/// +/// Despite the matching name, this reducer is **not** the companion of `DateSeekPickerView`: it +/// owns no view, and the picker owns no reducer. `DateSeekPickerView` is a store-agnostic +/// presentation component, while `DateSeekReducer` is logic-only, designed to be presented — as a +/// `@Presents` destination — by any gallery-list reducer that exposes a `DateSeekNavigation`. +/// +/// It is a self-contained sheet feature: it owns the picker's UI state (selected date, the +/// navigation being seeked), validates and clamps the date, and resolves a seek `URL`. It reports +/// that URL back to its host through `delegate(.performSeek)` and then dismisses itself. The host +/// performs the request and stores the result, because the gallery list and its loading state +/// belong to the host — not to this control. +@Reducer +public struct DateSeekReducer: Sendable { + @ObservableState + public struct State: Equatable, Sendable { + public var date: Date + /// The navigation whose picker is presented. + public var navigation: DateSeekNavigation + + public init(navigation: DateSeekNavigation) { + self.navigation = navigation + self.date = navigation.clampedDate(Date()) + } + } + + public enum Action: BindableAction { + case binding(BindingAction) + case performSeek(DateSeekDirection) + case delegate(Delegate) + } + + @CasePathable + public enum Delegate: Equatable { + case performSeek(URL) + } + + @Dependency(\.hapticsClient) private var hapticsClient + @Dependency(\.dismiss) private var dismiss + + public init() {} + + public var body: some Reducer { + BindingReducer() + + Reduce { state, action in + switch action { + case .binding: + return .none + + case .performSeek(let direction): + guard let url = state.navigation.seekURL(date: state.date, direction: direction) else { + return .run(operation: { _ in await hapticsClient.generateNotificationFeedback(.error) }) + } + return .merge( + .send(.delegate(.performSeek(url))), + .run(operation: { _ in await dismiss() }) + ) + + case .delegate: + return .none + } + } + } +} diff --git a/AppPackage/Sources/DateSeekFeature/Resources/Localizable.xcstrings b/AppPackage/Sources/DateSeekFeature/Resources/Localizable.xcstrings new file mode 100644 index 000000000..54125eefd --- /dev/null +++ b/AppPackage/Sources/DateSeekFeature/Resources/Localizable.xcstrings @@ -0,0 +1,170 @@ +{ + "sourceLanguage": "en", + "strings": { + "date": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Date" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Datum" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "日付" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "날짜" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "日期" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "日期" + } + } + } + }, + "seek_around_date": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Seek to galleries around the selected date." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Galerien rund um das gewählte Datum aufsuchen." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "選択した日付付近のギャラリーへ移動します。" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "선택한 날짜 근처의 갤러리로 이동합니다." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "前往所选日期附近的画廊。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "前往所選日期附近的畫廊。" + } + } + } + }, + "seek_newer": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Newer" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Neuere" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "新しい方" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "새로운 쪽" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "较新" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "較新" + } + } + } + }, + "seek_older": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Older" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Ältere" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "古い方" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "오래된 쪽" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "较旧" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "較舊" + } + } + } + } + }, + "version": "1.0" +} \ No newline at end of file diff --git a/AppPackage/Sources/DetailFeature/.swiftlint.yml b/AppPackage/Sources/DetailFeature/.swiftlint.yml new file mode 100644 index 000000000..1242ffcaa --- /dev/null +++ b/AppPackage/Sources/DetailFeature/.swiftlint.yml @@ -0,0 +1 @@ +parent_config: ../../../.swiftlint.yml diff --git a/EhPanda/View/Detail/Archives/ArchivesReducer.swift b/AppPackage/Sources/DetailFeature/Archives/ArchivesReducer.swift similarity index 62% rename from EhPanda/View/Detail/Archives/ArchivesReducer.swift rename to AppPackage/Sources/DetailFeature/Archives/ArchivesReducer.swift index b98ed509d..ba4d74d3b 100644 --- a/EhPanda/View/Detail/Archives/ArchivesReducer.swift +++ b/AppPackage/Sources/DetailFeature/Archives/ArchivesReducer.swift @@ -1,56 +1,50 @@ -// -// ArchivesReducer.swift -// EhPanda -// - import Foundation -import TTProgressHUD +import AppModels +import Sharing +import Resources import ComposableArchitecture +import AppTools +import HapticsClient +import NetworkingFeature +import CookieClient +import AppComponents @Reducer -struct ArchivesReducer { - @CasePathable - enum Route { - case messageHUD - case communicatingHUD - } - - private enum CancelID: CaseIterable { +public struct ArchivesReducer: Sendable { + private enum CancelID { case fetchArchive, fetchArchiveFunds, fetchDownloadResponse } @ObservableState - struct State: Equatable { - var route: Route? - var selectedArchive: GalleryArchive.HathArchive? + public struct State: Equatable { + @Shared(.user) public var user: User + @Presents public var toast: AppAlertState? + public var selectedArchive: GalleryArchive.HathArchive? - var loadingState: LoadingState = .idle - var hathArchives = [GalleryArchive.HathArchive]() - - var messageHUDConfig = TTProgressHUDConfig() - var communicatingHUDConfig: TTProgressHUDConfig = .communicating + public var loadingState: LoadingState = .idle + public var hathArchives = [GalleryArchive.HathArchive]() } - enum Action: BindableAction { + public enum Action: BindableAction { case binding(BindingAction) - case setNavigation(Route?) + case toast(PresentationAction) case syncGalleryFunds(String, String) - case teardown case fetchArchive(String, URL, URL) - case fetchArchiveDone(String, URL, Result<(GalleryArchive, String?, String?), AppError>) + case fetchArchiveDone(String, URL, Result) case fetchArchiveFunds(String, URL) case fetchArchiveFundsDone(Result<(String, String), AppError>) case fetchDownloadResponse(URL) case fetchDownloadResponseDone(Result) } - @Dependency(\.databaseClient) private var databaseClient @Dependency(\.hapticsClient) private var hapticsClient @Dependency(\.cookieClient) private var cookieClient - var body: some Reducer { + public init() {} + + public var body: some Reducer { BindingReducer() Reduce { state, action in @@ -58,17 +52,15 @@ struct ArchivesReducer { case .binding: return .none - case .setNavigation(let route): - state.route = route + case .toast: return .none case .syncGalleryFunds(let galleryPoints, let credits): - return .run { _ in - await databaseClient.updateGalleryFunds(galleryPoints: galleryPoints, credits: credits) + state.$user.withLock { + $0.galleryPoints = galleryPoints + $0.credits = credits } - - case .teardown: - return .merge(CancelID.allCases.map(Effect.cancel(id:))) + return .none case .fetchArchive(let gid, let galleryURL, let archiveURL): guard state.loadingState != .loading else { return .none } @@ -82,13 +74,13 @@ struct ArchivesReducer { case .fetchArchiveDone(let gid, let galleryURL, let result): state.loadingState = .idle switch result { - case .success(let (archive, galleryPoints, credits)): - guard !archive.hathArchives.isEmpty else { + case .success(let response): + guard !response.archive.hathArchives.isEmpty else { state.loadingState = .failed(.notFound) return .none } - state.hathArchives = archive.hathArchives - if let galleryPoints = galleryPoints, let credits = credits { + state.hathArchives = response.archive.hathArchives + if let galleryPoints = response.galleryPoints, let credits = response.credits { return .send(.syncGalleryFunds(galleryPoints, credits)) } else if cookieClient.isSameAccount { return .send(.fetchArchiveFunds(gid, galleryURL)) @@ -116,9 +108,9 @@ struct ArchivesReducer { case .fetchDownloadResponse(let archiveURL): guard let selectedArchive = state.selectedArchive, - state.route != .communicatingHUD + state.toast != .communicating else { return .none } - state.route = .communicatingHUD + state.toast = .communicating return .run {send in let response = await SendDownloadCommandRequest( archiveURL: archiveURL, @@ -130,32 +122,32 @@ struct ArchivesReducer { .cancellable(id: CancelID.fetchDownloadResponse) case .fetchDownloadResponseDone(let result): - state.route = .messageHUD let isSuccess: Bool switch result { case .success(let response): switch response { - case L10n.Constant.Website.Response.hathClientNotFound: - state.messageHUDConfig = .error(caption: L10n.Localizable.Website.Response.hathClientNotFound) + case String(localized: .Constant.responseHathClientNotFound): + state.toast = .error(caption: .hathClientNotFound) isSuccess = false - case L10n.Constant.Website.Response.hathClientNotOnline: - state.messageHUDConfig = .error(caption: L10n.Localizable.Website.Response.hathClientNotOnline) + case String(localized: .Constant.responseHathClientNotOnline): + state.toast = .error(caption: .hathClientNotOnline) isSuccess = false - case L10n.Constant.Website.Response.invalidResolution: - state.messageHUDConfig = .error(caption: L10n.Localizable.Website.Response.invalidResolution) + case String(localized: .Constant.responseInvalidResolution): + state.toast = .error(caption: .invalidResolution) isSuccess = false default: - state.messageHUDConfig = .success(caption: response) + state.toast = .success(caption: response) isSuccess = true } case .failure: - state.messageHUDConfig = .error + state.toast = .error() isSuccess = false } return .run { _ in - hapticsClient.generateNotificationFeedback(isSuccess ? .success : .error) + await hapticsClient.generateNotificationFeedback(isSuccess ? .success : .error) } } } + .ifLet(\.$toast, action: \.toast) } } diff --git a/EhPanda/View/Detail/Archives/ArchivesView.swift b/AppPackage/Sources/DetailFeature/Archives/ArchivesView.swift similarity index 84% rename from EhPanda/View/Detail/Archives/ArchivesView.swift rename to AppPackage/Sources/DetailFeature/Archives/ArchivesView.swift index 828e681bf..af504a34e 100644 --- a/EhPanda/View/Detail/Archives/ArchivesView.swift +++ b/AppPackage/Sources/DetailFeature/Archives/ArchivesView.swift @@ -1,39 +1,38 @@ -// -// ArchivesView.swift -// EhPanda -// - import SwiftUI +import AppModels +import Resources import ComposableArchitecture +import AppTools +import SystemNotificationExt +import AppComponents struct ArchivesView: View { @Bindable private var store: StoreOf private let gid: String - private let user: User private let galleryURL: URL private let archiveURL: URL init( store: StoreOf, - gid: String, user: User, galleryURL: URL, archiveURL: URL + gid: String, galleryURL: URL, archiveURL: URL ) { self.store = store self.gid = gid - self.user = user self.galleryURL = galleryURL self.archiveURL = archiveURL } // MARK: ArchiveView var body: some View { - NavigationView { + NavigationStack { ZStack { VStack { HathArchivesView(archives: store.hathArchives, selection: $store.selectedArchive) Spacer() - if let credits = Int(user.credits ?? ""), let galleryPoints = Int(user.galleryPoints ?? "") { + if let credits = Int(store.user.credits ?? ""), + let galleryPoints = Int(store.user.galleryPoints ?? "") { ArchiveFundsView(credits: credits, galleryPoints: galleryPoints) } @@ -47,7 +46,7 @@ struct ArchivesView: View { LoadingView() .opacity( store.loadingState == .loading - && store.hathArchives.isEmpty ? 1 : 0 + && store.hathArchives.isEmpty ? 1 : 0 ) let error = store.loadingState.failed @@ -56,23 +55,14 @@ struct ArchivesView: View { } .opacity(error != nil && store.hathArchives.isEmpty ? 1 : 0) } - .progressHUD( - config: store.communicatingHUDConfig, - unwrapping: $store.route, - case: \.communicatingHUD - ) - .progressHUD( - config: store.messageHUDConfig, - unwrapping: $store.route, - case: \.messageHUD - ) + .toast($store.scope(state: \.toast, action: \.toast)) .animation(.default, value: store.hathArchives) - .animation(.default, value: user.galleryPoints) - .animation(.default, value: user.credits) + .animation(.default, value: store.user.galleryPoints) + .animation(.default, value: store.user.credits) .onAppear { store.send(.fetchArchive(gid, galleryURL, archiveURL)) } - .navigationTitle(L10n.Localizable.ArchivesView.Title.archives) + .navigationTitle(.archives) } } } @@ -126,10 +116,18 @@ private struct ArchiveFundsView: View { var body: some View { HStack(spacing: 20) { - Label("\(galleryPoints)", systemSymbol: .gCircleFill) - Label("\(credits)", systemSymbol: .cCircleFill) + Label { + Text(galleryPoints, format: .number) + } icon: { + Image(systemSymbol: .gCircleFill) + } + Label { + Text(credits, format: .number) + } icon: { + Image(systemSymbol: .cCircleFill) + } } - .font(.headline).lineLimit(1).padding() + .font(.headline.monospacedDigit()).lineLimit(1).padding() } } @@ -214,7 +212,7 @@ private struct DownloadButton: View { } var body: some View { - Text(L10n.Localizable.ArchivesView.Button.downloadToHathClient) + Text(.downloadToHathClient) .font(.headline) .foregroundStyle(textColor) .frame(maxWidth: .infinity) @@ -239,7 +237,6 @@ struct ArchivesView_Previews: PreviewProvider { ArchivesView( store: .init(initialState: .init(), reducer: ArchivesReducer.init), gid: .init(), - user: .init(), galleryURL: .mock, archiveURL: .mock ) diff --git a/EhPanda/View/Detail/Comments/CommentsReducer.swift b/AppPackage/Sources/DetailFeature/Comments/CommentsReducer.swift similarity index 52% rename from EhPanda/View/Detail/Comments/CommentsReducer.swift rename to AppPackage/Sources/DetailFeature/Comments/CommentsReducer.swift index 432d412c0..70207b5f6 100644 --- a/EhPanda/View/Detail/Comments/CommentsReducer.swift +++ b/AppPackage/Sources/DetailFeature/Comments/CommentsReducer.swift @@ -1,103 +1,129 @@ -// -// CommentsReducer.swift -// EhPanda -// - import Foundation -import TTProgressHUD +import AppModels +import Sharing import ComposableArchitecture +import URLClient +import ApplicationClient +import HapticsClient +import NetworkingFeature +import CookieClient +import AppComponents @Reducer -struct CommentsReducer { - @CasePathable - enum Route: Equatable { - case hud - case detail(String) +public struct CommentsReducer: Sendable { + @Reducer + public enum Destination { + @ReducerCaseIgnored case postComment(String) } - private enum CancelID: CaseIterable { + public enum Delegate: Equatable, Sendable { + // Open the linked gallery (optionally deep-linking to a page or comment) as a new stack element. + case pushDetail(Gallery, GalleryDeepLink?) + // A comment was voted/edited; ask the host to refresh the detail with this gid so it stays in sync. + case performedCommentAction(String) + } + + private enum CancelID { case postComment, voteComment, fetchGallery } @ObservableState - struct State: Equatable { - var route: Route? - var commentContent = "" - var postCommentFocused = false - - var hudConfig: TTProgressHUDConfig = .loading - var scrollCommentID: String? - var scrollRowOpacity: Double = 1 - - var detailState: Heap - - init() { - detailState = .init(.init()) + public struct State: Equatable { + @SharedReader(.setting) public var setting: Setting + @Presents public var toast: AppAlertState? + @Presents public var destination: Destination.State? + public var commentContent = "" + public var postCommentFocused = false + public var scrollCommentID: String? + public var scrollRowOpacity: Double = 1 + + // Display data captured when this screen is pushed onto the host's gallery stack. + public var gid = "" + public var token = "" + public var apiKey = "" + public var galleryURL: URL + public var comments = [GalleryComment]() + + public init( + gid: String = "", token: String = "", apiKey: String = "", + galleryURL: URL, comments: [GalleryComment] = [], scrollCommentID: String? = nil + ) { + self.gid = gid + self.token = token + self.apiKey = apiKey + self.galleryURL = galleryURL + self.comments = comments + self.scrollCommentID = scrollCommentID } } - enum Action: BindableAction { + public enum Action: BindableAction { case binding(BindingAction) - case setNavigation(Route?) - case clearSubStates + case toast(PresentationAction) + case destination(PresentationAction) + case presentPostComment(commentID: String, content: String? = nil) case clearScrollCommentID + case delegate(Delegate) - case setHUDConfig(TTProgressHUDConfig) + case setToast(AppAlertState) case setPostCommentFocused(Bool) case setScrollRowOpacity(Double) - case setCommentContent(String) case performScrollOpacityEffect case handleCommentLink(URL) - case handleGalleryLink(URL) + case handleGalleryLink(URL, Gallery) case onPostCommentAppear case onAppear - case updateReadingProgress(String, Int) + case updateReadingProgress(gid: String, token: String, progress: Int) - case teardown case postComment(URL, String? = nil) case voteComment(String, String, String, String, Int) - case performCommentActionDone(Result) + case performCommentActionDone(Result) case fetchGallery(URL, Bool) case fetchGalleryDone(URL, Result) - - case detail(DetailReducer.Action) } - @Dependency(\.uiApplicationClient) private var uiApplicationClient - @Dependency(\.databaseClient) private var databaseClient + @Dependency(\.applicationClient) private var applicationClient @Dependency(\.hapticsClient) private var hapticsClient @Dependency(\.cookieClient) private var cookieClient @Dependency(\.urlClient) private var urlClient + @Dependency(\.date) private var date - var body: some Reducer { + public init() {} + + public var body: some Reducer { BindingReducer() - .onChange(of: \.route) { _, newValue in - Reduce({ _, _ in newValue == nil ? .send(.clearSubStates) : .none }) - } Reduce { state, action in switch action { case .binding: return .none - case .setNavigation(let route): - state.route = route - return route == nil ? .send(.clearSubStates) : .none + case .toast: + return .none + + case .destination: + return .none - case .clearSubStates: - state.detailState.wrappedValue = .init() - state.commentContent = .init() + case let .presentPostComment(commentID, content): + // Reset on present (not on dismiss): the sheet is a raw case binding, so an + // interactive swipe-down never sends `.destination(.dismiss)`. Editing passes the + // comment's text as `content`; the new-comment button passes nil to clear it. + state.commentContent = content ?? "" state.postCommentFocused = false - return .send(.detail(.teardown)) + state.destination = .postComment(commentID) + return .none case .clearScrollCommentID: state.scrollCommentID = nil return .none - case .setHUDConfig(let config): - state.hudConfig = config + case .delegate: + return .none + + case .setToast(let config): + state.toast = config return .none case .setPostCommentFocused(let isFocused): @@ -108,10 +134,6 @@ struct CommentsReducer { state.scrollRowOpacity = opacity return .none - case .setCommentContent(let content): - state.commentContent = content - return .none - case .performScrollOpacityEffect: return .merge( .run { send in @@ -130,37 +152,22 @@ struct CommentsReducer { case .handleCommentLink(let url): guard urlClient.checkIfHandleable(url) else { - return .run(operation: { _ in await uiApplicationClient.openURL(url) }) + return .run(operation: { _ in await applicationClient.openURL(url) }) } - let (isGalleryImageURL, _, _) = urlClient.analyzeURL(url) - let gid = urlClient.parseGalleryID(url) - guard databaseClient.fetchGallery(gid: gid) == nil else { - return .send(.handleGalleryLink(url)) - } - return .send(.fetchGallery(url, isGalleryImageURL)) + // Always fetch the linked gallery so the pushed detail is seeded from it (no cache). + let analysis = urlClient.analyzeURL(url) + return .send(.fetchGallery(url, analysis.isGalleryImageURL)) - case .handleGalleryLink(let url): - let (_, pageIndex, commentID) = urlClient.analyzeURL(url) - let gid = urlClient.parseGalleryID(url) + case .handleGalleryLink(let url, let gallery): + let analysis = urlClient.analyzeURL(url) + let deepLink = GalleryDeepLink(pageIndex: analysis.pageIndex, commentID: analysis.commentID) var effects = [Effect]() - if let pageIndex = pageIndex { - effects.append(.send(.updateReadingProgress(gid, pageIndex))) - effects.append( - .run { send in - try await Task.sleep(for: .milliseconds(750)) - await send(.detail(.setNavigation(.reading()))) - } - ) - } else if let commentID = commentID { - state.detailState.wrappedValue?.commentsState.wrappedValue?.scrollCommentID = commentID - effects.append( - .run { send in - try await Task.sleep(for: .milliseconds(750)) - await send(.detail(.setNavigation(.comments(url)))) - } - ) + if let pageIndex = analysis.pageIndex { + effects.append(.send(.updateReadingProgress( + gid: gallery.id, token: gallery.token, progress: pageIndex + ))) } - effects.append(.send(.setNavigation(.detail(gid)))) + effects.append(.send(.delegate(.pushDetail(gallery, deepLink)))) return .merge(effects) case .onPostCommentAppear: @@ -170,19 +177,17 @@ struct CommentsReducer { } case .onAppear: - if state.detailState.wrappedValue == nil { - state.detailState.wrappedValue = .init() - } return state.scrollCommentID != nil ? .send(.performScrollOpacityEffect) : .none - case .updateReadingProgress(let gid, let progress): - guard !gid.isEmpty else { return .none } - return .run { _ in - await databaseClient.updateReadingProgress(gid: gid, progress: progress) + case let .updateReadingProgress(gid, token, progress): + // The linked gallery is in scope, so persist the real token — the entry resolves + // immediately rather than waiting for the detail screen to backfill it. Invalid + // gid/token records are rejected inside the shared mutator. + @Shared(.galleryHistory) var galleryHistory + $galleryHistory.withLock { + $0.updateReadingProgress(gid: gid, token: token, progress: progress, date: date.now) } - - case .teardown: - return .merge(CancelID.allCases.map(Effect.cancel(id:))) + return .none case .postComment(let galleryURL, let commentID): guard !state.commentContent.isEmpty else { return .none } @@ -226,11 +231,19 @@ struct CommentsReducer { } .cancellable(id: CancelID.voteComment) - case .performCommentActionDone: - return .none + case .performCommentActionDone(let result): + switch result { + case .success: + return .merge( + .send(.delegate(.performedCommentAction(state.gid))), + .run(operation: { _ in await hapticsClient.generateNotificationFeedback(.success) }) + ) + case .failure: + return .run(operation: { _ in await hapticsClient.generateNotificationFeedback(.error) }) + } case .fetchGallery(let url, let isGalleryImageURL): - state.route = .hud + state.toast = .loading() return .run { send in let response = await GalleryReverseRequest( url: url, isGalleryImageURL: isGalleryImageURL @@ -241,28 +254,27 @@ struct CommentsReducer { .cancellable(id: CancelID.fetchGallery) case .fetchGalleryDone(let url, let result): - state.route = nil + state.toast = nil switch result { case .success(let gallery): - return .merge( - .run(operation: { _ in await databaseClient.cacheGalleries([gallery]) }), - .send(.handleGalleryLink(url)) - ) + return .send(.handleGalleryLink(url, gallery)) case .failure: + // Let the loading toast animate out before showing the error toast. return .run { send in try await Task.sleep(for: .milliseconds(500)) - await send(.setHUDConfig(.error)) + await send(.setToast(.error())) } } - - case .detail: - return .none } } .haptics( - unwrapping: \.route, + unwrapping: \.destination, case: \.postComment, hapticsClient: hapticsClient ) + .ifLet(\.$destination, action: \.destination) + .ifLet(\.$toast, action: \.toast) } } + +extension CommentsReducer.Destination.State: Equatable, Sendable {} diff --git a/AppPackage/Sources/DetailFeature/Comments/CommentsView.swift b/AppPackage/Sources/DetailFeature/Comments/CommentsView.swift new file mode 100644 index 000000000..21763171c --- /dev/null +++ b/AppPackage/Sources/DetailFeature/Comments/CommentsView.swift @@ -0,0 +1,257 @@ +import SwiftUI +import AppModels +import Resources +import Kingfisher +import ComposableArchitecture +import AppTools +import SystemNotificationExt +import AppComponents + +struct CommentsView: View { + @Bindable private var store: StoreOf + private let gid: String + private let token: String + private let apiKey: String + private let galleryURL: URL + private let comments: [GalleryComment] + private let blurRadius: Double + + init( + store: StoreOf, + gid: String, token: String, apiKey: String, galleryURL: URL, + comments: [GalleryComment], + blurRadius: Double + ) { + self.store = store + self.gid = gid + self.token = token + self.apiKey = apiKey + self.galleryURL = galleryURL + self.comments = comments + self.blurRadius = blurRadius + } + + // MARK: CommentView + var body: some View { + ScrollViewReader { proxy in + List(comments) { comment in + CommentCell( + gid: gid, comment: comment, + linkAction: { store.send(.handleCommentLink($0)) } + ) + .opacity( + comment.commentID == store.scrollCommentID + ? store.scrollRowOpacity : 1 + ) + .swipeActions(edge: .leading) { + if comment.votable { + Button { + store.send(.voteComment(gid, token, apiKey, comment.commentID, -1)) + } label: { + Image(systemSymbol: .handThumbsdown) + } + .tint(.red) + } + } + .swipeActions(edge: .trailing) { + if comment.votable { + Button { + store.send(.voteComment(gid, token, apiKey, comment.commentID, 1)) + } label: { + Image(systemSymbol: .handThumbsup) + } + .tint(.green) + } + if comment.editable { + Button { + store.send(.presentPostComment( + commentID: comment.commentID, content: comment.plainTextContent + )) + } label: { + Image(systemSymbol: .squareAndPencil) + } + } + } + } + .onAppear { + if let scrollCommentID = store.scrollCommentID { + DispatchQueue.main.asyncAfter(deadline: .now() + 0.75) { + withAnimation { + proxy.scrollTo(scrollCommentID, anchor: .top) + } + } + } + } + } + .sheet(item: $store.destination.postComment, id: \.self) { commentID in + let hasCommentID = !commentID.wrappedValue.isEmpty + PostCommentView( + title: hasCommentID ? .editComment : .postComment, + content: $store.commentContent, + isFocused: $store.postCommentFocused, + postAction: { + if hasCommentID { + store.send(.postComment(galleryURL, commentID.wrappedValue)) + } else { + store.send(.postComment(galleryURL)) + } + store.send(.destination(.dismiss)) + }, + cancelAction: { store.send(.destination(.dismiss)) }, + onAppearAction: { store.send(.onPostCommentAppear) } + ) + .accentColor(store.setting.accentColor) + .autoBlur(radius: blurRadius) + } + .toast($store.scope(state: \.toast, action: \.toast)) + .animation(.default, value: store.scrollRowOpacity) + .onAppear { + store.send(.onAppear) + } + .toolbar(content: toolbar) + .navigationTitle(.comments) + } + + private func toolbar() -> some ToolbarContent { + CustomToolbarItem { + Button { + store.send(.presentPostComment(commentID: "")) + } label: { + Image(systemSymbol: .squareAndPencil) + } + .disabled(!CookieUtil.didLogin) + } + } +} + +extension CommentsView { + struct CommentCell: View { + private let gid: String + private var comment: GalleryComment + private let linkAction: (URL) -> Void + + init(gid: String, comment: GalleryComment, linkAction: @escaping (URL) -> Void) { + self.gid = gid + self.comment = comment + self.linkAction = linkAction + } + + 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 { + NavigationStack { + CommentsView( + store: .init(initialState: .init(galleryURL: .mock), reducer: CommentsReducer.init), + gid: .init(), + token: .init(), + apiKey: .init(), + galleryURL: .mock, + comments: [], + blurRadius: 0 + ) + } + } +} diff --git a/EhPanda/View/Detail/Components/LinkedText.swift b/AppPackage/Sources/DetailFeature/Components/LinkedText.swift similarity index 81% rename from EhPanda/View/Detail/Components/LinkedText.swift rename to AppPackage/Sources/DetailFeature/Components/LinkedText.swift index 9e92ae5d9..7428769b4 100644 --- a/EhPanda/View/Detail/Components/LinkedText.swift +++ b/AppPackage/Sources/DetailFeature/Components/LinkedText.swift @@ -1,10 +1,3 @@ -// -// LinkedText.swift -// EhPanda -// -// Copied from https://gist.github.com/mjm/0581781f85db45b05e8e2c5c33696f88 -// - import SwiftUI private let linkDetector = try? NSDataDetector(types: NSTextCheckingResult.CheckingType.link.rawValue) @@ -31,7 +24,12 @@ private struct LinkColoredText: View { ) components.append(.text(trimmedText)) } - components.append(.link(nsText.substring(with: result.range), result.url!)) + let linkText = nsText.substring(with: result.range) + if let url = result.url { + components.append(.link(linkText, url)) + } else { + components.append(.text(linkText)) + } index = result.range.location + result.range.length } @@ -42,15 +40,19 @@ private struct LinkColoredText: View { self.components = components } - var body: some View { - components.map { component in + var body: Text { + var result = AttributedString() + for component in components { switch component { case .text(let text): - return Text(verbatim: text) + result.append(AttributedString(text)) case .link(let text, _): - return Text(verbatim: text).foregroundColor(.accentColor) + var link = AttributedString(text) + link.foregroundColor = .accentColor + result.append(link) } - }.reduce(Text(""), +) + } + return Text(result) } } @@ -106,8 +108,9 @@ private struct LinkTapOverlay: UIViewRepresentable { let attributedString = NSAttributedString( string: text, attributes: [.font: UIFont.preferredFont(forTextStyle: .body)] ) - context.coordinator.textStorage = NSTextStorage(attributedString: attributedString) - context.coordinator.textStorage!.addLayoutManager(context.coordinator.layoutManager) + let textStorage = NSTextStorage(attributedString: attributedString) + textStorage.addLayoutManager(context.coordinator.layoutManager) + context.coordinator.textStorage = textStorage } func makeCoordinator() -> Coordinator { @@ -131,13 +134,15 @@ private struct LinkTapOverlay: UIViewRepresentable { } func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldReceive touch: UITouch) -> Bool { - let location = touch.location(in: gestureRecognizer.view!) + guard let view = gestureRecognizer.view else { return false } + let location = touch.location(in: view) let result = link(at: location) return result != nil } @objc func didTapLabel(_ gesture: UITapGestureRecognizer) { - let location = gesture.location(in: gesture.view!) + guard let view = gesture.view else { return } + let location = gesture.location(in: view) guard let result = link(at: location) else { return } @@ -166,13 +171,13 @@ private struct LinkTapOverlay: UIViewRepresentable { } private final class LinkTapOverlayView: UIView { - var textContainer: NSTextContainer! + var textContainer: NSTextContainer? override func layoutSubviews() { super.layoutSubviews() var newSize = bounds.size newSize.height += 20 // need some extra space here to actually get the last line - textContainer.size = newSize + textContainer?.size = newSize } } diff --git a/EhPanda/View/Detail/Components/PostCommentView.swift b/AppPackage/Sources/DetailFeature/Components/PostCommentView.swift similarity index 91% rename from EhPanda/View/Detail/Components/PostCommentView.swift rename to AppPackage/Sources/DetailFeature/Components/PostCommentView.swift index f99e998d6..82c61fa2a 100644 --- a/EhPanda/View/Detail/Components/PostCommentView.swift +++ b/AppPackage/Sources/DetailFeature/Components/PostCommentView.swift @@ -1,12 +1,8 @@ -// -// PostCommentView.swift -// EhPanda -// - import SwiftUI +import AppComponents struct PostCommentView: View { - private let title: String + private let title: LocalizedStringResource @Binding private var content: String @Binding private var isFocused: Bool private let postAction: () -> Void @@ -16,7 +12,7 @@ struct PostCommentView: View { @FocusState private var isTextEditorFocused: Bool init( - title: String, + title: LocalizedStringResource, content: Binding, isFocused: Binding, postAction: @escaping () -> Void, @@ -32,7 +28,7 @@ struct PostCommentView: View { } var body: some View { - NavigationView { + NavigationStack { VStack { TextEditor(text: $content) .focused($isTextEditorFocused) diff --git a/EhPanda/View/Detail/Components/TagDetailView.swift b/AppPackage/Sources/DetailFeature/Components/TagDetailView.swift similarity index 91% rename from EhPanda/View/Detail/Components/TagDetailView.swift rename to AppPackage/Sources/DetailFeature/Components/TagDetailView.swift index 6b61d45db..9e9a721e5 100644 --- a/EhPanda/View/Detail/Components/TagDetailView.swift +++ b/AppPackage/Sources/DetailFeature/Components/TagDetailView.swift @@ -1,10 +1,9 @@ -// -// TagDetailView.swift -// EhPanda -// - import SwiftUI +import AppModels +import Resources import Kingfisher +import AppTools +import AppComponents struct TagDetailView: View { private let detail: TagDetail @@ -14,10 +13,10 @@ struct TagDetailView: View { } var body: some View { - NavigationView { + NavigationStack { ScrollView(showsIndicators: false) { VStack { - DescriptionSection(description: detail.description) + TagDescriptionSection(description: detail.description) ImagesSection(imageURLs: detail.imageURLs).padding(.vertical) LinksSection(links: detail.links).padding(.vertical) } @@ -27,7 +26,7 @@ struct TagDetailView: View { } } -private struct DescriptionSection: View { +private struct TagDescriptionSection: View { private let description: String init(description: String) { @@ -58,7 +57,7 @@ private struct ImagesSection: View { } var body: some View { - SubSection(title: L10n.Localizable.TagDetailView.Section.Title.images, showAll: false) { + SubSection(title: .images, showAll: false) { VStack { if !imageURLs.isEmpty { ScrollView(.horizontal, showsIndicators: false) { @@ -93,7 +92,7 @@ private struct LinksSection: View { } var body: some View { - SubSection(title: L10n.Localizable.TagDetailView.Section.Title.links, showAll: false) { + SubSection(title: .links, showAll: false) { HStack { if !links.isEmpty { VStack(alignment: .leading) { diff --git a/AppPackage/Sources/DetailFeature/DetailReducer+Actions.swift b/AppPackage/Sources/DetailFeature/DetailReducer+Actions.swift new file mode 100644 index 000000000..3c5dcab24 --- /dev/null +++ b/AppPackage/Sources/DetailFeature/DetailReducer+Actions.swift @@ -0,0 +1,184 @@ +import Foundation +import AppModels +import Sharing +import ComposableArchitecture +import ReadingFeature + +// MARK: - Navigation & UI Action Handlers +extension DetailReducer { + var navigationReducer: some ReducerOf { + Reduce { state, action in + switch action { + case .binding: + return .none + + case .delegate: + return .none + + case .destination(.presented(.reading(.onPerformDismiss))): + @Shared(.galleryHistory) var galleryHistory + state.readingProgress = galleryHistory.readingProgress(gid: state.gallery.id) + return .send(.destination(.dismiss)) + + case .destination: + return .none + + case .presentReading: + state.destination = .reading(.init( + gallery: state.gallery, previewConfig: state.previewConfig, + language: state.galleryDetail?.language + )) + return .none + + case .archivesButtonTapped: + state.destination = .archives(ArchivesReducer.State()) + return .none + + case .torrentsButtonTapped: + state.destination = .torrents(TorrentsReducer.State()) + return .none + + case .folderManagerButtonTapped: + state.destination = .folderManager(FolderManagerReducer.State()) + return .none + + case .shareButtonTapped(let url): + state.destination = .share(url) + return .none + + case .postCommentButtonTapped: + // Reset on present (not on dismiss): the sheet is a raw case binding, so a swipe-down + // never sends `.destination(.dismiss)`. This is the new-comment flow only, so clearing + // is always correct. + state.commentContent = .init() + state.postCommentFocused = false + state.destination = .postComment(.init()) + return .none + + case .presentNewDawn(let greeting): + state.destination = .newDawn(greeting) + return .none + + case .tagDetailButtonTapped(let tagDetail): + state.destination = .tagDetail(tagDetail) + return .none + + case .onPostCommentAppear: + return .run { send in + try await Task.sleep(for: .milliseconds(750)) + await send(.setPostCommentFocused(true)) + } + + case .onAppear(let gid): + return handleOnAppear(gid: gid, state: &state) + + default: + return .none + } + } + } + + private func handleOnAppear( + gid: String, + state: inout State + ) -> Effect { + state.gid = gid + state.isPreparingDownload = false + state.hasLoadedDownloadBadge = false + state.didRunLaunchAutomation = false + state.localPreviewURLs = .init() + @Shared(.galleryHistory) var galleryHistory + state.readingProgress = galleryHistory.readingProgress(gid: state.gallery.id) + // The gallery is already seeded from the pushing context, so we record the visit and fetch + // the (always network-sourced) detail directly. + return .merge( + .send(.saveGalleryHistory), + .send(.fetchGalleryDetail), + .send(.fetchDownloadBadge), + .send(.fetchDownloadFolders), + .send(.observeDownload), + .send(.loadLocalPreviewURLs) + ) + } + + var uiReducer: some ReducerOf { + Reduce { state, action in + switch action { + case .toggleShowFullTitle: + state.showsFullTitle.toggle() + return .run(operation: { _ in await hapticsClient.generateFeedback(.soft) }) + + case .toggleShowUserRating: + state.showsUserRating.toggle() + return .run(operation: { _ in await hapticsClient.generateFeedback(.soft) }) + + case .setPostCommentFocused(let isFocused): + state.postCommentFocused = isFocused + return .none + + case .updateRating(let value): + state.updateRating(value: value) + return .none + + case .confirmRating(let value): + state.updateRating(value: value) + return .merge( + .send(.rateGallery), + .run(operation: { _ in await hapticsClient.generateFeedback(.soft) }), + .run { send in + try await Task.sleep(for: .seconds(1)) + await send(.confirmRatingDone) + } + ) + + case .confirmRatingDone: + state.showsUserRating = false + return .none + + default: + return .none + } + } + } + + var syncReducer: some ReducerOf { + Reduce { state, action in + switch action { + case .syncGreeting(let greeting): + // Greeting is a session-only in-memory shared slot (resets each launch). Merge through + // the newer-only rule so a stale detail-page greeting can't clobber a fresher Setting + // fetch. + @Shared(.greeting) var sharedGreeting + $sharedGreeting.withLock { $0.mergeNewer(greeting) } + return .none + + case .saveGalleryHistory: + @Shared(.galleryHistory) var galleryHistory + $galleryHistory.withLock { + $0.recordGalleryOpen( + gid: state.gallery.id, + token: state.gallery.token, + date: date.now, + hasRated: state.gallery.hasRated, + favoriteTagIndex: state.gallery.favoriteTagIndex, + favoriteTagName: state.gallery.favoriteTagName + ) + } + return .none + + case .updateReadingProgress(let progress): + @Shared(.galleryHistory) var galleryHistory + $galleryHistory.withLock { + $0.updateReadingProgress( + gid: state.gallery.id, token: state.gallery.token, progress: progress, date: date.now + ) + } + return .none + + default: + return .none + } + } + } + +} diff --git a/AppPackage/Sources/DetailFeature/DetailReducer+Download.swift b/AppPackage/Sources/DetailFeature/DetailReducer+Download.swift new file mode 100644 index 000000000..17bfc42f6 --- /dev/null +++ b/AppPackage/Sources/DetailFeature/DetailReducer+Download.swift @@ -0,0 +1,343 @@ +import Foundation +import AppModels +import Resources +import ComposableArchitecture +import AppComponents +import AppTools +import ReadingFeature + +// MARK: - Download Action Handlers +extension DetailReducer { + var downloadReducer: some ReducerOf { + Reduce { state, action in + switch action { + case .deleteDownloadButtonTapped: + state.alert = AppAlertState { + TextState(localized: .RLocalizable.deleteDownload) + } actions: { + ButtonState(role: .destructive, action: .confirmDeleteDownload) { + TextState(localized: .RLocalizable.delete) + } + ButtonState(role: .cancel) { + TextState(localized: .RLocalizable.cancel) + } + } message: { + TextState(localized: .RLocalizable.deleteDownloadedGallery) + } + return .none + + case .retryDownloadButtonTapped(let mode): + state.alert = AppAlertState { + TextState(localized: Self.retryDownloadTitle(for: mode)) + } actions: { + ButtonState(action: .confirmRetryDownload(mode)) { + TextState(localized: Self.retryDownloadConfirmTitle(for: mode)) + } + ButtonState(role: .cancel) { + TextState(localized: .RLocalizable.cancel) + } + } message: { + TextState(localized: Self.retryDownloadMessage(for: mode)) + } + return .none + + case .alert(.presented(.confirmDeleteDownload)): + return .send(.deleteDownload) + + case .alert(.presented(.confirmRetryDownload(let mode))): + return .send(.retryDownload(mode)) + + case .alert: + return .none + + case .fetchDownloadBadge: + guard state.gid.isValidGID else { return .none } + return .run { [galleryID = state.gid] send in + let download = await downloadClient.fetchDownload(galleryID) + await send(.fetchDownloadBadgeDone(download)) + } + .cancellable(id: CancelID.fetchDownloadBadge(state.cancellationGalleryID), cancelInFlight: true) + + case .fetchDownloadBadgeDone(let download): + _ = applyDownload(download, state: &state) + var effects: [Effect] = [.send(.loadLocalPreviewURLs)] + if shouldRequestVersionMetadata(state: state) { + effects.append(.send(.fetchVersionMetadataIfNeeded)) + } + return .merge(effects) + + case .fetchDownloadFolders: + let cancellationID = CancelID.fetchDownloadFolders(state.cancellationGalleryID) + return .run { send in + await send(.fetchDownloadFoldersDone(try await downloadClient.fetchFolders())) + } + .cancellable(id: cancellationID, cancelInFlight: true) + + case .fetchDownloadFoldersDone(let folders): + state.downloadFolders = folders + return .none + + case .createDefaultFolder: + return .run { send in + try await downloadClient.createFolder(Defaults.FilePath.defaultDownloadFolder) + await send(.createDefaultFolderDone(.success(()))) + } catch: { error, send in + await send(.createDefaultFolderDone(.failure(AppError(error)))) + } + + case .createDefaultFolderDone(let result): + if case .success = result { + return .merge( + .run(operation: { _ in await hapticsClient.generateNotificationFeedback(.success) }), + .send(.fetchDownloadFolders) + ) + } + return .run(operation: { _ in await hapticsClient.generateNotificationFeedback(.error) }) + + case .destination(.presented(.folderManager(.createFolderDone))), + .destination(.presented(.folderManager(.renameFolderDone))), + .destination(.presented(.folderManager(.deleteFolderDone))): + return .send(.fetchDownloadFolders) + + case .observeDownload: + guard state.gid.isValidGID else { return .none } + return .run { [galleryID = state.gid] send in + for await downloads in downloadClient.observeDownloads() { + let download = downloads.first(where: { $0.gid == galleryID }) + await send(.observeDownloadDone(download)) + } + } + .cancellable(id: CancelID.observeDownload(state.cancellationGalleryID), cancelInFlight: true) + + case .observeDownloadDone(let download): + let didChangeBadge = applyDownload(download, state: &state) + guard didChangeBadge else { return .none } + var effects: [Effect] = [.send(.loadLocalPreviewURLs)] + if shouldRequestVersionMetadata(state: state) { + effects.append(.send(.fetchVersionMetadataIfNeeded)) + } + return .merge(effects) + + case .loadLocalPreviewURLs: + guard state.gid.isValidGID else { + state.localPreviewRequestID = UUID() + state.localPreviewURLs = .init() + return .none + } + let requestID = UUID() + state.localPreviewRequestID = requestID + return .run { [galleryID = state.gid] send in + let localPreviewURLs = await downloadClient.loadLocalPageURLs(galleryID) ?? [:] + await send(.loadLocalPreviewURLsDone(requestID, localPreviewURLs)) + } + .cancellable(id: CancelID.loadLocalPreviewURLs(state.cancellationGalleryID), cancelInFlight: true) + + case .loadLocalPreviewURLsDone(let requestID, let localPreviewURLs): + guard state.localPreviewRequestID == requestID else { return .none } + guard state.localPreviewURLs != localPreviewURLs else { return .none } + state.localPreviewURLs = localPreviewURLs + return .none + + case .openReading: + return .run { [galleryID = state.gallery.id] send in + guard galleryID.isValidGID else { + await send(.openReadingDone(.failure(.notFound))) + return + } + await send(.openReadingDone(.success(try await downloadClient.loadManifest(galleryID)))) + } catch: { error, send in + await send(.openReadingDone(.failure(AppError(error)))) + } + + case .openReadingDone(let result): + var readingState: ReadingReducer.State + if case .success(let (download, manifest)) = result { + readingState = .init( + gallery: state.gallery, contentSource: .local(download, manifest), + previewConfig: state.previewConfig, language: state.galleryDetail?.language + ) + } else { + readingState = .init( + gallery: state.gallery, contentSource: .remote, + previewConfig: state.previewConfig, language: state.galleryDetail?.language + ) + readingState.localPageURLs = state.localPreviewURLs + } + state.destination = .reading(readingState) + return .none + + case .runLaunchAutomationIfNeeded: + guard !state.didRunLaunchAutomation, + let automation = appLaunchAutomationClient.current(), + automation.autoDownloadGID == state.gallery.id, + state.galleryDetail != nil, + state.hasLoadedDownloadBadge + else { return .none } + state.didRunLaunchAutomation = true + guard state.downloadBadge == nil else { return .none } + return .send( + .startDownload(automation.downloadFolderName ?? Defaults.FilePath.automationDownloadFolder) + ) + + case .startDownload(let folderName): + guard !state.isPreparingDownload else { return .none } + state.didRunLaunchAutomation = true + guard let detail = state.galleryDetail else { return .none } + state.isPreparingDownload = true + let payload = DownloadRequestPayload( + gallery: state.gallery, + galleryDetail: detail, + previewURLs: state.galleryPreviewURLs, + previewConfig: state.previewConfig, + host: AppUtil.galleryHost, + folderName: folderName, + versionMetadata: state.galleryVersionMetadata, + mode: .initial + ) + return .run { send in + try await downloadClient.enqueue(payload) + await send(.startDownloadDone(.success(()))) + } catch: { error, send in + await send(.startDownloadDone(.failure(AppError(error)))) + } + + case .startDownloadDone(let result): + state.isPreparingDownload = false + if case .success = result { + state.downloadBadge = DownloadBadge( + status: .queued, + progress: DownloadProgress( + completedPageCount: 0, + pageCount: state.galleryDetail?.pageCount ?? 0 + ) + ) + state.downloadFailureCode = nil + state.hasLoadedDownloadBadge = true + return .merge( + .run(operation: { _ in await hapticsClient.generateNotificationFeedback(.success) }), + .send(.fetchDownloadBadge) + ) + } + return .run(operation: { _ in await hapticsClient.generateNotificationFeedback(.error) }) + + case .toggleDownloadPause: + guard !state.isPreparingDownload else { return .none } + state.isPreparingDownload = true + return .run { [galleryID = state.gallery.id] send in + try await downloadClient.togglePause(galleryID) + await send(.toggleDownloadPauseDone(.success(()))) + } catch: { error, send in + await send(.toggleDownloadPauseDone(.failure(AppError(error)))) + } + + case .toggleDownloadPauseDone(let result): + state.isPreparingDownload = false + if case .success = result { + switch state.downloadBadge?.status { + case .active: + if let badge = state.downloadBadge { + state.downloadBadge = DownloadBadge(status: .inactive, progress: badge.progress) + } + case .inactive: + if let badge = state.downloadBadge { + state.downloadBadge = DownloadBadge(status: .queued, progress: badge.progress) + } + default: + break + } + state.hasLoadedDownloadBadge = state.downloadBadge != nil + return .merge( + .run(operation: { _ in await hapticsClient.generateNotificationFeedback(.success) }), + .send(.fetchDownloadBadge) + ) + } + return .run(operation: { _ in await hapticsClient.generateNotificationFeedback(.error) }) + + case .retryDownload(let mode): + guard !state.isPreparingDownload else { return .none } + state.isPreparingDownload = true + return .run { [galleryID = state.gallery.id] send in + try await downloadClient.retry(galleryID, mode) + await send(.retryDownloadDone(.success(()))) + } catch: { error, send in + await send(.retryDownloadDone(.failure(AppError(error)))) + } + + case .retryDownloadDone(let result): + state.isPreparingDownload = false + if case .success = result { + state.downloadBadge = DownloadBadge( + status: .queued, + progress: state.downloadBadge?.progress ?? DownloadProgress( + completedPageCount: 0, + pageCount: state.galleryDetail?.pageCount ?? 0 + ) + ) + state.downloadFailureCode = nil + state.hasLoadedDownloadBadge = true + return .merge( + .run(operation: { _ in await hapticsClient.generateNotificationFeedback(.success) }), + .send(.fetchDownloadBadge) + ) + } + return .run(operation: { _ in await hapticsClient.generateNotificationFeedback(.error) }) + + case .deleteDownload: + return .run { [galleryID = state.gallery.id] send in + try await downloadClient.delete(galleryID) + await send(.deleteDownloadDone(.success(()))) + } catch: { error, send in + await send(.deleteDownloadDone(.failure(AppError(error)))) + } + + case .deleteDownloadDone(let result): + if case .success = result { + state.galleryVersionMetadata = nil + state.didRequestVersionMetadata = false + state.shouldCheckForRemoteUpdates = false + return .merge( + .run(operation: { _ in await hapticsClient.generateNotificationFeedback(.success) }), + .send(.fetchDownloadBadge) + ) + } + return .run(operation: { _ in await hapticsClient.generateNotificationFeedback(.error) }) + + default: + return .none + } + } + } + + private static func retryDownloadTitle(for mode: DownloadStartMode) -> LocalizedStringResource { + switch mode { + case .repair: + return .repairDownload + case .update: + return .updateDownload + case .initial, .redownload: + return .redownloadGallery + } + } + + private static func retryDownloadMessage(for mode: DownloadStartMode) -> LocalizedStringResource { + switch mode { + case .repair: + return .repairDownloadDescription + case .update: + return .updateDownloadDescription + case .initial, .redownload: + return .redownloadGalleryDescription + } + } + + private static func retryDownloadConfirmTitle(for mode: DownloadStartMode) -> LocalizedStringResource { + switch mode { + case .repair: + return .repair + case .update: + return .RLocalizable.update + case .initial, .redownload: + return .redownload + } + } +} diff --git a/AppPackage/Sources/DetailFeature/DetailReducer+Fetch.swift b/AppPackage/Sources/DetailFeature/DetailReducer+Fetch.swift new file mode 100644 index 000000000..b409c858d --- /dev/null +++ b/AppPackage/Sources/DetailFeature/DetailReducer+Fetch.swift @@ -0,0 +1,201 @@ +import Foundation +import SwiftUI +import ComposableArchitecture +import NetworkingFeature +import AppModels +import AppTools + +// MARK: - Fetch & Gallery Ops Action Handlers +extension DetailReducer { + var fetchReducer: some ReducerOf { + Reduce { state, action in + switch action { + case .fetchGalleryDetail: + guard state.loadingState != .loading, + let galleryURL = state.gallery.galleryURL + else { return .none } + let galleryID = state.gallery.id + state.loadingState = .loading + state.didRequestVersionMetadata = false + state.galleryVersionMetadata = nil + return .run { send in + let response = await GalleryDetailRequest(gid: galleryID, galleryURL: galleryURL).response() + await send(.fetchGalleryDetailDone(response)) + } + .cancellable(id: CancelID.fetchGalleryDetail(state.cancellationGalleryID)) + + case .fetchGalleryDetailDone(let result): + state.loadingState = .idle + switch result { + case .success(let response): + var effects: [Effect] = [ + .send(.fetchDownloadBadge) + ] + state.apiKey = response.apiKey + state.galleryDetail = response.galleryDetail + state.galleryTags = WatchedTagsSetting.applyWatchedTagColors(to: response.galleryState.tags) + state.galleryPreviewURLs = response.galleryState.previewURLs + state.galleryComments = response.galleryState.comments + if let config = response.galleryState.previewConfig { + state.previewConfig = config + } + state.userRating = Int(response.galleryDetail.userRating) * 2 + if shouldRequestVersionMetadata(state: state) { + effects.append(.send(.fetchVersionMetadataIfNeeded)) + } + if let greeting = response.greeting { + effects.append(.send(.syncGreeting(greeting))) + if !greeting.gainedNothing && state.setting.showsNewDawnGreeting { + effects.append(.send(.presentNewDawn(greeting))) + } + } + if let deepLink = state.pendingDeepLink { + state.pendingDeepLink = nil + switch deepLink { + case .reading: + // The linking comment already wrote the reading progress synchronously to + // `@Shared(.galleryHistory)`, so the reader can open immediately. + effects.append(.send(.presentReading)) + case .comments(let commentID): + if let galleryURL = state.gallery.galleryURL { + effects.append(.send(.delegate(.pushComments( + gid: state.gallery.id, token: state.gallery.token, apiKey: state.apiKey, + galleryURL: galleryURL, comments: state.galleryComments, + scrollCommentID: commentID + )))) + } + } + } + return .merge(effects) + case .failure(let error): + state.loadingState = .failed(error) + } + return .none + + case .fetchVersionMetadataIfNeeded: + guard state.shouldCheckForRemoteUpdates, + !state.didRequestVersionMetadata, + state.galleryDetail != nil + else { + return .none + } + state.didRequestVersionMetadata = true + let gallery = state.gallery + return .run { send in + let metadata = await downloadClient.fetchVersionMetadata(gallery.gid, gallery.token) + await send(.fetchVersionMetadataDone(.success(metadata))) + guard let metadata else { return } + let download = await downloadClient.updateRemoteVersion(gallery.gid, metadata) + await send(.fetchDownloadBadgeDone(download)) + } + .cancellable(id: CancelID.fetchVersionMetadata(state.cancellationGalleryID), cancelInFlight: true) + + case .fetchVersionMetadataDone(let result): + if case .success(let metadata) = result { + state.galleryVersionMetadata = metadata + } + return .none + + default: + return .none + } + } + } + + var galleryOpsReducer: some ReducerOf { + Reduce { state, action in + switch action { + case .rateGallery: + guard let apiuid = Int(cookieClient.apiuid), let gid = Int(state.gallery.id) + else { return .none } + return .run { [apiKey = state.apiKey, token = state.gallery.token, rating = state.userRating] send in + let response = await RateGalleryRequest( + apiuid: apiuid, apikey: apiKey, + gid: gid, token: token, rating: rating + ).response() + await send(.rateGalleryDone(response)) + } + .cancellable(id: CancelID.rateGallery(state.cancellationGalleryID)) + + case .rateGalleryDone(let result): + if case .success = result { + state.gallery.hasRated = true + } + return .send(.anyGalleryOpsDone(result)) + + case .favorGallery(let favIndex): + return .run { [gid = state.gallery.id, token = state.gallery.token] send in + let response = await FavorGalleryRequest( + gid: gid, token: token, favIndex: favIndex + ).response() + await send(.favorGalleryDone(response, favIndex: favIndex)) + } + .cancellable(id: CancelID.favorGallery(state.cancellationGalleryID)) + + case .favorGalleryDone(let result, let favIndex): + if case .success = result { + state.gallery.favoriteTagIndex = favIndex + if var detail = state.galleryDetail { + detail.favoriteTagIndex = favIndex + detail.favoriteTagName = state.user.getFavoriteCategory(index: favIndex) + state.galleryDetail = detail + } + } + return .send(.anyGalleryOpsDone(result)) + + case .unfavorGallery: + return .run { [galleryID = state.gallery.id] send in + let response = await UnfavorGalleryRequest(gid: galleryID).response() + await send(.unfavorGalleryDone(response)) + } + .cancellable(id: CancelID.unfavorGallery(state.cancellationGalleryID)) + + case .unfavorGalleryDone(let result): + if case .success = result { + state.gallery.favoriteTagIndex = nil + state.gallery.favoriteTagName = nil + if var detail = state.galleryDetail { + detail.favoriteTagIndex = nil + detail.favoriteTagName = nil + state.galleryDetail = detail + } + } + return .send(.anyGalleryOpsDone(result)) + + case .postComment(let galleryURL): + guard !state.commentContent.isEmpty else { return .none } + return .run { [commentContent = state.commentContent] send in + let response = await CommentGalleryRequest( + content: commentContent, galleryURL: galleryURL + ).response() + await send(.anyGalleryOpsDone(response)) + } + .cancellable(id: CancelID.postComment(state.cancellationGalleryID)) + + case .voteTag(let tag, let vote): + guard let apiuid = Int(cookieClient.apiuid), let gid = Int(state.gallery.id) + else { return .none } + return .run { [apiKey = state.apiKey, token = state.gallery.token] send in + let response = await VoteGalleryTagRequest( + apiuid: apiuid, apikey: apiKey, + gid: gid, token: token, tag: tag, vote: vote + ).response() + await send(.anyGalleryOpsDone(response)) + } + .cancellable(id: CancelID.voteTag(state.cancellationGalleryID)) + + case .anyGalleryOpsDone(let result): + if case .success = result { + return .merge( + .send(.fetchGalleryDetail), + .run(operation: { _ in await hapticsClient.generateNotificationFeedback(.success) }) + ) + } + return .run(operation: { _ in await hapticsClient.generateNotificationFeedback(.error) }) + + default: + return .none + } + } + } +} diff --git a/AppPackage/Sources/DetailFeature/DetailReducer.swift b/AppPackage/Sources/DetailFeature/DetailReducer.swift new file mode 100644 index 000000000..db01e03df --- /dev/null +++ b/AppPackage/Sources/DetailFeature/DetailReducer.swift @@ -0,0 +1,244 @@ +import AppTools +import SwiftUI +import AppModels +import Foundation +import ComposableArchitecture +import AppComponents +import HapticsClient +import NetworkingFeature +import DownloadClient +import CookieClient +import AppLaunchAutomationClient +import ReadingFeature + +@Reducer +public struct DetailReducer: Sendable { + // The gallery sub-screens are now standalone elements on the host's navigation stack. Detail asks + // the host to push them via these delegate actions instead of owning nested child state itself. + public enum Delegate: Equatable, Sendable { + case pushPreviews(Gallery, PreviewConfig, Language?) + case pushComments( + gid: String, token: String, apiKey: String, + galleryURL: URL, comments: [GalleryComment], scrollCommentID: String? + ) + case pushDetailSearch(String) + case pushGalleryInfos(Gallery, GalleryDetail) + } + + @Reducer + public enum Destination { + case reading(ReadingReducer) + case archives(ArchivesReducer) + case torrents(TorrentsReducer) + case folderManager(FolderManagerReducer) + @ReducerCaseIgnored case share(URL) + @ReducerCaseIgnored case postComment(EquatableVoid) + @ReducerCaseIgnored case newDawn(Greeting) + @ReducerCaseIgnored case tagDetail(TagDetail) + } + + public enum Alert: Equatable, Sendable { + case confirmDeleteDownload + case confirmRetryDownload(DownloadStartMode) + } + + public enum CancelID: Hashable, Sendable { + case fetchGalleryDetail(String) + case fetchVersionMetadata(String) + case fetchDownloadBadge(String) + case fetchDownloadFolders(String) + case observeDownload(String) + case loadLocalPreviewURLs(String) + case rateGallery(String) + case favorGallery(String) + case unfavorGallery(String) + case postComment(String) + case voteTag(String) + } + + @ObservableState + public struct State: Equatable { + @SharedReader(.tagTranslator) public var tagTranslator: TagTranslator + @SharedReader(.user) public var user: User + @SharedReader(.setting) public var setting: Setting + @Presents public var destination: Destination.State? + @Presents public var alert: AppAlertState? + public var commentContent = "" + public var postCommentFocused = false + public var showsUserRating = false + public var showsFullTitle = false + public var userRating = 0 + public var apiKey = "" + public var gid: String + public var loadingState: LoadingState = .idle + public var gallery: Gallery + public var galleryDetail: GalleryDetail? + public var galleryVersionMetadata: DownloadVersionMetadata? + public var galleryTags = [GalleryTag]() + public var galleryPreviewURLs = [Int: URL]() + public var localPreviewURLs = [Int: URL]() + public var galleryComments = [GalleryComment]() + public var previewConfig: PreviewConfig = .normal(rows: 4) + public var downloadBadge: DownloadBadge? + public var downloadFailureCode: DownloadFailureCode? + public var downloadFolders = [String]() + public var isPreparingDownload = false + public var hasLoadedDownloadBadge = false + public var readingProgress = 0 + + var cancellationGalleryID: String { + gid.isEmpty ? gallery.id : gid + } + + var downloadNeedsRepair: Bool { + guard let badge = downloadBadge, badge.status == .error else { return false } + return badge.progress.completedPageCount == 0 + && downloadFailureCode == .fileOperationFailed + } + public var didRunLaunchAutomation = false + public var shouldCheckForRemoteUpdates = false + public var didRequestVersionMetadata = false + public var localPreviewRequestID = UUID() + // A deep-link intent to act on once this detail finishes loading (see GalleryDeepLink). + public var pendingDeepLink: GalleryDeepLink? + + // Seeded from the pushing context (a tapped list item or a freshly-fetched gallery) so the + // detail header renders immediately and `fetchGalleryDetail` has a `galleryURL`. Gallery data + // lives only here and dies when the screen pops. This is the only initializer, so a Detail + // always holds a real `Gallery` — there is no empty-gallery construction path. + public init(gallery: Gallery, pendingDeepLink: GalleryDeepLink? = nil) { + self.gid = gallery.id + self.gallery = gallery + self.pendingDeepLink = pendingDeepLink + } + + mutating func updateRating(value: DragGesture.Value) { + let rating = Int(value.location.x / 31 * 2) + 1 + userRating = min(max(rating, 1), 10) + } + } + + public enum Action: BindableAction { + case binding(BindingAction) + case delegate(Delegate) + case destination(PresentationAction) + case presentReading + case archivesButtonTapped + case torrentsButtonTapped + case folderManagerButtonTapped + case shareButtonTapped(URL) + case postCommentButtonTapped + case presentNewDawn(Greeting) + case tagDetailButtonTapped(TagDetail) + case alert(PresentationAction) + case deleteDownloadButtonTapped + case retryDownloadButtonTapped(DownloadStartMode) + case onPostCommentAppear + case onAppear(String) + case toggleShowFullTitle + case toggleShowUserRating + case setPostCommentFocused(Bool) + case updateRating(DragGesture.Value) + case confirmRating(DragGesture.Value) + case confirmRatingDone + case syncGreeting(Greeting) + case saveGalleryHistory + case updateReadingProgress(Int) + case fetchDownloadBadge + case fetchDownloadBadgeDone(DownloadedGallery?) + case fetchDownloadFolders + case fetchDownloadFoldersDone([String]) + case createDefaultFolder + case createDefaultFolderDone(Result) + case observeDownload + case observeDownloadDone(DownloadedGallery?) + case loadLocalPreviewURLs + case loadLocalPreviewURLsDone(UUID, [Int: URL]) + case openReading + case openReadingDone(Result<(DownloadedGallery, DownloadManifest), AppError>) + case runLaunchAutomationIfNeeded + case startDownload(String) + case startDownloadDone(Result) + case toggleDownloadPause + case toggleDownloadPauseDone(Result) + case retryDownload(DownloadStartMode) + case retryDownloadDone(Result) + case deleteDownload + case deleteDownloadDone(Result) + case fetchGalleryDetail + case fetchGalleryDetailDone(Result) + case fetchVersionMetadataIfNeeded + case fetchVersionMetadataDone(Result) + case rateGallery + case rateGalleryDone(Result) + case favorGallery(Int) + case favorGalleryDone(Result, favIndex: Int) + case unfavorGallery + case unfavorGalleryDone(Result) + case postComment(URL) + case voteTag(String, Int) + case anyGalleryOpsDone(Result) + } + + @Dependency(\.downloadClient) var downloadClient + @Dependency(\.hapticsClient) var hapticsClient + @Dependency(\.cookieClient) var cookieClient + @Dependency(\.appLaunchAutomationClient) var appLaunchAutomationClient + @Dependency(\.date) var date + + public init() {} + + public var body: some Reducer { detailBody } +} + +// MARK: - Reducer Body +extension DetailReducer { + @ReducerBuilder + var detailBody: some Reducer { + BindingReducer() + navigationReducer + uiReducer + syncReducer + downloadReducer + fetchReducer + galleryOpsReducer + .ifLet(\.$destination, action: \.destination) + .ifLet(\.$alert, action: \.alert) + } +} + +// MARK: - Helpers +extension DetailReducer { + public func applyDownload(_ download: DownloadedGallery?, state: inout State) -> Bool { + let badge = download?.badge + let didChangeBadge = badge != state.downloadBadge || !state.hasLoadedDownloadBadge + state.downloadBadge = badge + state.downloadFailureCode = download?.lastError?.code + if badge != nil { state.isPreparingDownload = false } + state.hasLoadedDownloadBadge = true + state.shouldCheckForRemoteUpdates = badge != nil + if badge == nil { + state.galleryVersionMetadata = nil + state.didRequestVersionMetadata = false + } + return didChangeBadge + } + + func shouldRequestVersionMetadata(state: State) -> Bool { + state.galleryDetail != nil + && state.shouldCheckForRemoteUpdates + && !state.didRequestVersionMetadata + } +} + +extension DetailReducer.State { + // Pre-populated from a local download so a downloaded gallery renders instantly and offline; + // the live download observation keeps the state in sync afterwards. Shared by the Downloads + // tab's inline push and the app-level modal presentation (iPad / deep link). + public init(seededFrom download: DownloadedGallery) { + self.init(gallery: download.gallery) + _ = DetailReducer().applyDownload(download, state: &self) + } +} + +extension DetailReducer.Destination.State: Equatable {} diff --git a/EhPanda/View/Detail/DetailSearch/DetailSearchReducer.swift b/AppPackage/Sources/DetailFeature/DetailSearch/DetailSearchReducer.swift similarity index 57% rename from EhPanda/View/Detail/DetailSearch/DetailSearchReducer.swift rename to AppPackage/Sources/DetailFeature/DetailSearch/DetailSearchReducer.swift index 70e330c20..cb0868371 100644 --- a/EhPanda/View/Detail/DetailSearch/DetailSearchReducer.swift +++ b/AppPackage/Sources/DetailFeature/DetailSearch/DetailSearchReducer.swift @@ -1,40 +1,45 @@ -// -// DetailSearchReducer.swift -// EhPanda -// - +import AppTools import ComposableArchitecture +import AppModels +import Sharing +import HapticsClient +import NetworkingFeature +import FiltersFeature +import QuickSearchFeature @Reducer -struct DetailSearchReducer { - @dynamicMemberLookup @CasePathable - enum Route: Equatable { - case filters(EquatableVoid = .unique) - case quickSearch(EquatableVoid = .unique) - case detail(String) +public struct DetailSearchReducer: Sendable { + @Reducer + public enum Destination { + case filters(FiltersReducer) + case quickSearch(QuickSearchReducer) + } + + public enum Delegate: Equatable, Sendable { + case pushDetail(Gallery) } - private enum CancelID: CaseIterable { + private enum CancelID { case fetchGalleries, fetchMoreGalleries } @ObservableState - struct State: Equatable { - var route: Route? - var keyword = "" - var lastKeyword = "" - - var galleries = [Gallery]() - var pageNumber = PageNumber() - var loadingState: LoadingState = .idle - var footerLoadingState: LoadingState = .idle - - var detailState: Heap - var filtersState = FiltersReducer.State() - var quickDetailSearchState = QuickSearchReducer.State() - - init() { - detailState = .init(.init()) + public struct State: Equatable { + @SharedReader(.searchFilter) public var searchFilter: Filter + @SharedReader(.tagTranslator) public var tagTranslator: TagTranslator + @SharedReader(.setting) public var setting: Setting + @Presents public var destination: Destination.State? + public var keyword = "" + public var lastKeyword = "" + + public var galleries = [Gallery]() + public var pageNumber = PageNumber() + public var loadingState: LoadingState = .idle + public var footerLoadingState: LoadingState = .idle + + public init(keyword: String = "") { + self.keyword = keyword + self.lastKeyword = keyword } mutating func insertGalleries(_ galleries: [Gallery]) { @@ -46,37 +51,30 @@ struct DetailSearchReducer { } } - enum Action: BindableAction { + public enum Action: BindableAction { case binding(BindingAction) - case setNavigation(Route?) - case clearSubStates + case delegate(Delegate) + case filtersButtonTapped + case quickSearchButtonTapped + case destination(PresentationAction) - case teardown case fetchGalleries(String? = nil) case fetchGalleriesDone(Result<(PageNumber, [Gallery]), AppError>) case fetchMoreGalleries case fetchMoreGalleriesDone(Result<(PageNumber, [Gallery]), AppError>) - - case detail(DetailReducer.Action) - case filters(FiltersReducer.Action) - case quickSearch(QuickSearchReducer.Action) } - @Dependency(\.databaseClient) private var databaseClient @Dependency(\.hapticsClient) private var hapticsClient - var body: some Reducer { + public init() {} + + public var body: some Reducer { BindingReducer() - .onChange(of: \.route) { _, newValue in - Reduce({ _, _ in newValue == nil ? .send(.clearSubStates) : .none }) - } - .onChange(of: \.keyword) { _, newValue in - Reduce { state, _ in - if !newValue.isEmpty { - state.lastKeyword = newValue - } - return .none + .onChange(of: \.keyword) { _, state in + if !state.keyword.isEmpty { + state.lastKeyword = state.keyword } + return .none } Reduce { state, action in @@ -84,21 +82,19 @@ struct DetailSearchReducer { case .binding: return .none - case .setNavigation(let route): - state.route = route - return route == nil ? .send(.clearSubStates) : .none + case .delegate: + return .none - case .clearSubStates: - state.detailState.wrappedValue = .init() - state.filtersState = .init() - state.quickDetailSearchState = .init() - return .merge( - .send(.detail(.teardown)), - .send(.quickSearch(.teardown)) - ) + case .filtersButtonTapped: + state.destination = .filters(FiltersReducer.State()) + return .none - case .teardown: - return .merge(CancelID.allCases.map(Effect.cancel(id:))) + case .quickSearchButtonTapped: + state.destination = .quickSearch(QuickSearchReducer.State()) + return .none + + case .destination: + return .none case .fetchGalleries(let keyword): guard state.loadingState != .loading else { return .none } @@ -108,10 +104,10 @@ struct DetailSearchReducer { } state.loadingState = .loading state.pageNumber.resetPages() - let filter = databaseClient.fetchFilterSynchronously(range: .search) + let filter = state.searchFilter return .run { [lastKeyword = state.lastKeyword] send in let response = await SearchGalleriesRequest(keyword: lastKeyword, filter: filter).response() - await send(.fetchGalleriesDone(response)) + await send(.fetchGalleriesDone(response.map { ($0.pageNumber, $0.galleries) })) } .cancellable(id: CancelID.fetchGalleries) @@ -126,7 +122,7 @@ struct DetailSearchReducer { } state.pageNumber = pageNumber state.galleries = galleries - return .run(operation: { _ in await databaseClient.cacheGalleries(galleries) }) + return .none case .failure(let error): state.loadingState = .failed(error) } @@ -139,13 +135,13 @@ struct DetailSearchReducer { let lastID = state.galleries.last?.id else { return .none } state.footerLoadingState = .loading - let filter = databaseClient.fetchFilterSynchronously(range: .search) + let filter = state.searchFilter return .run { [lastKeyword = state.lastKeyword] send in let response = await MoreSearchGalleriesRequest( keyword: lastKeyword, filter: filter, lastID: lastID ) .response() - await send(.fetchMoreGalleriesDone(response)) + await send(.fetchMoreGalleriesDone(response.map { ($0.pageNumber, $0.galleries) })) } .cancellable(id: CancelID.fetchMoreGalleries) @@ -156,9 +152,7 @@ struct DetailSearchReducer { state.pageNumber = pageNumber state.insertGalleries(galleries) - var effects: [Effect] = [ - .run(operation: { _ in await databaseClient.cacheGalleries(galleries) }) - ] + var effects: [Effect] = [] if galleries.isEmpty, pageNumber.hasNextPage() { effects.append(.send(.fetchMoreGalleries)) } else if !galleries.isEmpty { @@ -170,29 +164,20 @@ struct DetailSearchReducer { state.footerLoadingState = .failed(error) } return .none - - case .detail: - return .none - - case .filters: - return .none - - case .quickSearch: - return .none } } .haptics( - unwrapping: \.route, + unwrapping: \.destination, case: \.quickSearch, hapticsClient: hapticsClient ) .haptics( - unwrapping: \.route, + unwrapping: \.destination, case: \.filters, hapticsClient: hapticsClient ) - - Scope(state: \.filtersState, action: \.filters, child: FiltersReducer.init) - Scope(state: \.quickDetailSearchState, action: \.quickSearch, child: QuickSearchReducer.init) + .ifLet(\.$destination, action: \.destination) } } + +extension DetailSearchReducer.Destination.State: Equatable, Sendable {} diff --git a/AppPackage/Sources/DetailFeature/DetailSearch/DetailSearchView.swift b/AppPackage/Sources/DetailFeature/DetailSearch/DetailSearchView.swift new file mode 100644 index 000000000..ffedb2632 --- /dev/null +++ b/AppPackage/Sources/DetailFeature/DetailSearch/DetailSearchView.swift @@ -0,0 +1,97 @@ +import SwiftUI +import AppModels +import TagTranslationFeature +import ComposableArchitecture +import AppTools +import AppComponents +import GalleryListComponents +import FiltersFeature +import QuickSearchFeature + +struct DetailSearchView: View { + @Bindable private var store: StoreOf + private let keyword: String + private let blurRadius: Double + + init( + store: StoreOf, + keyword: String, blurRadius: Double + ) { + self.store = store + self.keyword = keyword + self.blurRadius = blurRadius + } + + var body: some View { + GenericList( + galleries: store.galleries, + pageNumber: store.pageNumber, + loadingState: store.loadingState, + footerLoadingState: store.footerLoadingState, + fetchAction: { store.send(.fetchGalleries()) }, + fetchMoreAction: { store.send(.fetchMoreGalleries) }, + navigateAction: { store.send(.delegate(.pushDetail($0))) }, + translateAction: { + store.tagTranslator.lookup(word: $0, returnOriginal: !store.setting.translatesTags) + } + ) + .sheet( + item: $store.scope(state: \.destination?.quickSearch, action: \.destination.quickSearch) + ) { store in + QuickSearchView(store: store) { keyword in + self.store.send(.destination(.dismiss)) + self.store.send(.fetchGalleries(keyword)) + } + .accentColor(self.store.setting.accentColor) + .autoBlur(radius: blurRadius) + } + .sheet( + item: $store.scope(state: \.destination?.filters, action: \.destination.filters) + ) { store in + FiltersView(store: store) + .accentColor(self.store.setting.accentColor).autoBlur(radius: blurRadius) + } + .searchable(text: $store.keyword) + .searchSuggestions { + TagSuggestionView( + keyword: $store.keyword, translations: store.tagTranslator.translations, + showsImages: store.setting.showsImagesInTags, isEnabled: store.setting.showsTagsSearchSuggestion + ) + } + .onSubmit(of: .search) { + store.send(.fetchGalleries()) + } + .onAppear { + if store.galleries.isEmpty { + DispatchQueue.main.async { + store.send(.fetchGalleries(keyword)) + } + } + } + .toolbar(content: toolbar) + .navigationTitle(store.lastKeyword) + } + + private func toolbar() -> some ToolbarContent { + CustomToolbarItem { + ToolbarFeaturesMenu { + FiltersButton { + store.send(.filtersButtonTapped) + } + QuickSearchButton { + store.send(.quickSearchButtonTapped) + } + } + } + } +} + +struct DetailSearchView_Previews: PreviewProvider { + static var previews: some View { + DetailSearchView( + store: .init(initialState: .init(), reducer: DetailSearchReducer.init), + keyword: .init(), + blurRadius: 0 + ) + } +} diff --git a/AppPackage/Sources/DetailFeature/DetailView+CommentCells.swift b/AppPackage/Sources/DetailFeature/DetailView+CommentCells.swift new file mode 100644 index 000000000..bc261847b --- /dev/null +++ b/AppPackage/Sources/DetailFeature/DetailView+CommentCells.swift @@ -0,0 +1,65 @@ +import SwiftUI +import AppModels +import Resources +import AppComponents + +extension DetailView { + struct CommentCell: View { + let comment: GalleryComment + let backgroundColor: Color + + private var content: String { + comment.contents + .filter({ [.plainText, .linkedText].contains($0.type) }) + .compactMap(\.text).joined() + } + + var body: some View { + VStack(alignment: .leading) { + HStack { + Text(comment.author).font(.subheadline.bold()) + Spacer() + Group { + ZStack { + Image(systemSymbol: .handThumbsupFill) + .opacity(comment.votedUp ? 1 : 0) + Image(systemSymbol: .handThumbsdownFill) + .opacity(comment.votedDown ? 1 : 0) + } + Text(comment.score ?? "") + Text(comment.formattedDateString).lineLimit(1) + } + .font(.footnote).foregroundStyle(.secondary) + } + .minimumScaleFactor(0.75).lineLimit(1) + Text(content).padding(.top, 1) + Spacer() + } + .padding().background(backgroundColor) + .frame(width: 300, height: 120) + .cornerRadius(15) + } + } +} + +struct CommentButton: View { + let backgroundColor: Color + let action: () -> Void + + var body: some View { + let shape = RoundedRectangle(cornerRadius: 15) + + Button(action: action) { + HStack { + Image(systemSymbol: .squareAndPencil) + Text(.postComment) + .bold() + } + .padding() + .frame(maxWidth: .infinity) + .background(backgroundColor) + .clipShape(shape) + } + .glassEffect(.clear.interactive(), in: shape) + } +} diff --git a/AppPackage/Sources/DetailFeature/DetailView+HeaderSection.swift b/AppPackage/Sources/DetailFeature/DetailView+HeaderSection.swift new file mode 100644 index 000000000..a3bed9b43 --- /dev/null +++ b/AppPackage/Sources/DetailFeature/DetailView+HeaderSection.swift @@ -0,0 +1,382 @@ +import SwiftUI +import Sharing +import AppModels +import Resources +import Kingfisher +import SFSafeSymbols +import AppTools +import AppComponents + +// MARK: HeaderSection +struct HeaderSection: View { + @SharedReader(.user) var user: User + + let gallery: Gallery + let galleryDetail: GalleryDetail + let downloadBadge: DownloadBadge? + let downloadNeedsRepair: Bool + let downloadFolders: [String] + let isPreparingDownload: Bool + let canDownload: Bool + let displaysJapaneseTitle: Bool + let showFullTitle: Bool + let showFullTitleAction: () -> Void + let downloadAction: () -> Void + let downloadToFolderAction: (String) -> Void + let manageFoldersAction: () -> Void + let createDefaultFolderAction: () -> Void + let favorAction: (Int) -> Void + let unfavorAction: () -> Void + let readingProgress: Int + let navigateReadingAction: () -> Void + let navigateUploaderAction: () -> Void + + private let actionIconButtonSize: CGFloat = 32 + private let actionIconFont: Font = .system(size: 16, weight: .semibold) + + private var title: String { + let normalTitle = galleryDetail.title + return displaysJapaneseTitle ? galleryDetail.jpnTitle ?? normalTitle : normalTitle + } + private var showsMetadataPreparation: Bool { isPreparingDownload && downloadBadge == nil } + private var isDownloadActionDisabled: Bool { + guard canDownload else { return true } + return isPreparingDownload + } + private var downloadButtonTint: Color { + switch downloadBadge?.status { + case .updateAvailable: return .orange + case .completed: return .red + case .error: return isPartialDownloadError ? .orange : .red + default: return .accentColor + } + } + private var isPartialDownloadError: Bool { + guard let badge = downloadBadge, badge.status == .error else { return false } + return badge.progress.completedPageCount > 0 + && badge.progress.completedPageCount < badge.progress.pageCount + } + private var categoryLabel: some View { + CategoryLabel( + text: gallery.category.value, color: gallery.color(host: AppUtil.galleryHost), font: .headline, + insets: .init(top: 2, leading: 4, bottom: 2, trailing: 4), cornerRadius: 3 + ) + .lineLimit(1) + .minimumScaleFactor(0.72) + } + private var favoriteColor: Color { + let colors = Defaults.FavoriteColor.colors + + func color(for index: Int?) -> Color? { + guard let index, + index >= 0, + index < colors.count + else { + return nil + } + return colors[index] + } + + if let color = color(for: galleryDetail.favoriteTagIndex ?? gallery.favoriteTagIndex) { + return color + } + + if let favoriteTagName = galleryDetail.favoriteTagName, + let favoriteCategories = user.favoriteCategories, + let index = favoriteCategories.first(where: { $0.value == favoriteTagName })?.key, + let color = color(for: index) + { + return color + } + + return Color.accentColor + } + private var downloadButton: some View { + Group { + if let progress = activeDownloadProgress { + Button(action: downloadAction) { + progressIndicator( + progress: progress, + isDeterminate: true, + centerSymbol: activeDownloadIconSymbol + ) + } + .buttonStyle(.glass(.regular.interactive())) + .buttonBorderShape(.circle) + } else if let progress = queuedDownloadProgress { + Button(action: downloadAction) { + progressIndicator( + progress: progress, + isDeterminate: false, + centerSymbol: activeDownloadIconSymbol + ) + } + .buttonStyle(.glass(.regular.interactive())) + .buttonBorderShape(.circle) + } else if downloadBadge == nil { + Menu { + Section { + Button(action: manageFoldersAction) { + Label( + .RLocalizable.manageFolders, + systemSymbol: .folderBadgeGearshape + ) + } + // Without any folder there is nowhere to download to, so offer a + // one-tap shortcut to bootstrap one instead of forcing a trip + // through the folder manager. + if downloadFolders.isEmpty { + Button(action: createDefaultFolderAction) { + Label( + .createDefaultFolder, + systemSymbol: .folderBadgePlus + ) + } + .menuActionDismissBehavior(.disabled) + } + } + Section { + if downloadFolders.isEmpty { + Text(.noFolders) + } else { + ForEach(downloadFolders, id: \.self) { folder in + Button { + downloadToFolderAction(folder) + } label: { + Label(folder, systemSymbol: .folder) + } + } + } + } + } label: { + downloadIconLabel + } + .buttonStyle(.glass(.regular.interactive())) + .buttonBorderShape(.circle) + .animation( + showsMetadataPreparation + ? .linear(duration: 0.9).repeatForever(autoreverses: false) : .default, + value: showsMetadataPreparation + ) + } else { + Button(action: downloadAction) { + downloadIconLabel + } + .buttonStyle(.glass(.regular.interactive())) + .buttonBorderShape(.circle) + } + } + .disabled(isDownloadActionDisabled) + .frame(width: actionIconButtonSize, height: actionIconButtonSize) + .accessibilityLabel(downloadButtonAccessibilityLabel) + } + private var downloadIconLabel: some View { + Image(systemSymbol: downloadIconSymbol) + .font(actionIconFont) + .foregroundStyle(canDownload ? downloadButtonTint : .secondary) + .rotationEffect(.degrees(showsMetadataPreparation ? 360 : 0)) + .frame(width: actionIconButtonSize, height: actionIconButtonSize) + .contentShape(.circle) + } + private var favoriteButton: some View { + ZStack { + Button(action: unfavorAction) { + Image(systemSymbol: .heartFill) + .font(actionIconFont) + .frame(width: actionIconButtonSize, height: actionIconButtonSize) + } + .opacity(galleryDetail.isFavorited ? 1 : 0) + .foregroundStyle(favoriteColor) + .contextMenu { + ForEach(0..<10) { index in + Button(user.getFavoriteCategory(index: index)) { favorAction(index) } + } + } + Menu { + ForEach(0..<10) { index in + Button(user.getFavoriteCategory(index: index)) { favorAction(index) } + } + } label: { + Image(systemSymbol: .heart) + .font(actionIconFont) + .frame(width: actionIconButtonSize, height: actionIconButtonSize) + } + .opacity(galleryDetail.isFavorited ? 0 : 1) + .foregroundStyle(.tint) + } + .buttonStyle(.glass(.regular.interactive())) + .buttonBorderShape(.circle) + .disabled(!CookieUtil.didLogin) + } + private var readButton: some View { + Button(action: navigateReadingAction) { + Group { + if readingProgress > 0 { + Text("P\(readingProgress)") + .bold().textCase(.uppercase).font(.headline) + .foregroundColor(.white).padding(.vertical, -2) + .padding(.horizontal, 2).lineLimit(1) + .minimumScaleFactor(0.7) + .frame(minWidth: 50) + } else { + Image(systemSymbol: .bookFill) + .font(actionIconFont) + .foregroundStyle(.white) + .frame(width: actionIconButtonSize, height: actionIconButtonSize) + } + } + } + .buttonStyle(.glassProminent) + .buttonBorderShape(readingProgress > 0 ? .capsule : .circle) + .accessibilityLabel(.read) + } + private func progressIndicator( + progress: Double, isDeterminate: Bool, centerSymbol: SFSymbol + ) -> some View { + ZStack { + if isDeterminate { + Circle().stroke(downloadButtonTint.opacity(0.18), lineWidth: 2.5).padding(3) + Circle() + .trim(from: 0, to: progress) + .stroke(downloadButtonTint, style: .init(lineWidth: 2.5, lineCap: .round)) + .rotationEffect(.degrees(-90)) + .padding(3) + } else { + ProgressView() + .progressViewStyle(.circular) + .tint(downloadButtonTint) + .controlSize(.small) + } + Image(systemSymbol: centerSymbol) + .font(.system(size: 10, weight: .semibold)) + .foregroundStyle(downloadButtonTint) + } + .frame(width: actionIconButtonSize, height: actionIconButtonSize) + } + private var actionButtons: some View { + ViewThatFits(in: .horizontal) { + HStack(spacing: 6) { downloadButton; favoriteButton; readButton } + .fixedSize(horizontal: true, vertical: false) + + VStack(alignment: .trailing, spacing: 6) { + HStack(spacing: 6) { downloadButton; favoriteButton } + readButton + } + .fixedSize(horizontal: true, vertical: false) + + VStack(alignment: .trailing, spacing: 6) { downloadButton; favoriteButton; readButton } + .fixedSize(horizontal: true, vertical: false) + } + .layoutPriority(1) + } + private var bottomActionRow: some View { + ViewThatFits(in: .horizontal) { + HStack(spacing: 8) { categoryLabel; Spacer(minLength: 8); actionButtons } + + VStack(alignment: .leading, spacing: 8) { + categoryLabel + + actionButtons + .frame(maxWidth: .infinity, alignment: .trailing) + } + } + } + private var queuedDownloadProgress: Double? { + downloadBadge?.status == .queued ? 0 : nil + } + private var activeDownloadProgress: Double? { + guard let badge = downloadBadge, + [.active, .inactive].contains(badge.status) + else { return nil } + return badge.progress.fraction + } + private var activeDownloadIconSymbol: SFSymbol { + switch downloadBadge?.status { + case .inactive: return .playFill + case .active: return .pauseFill + default: return downloadIconSymbol + } + } + private var downloadIconSymbol: SFSymbol { + switch downloadBadge?.status { + case .completed: return .trash + case .updateAvailable: return .arrowTrianglehead2ClockwiseRotate90 + case .error: + return downloadNeedsRepair + ? .wrenchAndScrewdriver + : .exclamationmarkCircle + case .inactive: return .playFill + default: return .icloudAndArrowDown + } + } + + var body: some View { + HStack { + KFImage(gallery.coverURL) + .placeholder({ Placeholder(style: .activity(ratio: Defaults.ImageSize.headerAspect)) }) + .defaultModifier() + .scaledToFit() + .frame(width: Defaults.ImageSize.headerW, height: Defaults.ImageSize.headerH) + VStack(alignment: .leading) { + Button(action: showFullTitleAction) { + Text(title) + .font(.title3.bold()) + .multilineTextAlignment(.leading) + .tint(.primary) + .lineLimit(showFullTitle ? nil : 3) + .fixedSize(horizontal: false, vertical: true) + } + Button(gallery.uploader ?? "", action: navigateUploaderAction) + .lineLimit(1).font(.callout).foregroundStyle(.secondary) + Spacer() + bottomActionRow + } + .padding(.horizontal, 10) + .frame(minHeight: Defaults.ImageSize.headerH) + } + } +} + +// MARK: HeaderSection Accessibility +extension HeaderSection { + var downloadButtonAccessibilityLabel: String { + guard canDownload else { return String(localized: .accessibilityLogin) } + guard !showsMetadataPreparation else { + return String(localized: .accessibilityPreparing) + } + return downloadBadgeAccessibilityLabel + } + var downloadBadgeAccessibilityLabel: String { + guard let badge = downloadBadge else { + return String(localized: .accessibilityDownload) + } + let progress = badge.progress + switch badge.status { + case .queued: + return String(localized: .accessibilityQueued) + case .active: + let downloading = String(localized: .accessibilityDownloading( + completed: progress.completedPageCount, total: progress.displayPageCount + )) + return [downloading, String(localized: .accessibilityPauseAction)] + .joined(separator: ". ") + case .inactive: + return String(localized: .accessibilityPaused( + completed: progress.completedPageCount, total: progress.displayPageCount + )) + case .completed: + return String(localized: .accessibilityDownloaded) + case .updateAvailable: + return String(localized: .accessibilityUpdate) + case .error: + if isPartialDownloadError { + return String(localized: .accessibilityPartial( + available: progress.completedPageCount, total: progress.displayPageCount + )) + } + return downloadNeedsRepair + ? String(localized: .accessibilityRepair) + : String(localized: .accessibilityRetry) + } + } +} diff --git a/AppPackage/Sources/DetailFeature/DetailView+Navigation.swift b/AppPackage/Sources/DetailFeature/DetailView+Navigation.swift new file mode 100644 index 000000000..fc0fb427d --- /dev/null +++ b/AppPackage/Sources/DetailFeature/DetailView+Navigation.swift @@ -0,0 +1,39 @@ +import SwiftUI +import Resources +import ComposableArchitecture +import AppTools +import AppComponents +import SFSafeSymbolsExt + +// MARK: ToolBar +extension DetailView { + func toolbar() -> some ToolbarContent { + CustomToolbarItem { + ToolbarFeaturesMenu { + Button { + store.send(.archivesButtonTapped) + } label: { + Label(.archivesAction, systemSymbol: .zipperPage) + } + .disabled(store.galleryDetail?.archiveURL == nil || !CookieUtil.didLogin) + Button { + store.send(.torrentsButtonTapped) + } label: { + let torrentCount = store.galleryDetail?.torrentCount ?? 0 + let title: LocalizedStringResource = torrentCount > 0 + ? .torrentsCount(count: torrentCount) : .torrents + Label(title, systemSymbol: .leaf) + } + .disabled((store.galleryDetail?.torrentCount ?? 0 > 0) != true) + Button { + if let galleryURL = store.gallery.galleryURL { + store.send(.shareButtonTapped(galleryURL)) + } + } label: { + Label(.RLocalizable.share, systemSymbol: .squareAndArrowUp) + } + } + .disabled(store.galleryDetail == nil || store.loadingState == .loading) + } + } +} diff --git a/AppPackage/Sources/DetailFeature/DetailView+Subviews.swift b/AppPackage/Sources/DetailFeature/DetailView+Subviews.swift new file mode 100644 index 000000000..25bc8157b --- /dev/null +++ b/AppPackage/Sources/DetailFeature/DetailView+Subviews.swift @@ -0,0 +1,369 @@ +import SwiftUI +import AppModels +import TagTranslationFeature +import Resources +import Kingfisher +import AppTools +import AppComponents + +// MARK: DescriptionSection +struct DescriptionSection: View { + let gallery: Gallery + let galleryDetail: GalleryDetail + let navigateGalleryInfosAction: () -> Void + + private var infos: [DescScrollInfo] {[ + DescScrollInfo( + title: .favorited, + description: String(localized: .favoritedUnit), + value: .init(galleryDetail.favoritedCount) + ), + DescScrollInfo( + title: .RLocalizable.language, + description: String(localized: galleryDetail.language.value), + value: galleryDetail.language.abbreviation + ), + DescScrollInfo( + title: .ratingsCount(count: galleryDetail.ratingCount), + description: .init(), value: .init(), rating: galleryDetail.rating, isRating: true + ), + DescScrollInfo( + title: .pageCount, + description: String(localized: .pageCountUnit), + value: .init(galleryDetail.pageCount) + ), + DescScrollInfo( + title: .fileSize, + description: galleryDetail.sizeType, value: .init(galleryDetail.sizeCount) + ) + ]} + private var itemWidth: Double { + max(DeviceUtil.absWindowW / 5, 80) + } + + var body: some View { + ScrollView(.horizontal, showsIndicators: false) { + HStack { + ForEach(infos) { info in + Group { + if info.isRating { + DescScrollRatingItem( + title: info.title, + rating: info.rating, + userRating: galleryDetail.userRating + ) + } else { + DescScrollItem(title: info.title, value: info.value, description: info.description) + } + } + .frame(width: itemWidth).drawingGroup() + Divider() + if info == infos.last { + Button(action: navigateGalleryInfosAction) { + Image(systemSymbol: .ellipsis) + .font(.system(size: 20, weight: .bold)) + } + .frame(width: itemWidth) + } + } + .withHorizontalSpacing() + } + } + .frame(height: 60) + } +} + +extension DescriptionSection { + struct DescScrollInfo: Identifiable, Equatable { + var id: String { String(localized: title) } + let title: LocalizedStringResource + let description: String + let value: String + var rating: Float = 0 + var isRating = false + } + struct DescScrollItem: View { + let title: LocalizedStringResource + let value: String + let description: String + + var body: some View { + VStack(spacing: 3) { + Text(title).textCase(.uppercase).font(.caption) + Text(value).fontWeight(.medium).font(.title3).lineLimit(1) + Text(description).font(.caption) + } + } + } + struct DescScrollRatingItem: View { + let title: LocalizedStringResource + let rating: Float + let userRating: Float + + private var ratingColor: Color { + guard userRating > 0 else { return .primary } + switch userRating { + case 1..<2.5: return .red + case 2.5..<4.5: return .green + case 4.5...5: return .blue + default: return .primary + } + } + + var body: some View { + VStack(spacing: 3) { + Text(title).textCase(.uppercase).font(.caption).lineLimit(1) + Text(String(format: "%.2f", rating)).fontWeight(.medium).font(.title3) + RatingView(rating: rating).font(.system(size: 12)).foregroundStyle(ratingColor) + } + } + } +} + +// MARK: ActionSection +struct ActionSection: View { + let galleryDetail: GalleryDetail + let userRating: Int + let showUserRating: Bool + let showUserRatingAction: () -> Void + let updateRatingAction: (DragGesture.Value) -> Void + let confirmRatingAction: (DragGesture.Value) -> Void + let navigateSimilarGalleryAction: () -> Void + + private func ratingColor(for rating: Float) -> Color { + switch rating { + case 1..<2.5: return .red + case 2.5..<4.5: return .green + case 4.5...5: return .blue + default: return .yellow + } + } + + var body: some View { + VStack { + HStack { + Group { + Button(action: showUserRatingAction) { + Spacer() + Image(systemSymbol: .squareAndPencil) + Text(.giveARating).bold() + Spacer() + } + .disabled(!CookieUtil.didLogin) + Button(action: navigateSimilarGalleryAction) { + Spacer() + Image(systemSymbol: .photoOnRectangleAngled) + Text(.similarGallery).bold() + Spacer() + } + } + .font(.callout).foregroundStyle(.primary) + } + if showUserRating { + HStack { + RatingView(rating: Float(userRating) / 2) + .font(.system(size: 24)) + .foregroundStyle(userRating > 0 ? ratingColor(for: Float(userRating) / 2) : .yellow) + .gesture( + DragGesture(minimumDistance: 0) + .onChanged(updateRatingAction) + .onEnded(confirmRatingAction) + ) + } + .padding(.top, 10) + } + } + .padding(.horizontal) + } +} + +// MARK: TagsSection +struct TagsSection: View { + let tags: [GalleryTag] + let showsImages: Bool + let voteTagAction: (String, Int) -> Void + let navigateSearchAction: (String) -> Void + let navigateTagDetailAction: (TagDetail) -> Void + let translateAction: (String) -> (String, TagTranslation?) + + var body: some View { + VStack(alignment: .leading) { + ForEach(tags) { tag in + TagRow( + tag: tag, showsImages: showsImages, + voteTagAction: voteTagAction, + navigateSearchAction: navigateSearchAction, + navigateTagDetailAction: navigateTagDetailAction, + translateAction: translateAction + ) + } + } + .padding(.horizontal) + } +} + +extension TagsSection { + struct TagRow: View { + @Environment(\.colorScheme) private var colorScheme + @Environment(\.inSheet) private var inSheet + + let tag: GalleryTag + let showsImages: Bool + let voteTagAction: (String, Int) -> Void + let navigateSearchAction: (String) -> Void + let navigateTagDetailAction: (TagDetail) -> Void + let translateAction: (String) -> (String, TagTranslation?) + + private var reversedPrimary: Color { colorScheme == .light ? .white : .black } + private var backgroundColor: Color { + inSheet && colorScheme == .dark ? Color(.systemGray4) : Color(.systemGray5) + } + private var padding: EdgeInsets { .init(top: 5, leading: 14, bottom: 5, trailing: 14) } + + var body: some View { + HStack(alignment: .top) { + Text(tag.namespace.map { String(localized: $0.value) } ?? tag.rawNamespace).font(.subheadline.bold()) + .foregroundColor(reversedPrimary).padding(padding) + .background(Color(.systemGray)).cornerRadius(5) + TagCloudView(data: tag.contents) { content in + tagContentView(content: content) + } + } + } + + @ViewBuilder + private func tagContentView(content: GalleryTag.Content) -> some View { + let (_, translation) = translateAction(content.rawNamespace + content.text) + Button { + navigateSearchAction(content.serachKeyword(tag: tag)) + } label: { + TagCloudCell( + text: translation?.displayValue ?? content.text, + imageURL: translation?.valueImageURL, + showsImages: showsImages, + font: .subheadline, padding: padding, + textColor: content.textColor ?? (content.backgroundColor != nil ? .primary : .primary), + backgroundColor: content.backgroundColor ?? backgroundColor + ) + } + .contextMenu { + tagContextMenu(content: content, translation: translation) + } + } + + @ViewBuilder + private func tagContextMenu( + content: GalleryTag.Content, + translation: TagTranslation? + ) -> some View { + if let translation = translation, + let description = translation.descriptionPlainText, + !description.isEmpty { + Button { + navigateTagDetailAction(.init( + title: translation.displayValue, description: description, + imageURLs: translation.descriptionImageURLs, + links: translation.links + )) + } label: { + Image(systemSymbol: .richtextPage) + Text(.RLocalizable.detail) + } + } + if CookieUtil.didLogin { + tagVoteButtons(content: content) + } + } + + @ViewBuilder + private func tagVoteButtons(content: GalleryTag.Content) -> some View { + if content.isVotedUp || content.isVotedDown { + Button { + voteTagAction(content.voteKeyword(tag: tag), content.isVotedUp ? -1 : 1) + } label: { + Image(systemSymbol: content.isVotedUp ? .handThumbsup : .handThumbsdown) + .symbolVariant(.fill) + Text(.withdrawVote) + } + } else { + Button { + voteTagAction(content.voteKeyword(tag: tag), 1) + } label: { + Image(systemSymbol: .handThumbsup) + Text(.voteUp) + } + Button { + voteTagAction(content.voteKeyword(tag: tag), -1) + } label: { + Image(systemSymbol: .handThumbsdown) + Text(.voteDown) + } + } + } + } +} + +// MARK: PreviewsSection +struct PreviewsSection: View { + let pageCount: Int + let previewURLs: [Int: URL] + let navigatePreviewsAction: () -> Void + let navigateReadingAction: (Int) -> Void + + private var width: CGFloat { Defaults.ImageSize.previewAvgW } + private var height: CGFloat { width / Defaults.ImageSize.previewAspect } + + var body: some View { + SubSection( + title: .previews, + showAll: pageCount > 20, showAllAction: navigatePreviewsAction + ) { + ScrollView(.horizontal, showsIndicators: false) { + LazyHStack { + ForEach(previewURLs.tuples.sorted(by: { $0.0 < $1.0 }), id: \.0) { index, previewURL in + Button { + navigateReadingAction(index) + } label: { + PreviewImageView(originalURL: previewURL) + .frame(width: width, height: height) + } + } + .withHorizontalSpacing(height: height) + } + } + } + } +} + +// MARK: CommentsSection +struct CommentsSection: View { + @Environment(\.colorScheme) private var colorScheme + @Environment(\.inSheet) private var inSheet + + let comments: [GalleryComment] + let navigateCommentAction: () -> Void + let navigatePostCommentAction: () -> Void + + private var backgroundColor: Color { + inSheet && colorScheme == .dark ? Color(.systemGray5) : Color(.systemGray6) + } + + var body: some View { + SubSection( + title: .comments, + showAll: !comments.isEmpty, showAllAction: navigateCommentAction + ) { + ScrollView(.horizontal, showsIndicators: false) { + HStack { + ForEach(comments.prefix(min(comments.count, 6))) { comment in + DetailView.CommentCell(comment: comment, backgroundColor: backgroundColor) + } + .withHorizontalSpacing() + } + .drawingGroup() + } + CommentButton(backgroundColor: backgroundColor, action: navigatePostCommentAction) + .padding(.horizontal).disabled(!CookieUtil.didLogin) + } + } +} diff --git a/AppPackage/Sources/DetailFeature/DetailView.swift b/AppPackage/Sources/DetailFeature/DetailView.swift new file mode 100644 index 000000000..6e16ccac4 --- /dev/null +++ b/AppPackage/Sources/DetailFeature/DetailView.swift @@ -0,0 +1,320 @@ +import SwiftUI +import AppModels +import TagTranslationFeature +import Resources +import Kingfisher +import ComposableArchitecture +import CommonMark +import AppTools +import AppComponents +import ReadingFeature + +public struct DetailView: View { + @Bindable var store: StoreOf + let gid: String + let blurRadius: Double + + public init( + store: StoreOf, gid: String, + blurRadius: Double + ) { + self.store = store + self.gid = gid + self.blurRadius = blurRadius + } + + public var body: some View { + modalModifiers(content: { content }) + .animation(.default, value: store.showsUserRating) + .animation(.default, value: store.showsFullTitle) + .animation(.default, value: store.galleryDetail) + .onAppear { + DispatchQueue.main.async { + store.send(.onAppear(gid)) + } + } + .onChange(of: store.galleryDetail) { _, _ in + runLaunchAutomationIfNeeded() + } + .onChange(of: store.hasLoadedDownloadBadge) { _, _ in + runLaunchAutomationIfNeeded() + } + .appAlert($store.scope(state: \.alert, action: \.alert)) + .toolbar(content: toolbar) + } + +} + +// MARK: Content +private extension DetailView { + var content: some View { + ZStack { + ScrollView(showsIndicators: false) { + let content = + VStack(spacing: 30) { + if let error = store.loadingState.failed, + store.galleryDetail != nil { + offlineFallbackNotice(error: error) + .padding(.horizontal) + } + HeaderSection( + gallery: store.gallery, + galleryDetail: store.galleryDetail ?? .empty, + downloadBadge: store.downloadBadge, + downloadNeedsRepair: store.downloadNeedsRepair, + downloadFolders: store.downloadFolders, + isPreparingDownload: store.isPreparingDownload, + canDownload: !store.gallery.id.isEmpty + && (AppUtil.galleryHost == .ehentai || CookieUtil.didLogin), + displaysJapaneseTitle: store.setting.displaysJapaneseTitle, + showFullTitle: store.showsFullTitle, + showFullTitleAction: { store.send(.toggleShowFullTitle) }, + downloadAction: { handleDownloadAction() }, + downloadToFolderAction: { + store.send(.startDownload($0)) + }, + manageFoldersAction: { store.send(.folderManagerButtonTapped) }, + createDefaultFolderAction: { store.send(.createDefaultFolder) }, + favorAction: { store.send(.favorGallery($0)) }, + unfavorAction: { store.send(.unfavorGallery) }, + readingProgress: store.readingProgress, + navigateReadingAction: { store.send(.openReading) }, + navigateUploaderAction: { + if let uploader = store.galleryDetail?.uploader { + let keyword = "uploader:" + "\"\(uploader)\"" + store.send(.delegate(.pushDetailSearch(keyword))) + } + } + ) + .padding(.horizontal) + DescriptionSection( + gallery: store.gallery, + galleryDetail: store.galleryDetail ?? .empty, + navigateGalleryInfosAction: { + if let galleryDetail = store.galleryDetail { + store.send(.delegate(.pushGalleryInfos(store.gallery, galleryDetail))) + } + } + ) + ActionSection( + galleryDetail: store.galleryDetail ?? .empty, + userRating: store.userRating, + showUserRating: store.showsUserRating, + showUserRatingAction: { store.send(.toggleShowUserRating) }, + updateRatingAction: { store.send(.updateRating($0)) }, + confirmRatingAction: { store.send(.confirmRating($0)) }, + navigateSimilarGalleryAction: { + if let trimmedTitle = store.galleryDetail?.trimmedTitle { + store.send(.delegate(.pushDetailSearch(trimmedTitle))) + } + } + ) + if !store.galleryTags.isEmpty { + TagsSection( + tags: store.galleryTags, showsImages: store.setting.showsImagesInTags, + voteTagAction: { store.send(.voteTag($0, $1)) }, + navigateSearchAction: { store.send(.delegate(.pushDetailSearch($0))) }, + navigateTagDetailAction: { store.send(.tagDetailButtonTapped($0)) }, + translateAction: { + store.tagTranslator.lookup(word: $0, returnOriginal: !store.setting.translatesTags) + } + ) + .padding(.horizontal) + } + let displayPreviewURLs = store.localPreviewURLs.merging( + store.galleryPreviewURLs, + uniquingKeysWith: { local, _ in local } + ) + if !displayPreviewURLs.isEmpty { + PreviewsSection( + pageCount: store.galleryDetail?.pageCount ?? 0, + previewURLs: displayPreviewURLs, + navigatePreviewsAction: { + store.send(.delegate(.pushPreviews( + store.gallery, store.previewConfig, store.galleryDetail?.language + ))) + }, + navigateReadingAction: { + store.send(.updateReadingProgress($0)) + store.send(.openReading) + } + ) + } + CommentsSection( + comments: store.galleryComments, + navigateCommentAction: { + if let galleryURL = store.gallery.galleryURL { + store.send(.delegate(.pushComments( + gid: gid, token: store.gallery.token, apiKey: store.apiKey, + galleryURL: galleryURL, comments: store.galleryComments, + scrollCommentID: nil + ))) + } + }, + navigatePostCommentAction: { store.send(.postCommentButtonTapped) } + ) + } + .padding(.bottom, 20) + + if #available(iOS 18.0, *) { + content + .padding(.top, 25) + } else { + content + .padding(.top, -25) + } + } + .opacity(store.galleryDetail == nil ? 0 : 1) + + LoadingView() + .opacity( + store.galleryDetail == nil + && store.loadingState == .loading ? 1 : 0 + ) + + let error = store.loadingState.failed + let retryAction: () -> Void = { store.send(.fetchGalleryDetail) } + ErrorView(error: error ?? .unknown, action: error?.isRetryable != false ? retryAction : nil) + .opacity(store.galleryDetail == nil && error != nil ? 1 : 0) + } + } + + func modalModifiers(@ViewBuilder content: () -> Content) -> some View { + primaryModalModifiers(content: content) + .sheet(item: $store.destination.postComment, id: \.id) { _ in + PostCommentView( + title: .postComment, + content: $store.commentContent, + isFocused: $store.postCommentFocused, + postAction: { + if let galleryURL = store.gallery.galleryURL { + store.send(.postComment(galleryURL)) + } + store.send(.destination(.dismiss)) + }, + cancelAction: { store.send(.destination(.dismiss)) }, + onAppearAction: { store.send(.onPostCommentAppear) } + ) + .accentColor(self.store.setting.accentColor) + .autoBlur(radius: blurRadius) + } + .sheet(item: $store.destination.newDawn) { greeting in + NewDawnView(greeting: greeting.wrappedValue) + .autoBlur(radius: blurRadius) + } + .sheet(item: $store.destination.tagDetail, id: \.title) { detail in + TagDetailView(detail: detail.wrappedValue) + .autoBlur(radius: blurRadius) + } + } + + private func primaryModalModifiers(@ViewBuilder content: () -> Content) -> some View { + content() + .fullScreenCover( + item: $store.scope(state: \.destination?.reading, action: \.destination.reading) + ) { store in + ReadingView( + store: store, + gid: gid, + blurRadius: blurRadius + ) + .accentColor(self.store.setting.accentColor) + .autoBlur(radius: blurRadius) + } + .sheet( + item: $store.scope(state: \.destination?.archives, action: \.destination.archives) + ) { archivesStore in + if let galleryURL = store.gallery.galleryURL, let archiveURL = store.galleryDetail?.archiveURL { + ArchivesView( + store: archivesStore, + gid: gid, + galleryURL: galleryURL, + archiveURL: archiveURL + ) + .accentColor(self.store.setting.accentColor) + .autoBlur(radius: blurRadius) + } + } + .sheet( + item: $store.scope(state: \.destination?.torrents, action: \.destination.torrents) + ) { store in + TorrentsView( + store: store, + gid: gid, + token: self.store.gallery.token, + blurRadius: blurRadius + ) + .accentColor(self.store.setting.accentColor) + .autoBlur(radius: blurRadius) + } + .sheet( + item: $store.scope(state: \.destination?.folderManager, action: \.destination.folderManager) + ) { store in + FolderManagerView(store: store) + .accentColor(self.store.setting.accentColor) + .autoBlur(radius: blurRadius) + } + .sheet(item: $store.destination.share, id: \.absoluteString) { url in + ActivityView(activityItems: [url.wrappedValue]) + .autoBlur(radius: blurRadius) + } + } + +} + +// MARK: Actions +private extension DetailView { + private func handleDownloadAction() { + switch store.downloadBadge?.status { + case nil: + // Starting a new download requires picking a folder; the download + // button presents a folder menu for this case instead. + break + case .queued, .active, .inactive: + store.send(.toggleDownloadPause) + case .completed: + store.send(.deleteDownloadButtonTapped) + case .error: + store.send(.retryDownloadButtonTapped(store.downloadNeedsRepair ? .repair : .redownload)) + case .updateAvailable: + store.send(.retryDownloadButtonTapped(.update)) + } + } + + private func runLaunchAutomationIfNeeded() { + store.send(.runLaunchAutomationIfNeeded) + } + + @ViewBuilder private func offlineFallbackNotice(error: AppError) -> some View { + VStack(alignment: .leading, spacing: 10) { + Label( + .savedDetails, + systemSymbol: .wifiExclamationmark + ) + .font(.subheadline.weight(.semibold)) + .foregroundStyle(.orange) + if error.isRetryable != false { + Button(.RLocalizable.retry) { + store.send(.fetchGalleryDetail) + } + .buttonStyle(.glass) + .buttonBorderShape(.capsule) + } + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(14) + .background(.regularMaterial, in: .rect(cornerRadius: 18)) + } +} + +struct DetailView_Previews: PreviewProvider { + static var previews: some View { + NavigationStack { + DetailView( + store: .init(initialState: .init(gallery: .preview), reducer: DetailReducer.init), + gid: .init(), + blurRadius: 0 + ) + } + } +} diff --git a/AppPackage/Sources/DetailFeature/FolderManager/FolderManagerReducer.swift b/AppPackage/Sources/DetailFeature/FolderManager/FolderManagerReducer.swift new file mode 100644 index 000000000..6c9a12ea1 --- /dev/null +++ b/AppPackage/Sources/DetailFeature/FolderManager/FolderManagerReducer.swift @@ -0,0 +1,201 @@ +import SwiftUI +import AppModels +import Resources +import ComposableArchitecture +import DownloadClient +import AppComponents + +@Reducer +public struct FolderManagerReducer: Sendable { + public enum Dialog: Equatable, Sendable { + case confirmDelete(String) + } + + public enum EditingField: Equatable, Hashable { + case newFolder + case renameFolder(String) + } + + private enum CancelID { + case fetchFolders + } + + private static var invalidFolderNameError: AppError { + .fileOperationFailed( + String(localized: .RLocalizable.downloadStoreInvalidFolderName) + ) + } + + @ObservableState + public struct State: Equatable { + @Presents public var confirmationDialog: ConfirmationDialogState? + public var editingField: EditingField? + public var editingFolderName = "" + public var loadingState: LoadingState = .idle + public var folders = [String]() + + public init() {} + + var normalizedEditingFolderName: String? { + DownloadStore.normalizedUserFolderName(editingFolderName) + } + + var isEditingNameValid: Bool { + guard let normalizedName = normalizedEditingFolderName else { + return false + } + switch editingField { + case .renameFolder(let oldName): + return normalizedName == oldName || !folders.contains(normalizedName) + case .newFolder, nil: + return !folders.contains(normalizedName) + } + } + } + + public enum Action: BindableAction { + case binding(BindingAction) + case confirmationDialog(PresentationAction) + case deleteButtonTapped(String) + case setEditingField(EditingField?) + case submitEditingField + + case createFolder + case createFolderDone(Result) + case renameFolder(String) + case renameFolderDone(Result) + case deleteFolder(String) + case deleteFolderDone(Result) + + case fetchFolders + case fetchFoldersDone([String]) + } + + @Dependency(\.downloadClient) private var downloadClient + + public init() {} + + public var body: some Reducer { + BindingReducer() + + Reduce { state, action in + switch action { + case .binding: + return .none + + case .deleteButtonTapped(let folder): + state.confirmationDialog = ConfirmationDialogState(titleVisibility: .hidden) { + TextState(localized: .RLocalizable.delete) + } actions: { + ButtonState(role: .destructive, action: .confirmDelete(folder)) { + TextState(localized: .RLocalizable.delete) + } + ButtonState(role: .cancel) { + TextState(localized: .RLocalizable.cancel) + } + } message: { + TextState(localized: .deleteFolder) + } + return .none + + case .confirmationDialog(.presented(.confirmDelete(let folder))): + return .send(.deleteFolder(folder)) + + case .confirmationDialog: + return .none + + case .setEditingField(let editingField): + state.editingField = editingField + switch editingField { + case .renameFolder(let folderName): + state.editingFolderName = folderName + case .newFolder, nil: + state.editingFolderName = "" + } + return .none + + case .submitEditingField: + let editingField = state.editingField + state.editingField = nil + guard state.isEditingNameValid else { return .none } + switch editingField { + case .newFolder: + return .send(.createFolder) + case .renameFolder(let oldName): + return .send(.renameFolder(oldName)) + case nil: + return .none + } + + case .createFolder: + guard let name = state.normalizedEditingFolderName else { + state.loadingState = .failed(Self.invalidFolderNameError) + return .none + } + state.loadingState = .loading + return .run { send in + try await downloadClient.createFolder(name) + await send(.createFolderDone(.success(()))) + } catch: { error, send in + await send(.createFolderDone(.failure(AppError(error)))) + } + + case .createFolderDone(.success): + return .send(.fetchFolders) + + case .createFolderDone(.failure(let error)): + state.loadingState = .failed(error) + return .none + + case .renameFolder(let oldName): + guard let newName = state.normalizedEditingFolderName else { + state.loadingState = .failed(Self.invalidFolderNameError) + return .none + } + state.loadingState = .loading + return .run { send in + try await downloadClient.renameFolder(oldName, newName) + await send(.renameFolderDone(.success(()))) + } catch: { error, send in + await send(.renameFolderDone(.failure(AppError(error)))) + } + + case .renameFolderDone(.success): + return .send(.fetchFolders) + + case .renameFolderDone(.failure(let error)): + state.loadingState = .failed(error) + return .none + + case .deleteFolder(let name): + state.loadingState = .loading + return .run { send in + try await downloadClient.deleteFolder(name) + await send(.deleteFolderDone(.success(()))) + } catch: { error, send in + await send(.deleteFolderDone(.failure(AppError(error)))) + } + + case .deleteFolderDone(.success): + return .send(.fetchFolders) + + case .deleteFolderDone(.failure(let error)): + state.loadingState = .failed(error) + return .none + + case .fetchFolders: + state.loadingState = .loading + return .run { send in + await send(.fetchFoldersDone(try await downloadClient.fetchFolders())) + } + .cancellable(id: CancelID.fetchFolders, cancelInFlight: true) + + case .fetchFoldersDone(let folders): + state.loadingState = .idle + state.folders = folders + return .none + } + } + .ifLet(\.$confirmationDialog, action: \.confirmationDialog) + } +} diff --git a/AppPackage/Sources/DetailFeature/FolderManager/FolderManagerView.swift b/AppPackage/Sources/DetailFeature/FolderManager/FolderManagerView.swift new file mode 100644 index 000000000..226d88c59 --- /dev/null +++ b/AppPackage/Sources/DetailFeature/FolderManager/FolderManagerView.swift @@ -0,0 +1,137 @@ +import SwiftUI +import Resources +import SFSafeSymbols +import ComposableArchitecture +import AppComponents + +public struct FolderManagerView: View { + @Bindable private var store: StoreOf + @FocusState private var focusedField: FolderManagerReducer.EditingField? + @Environment(\.dismiss) private var dismiss + + public init(store: StoreOf) { + self.store = store + } + + public var body: some View { + NavigationStack { + ZStack { + List { + if store.editingField == .newFolder { + newFolderRow + .padding(5) + } + ForEach(store.folders, id: \.self) { folder in + folderRow(folder) + .padding(5) + .swipeActions(edge: .trailing) { + Button { + store.send(.deleteButtonTapped(folder)) + } label: { + Image(systemSymbol: .trash) + } + .tint(.red) + Button { + store.send(.setEditingField(.renameFolder(folder))) + } label: { + Image(systemSymbol: .squareAndPencil) + } + } + } + } + + stateOverlay + } + .confirmationDialog( + $store.scope(state: \.confirmationDialog, action: \.confirmationDialog) + ) + .animation(.default, value: store.folders) + .animation(.default, value: store.editingField) + .synchronize($store.editingField, $focusedField) + .onAppear { + store.send(.fetchFolders) + } + .toolbar(content: toolbar) + .navigationTitle(.folders) + .navigationBarTitleDisplayMode(.inline) + } + } + + @ViewBuilder private var stateOverlay: some View { + switch store.loadingState { + case .loading where store.folders.isEmpty: + LoadingView() + + case .failed(let error): + ErrorView(error: error) { + store.send(.fetchFolders) + } + + case .idle, .loading: + if store.folders.isEmpty && store.editingField != .newFolder { + AlertView( + symbol: .folder, + message: .emptyFolders + ) { + EmptyView() + } + } + } + } + + private var newFolderRow: some View { + Label { + editingTextField(.newFolder) + } icon: { + Image(systemSymbol: .folderBadgePlus) + } + } + + @ViewBuilder private func folderRow(_ folder: String) -> some View { + if store.editingField == .renameFolder(folder) { + Label { + editingTextField(.renameFolder(folder)) + } icon: { + Image(systemSymbol: .folder) + } + } else { + Label(folder, systemSymbol: .folder) + } + } + + private func editingTextField(_ field: FolderManagerReducer.EditingField) -> some View { + TextField( + .folderName, + text: $store.editingFolderName + ) + .disableAutocorrection(true) + .submitLabel(.done) + .focused($focusedField, equals: field) + .onSubmit { + store.send(.submitEditingField) + } + } + + private func toolbar() -> some ToolbarContent { + Group { + ToolbarItem(placement: .cancellationAction) { + Button(role: .close, action: dismiss.callAsFunction) + } + CustomToolbarItem { + Button { + store.send(.setEditingField(.newFolder)) + } label: { + Image(systemSymbol: .plus) + } + } + } + } +} + +struct FolderManagerView_Previews: PreviewProvider { + static var previews: some View { + FolderManagerView( + store: .init(initialState: .init(), reducer: FolderManagerReducer.init) + ) + } +} diff --git a/AppPackage/Sources/DetailFeature/GalleryDeepLink.swift b/AppPackage/Sources/DetailFeature/GalleryDeepLink.swift new file mode 100644 index 000000000..a6460a34e --- /dev/null +++ b/AppPackage/Sources/DetailFeature/GalleryDeepLink.swift @@ -0,0 +1,23 @@ +import Foundation + +// A deferred navigation intent carried onto a freshly-pushed Detail screen. It lets a gallery link +// tapped inside a comment open the linked gallery and then, once that detail finishes loading, jump +// to a specific reading page or scroll its comment list to a specific comment — behavior that the old +// recursive detail achieved by mutating the embedded child before pushing. +public enum GalleryDeepLink: Equatable, Sendable { + case reading(page: Int) + case comments(commentID: String) + + /// The deep-link intent encoded by a parsed gallery URL: a resume page takes precedence over a + /// target comment. Returns `nil` when the link carries neither. Shared by every gallery-link + /// handler so the precedence can't drift between call sites. + public init?(pageIndex: Int?, commentID: String?) { + if let pageIndex { + self = .reading(page: pageIndex) + } else if let commentID { + self = .comments(commentID: commentID) + } else { + return nil + } + } +} diff --git a/AppPackage/Sources/DetailFeature/GalleryDestination.swift b/AppPackage/Sources/DetailFeature/GalleryDestination.swift new file mode 100644 index 000000000..13ded2b05 --- /dev/null +++ b/AppPackage/Sources/DetailFeature/GalleryDestination.swift @@ -0,0 +1,75 @@ +import SwiftUI +import ComposableArchitecture + +// Builds the view for a single gallery stack element. Shared by every gallery host (and reused by the +// nested `.gallery` case of Home's and SearchRoot's paths) so the screen wiring lives in one place. +@MainActor +@ViewBuilder +public func galleryDestination( + _ store: StoreOf, + blurRadius: Double +) -> some View { + switch store.case { + case .detail(let detailStore): + DetailView( + store: detailStore, gid: detailStore.gid, + blurRadius: blurRadius + ) + case .previews(let previewsStore): + PreviewsView( + store: previewsStore, gid: previewsStore.gid, + blurRadius: blurRadius + ) + case .comments(let commentsStore): + CommentsView( + store: commentsStore, gid: commentsStore.gid, token: commentsStore.token, + apiKey: commentsStore.apiKey, galleryURL: commentsStore.galleryURL, + comments: commentsStore.comments, + blurRadius: blurRadius + ) + case .detailSearch(let searchStore): + DetailSearchView( + store: searchStore, keyword: searchStore.keyword, + blurRadius: blurRadius + ) + case .galleryInfos(let infosStore): + GalleryInfosView( + store: infosStore, gallery: infosStore.gallery, galleryDetail: infosStore.galleryDetail + ) + } +} + +// A `NavigationStack` whose drill-down is the shared `GalleryPath`. Hosts that stack only gallery +// screens (Favorites, Downloads, deep-link detail) build their root list here; the iPhone/iPad +// navigation container decision is centralized in this one type. +public struct GalleryNavigationContainer: View { + @Bindable private var store: Store + private let statePath: KeyPath> + private let actionPath: CaseKeyPath> + private let blurRadius: Double + private let root: Root + + public init( + store: Store, + state statePath: KeyPath>, + action actionPath: CaseKeyPath>, + blurRadius: Double, + @ViewBuilder root: () -> Root + ) { + self.store = store + self.statePath = statePath + self.actionPath = actionPath + self.blurRadius = blurRadius + self.root = root() + } + + public var body: some View { + NavigationStack(path: $store.scope(state: statePath, action: actionPath)) { + root + } destination: { elementStore in + galleryDestination( + elementStore, blurRadius: blurRadius + ) + } + } +} diff --git a/AppPackage/Sources/DetailFeature/GalleryInfos/GalleryInfosReducer.swift b/AppPackage/Sources/DetailFeature/GalleryInfos/GalleryInfosReducer.swift new file mode 100644 index 000000000..b2f3d24fa --- /dev/null +++ b/AppPackage/Sources/DetailFeature/GalleryInfos/GalleryInfosReducer.swift @@ -0,0 +1,54 @@ +import AppModels +import ComposableArchitecture +import HapticsClient +import ClipboardClient +import AppComponents + +@Reducer +public struct GalleryInfosReducer: Sendable { + @ObservableState + public struct State: Equatable { + @Presents public var toast: AppAlertState? + // Display data captured when this screen is pushed onto the host's gallery stack. + public var gallery: Gallery + public var galleryDetail: GalleryDetail + + public init(gallery: Gallery, galleryDetail: GalleryDetail) { + self.gallery = gallery + self.galleryDetail = galleryDetail + } + } + + public enum Action: BindableAction, Equatable { + case binding(BindingAction) + case toast(PresentationAction) + case copyText(String) + } + + @Dependency(\.clipboardClient) private var clipboardClient + @Dependency(\.hapticsClient) private var hapticsClient + + public init() {} + + public var body: some Reducer { + BindingReducer() + + Reduce { state, action in + switch action { + case .binding: + return .none + + case .toast: + return .none + + case .copyText(let text): + state.toast = .copiedToClipboardSucceeded + return .merge( + .run(operation: { _ in clipboardClient.saveText(text) }), + .run(operation: { _ in await hapticsClient.generateNotificationFeedback(.success) }) + ) + } + } + .ifLet(\.$toast, action: \.toast) + } +} diff --git a/EhPanda/View/Detail/GalleryInfos/GalleryInfosView.swift b/AppPackage/Sources/DetailFeature/GalleryInfos/GalleryInfosView.swift similarity index 52% rename from EhPanda/View/Detail/GalleryInfos/GalleryInfosView.swift rename to AppPackage/Sources/DetailFeature/GalleryInfos/GalleryInfosView.swift index d28c870f4..67707fb81 100644 --- a/EhPanda/View/Detail/GalleryInfos/GalleryInfosView.swift +++ b/AppPackage/Sources/DetailFeature/GalleryInfos/GalleryInfosView.swift @@ -1,10 +1,9 @@ -// -// GalleryInfosView.swift -// EhPanda -// - import SwiftUI +import AppModels +import Resources import ComposableArchitecture +import AppTools +import SystemNotificationExt struct GalleryInfosView: View { @Bindable private var store: StoreOf @@ -19,72 +18,72 @@ struct GalleryInfosView: View { private var infos: [Info] { [ - Info(title: L10n.Localizable.GalleryInfosView.Title.id, value: galleryDetail.gid), - Info(title: L10n.Localizable.GalleryInfosView.Title.token, value: gallery.token), - Info(title: L10n.Localizable.GalleryInfosView.Title.title, value: galleryDetail.title), - Info(title: L10n.Localizable.GalleryInfosView.Title.japaneseTitle, value: galleryDetail.jpnTitle), + Info(title: .metadataId, value: galleryDetail.gid), + Info(title: .metadataToken, value: gallery.token), + Info(title: .metadataTitle, value: galleryDetail.title), + Info(title: .metadataJapaneseTitle, value: galleryDetail.jpnTitle), Info( - title: L10n.Localizable.GalleryInfosView.Title.galleryURL, + title: .metadataGalleryUrl, value: gallery.galleryURL?.absoluteString ), Info( - title: L10n.Localizable.GalleryInfosView.Title.coverURL, + title: .metadataCoverUrl, value: galleryDetail.coverURL?.absoluteString ), Info( - title: L10n.Localizable.GalleryInfosView.Title.archiveURL, + title: .metadataArchiveUrl, value: galleryDetail.archiveURL?.absoluteString ), Info( - title: L10n.Localizable.GalleryInfosView.Title.torrentURL, + title: .metadataTorrentUrl, value: URLUtil.galleryTorrents(gid: gallery.gid, token: gallery.token).absoluteString ), Info( - title: L10n.Localizable.GalleryInfosView.Title.parentURL, + title: .metadataParentUrl, value: galleryDetail.parentURL?.absoluteString ), Info( - title: L10n.Localizable.GalleryInfosView.Title.category, - value: galleryDetail.category.value + title: .metadataCategory, + value: String(localized: galleryDetail.category.value) ), - Info(title: L10n.Localizable.GalleryInfosView.Title.uploader, value: galleryDetail.uploader), + Info(title: .metadataUploader, value: galleryDetail.uploader), Info( - title: L10n.Localizable.GalleryInfosView.Title.postedDate, + title: .metadataPostedDate, value: galleryDetail.formattedDateString ), Info( - title: L10n.Localizable.GalleryInfosView.Title.visibility, + title: .metadataVisibility, value: galleryDetail.visibility.value ), - Info(title: L10n.Localizable.GalleryInfosView.Title.language, value: galleryDetail.language.value), - Info(title: L10n.Localizable.GalleryInfosView.Title.pageCount, value: String(galleryDetail.pageCount)), + Info(title: .metadataLanguage, value: String(localized: galleryDetail.language.value)), + Info(title: .metadataPageCount, value: String(galleryDetail.pageCount)), Info( - title: L10n.Localizable.GalleryInfosView.Title.fileSize, + title: .metadataFileSize, value: String(Int(galleryDetail.sizeCount)) + galleryDetail.sizeType ), Info( - title: L10n.Localizable.GalleryInfosView.Title.favoritedTimes, + title: .metadataFavoritedTimes, value: String(galleryDetail.favoritedCount) ), Info( - title: L10n.Localizable.GalleryInfosView.Title.favorited, - value: galleryDetail.isFavorited ? L10n.Localizable.GalleryInfosView.Value.yes - : L10n.Localizable.GalleryInfosView.Value.no + title: .metadataFavorited, + value: galleryDetail.isFavorited ? String(localized: .metadataYes) + : String(localized: .metadataNo) ), Info( - title: L10n.Localizable.GalleryInfosView.Title.ratingCount, + title: .metadataRatingCount, value: String(galleryDetail.ratingCount) ), Info( - title: L10n.Localizable.GalleryInfosView.Title.averageRating, + title: .metadataAverageRating, value: String(Int(galleryDetail.rating)) ), Info( - title: L10n.Localizable.GalleryInfosView.Title.myRating, + title: .metadataMyRating, value: galleryDetail.userRating == 0 ? nil : String(Int(galleryDetail.userRating)) ), Info( - title: L10n.Localizable.GalleryInfosView.Title.torrentCount, + title: .metadataTorrentCount, value: String(galleryDetail.torrentCount) ) ] @@ -105,33 +104,32 @@ struct GalleryInfosView: View { store.send(.copyText(text)) } } label: { - Text(info.value ?? L10n.Localizable.GalleryInfosView.Value.none) + Text(info.value ?? String(localized: .metadataNone)) .lineLimit(3).font(.caption) .foregroundStyle(.tint) } } } } - .progressHUD( - config: store.hudConfig, - unwrapping: $store.route, - case: \.hud - ) - .navigationTitle(L10n.Localizable.GalleryInfosView.Title.galleryInfos) + .toast($store.scope(state: \.toast, action: \.toast)) + .navigationTitle(.metadataGalleryInfos) } } private struct Info: Identifiable { - var id: Int { title.hashValue } - let title: String + var id: Int { String(localized: title).hashValue } + let title: LocalizedStringResource let value: String? } struct GalleryInfosView_Previews: PreviewProvider { static var previews: some View { - NavigationView { + NavigationStack { GalleryInfosView( - store: .init(initialState: .init(), reducer: GalleryInfosReducer.init), + store: .init( + initialState: .init(gallery: .preview, galleryDetail: .preview), + reducer: GalleryInfosReducer.init + ), gallery: .preview, galleryDetail: .preview ) diff --git a/AppPackage/Sources/DetailFeature/GalleryNavigation.swift b/AppPackage/Sources/DetailFeature/GalleryNavigation.swift new file mode 100644 index 000000000..c246cb509 --- /dev/null +++ b/AppPackage/Sources/DetailFeature/GalleryNavigation.swift @@ -0,0 +1,128 @@ +import ComposableArchitecture + +// Shared routing for gallery stacks. `nextScreen` maps a gallery element's delegate action to the +// screen that should be pushed next, so every host appends new elements the same way regardless of +// whether it stacks `GalleryPath` directly or nested under a `.gallery` case. +public enum GalleryNavigation { + // Centralizes the device branch every gallery host shares: iPad presents the detail as a modal + // sheet via the host's `present` delegate; iPhone pushes it inline via the host's `push` action. + // Actions are supplied as closures because host `Action` types are not `Sendable`. + public static func routeGalleryDetail( + isPad: @escaping @Sendable () async -> Bool, + present: @escaping @Sendable () -> Action, + push: @escaping @Sendable () -> Action + ) -> Effect { + .run { send in + await send(await isPad() ? present() : push()) + } + } + + public static func nextScreen(for action: GalleryPath.Action) -> GalleryPath.State? { + switch action { + case let .detail(.delegate(delegate)): + switch delegate { + case let .pushPreviews(gallery, previewConfig, language): + return .previews(.init( + gallery: gallery, + previewConfig: previewConfig, language: language + )) + case let .pushComments(gid, token, apiKey, galleryURL, comments, scrollCommentID): + return .comments(.init( + gid: gid, token: token, apiKey: apiKey, + galleryURL: galleryURL, comments: comments, scrollCommentID: scrollCommentID + )) + case .pushDetailSearch(let keyword): + return .detailSearch(.init(keyword: keyword)) + case let .pushGalleryInfos(gallery, galleryDetail): + return .galleryInfos(.init(gallery: gallery, galleryDetail: galleryDetail)) + } + + case let .comments(.delegate(delegate)): + switch delegate { + case let .pushDetail(gallery, deepLink): + return .detail(.init(gallery: gallery, pendingDeepLink: deepLink)) + case .performedCommentAction: + return nil + } + + case let .detailSearch(.delegate(.pushDetail(gallery))): + return .detail(.init(gallery: gallery)) + + default: + return nil + } + } +} + +// A stable per-screen identity for suppressing duplicate adjacent pushes. Screens for the same +// destination share a key even when volatile per-init state differs (e.g. the `localPreviewRequestID` +// UUID on detail/previews states), which plain `Equatable` would treat as distinct. +public protocol GalleryRouteIdentifiable { + var routeKey: String { get } +} + +extension StackState where Element: GalleryRouteIdentifiable { + // Append a screen unless it has the same route key as the current top of the stack, so a rapid + // double-activation can't push the same detail twice. Only the adjacent element is compared, so + // legitimate same-gid re-pushes through a deeper screen (Detail → Comments → same Detail) work. + public mutating func appendGuardingDuplicate(_ element: Element) { + guard last?.routeKey != element.routeKey else { return } + append(element) + } +} + +extension GalleryPath.State: GalleryRouteIdentifiable { + public var routeKey: String { + switch self { + case .detail(let state): + return "detail:\(state.gid)" + case .previews(let state): + return "previews:\(state.gid)" + case .comments(let state): + return "comments:\(state.gid)" + case .detailSearch(let state): + return "detailSearch:\(state.keyword)" + case .galleryInfos(let state): + return "galleryInfos:\(state.gallery.id)" + } + } +} + +extension StackState where Element == GalleryPath.State { + // The id of the pushed `.detail` element for `gid`, so a comment action performed on a deeper + // `.comments` screen can refresh the detail it belongs to. + public func detailID(forGID gid: String) -> StackElementID? { + for id in ids { + guard let element = self[id: id] else { continue } + if case .detail(let state) = element, state.gid == gid { + return id + } + } + return nil + } + + /// The `(id, action)` that flushes the reading session presented by the top element, if the top + /// element presents one. Used by the app-level scene-phase router so a background force-quit + /// persists the active reader's last debounced page from navigation state. + public var topReadingFlush: (id: StackElementID, action: GalleryPath.Action)? { + guard let id = ids.last, let action = self[id: id]?.readingFlushAction else { return nil } + return (id, action) + } +} + +extension GalleryPath.State { + /// The scoped action that flushes the reading session this gallery screen presents, if any. A + /// reader is a `.reading` destination of `.detail`/`.previews`. A new gallery screen that can host + /// a reader must be handled here (and any new *host* of a reader registered in `AppReducer`'s + /// scene-phase router). + public var readingFlushAction: GalleryPath.Action? { + switch self { + case .detail(let detail) where detail.destination?.reading != nil: + return .detail(.destination(.presented(.reading(.flushReadingProgress)))) + case .previews(let previews) where previews.destination?.reading != nil: + return .previews(.destination(.presented(.reading(.flushReadingProgress)))) + default: + return nil + } + } +} diff --git a/AppPackage/Sources/DetailFeature/GalleryPath.swift b/AppPackage/Sources/DetailFeature/GalleryPath.swift new file mode 100644 index 000000000..978f8a4c7 --- /dev/null +++ b/AppPackage/Sources/DetailFeature/GalleryPath.swift @@ -0,0 +1,17 @@ +import ComposableArchitecture + +// The shared set of gallery drill-down screens. Every gallery host drives a flat `StackState` of these +// elements: tapping a gallery pushes `.detail`, which in turn asks the host (via its `Delegate` actions) +// to push `.previews`/`.comments`/`.detailSearch`/`.galleryInfos`, and `.comments`/`.detailSearch` can +// push another `.detail`. Hosts that also stack their own list screens (Home, SearchRoot) nest this enum +// as a `.gallery(GalleryPath)` case so the gallery routing stays defined in one place. +@Reducer +public enum GalleryPath { + case detail(DetailReducer) + case previews(PreviewsReducer) + case comments(CommentsReducer) + case detailSearch(DetailSearchReducer) + case galleryInfos(GalleryInfosReducer) +} + +extension GalleryPath.State: Equatable {} diff --git a/AppPackage/Sources/DetailFeature/Previews/PreviewsReducer.swift b/AppPackage/Sources/DetailFeature/Previews/PreviewsReducer.swift new file mode 100644 index 000000000..4b2040db4 --- /dev/null +++ b/AppPackage/Sources/DetailFeature/Previews/PreviewsReducer.swift @@ -0,0 +1,222 @@ +import Foundation +import AppModels +import Sharing +import ComposableArchitecture +import AppTools +import HapticsClient +import NetworkingFeature +import DownloadClient +import ReadingFeature + +@Reducer +public struct PreviewsReducer: Sendable { + @Reducer + public enum Destination { + case reading(ReadingReducer) + } + + private enum CancelID { + case observeDownloads + case loadLocalPreviewURLs + case fetchPreviewURLs + } + + @ObservableState + public struct State: Equatable, Sendable { + @SharedReader(.setting) public var setting: Setting + @Presents public var destination: Destination.State? + + // The gallery id this screen fetches; captured when pushed onto the host's gallery stack. + public var gid: String + public var gallery: Gallery + // Threaded from the detail context (via the `pushPreviews` delegate) so a reader opened from + // this screen keeps the correct page math (`previewConfig`) and Live Text `language` for + // remote sessions — Previews itself never fetches a gallery detail to re-derive them. + public var language: Language? + public var loadingState: LoadingState = .idle + + public var previewURLs = [Int: URL]() + public var localPreviewURLs = [Int: URL]() + public var previewConfig: PreviewConfig = .normal(rows: 4) + public var localPreviewRequestID = UUID() + + public init( + gallery: Gallery, + previewConfig: PreviewConfig = .normal(rows: 4), language: Language? = nil + ) { + self.gid = gallery.id + self.gallery = gallery + self.previewConfig = previewConfig + self.language = language + } + + mutating func updatePreviewURLs(_ previewURLs: [Int: URL]) { + self.previewURLs = self.previewURLs.merging( + previewURLs, uniquingKeysWith: { stored, _ in stored } + ) + } + } + + public enum Action: BindableAction { + case binding(BindingAction) + case destination(PresentationAction) + + case updateReadingProgress(Int) + + case onAppear(String) + case observeDownloads(String) + case observeDownloadsDone([DownloadedGallery]) + case loadLocalPreviewURLs(String) + case loadLocalPreviewURLsDone(UUID, [Int: URL]) + case openReading(Int) + case openReadingDone(Result<(DownloadedGallery, DownloadManifest), AppError>) + case fetchPreviewURLs(Int) + case fetchPreviewURLsDone(Result<[Int: URL], AppError>) + } + + @Dependency(\.downloadClient) private var downloadClient + @Dependency(\.hapticsClient) private var hapticsClient + @Dependency(\.date) private var date + + public init() {} + + public var body: some Reducer { + BindingReducer() + + Reduce { state, action in + switch action { + case .binding: + return .none + + case .destination(.presented(.reading(.onPerformDismiss))): + return .send(.destination(.dismiss)) + + case .destination: + return .none + + case .updateReadingProgress(let progress): + @Shared(.galleryHistory) var galleryHistory + $galleryHistory.withLock { + $0.updateReadingProgress( + gid: state.gallery.id, token: state.gallery.token, progress: progress, date: date.now + ) + } + return .none + + case .onAppear(let gid): + // Gallery is seeded from the pushing context; preview URLs are fetched on demand. + return .merge( + .send(.observeDownloads(gid)), + .send(.loadLocalPreviewURLs(gid)) + ) + + case .observeDownloads(let gid): + guard gid.isValidGID else { return .none } + return .run { send in + var previousRelevantDownloads = [DownloadedGallery]() + var hadRelevantDownloads = false + for await downloads in downloadClient.observeDownloads() { + let relevantDownloads = downloads.filter { $0.gid == gid } + let hasRelevantDownloads = !relevantDownloads.isEmpty + guard hasRelevantDownloads || hadRelevantDownloads else { continue } + if relevantDownloads == previousRelevantDownloads { + hadRelevantDownloads = hasRelevantDownloads + continue + } + previousRelevantDownloads = relevantDownloads + hadRelevantDownloads = hasRelevantDownloads + await send(.observeDownloadsDone(relevantDownloads)) + } + } + .cancellable(id: CancelID.observeDownloads, cancelInFlight: true) + + case .observeDownloadsDone: + return .send(.loadLocalPreviewURLs(state.gallery.id)) + + case .loadLocalPreviewURLs(let gid): + guard gid.isValidGID else { + state.localPreviewRequestID = UUID() + state.localPreviewURLs = .init() + return .none + } + let requestID = UUID() + state.localPreviewRequestID = requestID + return .run { send in + let localPreviewURLs = await downloadClient.loadLocalPageURLs(gid) ?? [:] + await send(.loadLocalPreviewURLsDone(requestID, localPreviewURLs)) + } + .cancellable(id: CancelID.loadLocalPreviewURLs, cancelInFlight: true) + + case .loadLocalPreviewURLsDone(let requestID, let localPreviewURLs): + guard state.localPreviewRequestID == requestID else { return .none } + guard state.localPreviewURLs != localPreviewURLs else { return .none } + state.localPreviewURLs = localPreviewURLs + return .none + + case .openReading: + return .run { [galleryID = state.gallery.id] send in + guard galleryID.isValidGID else { + await send(.openReadingDone(.failure(.notFound))) + return + } + await send(.openReadingDone(.success(try await downloadClient.loadManifest(galleryID)))) + } catch: { error, send in + await send(.openReadingDone(.failure(AppError(error)))) + } + + case .openReadingDone(let result): + var readingState: ReadingReducer.State + if case .success(let (download, manifest)) = result { + readingState = .init( + gallery: state.gallery, contentSource: .local(download, manifest), + previewConfig: state.previewConfig, language: state.language + ) + } else { + readingState = .init( + gallery: state.gallery, contentSource: .remote, + previewConfig: state.previewConfig, language: state.language + ) + readingState.localPageURLs = state.localPreviewURLs + } + state.destination = .reading(readingState) + return .none + + 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 .run { send in + let response = await GalleryPreviewURLsRequest(galleryURL: galleryURL, pageNum: pageNum).response() + await send(.fetchPreviewURLsDone(response)) + } + .cancellable(id: CancelID.fetchPreviewURLs) + + 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 .none + case .failure(let error): + state.loadingState = .failed(error) + } + return .none + } + } + .haptics( + unwrapping: \.destination, + case: \.reading, + hapticsClient: hapticsClient + ) + .ifLet(\.$destination, action: \.destination) + } +} + +extension PreviewsReducer.Destination.State: Equatable, Sendable {} diff --git a/EhPanda/View/Detail/Previews/PreviewsView.swift b/AppPackage/Sources/DetailFeature/Previews/PreviewsView.swift similarity index 53% rename from EhPanda/View/Detail/Previews/PreviewsView.swift rename to AppPackage/Sources/DetailFeature/Previews/PreviewsView.swift index 8df2e8a96..0e5641d8e 100644 --- a/EhPanda/View/Detail/Previews/PreviewsView.swift +++ b/AppPackage/Sources/DetailFeature/Previews/PreviewsView.swift @@ -1,25 +1,22 @@ -// -// PreviewsView.swift -// EhPanda -// - import SwiftUI -import Kingfisher +import AppModels +import Resources import ComposableArchitecture +import AppTools +import AppComponents +import ReadingFeature struct PreviewsView: View { @Bindable private var store: StoreOf private let gid: String - @Binding private var setting: Setting private let blurRadius: Double init( store: StoreOf, - gid: String, setting: Binding, blurRadius: Double + gid: String, blurRadius: Double ) { self.store = store self.gid = gid - _setting = setting self.blurRadius = blurRadius } @@ -34,32 +31,26 @@ struct PreviewsView: View { } var body: some View { + let displayPreviewURLs = store.localPreviewURLs.merging( + store.previewURLs, + uniquingKeysWith: { local, _ in local } + ) ScrollView { LazyVGrid(columns: gridItems) { ForEach(1..? + @Presents public var destination: Destination.State? + public var torrents = [GalleryTorrent]() + public var loadingState: LoadingState = .idle } - enum Action: BindableAction, Equatable { + public enum Action: BindableAction, Equatable { case binding(BindingAction) - case setNavigation(Route?) + case toast(PresentationAction) + case destination(PresentationAction) + case presentShare(URL) case copyText(String) case presentTorrentActivity(String, Data) - case teardown case fetchTorrent(String, URL) case fetchTorrentDone(String, Result) case fetchGalleryTorrents(String, String) @@ -45,7 +46,9 @@ struct TorrentsReducer { @Dependency(\.hapticsClient) private var hapticsClient @Dependency(\.fileClient) private var fileClient - var body: some Reducer { + public init() {} + + public var body: some Reducer { BindingReducer() Reduce { state, action in @@ -53,20 +56,26 @@ struct TorrentsReducer { case .binding: return .none - case .setNavigation(let route): - state.route = route + case .toast: + return .none + + case .destination: + return .none + + case .presentShare(let url): + state.destination = .share(url) return .none case .copyText(let magnetURL): - state.route = .hud + state.toast = .copiedToClipboardSucceeded return .merge( .run(operation: { _ in clipboardClient.saveText(magnetURL) }), - .run(operation: { _ in hapticsClient.generateNotificationFeedback(.success) }) + .run(operation: { _ in await hapticsClient.generateNotificationFeedback(.success) }) ) case .presentTorrentActivity(let hash, let data): if let url = fileClient.saveTorrent(hash: hash, data: data) { - return .send(.setNavigation(.share(url))) + return .send(.presentShare(url)) } return .none @@ -77,9 +86,6 @@ struct TorrentsReducer { } .cancellable(id: CancelID.fetchTorrent) - case .teardown: - return .merge(CancelID.allCases.map(Effect.cancel(id:))) - case .fetchTorrentDone(let hash, let result): if case .success(let data) = result, !data.isEmpty { return .send(.presentTorrentActivity(hash, data)) @@ -111,9 +117,14 @@ struct TorrentsReducer { } } .haptics( - unwrapping: \.route, + unwrapping: \.destination, case: \.share, hapticsClient: hapticsClient ) + .ifLet(\.$destination, action: \.destination) + .ifLet(\.$toast, action: \.toast) } } + +extension TorrentsReducer.Destination.State: Equatable, Sendable {} +extension TorrentsReducer.Destination.Action: Equatable, Sendable {} diff --git a/EhPanda/View/Detail/Torrents/TorrentsView.swift b/AppPackage/Sources/DetailFeature/Torrents/TorrentsView.swift similarity index 82% rename from EhPanda/View/Detail/Torrents/TorrentsView.swift rename to AppPackage/Sources/DetailFeature/Torrents/TorrentsView.swift index 8d1c9df20..2d66b7d62 100644 --- a/EhPanda/View/Detail/Torrents/TorrentsView.swift +++ b/AppPackage/Sources/DetailFeature/Torrents/TorrentsView.swift @@ -1,10 +1,9 @@ -// -// TorrentsView.swift -// EhPanda -// - import SwiftUI +import AppModels +import Resources import ComposableArchitecture +import SystemNotificationExt +import AppComponents struct TorrentsView: View { @Bindable private var store: StoreOf @@ -20,7 +19,7 @@ struct TorrentsView: View { } var body: some View { - NavigationView { + NavigationStack { ZStack { List(store.torrents) { torrent in TorrentRow(torrent: torrent) { magnetURL in @@ -30,7 +29,7 @@ struct TorrentsView: View { Button { store.send(.fetchTorrent(torrent.hash, torrent.torrentURL)) } label: { - Image(systemSymbol: .arrowDownDocFill) + Image(systemSymbol: .arrowDownDocumentFill) } } } @@ -44,20 +43,16 @@ struct TorrentsView: View { } .opacity(error != nil && store.torrents.isEmpty ? 1 : 0) } - .sheet(item: $store.route.sending(\.setNavigation).share, id: \.absoluteString) { route in - ActivityView(activityItems: [route.wrappedValue]) + .sheet(item: $store.destination.share, id: \.absoluteString) { url in + ActivityView(activityItems: [url.wrappedValue]) .autoBlur(radius: blurRadius) } - .progressHUD( - config: store.hudConfig, - unwrapping: $store.route, - case: \.hud - ) + .toast($store.scope(state: \.toast, action: \.toast)) .animation(.default, value: store.torrents) .onAppear { store.send(.fetchGalleryTorrents(gid, token)) } - .navigationTitle(L10n.Localizable.TorrentsView.Title.torrents) + .navigationTitle(.torrents) } } } @@ -77,19 +72,19 @@ private extension TorrentsView { HStack(spacing: 10) { HStack(spacing: 3) { Image(systemSymbol: .arrowUpCircle) - Text("\(torrent.seedCount)") + Text(torrent.seedCount, format: .number) } HStack(spacing: 3) { Image(systemSymbol: .arrowDownCircle) - Text("\(torrent.peerCount)") + Text(torrent.peerCount, format: .number) } HStack(spacing: 3) { Image(systemSymbol: .checkmarkCircle) - Text("\(torrent.downloadCount)") + Text(torrent.downloadCount, format: .number) } Spacer() HStack(spacing: 3) { - Image(systemSymbol: .docCircle) + Image(systemSymbol: .documentCircle) Text(torrent.fileSize) } } diff --git a/AppPackage/Sources/DeviceClient/.swiftlint.yml b/AppPackage/Sources/DeviceClient/.swiftlint.yml new file mode 100644 index 000000000..1242ffcaa --- /dev/null +++ b/AppPackage/Sources/DeviceClient/.swiftlint.yml @@ -0,0 +1 @@ +parent_config: ../../../.swiftlint.yml diff --git a/AppPackage/Sources/DeviceClient/DeviceClient.swift b/AppPackage/Sources/DeviceClient/DeviceClient.swift new file mode 100644 index 000000000..76970950b --- /dev/null +++ b/AppPackage/Sources/DeviceClient/DeviceClient.swift @@ -0,0 +1,74 @@ +import SwiftUI +import Dependencies +import AppTools + +public struct DeviceClient: Sendable { + public let isPad: @Sendable () async -> Bool + public let absWindowW: @MainActor @Sendable () -> Double + public let absWindowH: @MainActor @Sendable () -> Double + public let touchPoint: @MainActor @Sendable () -> CGPoint? + + public init( + isPad: @escaping @Sendable () async -> Bool, + absWindowW: @escaping @MainActor @Sendable () -> Double, + absWindowH: @escaping @MainActor @Sendable () -> Double, + touchPoint: @escaping @MainActor @Sendable () -> CGPoint? + ) { + self.isPad = isPad + self.absWindowW = absWindowW + self.absWindowH = absWindowH + self.touchPoint = touchPoint + } +} + +extension DeviceClient { + public static let live: Self = .init( + isPad: { + await MainActor.run { + DeviceUtil.isPad + } + }, + absWindowW: { + DeviceUtil.absWindowW + }, + absWindowH: { + DeviceUtil.absWindowH + }, + touchPoint: { + TouchHandler.shared.currentPoint + } + ) +} + +// MARK: API +public enum DeviceClientKey: DependencyKey { + public static let liveValue = DeviceClient.live + public static let previewValue = DeviceClient.noop + public static let testValue = DeviceClient.unimplemented +} + +extension DependencyValues { + public var deviceClient: DeviceClient { + get { self[DeviceClientKey.self] } + set { self[DeviceClientKey.self] = newValue } + } +} + +// MARK: Test +extension DeviceClient { + public static let noop: Self = .init( + isPad: { false }, + absWindowW: { .zero }, + absWindowH: { .zero }, + touchPoint: { .zero } + ) + + public static func placeholder() -> Result { fatalError() } + + public static let unimplemented: Self = .init( + isPad: IssueReporting.unimplemented(placeholder: placeholder()), + absWindowW: IssueReporting.unimplemented(placeholder: placeholder()), + absWindowH: IssueReporting.unimplemented(placeholder: placeholder()), + touchPoint: IssueReporting.unimplemented(placeholder: placeholder()) + ) +} diff --git a/AppPackage/Sources/DownloadClient/.swiftlint.yml b/AppPackage/Sources/DownloadClient/.swiftlint.yml new file mode 100644 index 000000000..1242ffcaa --- /dev/null +++ b/AppPackage/Sources/DownloadClient/.swiftlint.yml @@ -0,0 +1 @@ +parent_config: ../../../.swiftlint.yml diff --git a/AppPackage/Sources/DownloadClient/BackgroundTaskClient.swift b/AppPackage/Sources/DownloadClient/BackgroundTaskClient.swift new file mode 100644 index 000000000..d4f35f56a --- /dev/null +++ b/AppPackage/Sources/DownloadClient/BackgroundTaskClient.swift @@ -0,0 +1,61 @@ +import UIKit +import ComposableArchitecture + +public typealias BackgroundTaskToken = UIBackgroundTaskIdentifier + +/// Wraps `UIApplication`'s background-task assertion API so the download coordinator +/// can hold an OS execution assertion while a download is in flight, keeping the +/// in-process orchestration alive through iOS's grace window after backgrounding +/// instead of being suspended within seconds. +/// +/// Mirrors `AppDelegateClient`: a plain `Sendable` struct of `@MainActor` closures +/// rather than a `@DependencyClient`. It is injected straight into `DownloadCoordinator` +/// (like `pageDownloader`) rather than being resolved through `DependencyValues`, so it +/// has no place for the macro's auto-generated unimplemented `testValue` to live. +public struct BackgroundTaskClient: Sendable { + /// Begins a background-task assertion and returns its token. `expirationHandler` + /// fires when the OS is about to reclaim the assertion; the caller must end it then. + public let begin: @MainActor @Sendable (_ expirationHandler: @escaping @Sendable () -> Void) -> BackgroundTaskToken + /// Ends a previously begun assertion. A no-op for `.invalid` tokens. + public let end: @MainActor @Sendable (BackgroundTaskToken) -> Void + + public init( + begin: @escaping @MainActor @Sendable ( + _ expirationHandler: @escaping @Sendable () -> Void + ) -> BackgroundTaskToken, + end: @escaping @MainActor @Sendable (BackgroundTaskToken) -> Void + ) { + self.begin = begin + self.end = end + } +} + +extension BackgroundTaskClient { + public static let live = Self( + begin: { expirationHandler in + UIApplication.shared.beginBackgroundTask( + withName: "app.ehpanda.downloads.assertion", + expirationHandler: expirationHandler + ) + }, + end: { token in + guard token != .invalid else { return } + UIApplication.shared.endBackgroundTask(token) + } + ) +} + +// MARK: Test +extension BackgroundTaskClient { + public static let noop = Self( + begin: { _ in .invalid }, + end: { _ in } + ) + + public static func placeholder() -> Result { fatalError() } + + public static let unimplemented = Self( + begin: IssueReporting.unimplemented(placeholder: placeholder()), + end: IssueReporting.unimplemented(placeholder: placeholder()) + ) +} diff --git a/AppPackage/Sources/DownloadClient/DownloadBackgroundTaskStore.swift b/AppPackage/Sources/DownloadClient/DownloadBackgroundTaskStore.swift new file mode 100644 index 000000000..850d0c753 --- /dev/null +++ b/AppPackage/Sources/DownloadClient/DownloadBackgroundTaskStore.swift @@ -0,0 +1,100 @@ +import OSLogExt +import Foundation +import AppModels + +private let logger = Logger(category: .init(describing: DownloadBackgroundTaskStore.self)) + +public actor DownloadBackgroundTaskStore { + public struct Record: Codable, Equatable, Sendable { + public let gid: String + public let pageIndex: Int + public init( + gid: String, + pageIndex: Int + ) { + self.gid = gid + self.pageIndex = pageIndex + } + } + + private let fileURL: URL + private let fileManager: DownloadFileManager + private var records: [Int: Record] + + public init( + fileURL: URL, + fileManager: sending FileManager = FileManager() + ) { + self.fileURL = fileURL + self.fileManager = DownloadFileManager(fileManager) + self.records = Self.loadRecords( + fileURL: fileURL, + fileManager: self.fileManager + ) + } + + public func record( + taskIdentifier: Int, + gid: String, + pageIndex: Int + ) async { + records[taskIdentifier] = .init(gid: gid, pageIndex: pageIndex) + await save() + } + + public func record(taskIdentifier: Int) -> Record? { + records[taskIdentifier] + } + + public func records(for gid: String) -> [Int: Record] { + records.filter { $0.value.gid == gid } + } + + @discardableResult + public func remove(taskIdentifier: Int) async -> Record? { + let record = records.removeValue(forKey: taskIdentifier) + await save() + return record + } + + public func removeAll(for gid: String) async { + records = records.filter { $0.value.gid != gid } + await save() + } + + public func removeAll() async { + records.removeAll() + await save() + } + + private static func loadRecords( + fileURL: URL, + fileManager: DownloadFileManager + ) -> [Int: Record] { + guard fileManager.operate({ $0.fileExists(atPath: fileURL.path) }) else { + return [:] + } + do { + let data = try Data(contentsOf: fileURL) + return try JSONDecoder().decode([Int: Record].self, from: data) + } catch { + logger.error("\(error, privacy: .public)") + return [:] + } + } + + private func save() async { + do { + try fileManager.operate { + try $0.createDirectory( + at: fileURL.deletingLastPathComponent(), + withIntermediateDirectories: true + ) + } + let data = try JSONEncoder().encode(records) + try data.write(to: fileURL, options: .atomic) + } catch { + logger.error("\(error, privacy: .public)") + } + } +} diff --git a/AppPackage/Sources/DownloadClient/DownloadClient+BackgroundAssertion.swift b/AppPackage/Sources/DownloadClient/DownloadClient+BackgroundAssertion.swift new file mode 100644 index 000000000..7fe275829 --- /dev/null +++ b/AppPackage/Sources/DownloadClient/DownloadClient+BackgroundAssertion.swift @@ -0,0 +1,54 @@ +import Foundation + +// MARK: - Background Execution Assertion +extension DownloadCoordinator { + /// Whether any download still needs the in-process orchestration to run. + /// + /// Drives the background-task assertion and the `BGProcessingTask` drain loop, so + /// it must agree with the scheduler about what counts as schedulable work. + public func hasPendingWork() async -> Bool { + // A running task is unambiguous work; skip the disk-backed index read. + if activeTask != nil { return true } + let queuedGIDs = queueStore.gids + let downloads = queuedGIDs.isEmpty + ? await indexedDownloads() + : await indexedDownloads(gids: queuedGIDs) + return downloads.contains { + !schedulingBlockedGalleryIDs.contains($0.gid) && shouldSchedule(download: $0) + } + } + + /// Begins or ends the OS background-task assertion to match the current queue + /// state. Invoked from the tail of `scheduleNextIfNeeded()`, the single point every + /// queue mutation converges on, so the assertion can never be leaked when the last + /// active download is paused or deleted (those paths null `activeTask` directly but + /// still reschedule afterward). + public func reconcileBackgroundAssertion() async { + guard await hasPendingWork() else { + await endBackgroundAssertion() + return + } + guard backgroundAssertionToken == nil, !isBeginningBackgroundAssertion else { + return + } + isBeginningBackgroundAssertion = true + let token = await backgroundTaskClient.begin { [weak self] in + Task { await self?.endBackgroundAssertion() } + } + // `begin` hops to the main actor, a suspension point across which the queue may + // have drained; re-validate before committing to holding the assertion. + if await hasPendingWork() { + backgroundAssertionToken = token + isBeginningBackgroundAssertion = false + } else { + isBeginningBackgroundAssertion = false + await backgroundTaskClient.end(token) + } + } + + private func endBackgroundAssertion() async { + guard let token = backgroundAssertionToken else { return } + backgroundAssertionToken = nil + await backgroundTaskClient.end(token) + } +} diff --git a/AppPackage/Sources/DownloadClient/DownloadClient+BackgroundDownloads.swift b/AppPackage/Sources/DownloadClient/DownloadClient+BackgroundDownloads.swift new file mode 100644 index 000000000..870c78320 --- /dev/null +++ b/AppPackage/Sources/DownloadClient/DownloadClient+BackgroundDownloads.swift @@ -0,0 +1,239 @@ +import OSLogExt +import Foundation +import AppModels + +private let logger = Logger(category: .init(describing: DownloadCoordinator.self)) + +public actor BackgroundPageCompletionReceiver { + private enum PendingEvent { + case completion(taskIdentifier: Int, fileURL: URL, response: URLResponse) + case failure(taskIdentifier: Int, error: AppError?) + } + + private var coordinator: DownloadCoordinator? + private var pendingEvents = [PendingEvent]() + + public init() {} + + // The background URLSession is live the moment it's created, so iOS can replay a + // stored completion before the coordinator is installed one task-hop later. Buffer + // anything that arrives in that window and drain it here, or the event would no-op + // against a nil coordinator — stranding the staged file and letting resumeQueue + // re-download an already-finished page (defeats the offline-finish guarantee). + public func setCoordinator(_ coordinator: DownloadCoordinator) async { + self.coordinator = coordinator + let bufferedEvents = pendingEvents + pendingEvents.removeAll() + for event in bufferedEvents { + await deliver(event, to: coordinator) + } + } + + public func handleCompletion( + taskIdentifier: Int, + fileURL: URL, + response: URLResponse + ) async { + guard let coordinator else { + pendingEvents.append( + .completion(taskIdentifier: taskIdentifier, fileURL: fileURL, response: response) + ) + return + } + await coordinator.handleBackgroundPageDownloadCompleted( + taskIdentifier: taskIdentifier, + fileURL: fileURL, + response: response + ) + } + + public func handleFailure( + taskIdentifier: Int, + error: AppError? + ) async { + guard let coordinator else { + pendingEvents.append(.failure(taskIdentifier: taskIdentifier, error: error)) + return + } + await coordinator.handleBackgroundPageDownloadFailed( + taskIdentifier: taskIdentifier, + error: error + ) + } + + private func deliver( + _ event: PendingEvent, + to coordinator: DownloadCoordinator + ) async { + switch event { + case let .completion(taskIdentifier, fileURL, response): + await coordinator.handleBackgroundPageDownloadCompleted( + taskIdentifier: taskIdentifier, + fileURL: fileURL, + response: response + ) + case let .failure(taskIdentifier, error): + await coordinator.handleBackgroundPageDownloadFailed( + taskIdentifier: taskIdentifier, + error: error + ) + } + } +} + +extension DownloadCoordinator { + public func handleBackgroundPageDownloadCompleted( + taskIdentifier: Int, + fileURL: URL, + response: URLResponse + ) async { + guard let record = await backgroundTaskStore.record( + taskIdentifier: taskIdentifier + ) else { + removeStagedBackgroundFile(fileURL) + return + } + + do { + try await attachBackgroundPageDownload( + record: record, + fileURL: fileURL, + response: response + ) + } catch { + logger.error("\(error, privacy: .public)") + removeStagedBackgroundFile(fileURL) + // A fatal account error (quota/auth/ban) detected on an orphaned page must + // settle the whole download like the foreground does, so scheduleNextIfNeeded + // below can't auto-resume it against the ban (BUG-6 / no-auto-retry). + if let appError = error as? AppError, isFatalAccountAppError(appError) { + await settleDownloadFailure(gid: record.gid, error: appError) + } + } + + await backgroundTaskStore.remove(taskIdentifier: taskIdentifier) + await notifyObservers() + await scheduleNextIfNeeded() + } + + public func handleBackgroundPageDownloadFailed( + taskIdentifier: Int, + error: AppError? + ) async { + guard let record = await backgroundTaskStore.record( + taskIdentifier: taskIdentifier + ) else { + return + } + + // A non-cancellation error surfaces as a page failure (DES-8: in-memory); + // a cancellation only cleans up the persisted task record below. + if let error { + if !hasLoadedIndex { + await reloadDownloadIndex() + } + if let folderRecord = downloadIndex[record.gid], + folderRecord.manifest.pages[record.pageIndex] != nil { + failedPageErrors[record.gid, default: [:]][record.pageIndex] = .init( + index: record.pageIndex, + relativePath: nil, + error: error + ) + } + if isFatalAccountAppError(error) { + await settleDownloadFailure(gid: record.gid, error: error) + } + } + + await backgroundTaskStore.remove(taskIdentifier: taskIdentifier) + await notifyObservers() + await scheduleNextIfNeeded() + } + + private func attachBackgroundPageDownload( + record: DownloadBackgroundTaskStore.Record, + fileURL: URL, + response: URLResponse + ) async throws { + if !hasLoadedIndex { + await reloadDownloadIndex() + } + guard let folderRecord = downloadIndex[record.gid] else { + throw AppError.notFound + } + guard folderRecord.manifest.pages[record.pageIndex] != nil else { + throw AppError.notFound + } + if let error = detectResponseError( + fileURL: fileURL, + response: response, + requestURL: response.url + ) { + failedPageErrors[record.gid, default: [:]][record.pageIndex] = .init( + index: record.pageIndex, + relativePath: nil, + error: error + ) + throw error + } + + let relativePath = try backgroundPageRelativePath( + record: record, + fileURL: fileURL, + response: response, + folderRecord: folderRecord + ) + let destinationURL = folderRecord.folderURL + .appendingPathComponent(relativePath) + if fileManager.operate({ $0.fileExists(atPath: destinationURL.path) }) { + removeStagedBackgroundFile(fileURL) + } else { + try moveDownloadedFile(from: fileURL, to: destinationURL) + } + try flushManifestPageProgress( + folderURL: folderRecord.folderURL, + pages: [ + .init( + index: record.pageIndex, + relativePath: relativePath, + imageURL: response.url + ) + ] + ) + } + + private func backgroundPageRelativePath( + record: DownloadBackgroundTaskStore.Record, + fileURL: URL, + response: URLResponse, + folderRecord: DownloadFolderRecord + ) throws -> String { + let existingPages = storage.existingPageRelativePaths( + folderURL: folderRecord.folderURL, + manifest: folderRecord.manifest + ) + if let relativePath = existingPages[record.pageIndex] { + return relativePath + } + + let prefixData = try readResponsePrefixData(at: fileURL) + let ext = fileExtension( + for: response.url ?? URL(fileURLWithPath: "download"), + response: response, + prefixData: prefixData + ) + return storage.makePageRelativePath( + gid: folderRecord.manifest.gid, + token: folderRecord.manifest.token, + index: record.pageIndex, + fileExtension: ext + ) + } + + public func removeStagedBackgroundFile(_ fileURL: URL) { + try? fileManager.operate { + guard $0.fileExists(atPath: fileURL.path) else { return } + try $0.removeItem(at: fileURL) + } + } +} diff --git a/AppPackage/Sources/DownloadClient/DownloadClient+BackgroundProcessing.swift b/AppPackage/Sources/DownloadClient/DownloadClient+BackgroundProcessing.swift new file mode 100644 index 000000000..535b1a2eb --- /dev/null +++ b/AppPackage/Sources/DownloadClient/DownloadClient+BackgroundProcessing.swift @@ -0,0 +1,32 @@ +import Foundation + +// MARK: - Background Processing Drain +extension DownloadCoordinator { + /// Drives the queue to completion (or until cancelled), pumping the scheduler + /// itself rather than relying on the detached reschedule `Task` that + /// `finishActiveTaskIfOwned` installs one hop later. + /// + /// Invoked from the `BGProcessingTask` handler. On cancellation (the task's + /// expiration), the in-flight download is cancelled so the loop can observe the + /// cancellation and return promptly instead of waiting out a transfer that may not + /// finish before the process is suspended. + public func runQueueUntilIdle() async { + while !Task.isCancelled { + await scheduleNextIfNeeded() + guard let task = activeTask else { + if await hasPendingWork() { + // The reschedule hop has not installed the next task yet; yield and + // re-check rather than declaring the queue idle prematurely. + await Task.yield() + continue + } + break + } + await withTaskCancellationHandler { + await task.value + } onCancel: { + task.cancel() + } + } + } +} diff --git a/AppPackage/Sources/DownloadClient/DownloadClient+Cache.swift b/AppPackage/Sources/DownloadClient/DownloadClient+Cache.swift new file mode 100644 index 000000000..2cf234d3c --- /dev/null +++ b/AppPackage/Sources/DownloadClient/DownloadClient+Cache.swift @@ -0,0 +1,137 @@ +import Foundation +import AppModels +import AppTools + +// MARK: - Cache Operations +extension DownloadCoordinator { + public func removeCachedImages( + for urls: [URL?] + ) async { + let keys = urls + .compactMap(\.self) + .flatMap(\.imageCacheKeys) + + let uniqueKeys = Array(Set(keys)) + try? await DataCache.shared.removeData(forKeys: uniqueKeys) + for key in uniqueKeys { + await libraryClient.removeCachedImage(key) + } + } + + public func pageImageCacheURLs( + imageURL: URL? + ) -> [URL?] { + [imageURL] + } + + public func restorePageFromCache( + index: Int, + source: CacheRestoreSource, + folderURL: URL, + preferredRelativePath: String?, + overwriteExistingFile: Bool = false + ) async throws -> PageResult? { + guard let cachedData = await validatedCachedAssetData( + for: source.cacheURLs + ) + else { + return nil + } + + let relativePath: String + if let preferredRelativePath { + relativePath = preferredRelativePath + } else if let fallbackURL = source.referenceURL { + let ext = fileExtension( + for: fallbackURL, + response: nil, + prefixData: cachedData + ) + relativePath = storage.makePageRelativePath( + gid: source.gid, + token: source.token, + index: index, + fileExtension: ext + ) + } else { + return nil + } + + let fileURL = folderURL + .appendingPathComponent(relativePath) + if overwriteExistingFile + || !fileManager.operate({ $0.fileExists(atPath: fileURL.path) }) { + try write(data: cachedData, to: fileURL) + } + + return .init( + index: index, + relativePath: relativePath, + imageURL: source.imageURL + ) + } + + public func preferredPageReferenceURL( + resolvedImageSource: ResolvedImageSource + ) -> URL? { + resolvedImageSource.imageURL + } + + public func preferredPageReferenceURL( + imageURL: URL? + ) -> URL? { + imageURL + } + + public func cachedImageData( + for urls: [URL?] + ) async -> Data? { + let keys = urls + .compactMap { $0 } + .flatMap(\.imageCacheKeys) + return await DataCache.shared.data(forKeys: keys) + } + + public func validatedCachedAssetData( + for urls: [URL?] + ) async -> Data? { + guard let cachedData = await cachedImageData(for: urls) else { + return nil + } + guard detectCachedAssetError( + data: cachedData, + referenceURLs: urls + ) == nil else { + await removeCachedImages(for: urls) + return nil + } + return cachedData + } + + public func detectCachedAssetError( + data: Data, + referenceURLs _: [URL?] + ) -> AppError? { + guard !data.isEmpty else { return .parseFailed } + if isAuthenticationRequiredPlaceholderImageData(data) { + return .authenticationRequired + } + if isQuotaExceededAssetData(data) { + return .quotaExceeded + } + + let looksLikeHTML = prefixLooksLikeHTML( + Data( + data.prefix(Self.responseInspectionPrefixLength) + ) + ) + if let error = detectTextualDownloadError( + data: data, + looksLikeHTML: looksLikeHTML + ) { + return error + } + + return isDecodableImageData(data) ? nil : .parseFailed + } +} diff --git a/AppPackage/Sources/DownloadClient/DownloadClient+Execution.swift b/AppPackage/Sources/DownloadClient/DownloadClient+Execution.swift new file mode 100644 index 000000000..9b856cbe9 --- /dev/null +++ b/AppPackage/Sources/DownloadClient/DownloadClient+Execution.swift @@ -0,0 +1,280 @@ +import OSLogExt +import Foundation +import AppModels + +private let logger = Logger(category: .init(describing: DownloadCoordinator.self)) + +// MARK: - Process Download +extension DownloadCoordinator { + public func processDownload( + gid: String, + generation: Int? = nil + ) async { + defer { + finishActiveTaskIfOwned( + gid: gid, + generation: generation, + schedulesNext: true + ) + } + + guard let download = await fetchDownload(gid: gid) else { + return + } + let mode = queuedMode(for: download) + let options = await downloadOptionsProvider() + + do { + clearDownloadFailureState(gid: gid, includePageFailures: false) + await notifyObservers() + let result = try await fetchNormalizeAndDownload( + gid: gid, + download: download, + mode: mode, + options: options + ) + guard !Task.isCancelled else { return } + await completeDownload( + gid: gid, + download: download, + result: result + ) + } catch is CancellationError { + return + } catch { + let context = FailureContext( + gid: gid, + originalDownload: download, + mode: mode + ) + await handleProcessDownloadError(error: error, context: context) + } + } + + private func completeDownload( + gid: String, + download: DownloadedGallery, + result: ProcessDownloadResult + ) async { + logger.notice( + """ + Download completed, gid: \(gid, privacy: .public), \ + pages: \(download.pageCount, privacy: .public), \ + title: \(download.title, privacy: .public). + """ + ) + await settleCompletedDownload(gid: gid) + let completedFolderURL = storage.folderURL( + relativePath: result.folderRelativePath + ) + removeSupersededFolders( + gid: gid, + token: download.token, + keeping: completedFolderURL + ) + await notifyObservers() + } + + // A download can finish in a different folder than it started in + // (re-slot after a title change), and an interrupted session can leave + // both behind; only the completed folder may survive, or the stale + // duplicate resurfaces once the surviving record is deleted. + public func removeSupersededFolders(gid: String, token: String, keeping folderURL: URL) { + do { + try removeGalleryFolders(gid: gid, token: token, keeping: folderURL) + } catch { + logger.error("\(error, privacy: .public)") + } + } + + public func removeGalleryFolders(gid: String, token: String, keeping folderURL: URL? = nil) throws { + let keptPath = folderURL?.standardizedFileURL.path + for galleryFolderURL in storage.galleryFolderURLs(gid: gid, token: token) { + guard galleryFolderURL.standardizedFileURL.path != keptPath else { + continue + } + try storage.removeFolder(at: galleryFolderURL) + } + } + + private func handleProcessDownloadError( + error: Error, + context: FailureContext + ) async { + if let appError = error as? AppError { + await handleProcessDownloadAppError( + error: appError, + context: context + ) + } else if let partialError = error as? PartialDownloadError { + await handleProcessDownloadPartialError( + error: partialError, + context: context + ) + } else if let incompleteError = error as? IncompleteDownloadError { + await handleProcessDownloadIncompleteError( + error: incompleteError, + context: context + ) + } else { + await handleProcessDownloadGenericError( + error: error, + context: context + ) + } + } + + private struct ProcessDownloadResult { + let folderRelativePath: String + } + + private func fetchNormalizeAndDownload( + gid: String, + download: DownloadedGallery, + mode: DownloadStartMode, + options: DownloadRequestOptions + ) async throws -> ProcessDownloadResult { + let rawPageSelection = queuedPageSelections[gid] + let fetchedPayload = try await fetchLatestPayload( + for: download, + mode: mode, + options: options, + pageSelection: rawPageSelection + ) + let payload = normalizeFetchedPayload( + fetchedPayload, + mode: mode, + rawPageSelection: rawPageSelection + ) + let folderRelativePath = folderRelativePath( + for: payload, + parentFolderName: download.folderName + ) + _ = try await performDownload( + payload: payload, + options: options, + folderRelativePath: folderRelativePath, + existingDownload: download + ) + return ProcessDownloadResult( + folderRelativePath: folderRelativePath + ) + } + + private func handleProcessDownloadAppError( + error: AppError, + context: FailureContext + ) async { + guard !isCancellationLikeAppError(error) else { return } + guard !shouldSuppressFailurePersistence(for: context.gid) else { + return + } + logger.error( + """ + Download failed, gid: \(context.gid, privacy: .public), \ + mode: \(context.mode.rawValue, privacy: .public), \ + error: \(error.localizedDescription, privacy: .public) + """ + ) + await persistFailure(error: error, context: context) + await notifyObservers() + } + + private func handleProcessDownloadPartialError( + error: PartialDownloadError, + context: FailureContext + ) async { + let pageError = + error.failedPages.first?.error ?? .unknown + guard !isCancellationLikeAppError(pageError) else { return } + guard !shouldSuppressFailurePersistence(for: context.gid) else { + return + } + failedPageErrors[context.gid] = Dictionary( + uniqueKeysWithValues: error.failedPages.map { ($0.index, $0) } + ) + logger.error( + """ + Download partially failed, gid: \(context.gid, privacy: .public), \ + mode: \(context.mode.rawValue, privacy: .public), \ + failedPages: \(String(describing: error.failedPages.map(\.index)), privacy: .public) + """ + ) + await persistFailure(error: pageError, context: context) + await notifyObservers() + } + + private func handleProcessDownloadIncompleteError( + error _: IncompleteDownloadError, + context: FailureContext + ) async { + guard !shouldSuppressFailurePersistence(for: context.gid) else { + return + } + clearDownloadQueueIntent(gid: context.gid) + await queueStore.remove(context.gid) + await reloadDownloadRecord( + gid: context.gid, + token: context.originalDownload.token + ) + await notifyObservers() + } + + private func handleProcessDownloadGenericError( + error: Error, + context: FailureContext + ) async { + let appError = AppError.fileOperationFailed( + error.localizedDescription + ) + guard !isCancellationLikeAppError(appError) else { return } + guard !shouldSuppressFailurePersistence(for: context.gid) else { + return + } + logger.error("\(error, privacy: .public)") + await persistFailure( + error: appError, + context: context + ) + await notifyObservers() + } + + public func settleCompletedDownload(gid: String) async { + clearDownloadSessionState(gid: gid, includeUpdateFlag: true) + await queueStore.remove(gid) + await backgroundTaskStore.removeAll(for: gid) + } + + public func finishActiveTaskIfOwned( + gid: String, + generation: Int?, + schedulesNext: Bool + ) { + guard isActiveTaskOwner(gid: gid, generation: generation) else { + return + } + activeTask = nil + activeGalleryID = nil + guard schedulesNext else { + // The collision-cleanup path skips rescheduling, but the assertion still + // has to be released if this was the last in-flight download. + Task { await self.reconcileBackgroundAssertion() } + return + } + Task { + await self.scheduleNextIfNeeded() + } + } + + private func isActiveTaskOwner( + gid: String, + generation: Int? + ) -> Bool { + if let generation { + return activeGalleryID == gid + && activeTaskGeneration == generation + } + guard activeTask == nil else { return false } + return activeGalleryID == nil || activeGalleryID == gid + } +} diff --git a/AppPackage/Sources/DownloadClient/DownloadClient+ExecutionFetch.swift b/AppPackage/Sources/DownloadClient/DownloadClient+ExecutionFetch.swift new file mode 100644 index 000000000..fa8b9a476 --- /dev/null +++ b/AppPackage/Sources/DownloadClient/DownloadClient+ExecutionFetch.swift @@ -0,0 +1,161 @@ +import Foundation +import AppModels +import NetworkingFeature + +// MARK: - Fetch & Normalize Payload +extension DownloadCoordinator { + public func fetchLatestPayload( + for download: DownloadedGallery, + mode: DownloadStartMode, + options: DownloadRequestOptions, + pageSelection: [Int]? + ) async throws -> DownloadRequestPayload { + let galleryURL = download.gallery.galleryURL + guard let galleryURL else { throw AppError.notFound } + let detailResponse = try await GalleryDetailRequest( + gid: download.gid, + galleryURL: galleryURL, + urlSession: urlSession, + allowsCellular: options.allowCellular + ) + .response() + .get() + let detail = detailResponse.galleryDetail + let galleryState = detailResponse.galleryState + let components = buildGalleryComponents( + download: download, + detail: detail, + galleryState: galleryState, + galleryURL: galleryURL + ) + let versionMetadata = await fetchOptionalVersionMetadata( + gid: download.gid, + token: download.token + ) + let fetchedData = FetchedGalleryData( + download: download, + detail: detail, + versionMetadata: versionMetadata + ) + return buildPayload( + fetchedData: fetchedData, + components: components, + mode: mode, + pageSelection: pageSelection + ) + } + + private struct FetchedGalleryData { + let download: DownloadedGallery + let detail: GalleryDetail + let versionMetadata: DownloadVersionMetadata? + } + + private func buildPayload( + fetchedData: FetchedGalleryData, + components: GalleryComponents, + mode: DownloadStartMode, + pageSelection: [Int]? + ) -> DownloadRequestPayload { + let download = fetchedData.download + let detail = fetchedData.detail + let versionMetadata = fetchedData.versionMetadata + return .init( + gallery: components.gallery, + galleryDetail: detail, + previewURLs: components.previewURLs, + previewConfig: components.previewConfig, + host: download.host, + folderName: download.folderName, + versionMetadata: versionMetadata, + mode: mode, + pageSelection: pageSelection.map(Set.init) + ) + } + + private struct GalleryComponents { + let gallery: Gallery + let previewURLs: [Int: URL] + let previewConfig: PreviewConfig + } + + private func buildGalleryComponents( + download: DownloadedGallery, + detail: GalleryDetail, + galleryState: GalleryState, + galleryURL: URL + ) -> GalleryComponents { + let gallery = Gallery( + gid: download.gid, + token: download.token, + title: detail.title, + rating: detail.rating, + tags: galleryState.tags, + category: detail.category, + uploader: detail.uploader, + pageCount: detail.pageCount, + postedDate: detail.postedDate, + coverURL: detail.coverURL ?? download.onlineCoverURL, + galleryURL: galleryURL + ) + return GalleryComponents( + gallery: gallery, + previewURLs: galleryState.previewURLs, + previewConfig: galleryState.previewConfig ?? .normal(rows: 4) + ) + } + + public func fetchVersionMetadata( + gid: String, + token: String + ) async -> Result { + await GalleryVersionMetadataRequest( + gid: gid, + token: token, + urlSession: urlSession + ).response() + } + + private func fetchOptionalVersionMetadata( + gid: String, + token: String + ) async -> DownloadVersionMetadata? { + switch await fetchVersionMetadata( + gid: gid, + token: token + ) { + case .success(let metadata): + return metadata + case .failure: + return nil + } + } + + public func normalizeFetchedPayload( + _ payload: DownloadRequestPayload, + mode: DownloadStartMode, + rawPageSelection: [Int]? + ) -> DownloadRequestPayload { + let validPageSelection = rawPageSelection? + .filter { (1...payload.galleryDetail.pageCount).contains($0) } + let pageSelection = validPageSelection?.isEmpty == false && mode != .update + ? validPageSelection + : nil + + guard pageSelection != rawPageSelection else { + return payload + } + + return .init( + gallery: payload.gallery, + galleryDetail: payload.galleryDetail, + previewURLs: payload.previewURLs, + previewConfig: payload.previewConfig, + host: payload.host, + folderName: payload.folderName, + versionMetadata: payload.versionMetadata, + mode: payload.mode, + pageSelection: pageSelection.map(Set.init) + ) + } +} diff --git a/AppPackage/Sources/DownloadClient/DownloadClient+ExecutionPerform.swift b/AppPackage/Sources/DownloadClient/DownloadClient+ExecutionPerform.swift new file mode 100644 index 000000000..6f9cd0879 --- /dev/null +++ b/AppPackage/Sources/DownloadClient/DownloadClient+ExecutionPerform.swift @@ -0,0 +1,186 @@ +import Foundation +import AppModels + +// MARK: - Perform Download +extension DownloadCoordinator { + public struct PerformDownloadResult { + public let coverRelativePath: String? + public let pages: [PageResult] + public init( + coverRelativePath: String? = nil, + pages: [PageResult] + ) { + self.coverRelativePath = coverRelativePath + self.pages = pages + } + } + + public func performDownload( + payload: DownloadRequestPayload, + options: DownloadRequestOptions, + folderRelativePath: String, + existingDownload: DownloadedGallery + ) async throws -> PerformDownloadResult { + try storage.ensureRootDirectory() + + let workingFolderURL = storage.folderURL( + relativePath: folderRelativePath + ) + let workingSeed = try prepareWorkingSeed( + payload: payload, + existingDownload: existingDownload, + folderURL: workingFolderURL + ) + let pendingIndices = pendingPageIndices( + payload: payload, + folderURL: workingFolderURL, + existingPageRelativePaths: workingSeed.existingPages + ) + + let executionContext = DownloadExecutionContext( + payload: payload, + options: options, + existingDownload: existingDownload + ) + return try await executePageDownloads( + context: executionContext, + workingSeed: workingSeed, + pendingIndices: pendingIndices + ) + } + + private func executePageDownloads( + context: DownloadExecutionContext, + workingSeed: WorkingSeed, + pendingIndices: [Int] + ) async throws -> PerformDownloadResult { + let payload = context.payload + let options = context.options + let folderURL = workingSeed.folderURL + let coverRelativePath = try await downloadCoverImage( + payload: payload, + options: options, + folderURL: folderURL, + existingCoverRelativePath: workingSeed.coverRelativePath + ) + let source = try await resolveSourceIfNeeded( + payload: payload, + options: options, + pendingIndices: pendingIndices, + folderURL: folderURL, + existingPages: workingSeed.existingPages + ) + let downloadContext = PageDownloadContext( + payload: payload, + options: options, + source: source, + folderURL: folderURL + ) + let batchResult = try await downloadPages( + context: downloadContext, + pendingPageIndices: pendingIndices, + existingManifest: workingSeed.manifest, + existingPageRelativePaths: workingSeed.existingPages + ) + let finalizeCtx = FinalizeContext( + coverRelativePath: coverRelativePath, + batchResult: batchResult, + existingDownload: context.existingDownload + ) + try await finalizeBatchResult( + context: finalizeCtx, + payload: payload, + folderURL: folderURL + ) + return PerformDownloadResult( + coverRelativePath: coverRelativePath, + pages: batchResult.pages + ) + } + + private func finalizeBatchResult( + context: FinalizeContext, + payload: DownloadRequestPayload, + folderURL: URL + ) async throws { + if !context.batchResult.failedPages.isEmpty { + throw PartialDownloadError( + failedPages: context.batchResult.failedPages + ) + } + let missingPageIndices = try missingFinalizedPageIndices(folderURL: folderURL) + guard missingPageIndices.isEmpty else { + throw IncompleteDownloadError( + missingPageIndices: missingPageIndices + ) + } + try await finalizeDownload( + payload: payload, + folderURL: folderURL, + finalizeContext: context + ) + } + + private func missingFinalizedPageIndices( + folderURL: URL + ) throws -> [Int] { + let manifest = try storage.readManifest(folderURL: folderURL) + let existingPages = storage.existingPageRelativePaths( + folderURL: folderURL, + manifest: manifest + ) + return manifest.pages.keys.sorted().filter { index in + existingPages[index] == nil + } + } + + private func resolveSourceIfNeeded( + payload: DownloadRequestPayload, + options: DownloadRequestOptions, + pendingIndices: [Int], + folderURL: URL, + existingPages: [Int: String] + ) async throws -> ResolvedSource? { + let missingIndices = pendingIndices.filter { index in + guard let relativePath = existingPages[index] else { + return true + } + let fileURL = folderURL.appendingPathComponent(relativePath) + return !fileManager.operate { + $0.fileExists(atPath: fileURL.path) + } + } + if missingIndices.isEmpty { + return nil + } + return try await resolveSource( + payload: payload, + options: options, + requiredPageIndices: missingIndices + ) + } + + private func finalizeDownload( + payload: DownloadRequestPayload, + folderURL: URL, + finalizeContext: FinalizeContext + ) async throws { + let batchResult = finalizeContext.batchResult + let existingDownload = finalizeContext.existingDownload + let manifest = try storage.readManifest(folderURL: folderURL) + let hashedManifest = try storage.addingCurrentFileHashes( + to: manifest, + folderURL: folderURL + ) + try storage.writeManifest( + hashedManifest, + folderURL: folderURL + ) + updateDownloadIndex(folderURL: folderURL, manifest: hashedManifest) + await cleanupCachedRemoteAssetsAfterSuccessfulDownload( + payload: payload, + pages: batchResult.pages, + existingDownload: existingDownload + ) + } +} diff --git a/AppPackage/Sources/DownloadClient/DownloadClient+ExecutionSupport.swift b/AppPackage/Sources/DownloadClient/DownloadClient+ExecutionSupport.swift new file mode 100644 index 000000000..9807fcc39 --- /dev/null +++ b/AppPackage/Sources/DownloadClient/DownloadClient+ExecutionSupport.swift @@ -0,0 +1,427 @@ +import AppTools +import Foundation +import AppModels +import URLClient +import NetworkingFeature + +// MARK: - Execution Support +extension DownloadCoordinator { + public func makeInitialManifest(payload: DownloadRequestPayload) -> DownloadManifest { + let pageCount = payload.galleryDetail.pageCount + let pages = pageCount > 0 + ? Dictionary(uniqueKeysWithValues: (1...pageCount).map { ($0, "") }) + : [:] + return DownloadManifest( + gid: payload.gallery.gid, + host: payload.host, + token: payload.gallery.token, + title: payload.gallery.title, + jpnTitle: payload.galleryDetail.jpnTitle, + category: payload.gallery.category, + language: payload.galleryDetail.language, + remoteCoverURL: + payload.galleryDetail.coverURL ?? payload.gallery.coverURL, + uploader: payload.galleryDetail.uploader, + tags: payload.gallery.tags, + postedDate: payload.galleryDetail.postedDate, + rating: payload.galleryDetail.rating, + pages: pages + ) + } + + public func folderRelativePath( + for payload: DownloadRequestPayload, + parentFolderName: String + ) -> String { + let galleryFolderName = storage.makeFolderRelativePath( + gid: payload.gallery.gid, + token: payload.gallery.token, + title: payload.galleryDetail.trimmedTitle.isEmpty + ? payload.gallery.title + : payload.galleryDetail.trimmedTitle + ) + return "\(parentFolderName)/\(galleryFolderName)" + } + + public func downloadCoverImage( + payload: DownloadRequestPayload, + options: DownloadRequestOptions, + folderURL: URL, + existingCoverRelativePath: String? + ) async throws -> String? { + if let coverRelativePath = existingCoverRelativePath, + !coverRelativePath.isEmpty { + let localCoverURL = folderURL + .appendingPathComponent(coverRelativePath) + if fileManager.operate({ $0.fileExists(atPath: localCoverURL.path) }) { + return coverRelativePath + } + } + guard let coverURL = + payload.galleryDetail.coverURL + ?? payload.gallery.coverURL + else { + return nil + } + if let cachedData = await validatedCachedAssetData( + for: [coverURL] + ) { + return try saveCoverFromCache( + cachedData: cachedData, + coverURL: coverURL, + payload: payload, + folderURL: folderURL + ) + } + return try await downloadCoverFromNetwork( + coverURL: coverURL, + payload: payload, + folderURL: folderURL, + allowsCellular: options.allowCellular + ) + } + + private func saveCoverFromCache( + cachedData: Data, + coverURL: URL, + payload: DownloadRequestPayload, + folderURL: URL + ) throws -> String { + let ext = fileExtension( + for: coverURL, + response: nil, + prefixData: cachedData + ) + let relativePath = storage + .makeCoverRelativePath( + gid: payload.gallery.gid, + token: payload.gallery.token, + fileExtension: ext + ) + let fileURL = folderURL + .appendingPathComponent(relativePath) + try write(data: cachedData, to: fileURL) + return relativePath + } + + private func downloadCoverFromNetwork( + coverURL: URL, + payload: DownloadRequestPayload, + folderURL: URL, + allowsCellular: Bool + ) async throws -> String { + let (downloadedFileURL, response) = + try await downloadResponse( + url: coverURL, + allowsCellular: allowsCellular + ) + let prefixData = try readResponsePrefixData( + at: downloadedFileURL + ) + let ext = fileExtension( + for: coverURL, + response: response, + prefixData: prefixData + ) + let relativePath = storage + .makeCoverRelativePath( + gid: payload.gallery.gid, + token: payload.gallery.token, + fileExtension: ext + ) + let fileURL = folderURL + .appendingPathComponent(relativePath) + try moveDownloadedFile( + from: downloadedFileURL, + to: fileURL + ) + return relativePath + } + + public func cleanupCachedRemoteAssetsAfterSuccessfulDownload( + payload: DownloadRequestPayload, + pages: [PageResult], + existingDownload: DownloadedGallery + ) async { + let previewURLs = Array(payload.previewURLs.values) + .flatMap { $0.previewCacheCleanupURLs() } + let pageURLs = pages.compactMap(\.imageURL) + let coverURLs = [ + payload.galleryDetail.coverURL, + payload.gallery.coverURL, + existingDownload.onlineCoverURL + ] + .compactMap(\.self) + + let urls = Array(Set(previewURLs + pageURLs + coverURLs)) + .map(Optional.some) + await removeCachedImages(for: urls) + } + + public func resolveSource( + payload: DownloadRequestPayload, + options: DownloadRequestOptions, + requiredPageIndices: [Int] + ) async throws -> ResolvedSource { + let requiredPageNumbers = Array( + Set(requiredPageIndices.map { + payload.previewConfig.pageNumber(index: $0) + }) + ) + .sorted() + var thumbnailURLs = [Int: URL]() + for pageNumber in requiredPageNumbers { + let pageURLs = try await ThumbnailURLsRequest( + galleryURL: payload.gallery.galleryURL.forceUnwrapped, + pageNum: pageNumber, + urlSession: urlSession, + allowsCellular: options.allowCellular + ) + .response() + .get() + thumbnailURLs + .merge(pageURLs, uniquingKeysWith: { _, new in new }) + } + guard let firstURL = requiredPageIndices.lazy + .compactMap({ thumbnailURLs[$0] }).first + ?? thumbnailURLs.values.first + else { + throw AppError.notFound + } + if URLClient.isMPVURL(firstURL) { + let (mpvKey, imageKeys) = try await MPVKeysRequest( + mpvURL: firstURL, + urlSession: urlSession, + allowsCellular: options.allowCellular + ) + .response() + .get() + return .mpv(mpvKey, imageKeys) + } else { + return .normal(thumbnailURLs) + } + } + + public func prepareWorkingSeed( + payload: DownloadRequestPayload, + existingDownload: DownloadedGallery, + folderURL: URL + ) throws -> WorkingSeed { + let shouldReuseFolder = shouldReuseWorkingFolder( + payload: payload, + folderURL: folderURL + ) + let seedContext = RepairSeedContext( + existingDownload: existingDownload, + payload: payload + ) + try setupWorkingFolder( + folderURL: folderURL, + shouldReuse: shouldReuseFolder, + seedContext: seedContext + ) + + let manifest = try ensureWorkingManifest( + payload: payload, + folderURL: folderURL + ) + let existingPages = storage.existingPageRelativePaths( + folderURL: folderURL, + manifest: manifest + ) + let coverRelativePath = storage.existingCoverRelativePath( + folderURL: folderURL, + manifest: manifest + ) + return .init( + folderURL: folderURL, + manifest: manifest, + existingPages: existingPages, + coverRelativePath: coverRelativePath + ) + } + + // The disk index drops manifest-less folders and progress flushes skip + // them, so the working folder must carry a manifest before any page + // lands; otherwise an interruption strands the folder invisibly. + private func ensureWorkingManifest( + payload: DownloadRequestPayload, + folderURL: URL + ) throws -> DownloadManifest { + if let manifest = validatedManifest( + at: folderURL, + gid: payload.gallery.gid, + pageCount: payload.galleryDetail.pageCount + ) { + return manifest + } + let manifest = makeInitialManifest(payload: payload) + try storage.writeManifest(manifest, folderURL: folderURL) + updateDownloadIndex(folderURL: folderURL, manifest: manifest) + return manifest + } + + private func shouldReuseWorkingFolder( + payload: DownloadRequestPayload, + folderURL: URL + ) -> Bool { + guard fileManager.operate({ $0.fileExists(atPath: folderURL.path) }) else { + return false + } + switch payload.mode { + case .initial: + guard let manifest = try? storage.readManifest(folderURL: folderURL) else { + return true + } + return manifest.gid == payload.gallery.gid + && manifest.token == payload.gallery.token + && manifest.pageCount == payload.galleryDetail.pageCount + case .repair: + return true + case .redownload, .update: + return false + } + } + + private struct RepairSeedContext { + let existingDownload: DownloadedGallery + let payload: DownloadRequestPayload + } + + private func setupWorkingFolder( + folderURL: URL, + shouldReuse: Bool, + seedContext: RepairSeedContext + ) throws { + if !shouldReuse { + try? fileManager.operate { + try $0.removeItem(at: folderURL) + } + } + if !fileManager.operate({ $0.fileExists(atPath: folderURL.path) }) { + if let seed = repairSeed( + for: seedContext.existingDownload, + payload: seedContext.payload + ) { + try storage.materializeRepairSeed( + from: seed.folderURL, + manifest: seed.manifest, + to: folderURL + ) + } else { + try createDirectory(at: folderURL) + } + } + } + + public func resolvedImageSource( + index: Int, + payload: DownloadRequestPayload, + options: DownloadRequestOptions, + source: ResolvedSource, + failover: ResolvedImageSource? = nil + ) async throws -> ResolvedImageSource { + switch source { + case .normal(let thumbnailURLs): + guard let thumbnailURL = thumbnailURLs[index] else { + throw AppError.notFound + } + if let failover { + let (imageURLs, _) = try await GalleryNormalImageURLRefetchRequest( + index: index, + pageNum: 0, + galleryURL: payload.gallery.galleryURL ?? payload.host.url, + thumbnailURL: thumbnailURL, + storedImageURL: failover.imageURL, + urlSession: urlSession, + allowsCellular: options.allowCellular + ) + .response() + .get() + guard let imageURL = imageURLs[index] else { + throw AppError.notFound + } + return .init(imageURL: imageURL, mpvSkipServerIdentifier: nil) + } + let (imageURLs, _) = try await GalleryNormalImageURLsRequest( + thumbnailURLs: [index: thumbnailURL], + urlSession: urlSession, + allowsCellular: options.allowCellular + ) + .response() + .get() + guard let imageURL = imageURLs[index] else { + throw AppError.notFound + } + return .init(imageURL: imageURL, mpvSkipServerIdentifier: nil) + + case .mpv(let mpvKey, let imageKeys): + guard let gid = Int(payload.gallery.gid) else { + throw AppError.notFound + } + guard let imageKey = imageKeys[index] else { + throw AppError.notFound + } + let response = try await GalleryMPVImageURLRequest( + gid: gid, + index: index, + mpvKey: mpvKey, + mpvImageKey: imageKey, + skipServerIdentifier: failover?.mpvSkipServerIdentifier, + apiURL: payload.host.url.appendingPathComponent("api.php"), + urlSession: urlSession, + allowsCellular: options.allowCellular, + requiresSkipServerIdentifier: failover != nil + ) + .response() + .get() + return .init( + imageURL: response.imageURL, + mpvSkipServerIdentifier: response.skipServerIdentifier + ) + } + } + + public func repairSeed( + for download: DownloadedGallery, + payload: DownloadRequestPayload + ) -> RepairSeed? { + let folderURL = download.folderURL + guard payload.mode == .repair, + fileManager.operate({ + $0.fileExists(atPath: folderURL.path) + }), + let manifest = try? storage + .readManifest(folderURL: folderURL), + manifest.gid == download.gid, + manifest.pageCount == + payload.galleryDetail.pageCount + else { + return nil + } + return .init(folderURL: folderURL, manifest: manifest) + } + + public func pendingPageIndices( + payload: DownloadRequestPayload, + folderURL: URL, + existingPageRelativePaths: [Int: String] + ) -> [Int] { + let selectedIndices = payload.pageSelection.map(Set.init) + return (1...payload.galleryDetail.pageCount).filter { index in + if let selectedIndices, + !selectedIndices.contains(index) { + return false + } + guard let relativePath = + existingPageRelativePaths[index] else { + return true + } + let fileURL = folderURL + .appendingPathComponent(relativePath) + return !fileManager.operate { + $0.fileExists(atPath: fileURL.path) + } + } + } +} diff --git a/AppPackage/Sources/DownloadClient/DownloadClient+Folders.swift b/AppPackage/Sources/DownloadClient/DownloadClient+Folders.swift new file mode 100644 index 000000000..31bf9255e --- /dev/null +++ b/AppPackage/Sources/DownloadClient/DownloadClient+Folders.swift @@ -0,0 +1,237 @@ +import OSLogExt +import Foundation +import AppModels +import Resources + +private let logger = Logger(category: .init(describing: DownloadCoordinator.self)) + +// MARK: - User Folder Operations +extension DownloadCoordinator { + public func fetchFolders() async -> [String] { + return userFolders + } + + public func createFolder(name: String) async -> Result { + guard let normalizedName = storage.normalizedUserFolderName(name) else { + return .failure( + .fileOperationFailed( + String(localized: .RLocalizable.downloadStoreInvalidFolderName) + ) + ) + } + let folderURL = storage.userFolderURL(name: normalizedName) + guard !fileManager.operate({ $0.fileExists(atPath: folderURL.path) }) else { + return .failure( + .fileOperationFailed( + String(localized: .downloadStoreFolderAlreadyExists) + ) + ) + } + do { + try storage.ensureRootDirectory() + try createDirectory(at: folderURL) + } catch { + logger.error("\(error, privacy: .public)") + return .failure(.fileOperationFailed(error.localizedDescription)) + } + insertUserFolder(normalizedName) + return .success(()) + } + + public func renameFolder( + oldName: String, + newName: String + ) async -> Result { + guard let normalizedName = storage.normalizedUserFolderName(newName) else { + return .failure( + .fileOperationFailed( + String(localized: .RLocalizable.downloadStoreInvalidFolderName) + ) + ) + } + let sourceURL = storage.userFolderURL(name: oldName) + let destinationURL = storage.userFolderURL(name: normalizedName) + guard sourceURL.standardizedFileURL != destinationURL.standardizedFileURL else { + return .success(()) + } + guard fileManager.operate({ $0.fileExists(atPath: sourceURL.path) }) else { + return .failure(.notFound) + } + guard !fileManager.operate({ $0.fileExists(atPath: destinationURL.path) }) else { + return .failure( + .fileOperationFailed( + String(localized: .downloadStoreFolderAlreadyExists) + ) + ) + } + // The active task holds absolute paths inside the folder; renaming + // underneath it would resurrect the old directory on the next write. + if let activeGalleryID, + downloadIndex[activeGalleryID]?.parentFolderName == oldName { + return .failure( + .fileOperationFailed( + String(localized: .downloadStoreFolderBusyDownloading) + ) + ) + } + do { + try fileManager.operate { + try $0.moveItem(at: sourceURL, to: destinationURL) + } + } catch { + logger.error("\(error, privacy: .public)") + await reloadDownloadRecordIfPossible(gidInFolder: oldName) + return .failure(.fileOperationFailed(error.localizedDescription)) + } + renameUserFolder(oldName: oldName, newName: normalizedName) + await notifyObservers() + return .success(()) + } + + public func deleteFolder(name: String) async -> Result { + let folderURL = storage.userFolderURL(name: name) + guard fileManager.operate({ $0.fileExists(atPath: folderURL.path) }) else { + return .failure(.notFound) + } + let containedRecords = downloadIndex.values + .filter { $0.parentFolderName == name } + let containedGIDs = containedRecords.map(\.manifest.gid) + for gid in containedGIDs { + schedulingBlockedGalleryIDs.insert(gid) + } + defer { + for gid in containedGIDs { + schedulingBlockedGalleryIDs.remove(gid) + } + } + if let activeGalleryID, + containedGIDs.contains(activeGalleryID) { + let taskToCancel = activeTask + activeTask?.cancel() + activeTask = nil + self.activeGalleryID = nil + await taskToCancel?.value + } + do { + try storage.removeFolder(at: folderURL) + } catch let error as AppError { + await reloadDownloadRecords(containedRecords) + return .failure(error) + } catch { + logger.error("\(error, privacy: .public)") + await reloadDownloadRecords(containedRecords) + return .failure(.fileOperationFailed(error.localizedDescription)) + } + // Clear session and queue state only once the folder is gone; a failed + // removal above leaves the galleries intact and must not silently + // dequeue a download that lived inside the folder. + for gid in containedGIDs { + clearDownloadSessionState(gid: gid, includeUpdateFlag: true) + await queueStore.remove(gid) + await backgroundTaskStore.removeAll(for: gid) + downloadIndex[gid] = nil + } + userFolders.removeAll { $0 == name } + await notifyObservers() + await scheduleNextIfNeeded() + return .success(()) + } + + public func moveDownload( + gid: String, + toFolderName folderName: String + ) async -> Result { + guard let normalizedName = storage.normalizedUserFolderName(folderName) else { + return .failure( + .fileOperationFailed( + String(localized: .RLocalizable.downloadStoreInvalidFolderName) + ) + ) + } + schedulingBlockedGalleryIDs.insert(gid) + defer { + schedulingBlockedGalleryIDs.remove(gid) + } + guard let download = await fetchDownload(gid: gid) else { + return .failure(.notFound) + } + guard activeGalleryID != gid else { + return .failure( + .fileOperationFailed( + String(localized: .downloadStoreDownloadBusy) + ) + ) + } + let destinationParentURL = storage.userFolderURL(name: normalizedName) + let destinationURL = destinationParentURL.appendingPathComponent( + download.folderURL.lastPathComponent, + isDirectory: true + ) + guard destinationURL.standardizedFileURL != download.folderURL.standardizedFileURL else { + return .success(()) + } + guard !fileManager.operate({ $0.fileExists(atPath: destinationURL.path) }) else { + return .failure( + .fileOperationFailed( + String(localized: .downloadStoreFolderAlreadyExists) + ) + ) + } + do { + // Recreate the destination folder if it vanished via the Files app. + try createDirectory(at: destinationParentURL) + try fileManager.operate { + try $0.moveItem(at: download.folderURL, to: destinationURL) + } + } catch { + logger.error("\(error, privacy: .public)") + await reloadDownloadRecord(gid: download.gid, token: download.token) + return .failure(.fileOperationFailed(error.localizedDescription)) + } + await reloadDownloadRecord(gid: download.gid, token: download.token) + await notifyObservers() + return .success(()) + } + + private func insertUserFolder(_ name: String) { + guard !userFolders.contains(name) else { return } + userFolders.append(name) + userFolders.sort { + $0.localizedStandardCompare($1) == .orderedAscending + } + } + + private func renameUserFolder(oldName: String, newName: String) { + userFolders.removeAll { $0 == oldName } + insertUserFolder(newName) + let movedRecords = downloadIndex.values.filter { $0.parentFolderName == oldName } + for record in movedRecords { + let destinationFolderURL = storage.userFolderURL(name: newName) + .appendingPathComponent(record.folderURL.lastPathComponent, isDirectory: true) + downloadIndex[record.manifest.gid] = DownloadFolderRecord( + relativePath: "\(newName)/\(record.folderURL.lastPathComponent)", + folderURL: destinationFolderURL, + manifest: record.manifest, + localCoverURL: record.localCoverURL.map { + destinationFolderURL.appendingPathComponent($0.lastPathComponent) + }, + localPageURLs: record.localPageURLs.mapValues { + destinationFolderURL.appendingPathComponent($0.lastPathComponent) + }, + modificationDate: record.modificationDate, + parentFolderName: newName + ) + } + } + + private func reloadDownloadRecordIfPossible(gidInFolder folderName: String) async { + let records = downloadIndex.values.filter { $0.parentFolderName == folderName } + await reloadDownloadRecords(records) + } + + private func reloadDownloadRecords(_ records: [DownloadFolderRecord]) async { + for record in records { + await reloadDownloadRecord(gid: record.manifest.gid, token: record.manifest.token) + } + } +} diff --git a/AppPackage/Sources/DownloadClient/DownloadClient+Manager.swift b/AppPackage/Sources/DownloadClient/DownloadClient+Manager.swift new file mode 100644 index 000000000..7fbff7c1c --- /dev/null +++ b/AppPackage/Sources/DownloadClient/DownloadClient+Manager.swift @@ -0,0 +1,457 @@ +import Foundation +import AppModels +import LibraryClient + +public typealias ScheduledDownloadOperation = @Sendable () async -> Void + +public enum ScheduledDownloadRunResult: Equatable, Sendable { + case ranOperation + case skippedOperation +} + +public struct DownloadTaskRunner: Sendable { + public var beforeActiveTaskCheck: @Sendable () async -> Void + public var recordScheduledGallery: @Sendable (String) async -> Void + public var runScheduledDownload: @Sendable ( + String, + @escaping ScheduledDownloadOperation + ) async -> ScheduledDownloadRunResult + public var beforeFailurePersistence: @Sendable () async -> Void + + public init( + beforeActiveTaskCheck: @escaping @Sendable () async -> Void = {}, + recordScheduledGallery: @escaping @Sendable (String) async -> Void = { _ in }, + runScheduledDownload: @escaping @Sendable ( + String, + @escaping ScheduledDownloadOperation + ) async -> ScheduledDownloadRunResult = { _, operation in + await operation() + return .ranOperation + }, + beforeFailurePersistence: @escaping @Sendable () async -> Void = {} + ) { + self.beforeActiveTaskCheck = beforeActiveTaskCheck + self.recordScheduledGallery = recordScheduledGallery + self.runScheduledDownload = runScheduledDownload + self.beforeFailurePersistence = beforeFailurePersistence + } +} + +/// The brain of the download subsystem: the in-memory read model (`downloadIndex`, +/// `userFolders`) fused with scheduling (`activeGalleryID`, `activeTask`, queued +/// modes / selections). It is one of three types split by invariant ownership, +/// alongside `DownloadStore` (pure disk I/O) and +/// `DownloadObserverHub` (observer fan-out), all behind the unchanged `DownloadClient` +/// facade. Read model and scheduling stay fused on purpose: only one gallery downloads at +/// a time (E-Hentai rate-limits gallery downloads, so concurrency is unwanted), and +/// scheduling reads and writes the index on every step, so splitting them would buy nothing +/// and reintroduce the cross-actor races this single actor exists to prevent. +public actor DownloadCoordinator { + public static let retryLimit = 3 + public static let progressFlushPageInterval = 8 + public static let progressFlushMinimumInterval: TimeInterval = 0.4 + public static let responseInspectionPrefixLength = 4096 + public static let kokomadeImageURLSuffixes = [ + "exhentai.org/img/kokomade.jpg" + ] + public static let quotaExceededImageURLSuffixes = [ + "exhentai.org/img/509.gif", + "ehgt.org/g/509.gif" + ] + + public struct PageResult: Sendable { + public let index: Int + public let relativePath: String + public let imageURL: URL? + + public init(index: Int, relativePath: String, imageURL: URL?) { + self.index = index + self.relativePath = relativePath + self.imageURL = imageURL + } + } + + public struct PageFailure: Error, Sendable { + public let index: Int + public let relativePath: String? + public let error: AppError + + public init(index: Int, relativePath: String?, error: AppError) { + self.index = index + self.relativePath = relativePath + self.error = error + } + } + + public struct DownloadBatchResult: Sendable { + public let pages: [PageResult] + public let failedPages: [PageFailure] + public init( + pages: [PageResult], + failedPages: [PageFailure] + ) { + self.pages = pages + self.failedPages = failedPages + } + } + + public enum PageTaskOutcome: Sendable { + case success(PageResult) + case failure(PageFailure) + case cancelled + } + + public struct RepairSeed: Sendable { + public let folderURL: URL + public let manifest: DownloadManifest + public init( + folderURL: URL, + manifest: DownloadManifest + ) { + self.folderURL = folderURL + self.manifest = manifest + } + } + + public struct WorkingSeed: Sendable { + public let folderURL: URL + public let manifest: DownloadManifest + public let existingPages: [Int: String] + public let coverRelativePath: String? + public init( + folderURL: URL, + manifest: DownloadManifest, + existingPages: [Int: String], + coverRelativePath: String? = nil + ) { + self.folderURL = folderURL + self.manifest = manifest + self.existingPages = existingPages + self.coverRelativePath = coverRelativePath + } + } + + public enum ResolvedSource: Sendable { + case normal([Int: URL]) + case mpv(String, [Int: String]) + } + + public struct ResolvedImageSource: Sendable { + public let imageURL: URL + public var mpvSkipServerIdentifier: String? + public init( + imageURL: URL, + mpvSkipServerIdentifier: String? = nil + ) { + self.imageURL = imageURL + self.mpvSkipServerIdentifier = mpvSkipServerIdentifier + } + } + + public struct PartialDownloadError: Error, Sendable { + public let failedPages: [PageFailure] + public init( + failedPages: [PageFailure] + ) { + self.failedPages = failedPages + } + } + + public struct IncompleteDownloadError: Error, Sendable { + public let missingPageIndices: [Int] + public init( + missingPageIndices: [Int] + ) { + self.missingPageIndices = missingPageIndices + } + } + + public struct FailureContext: Sendable { + public let gid: String + public let originalDownload: DownloadedGallery + public let mode: DownloadStartMode + public init( + gid: String, + originalDownload: DownloadedGallery, + mode: DownloadStartMode + ) { + self.gid = gid + self.originalDownload = originalDownload + self.mode = mode + } + } + + public struct ProgressFlushContext: Sendable { + public let gid: String + public let folderURL: URL + + public init(gid: String, folderURL: URL) { + self.gid = gid + self.folderURL = folderURL + } + } + + public struct PageDownloadContext: Sendable { + public let payload: DownloadRequestPayload + public let options: DownloadRequestOptions + public let source: ResolvedSource? + public let folderURL: URL + public init( + payload: DownloadRequestPayload, + options: DownloadRequestOptions, + source: ResolvedSource? = nil, + folderURL: URL + ) { + self.payload = payload + self.options = options + self.source = source + self.folderURL = folderURL + } + } + + public struct CacheRestoreSource: Sendable { + public let gid: String + public let token: String + public let cacheURLs: [URL?] + public let referenceURL: URL? + public let imageURL: URL? + public init( + gid: String, + token: String, + cacheURLs: [URL?], + referenceURL: URL? = nil, + imageURL: URL? = nil + ) { + self.gid = gid + self.token = token + self.cacheURLs = cacheURLs + self.referenceURL = referenceURL + self.imageURL = imageURL + } + } + + public struct CaptureTargetResult: Sendable { + public let folderURL: URL + public let preferredRelativePath: String? + public init( + folderURL: URL, + preferredRelativePath: String? = nil + ) { + self.folderURL = folderURL + self.preferredRelativePath = preferredRelativePath + } + } + + public struct HTMLResponseContext { + public let prefixData: Data + public let fullData: Data? + public let response: URLResponse + public let requestURL: URL? + public let mimeType: String? + public init( + prefixData: Data, + fullData: Data? = nil, + response: URLResponse, + requestURL: URL? = nil, + mimeType: String? = nil + ) { + self.prefixData = prefixData + self.fullData = fullData + self.response = response + self.requestURL = requestURL + self.mimeType = mimeType + } + } + + public struct DownloadExecutionContext: Sendable { + public let payload: DownloadRequestPayload + public let options: DownloadRequestOptions + public let existingDownload: DownloadedGallery + public init( + payload: DownloadRequestPayload, + options: DownloadRequestOptions, + existingDownload: DownloadedGallery + ) { + self.payload = payload + self.options = options + self.existingDownload = existingDownload + } + } + + public struct FinalizeContext: Sendable { + public let coverRelativePath: String? + public let batchResult: DownloadBatchResult + public let existingDownload: DownloadedGallery + public init( + coverRelativePath: String? = nil, + batchResult: DownloadBatchResult, + existingDownload: DownloadedGallery + ) { + self.coverRelativePath = coverRelativePath + self.batchResult = batchResult + self.existingDownload = existingDownload + } + } + + public let storage: DownloadStore + public let urlSession: URLSession + public let pageDownloader: DownloadPageDownloader + public let backgroundTaskStore: DownloadBackgroundTaskStore + public let backgroundTaskClient: BackgroundTaskClient + public let storedCookiesProvider: @Sendable (URL) -> [HTTPCookie] + public let libraryClient: LibraryClient + /// Supplies the latest runtime settings immediately before a queued download starts. + /// + /// Options are not stored in manifests or request payloads so settings changed while + /// a gallery is queued apply to the eventual detail fetch and page workers. + public let downloadOptionsProvider: @Sendable () async -> DownloadRequestOptions + public let queueStore: DownloadQueueStore + public let taskRunner: DownloadTaskRunner + public let observerHub = DownloadObserverHub() + /// Write-through cache of the on-disk download tree and the read authority between the + /// explicit scan boundaries (see `indexedDownload(gid:)`). The filesystem stays the + /// source of truth, so this is rebuilt from disk only at those boundaries, never on a + /// hot lookup. + public var downloadIndex = [String: DownloadFolderRecord]() + public var hasLoadedIndex = false + public var userFolders = [String]() + /// Transient, session-scoped status: deliberately in-memory only, never written to disk. + /// Download-level errors, per-page failures, validation results, and the update-available + /// set are status *about* a download, not durable properties of it; they are cheap to + /// re-derive and re-derivation yields the *current* truth (e.g. a lifted quota simply + /// succeeds on the next attempt). Durable facts (downloaded pages, hashes, metadata) + /// live in the manifest. The accepted cost is that after relaunch a failed download + /// surfaces as inactive ("Paused") until its error re-surfaces on the next manual retry. + public var downloadErrors = [String: DownloadFailure]() + public var validationErrors = [String: DownloadFailure]() + public var failedPageErrors = [String: [Int: PageFailure]]() + public var updatedGalleryIDs = Set() + public var queuedModes = [String: DownloadStartMode]() + public var queuedPageSelections = [String: [Int]]() + public var activeGalleryID: String? + public var activeTask: Task? + public var activeTaskGeneration = 0 + public var schedulingBlockedGalleryIDs = Set() + public var backgroundAssertionToken: BackgroundTaskToken? + /// Set synchronously across the `begin` MainActor hop so a concurrent reconcile + /// cannot issue a second assertion before the first token is recorded. + public var isBeginningBackgroundAssertion = false + + public init( + storage: DownloadStore, + urlSession: URLSession, + pageDownloader: DownloadPageDownloader? = nil, + backgroundTaskStore: DownloadBackgroundTaskStore? = nil, + backgroundTaskClient: BackgroundTaskClient = .noop, + storedCookiesProvider: @escaping @Sendable (URL) -> [HTTPCookie] = { + HTTPCookieStorage.shared.cookies(for: $0) ?? [] + }, + libraryClient: LibraryClient = .live, + downloadOptionsProvider: @escaping @Sendable () async -> DownloadRequestOptions = { + DownloadRequestOptions() + }, + queueStore: DownloadQueueStore? = nil, + taskRunner: DownloadTaskRunner = .init() + ) { + self.storage = storage + self.urlSession = urlSession + self.pageDownloader = pageDownloader ?? .foreground(urlSession: urlSession) + self.backgroundTaskStore = backgroundTaskStore ?? DownloadBackgroundTaskStore( + fileURL: storage.backgroundTaskRegistryURL() + ) + self.backgroundTaskClient = backgroundTaskClient + self.storedCookiesProvider = storedCookiesProvider + self.libraryClient = libraryClient + self.downloadOptionsProvider = downloadOptionsProvider + self.queueStore = queueStore ?? DownloadQueueStore(fileURL: storage.queueURL()) + self.taskRunner = taskRunner + } + + public var fileManager: DownloadFileManager { + storage.fileManager + } +} + +/// Owns the observer continuations and the last snapshot broadcast to them, kept apart from +/// the coordinator's state so notification can never interleave with a state mutation. The +/// coordinator computes a snapshot and hands it here to fan out; this type holds no download +/// state of its own. +public actor DownloadObserverHub { + private var lastObservedDownloads = [DownloadedGallery]() + private var observers = [UUID: AsyncStream<[DownloadedGallery]>.Continuation]() + private var notifyGeneration = 0 + + public init() {} + + public func observe( + snapshot: @Sendable () async -> [DownloadedGallery] + ) async -> AsyncStream<[DownloadedGallery]> { + let identifier = UUID() + let (stream, continuation) = AsyncStream.makeStream( + of: [DownloadedGallery].self + ) + // Register before the snapshot resolves so a `notify` landing while the + // snapshot is in flight reaches this observer instead of being missed. + observers[identifier] = continuation + continuation.onTermination = { [weak self] _ in + guard let self else { return } + Task { + await self.removeObserver(id: identifier) + } + } + + let generationBeforeSnapshot = notifyGeneration + let initialDownloads = await snapshot() + if notifyGeneration == generationBeforeSnapshot { + // No notify reached this observer during resolution; deliver the snapshot. + continuation.yield(initialDownloads) + } + // Otherwise a fresher value already arrived via notify; skipping the now-stale + // snapshot keeps emissions ordered newest-last. + return stream + } + + public func notify(_ downloads: [DownloadedGallery]) { + guard downloads != lastObservedDownloads else { return } + lastObservedDownloads = downloads + notifyGeneration += 1 + observers.values.forEach { $0.yield(downloads) } + } + + private func removeObserver(id: UUID) { + observers[id] = nil + } +} + +extension DownloadCoordinator { + public func clearDownloadFailureState( + gid: String, + includePageFailures: Bool = true + ) { + downloadErrors[gid] = nil + validationErrors[gid] = nil + if includePageFailures { + failedPageErrors[gid] = nil + } + } + + public func clearDownloadQueueIntent(gid: String) { + queuedModes[gid] = nil + queuedPageSelections[gid] = nil + } + + public func clearDownloadSessionState( + gid: String, + includePageFailures: Bool = true, + includeUpdateFlag: Bool = false + ) { + clearDownloadFailureState( + gid: gid, + includePageFailures: includePageFailures + ) + clearDownloadQueueIntent(gid: gid) + if includeUpdateFlag { + updatedGalleryIDs.remove(gid) + } + } +} diff --git a/AppPackage/Sources/DownloadClient/DownloadClient+Networking.swift b/AppPackage/Sources/DownloadClient/DownloadClient+Networking.swift new file mode 100644 index 000000000..a21cdcebe --- /dev/null +++ b/AppPackage/Sources/DownloadClient/DownloadClient+Networking.swift @@ -0,0 +1,314 @@ +import OSLogExt +import Foundation +import AppModels +import UniformTypeIdentifiers +import AnimatedImageFeature + +private let logger = Logger(category: .init(describing: DownloadCoordinator.self)) + +// MARK: - Network +extension DownloadCoordinator { + public func downloadResponse( + url: URL, + allowsCellular: Bool, + retriesRequest: Bool = true + ) async throws -> (URL, URLResponse) { + var request = URLRequest(url: url) + request.allowsCellularAccess = allowsCellular + return try await downloadResponse( + for: request, + retriesRequest: retriesRequest + ) + } + + public func downloadResponse( + for request: URLRequest, + retriesRequest: Bool = true + ) async throws -> (URL, URLResponse) { + let performRequest = { + try await self.rawDownloadResponse(for: request) + } + + let response: (URL, URLResponse) + if retriesRequest { + response = try await withRetry( + operation: "downloadResponse" + ) { + try await performRequest() + } + } else { + response = try await performRequest() + } + + if let error = detectResponseError( + fileURL: response.0, + response: response.1, + requestURL: request.url + ) { + try? fileManager.operate { + try $0.removeItem(at: response.0) + } + throw error + } + + return response + } + + public func dataResponse( + for request: URLRequest, + retriesRequest: Bool = true + ) async throws -> (Data, URLResponse) { + if retriesRequest { + return try await withRetry( + operation: "dataResponse" + ) { + try await rawDataResponse(for: request) + } + } + return try await rawDataResponse(for: request) + } + + public func rawDataResponse( + for request: URLRequest + ) async throws -> (Data, URLResponse) { + do { + return try await urlSession.data(for: request) + } catch let error as AppError { + throw error + } catch is CancellationError { + throw CancellationError() + } catch let error as URLError + where error.code == .cancelled { + throw CancellationError() + } catch { + if Self.isCancellationLikeError(error) { + throw CancellationError() + } + if error is URLError { + throw AppError.networkingFailed + } + throw AppError.unknown + } + } + + public func rawDownloadResponse( + for request: URLRequest + ) async throws -> (URL, URLResponse) { + do { + return try await urlSession.download(for: request) + } catch let error as AppError { + throw error + } catch is CancellationError { + throw CancellationError() + } catch let error as URLError + where error.code == .cancelled { + throw CancellationError() + } catch { + if Self.isCancellationLikeError(error) { + throw CancellationError() + } + if error is URLError { + throw AppError.networkingFailed + } + throw AppError.unknown + } + } + + public func pageDownloadResponse( + url: URL, + allowsCellular: Bool, + context: DownloadPageTaskContext, + retriesRequest: Bool = true + ) async throws -> DownloadPageTransfer { + var request = URLRequest(url: url) + request.allowsCellularAccess = allowsCellular + return try await pageDownloadResponse( + for: request, + context: context, + retriesRequest: retriesRequest + ) + } + + public func pageDownloadResponse( + for request: URLRequest, + context: DownloadPageTaskContext, + retriesRequest: Bool = true + ) async throws -> DownloadPageTransfer { + let performRequest = { + try await self.rawPageDownloadResponse( + for: request, + context: context + ) + } + + let transfer: DownloadPageTransfer + if retriesRequest { + transfer = try await withRetry( + operation: "pageDownloadResponse" + ) { + try await performRequest() + } + } else { + transfer = try await performRequest() + } + + if let error = detectResponseError( + fileURL: transfer.fileURL, + response: transfer.response, + requestURL: request.url + ) { + try? fileManager.operate { + try $0.removeItem(at: transfer.fileURL) + } + if let taskIdentifier = transfer.taskIdentifier { + await backgroundTaskStore.remove(taskIdentifier: taskIdentifier) + } + throw error + } + + return transfer + } + + public func rawPageDownloadResponse( + for request: URLRequest, + context: DownloadPageTaskContext + ) async throws -> DownloadPageTransfer { + do { + return try await pageDownloader.download(request, context) + } catch let error as AppError { + throw error + } catch is CancellationError { + throw CancellationError() + } catch let error as URLError + where error.code == .cancelled { + throw CancellationError() + } catch { + if Self.isCancellationLikeError(error) { + throw CancellationError() + } + if error is URLError { + throw AppError.networkingFailed + } + throw AppError.unknown + } + } + + public func withRetry( + operation: String, + maxAttempts: Int = retryLimit, + body: () async throws -> T + ) async throws -> T { + var attempt = 1 + while true { + do { + return try await body() + } catch is CancellationError { + throw CancellationError() + } catch let error as AppError { + guard error.isRetryable, + attempt < maxAttempts else { + throw error + } + logger.warning( + """ + Download operation will retry, operation: \(operation, privacy: .public), \ + attempt: \(attempt, privacy: .public), \ + error: \(error.localizedDescription, privacy: .public) + """ + ) + attempt += 1 + } catch { + guard attempt < maxAttempts else { + throw error + } + logger.warning( + """ + Download operation will retry after unexpected error, \ + operation: \(operation, privacy: .public), \ + attempt: \(attempt, privacy: .public), \ + error: \(error.localizedDescription, privacy: .public) + """ + ) + attempt += 1 + } + } + } +} + +// MARK: - File Operations +extension DownloadCoordinator { + public func fileExtension( + for url: URL, + response: URLResponse?, + prefixData: Data + ) -> String { + if !url.pathExtension.isEmpty { + return url.pathExtension.lowercased() + } + if let ext = extensionFromMimeType(response) { + return ext + } + return prefixData.knownBinaryImageFileExtension ?? "jpg" + } + + private func extensionFromMimeType( + _ response: URLResponse? + ) -> String? { + guard let mimeType = response?.mimeType?.lowercased() + else { + return nil + } + switch mimeType { + case "image/jpeg": + return "jpg" + case "image/png": + return "png" + case "image/gif": + return "gif" + case "image/webp": + return "webp" + default: + guard mimeType.hasPrefix("image/") else { return nil } + return UTType(mimeType: mimeType)?.preferredFilenameExtension + } + } + + public func createDirectory(at url: URL) throws { + try fileManager.operate { + try $0.createDirectory( + at: url, + withIntermediateDirectories: true + ) + } + } + + public func write(data: Data, to url: URL) throws { + try createDirectory(at: url.deletingLastPathComponent()) + try data.write(to: url, options: .atomic) + } + + public func moveDownloadedFile( + from sourceURL: URL, + to destinationURL: URL + ) throws { + try createDirectory( + at: destinationURL.deletingLastPathComponent() + ) + if fileManager.operate({ $0.fileExists(atPath: destinationURL.path) }) { + try fileManager.operate { + try $0.removeItem(at: destinationURL) + } + } + try fileManager.operate { + try $0.moveItem(at: sourceURL, to: destinationURL) + } + } + + public func readResponsePrefixData(at fileURL: URL) throws -> Data { + let handle = try FileHandle(forReadingFrom: fileURL) + defer { try? handle.close() } + return try handle.read( + upToCount: Self.responseInspectionPrefixLength + ) ?? Data() + } +} diff --git a/AppPackage/Sources/DownloadClient/DownloadClient+PageDownload.swift b/AppPackage/Sources/DownloadClient/DownloadClient+PageDownload.swift new file mode 100644 index 000000000..5fbf33739 --- /dev/null +++ b/AppPackage/Sources/DownloadClient/DownloadClient+PageDownload.swift @@ -0,0 +1,322 @@ +import Foundation +import AppModels + +// MARK: - Download Pages +extension DownloadCoordinator { + private struct PageDownloadProgress { + var results: [PageResult] = [] + var failedPages: [Int: PageFailure?] = [:] + var completedCount: Int = 0 + var pendingResolvedPages: [PageResult] = [] + var lastFlushDate: Date = Date() + } + + private struct PageDownloadControl { + var wasCancelled = false + var didAbortForFatalError = false + } + + public func downloadPages( + context: PageDownloadContext, + pendingPageIndices: [Int], + existingManifest: DownloadManifest, + existingPageRelativePaths: [Int: String] + ) async throws -> DownloadBatchResult { + let existingPages = buildExistingPages( + existingManifest: existingManifest, + existingPageRelativePaths: existingPageRelativePaths + ) + var progress = PageDownloadProgress() + progress.failedPages = failedPageErrors[context.payload.gallery.gid]? + .mapValues(Optional.some) ?? [:] + + try await initializePageDownloadState( + context: context, + existingPages: existingPages, + progress: &progress + ) + + let restoredIndices = Set( + progress.results + .prefix(progress.completedCount) + .map(\.index) + ) + let remainingPageIndices = pendingPageIndices + .filter { !restoredIndices.contains($0) } + var control = PageDownloadControl() + await processRemainingPages( + context: context, + remainingPageIndices: remainingPageIndices, + existingPages: existingPages, + progress: &progress, + control: &control + ) + + if control.wasCancelled || Task.isCancelled { + throw CancellationError() + } + try await flushDownloadProgress( + context: .init( + gid: context.payload.gallery.gid, + folderURL: context.folderURL + ), + pendingResolvedPages: &progress.pendingResolvedPages, + lastFlushDate: &progress.lastFlushDate, + force: true + ) + return try buildBatchResult( + results: progress.results, + failedPages: progress.failedPages + ) + } + + private func initializePageDownloadState( + context: PageDownloadContext, + existingPages: [Int: String], + progress: inout PageDownloadProgress + ) async throws { + let pageIndices = Array( + 1...context.payload.galleryDetail.pageCount + ) + collectExistingPages( + pageIndices: pageIndices, + existingPages: existingPages, + context: context, + results: &progress.results, + failedPages: &progress.failedPages + ) + progress.completedCount = progress.results.count + guard progress.completedCount > 0 else { return } + try flushManifestPageProgress( + folderURL: context.folderURL, + pages: progress.results + ) + await notifyObservers() + } + + private func buildBatchResult( + results: [PageResult], + failedPages: [Int: PageFailure?] + ) throws -> DownloadBatchResult { + let activeFailedPages = failedPages.values + .compactMap { $0 } + .filter { + !isCancellationLikeAppError($0.error) + } + .sorted(by: { $0.index < $1.index }) + return .init( + pages: results, + failedPages: activeFailedPages + ) + } + + private func buildExistingPages( + existingManifest: DownloadManifest, + existingPageRelativePaths: [Int: String] + ) -> [Int: String] { + let manifestPageIndices = Set(existingManifest.pages.keys) + return existingPageRelativePaths.filter { + manifestPageIndices.contains($0.key) + } + } + + private func collectExistingPages( + pageIndices: [Int], + existingPages: [Int: String], + context: PageDownloadContext, + results: inout [PageResult], + failedPages: inout [Int: PageFailure?] + ) { + for index in pageIndices { + guard let relativePath = existingPages[index] else { + continue + } + let fileURL = context.folderURL + .appendingPathComponent(relativePath) + guard fileManager.operate({ $0.fileExists(atPath: fileURL.path) }) else { + continue + } + failedPages[index] = nil + results.append( + .init( + index: index, + relativePath: relativePath, + imageURL: nil + ) + ) + } + } + + private func processRemainingPages( + context: PageDownloadContext, + remainingPageIndices: [Int], + existingPages: [Int: String], + progress: inout PageDownloadProgress, + control: inout PageDownloadControl + ) async { + let payload = context.payload + await withTaskGroup(of: PageTaskOutcome.self) { group in + var pendingIterator = + remainingPageIndices.makeIterator() + seedInitialPageTasks( + to: &group, + iterator: &pendingIterator, + context: context, + pageCount: remainingPageIndices.count, + existingPages: existingPages + ) + while let outcome = await group.next() { + guard !control.didAbortForFatalError else { + group.cancelAll() + continue + } + if control.wasCancelled || Task.isCancelled + || schedulingBlockedGalleryIDs + .contains(payload.gallery.gid) { + control.wasCancelled = true + group.cancelAll() + continue + } + applyPageTaskOutcome( + outcome, + progress: &progress, + wasCancelled: &control.wasCancelled, + didAbortForFatalError: &control.didAbortForFatalError, + group: &group + ) + guard !control.wasCancelled, !control.didAbortForFatalError else { continue } + try? await flushDownloadProgress( + context: .init( + gid: payload.gallery.gid, + folderURL: context.folderURL + ), + pendingResolvedPages: + &progress.pendingResolvedPages, + lastFlushDate: &progress.lastFlushDate, + force: false + ) + if let nextIndex = pendingIterator.next() { + addPageDownloadTask( + to: &group, + index: nextIndex, + context: context, + existingPages: existingPages + ) + } + } + } + } + + private func seedInitialPageTasks( + to group: inout TaskGroup, + iterator: inout IndexingIterator<[Int]>, + context: PageDownloadContext, + pageCount: Int, + existingPages: [Int: String] + ) { + let workerCount = context.options.workerCount + for _ in 0.. + ) { + switch outcome { + case .success(let pageResult): + progress.completedCount += 1 + progress.failedPages[pageResult.index] = nil + progress.results.append(pageResult) + progress.pendingResolvedPages.append(pageResult) + + case .failure(let failure): + if isCancellationLikeAppError(failure.error) { + wasCancelled = true + group.cancelAll() + return + } + progress.failedPages[failure.index] = failure + if isFatalAccountAppError(failure.error) { + didAbortForFatalError = true + group.cancelAll() + } + + case .cancelled: + guard !didAbortForFatalError else { return } + wasCancelled = true + group.cancelAll() + } + } + + private func addPageDownloadTask( + to group: inout TaskGroup, + index: Int, + context: PageDownloadContext, + existingPages: [Int: String] + ) { + group.addTask { + do { + return .success( + try await self.downloadPage( + index: index, + context: context, + preferredRelativePath: + existingPages[index] + ) + ) + } catch is CancellationError { + return .cancelled + } catch let error as AppError { + return .failure( + .init( + index: index, + relativePath: existingPages[index], + error: error + ) + ) + } catch { + if Self.isCancellationLikeError(error) { + return .cancelled + } + return .failure( + .init( + index: index, + relativePath: existingPages[index], + error: .fileOperationFailed( + error.localizedDescription + ) + ) + ) + } + } + } + + /// Whether an error is account-level fatal and must abort the whole page batch. + /// + /// Scope is intentionally **account-level only**: quota, auth, and IP ban affect every + /// in-flight and queued page, so continuing the batch only wastes requests and can worsen a ban. + /// Gallery-level errors (`.expunged`, `.copyrightClaim`) are deliberately *not* fatal here — they + /// mean the gallery is gone, but they surface before per-page download and are handled upstream, + /// so a per-page occurrence is treated like any other page failure rather than aborting the batch. + public func isFatalAccountAppError(_ error: AppError) -> Bool { + switch error { + case .quotaExceeded, .authenticationRequired, .ipBanned: + return true + case .copyrightClaim, .expunged, .networkingFailed, + .webImageFailed, .parseFailed, .fileOperationFailed, .noUpdates, + .notFound, .unknown: + return false + } + } +} diff --git a/AppPackage/Sources/DownloadClient/DownloadClient+PageDownloadHelpers.swift b/AppPackage/Sources/DownloadClient/DownloadClient+PageDownloadHelpers.swift new file mode 100644 index 000000000..10078eb3e --- /dev/null +++ b/AppPackage/Sources/DownloadClient/DownloadClient+PageDownloadHelpers.swift @@ -0,0 +1,147 @@ +import Foundation +import AppModels + +// MARK: - Download Single Page +extension DownloadCoordinator { + public func downloadPage( + index: Int, + context: PageDownloadContext, + preferredRelativePath: String? + ) async throws -> PageResult { + guard let source = context.source else { + throw AppError.notFound + } + let attempts = context.options.autoRetryFailedPages ? 2 : 1 + var capturedError: AppError = .unknown + var failover: ResolvedImageSource? + + for _ in 0.. PageResult? { + let payload = context.payload + let resolvedCacheURLs = pageImageCacheURLs( + imageURL: resolvedImageSource.imageURL + ) + let resolvedSource = CacheRestoreSource( + gid: payload.gallery.gid, + token: payload.gallery.token, + cacheURLs: resolvedCacheURLs, + referenceURL: preferredPageReferenceURL( + resolvedImageSource: resolvedImageSource + ), + imageURL: resolvedImageSource.imageURL + ) + return try await restorePageFromCache( + index: index, + source: resolvedSource, + folderURL: context.folderURL, + preferredRelativePath: preferredRelativePath + ) + } + + private func downloadAndSavePage( + index: Int, + resolvedImageSource: ResolvedImageSource, + context: PageDownloadContext, + preferredRelativePath: String? + ) async throws -> PageResult { + let payload = context.payload + let targetURL = resolvedImageSource.imageURL + let transfer = + try await pageDownloadResponse( + url: targetURL, + allowsCellular: context.options.allowCellular, + context: .init( + gid: payload.gallery.gid, + pageIndex: index + ), + retriesRequest: false + ) + let relativePath: String + if let preferredRelativePath { + relativePath = preferredRelativePath + } else { + let prefixData = try readResponsePrefixData( + at: transfer.fileURL + ) + let ext = fileExtension( + for: targetURL, + response: transfer.response, + prefixData: prefixData + ) + relativePath = storage.makePageRelativePath( + gid: payload.gallery.gid, + token: payload.gallery.token, + index: index, + fileExtension: ext + ) + } + let fileURL = context.folderURL + .appendingPathComponent(relativePath) + do { + try moveDownloadedFile( + from: transfer.fileURL, + to: fileURL + ) + if let taskIdentifier = transfer.taskIdentifier { + await backgroundTaskStore.remove(taskIdentifier: taskIdentifier) + } + } catch { + // The move never consumed the staged file; drop it unconditionally so it + // can't strand, matching the sibling failure paths. + removeStagedBackgroundFile(transfer.fileURL) + if let taskIdentifier = transfer.taskIdentifier { + await backgroundTaskStore.remove(taskIdentifier: taskIdentifier) + } + throw error + } + return .init( + index: index, + relativePath: relativePath, + imageURL: resolvedImageSource.imageURL + ) + } +} diff --git a/AppPackage/Sources/DownloadClient/DownloadClient+Persistence.swift b/AppPackage/Sources/DownloadClient/DownloadClient+Persistence.swift new file mode 100644 index 000000000..0991791ae --- /dev/null +++ b/AppPackage/Sources/DownloadClient/DownloadClient+Persistence.swift @@ -0,0 +1,236 @@ +import OSLogExt +import AppTools +import Foundation +import AppModels + +private let logger = Logger(category: .init(describing: DownloadCoordinator.self)) + +// MARK: - Disk Index +extension DownloadCoordinator { + @discardableResult + public func reloadDownloadIndex() async -> [DownloadedGallery] { + do { + let scanResult = try storage.scanDownloads() + downloadIndex = deduplicatedDownloadIndex(from: scanResult.records) + userFolders = scanResult.userFolders + hasLoadedIndex = true + return await downloads(from: scanResult.records) + } catch { + logger.error("\(error, privacy: .public)") + downloadIndex = [:] + userFolders = [] + hasLoadedIndex = true + return [] + } + } + + /// The filesystem is the durable source of truth, but this actor's index is the read authority + /// between explicit sync points. Hot lookups must not walk download folders; app launch, + /// foreground return, pull-to-refresh, and targeted surprise repair are the scan boundaries. + public func indexedDownload(gid: String) async -> DownloadedGallery? { + guard hasLoadedIndex else { return nil } + guard let record = downloadIndex[gid] else { return nil } + return downloadedGallery(from: record) + } + + public func indexedDownloads() async -> [DownloadedGallery] { + guard hasLoadedIndex else { return [] } + return await downloads(from: Array(downloadIndex.values)) + } + + private func downloads( + from records: [DownloadFolderRecord] + ) async -> [DownloadedGallery] { + return deduplicatedDownloadIndex(from: records).values + .map { + downloadedGallery(from: $0) + } + .sorted(by: sortDownloadsByDisplayStatus) + } + + public func indexedDownloads(gids: [String]) async -> [DownloadedGallery] { + guard hasLoadedIndex else { return [] } + let gidSet = Set(gids) + return await downloads( + from: downloadIndex.values.filter { gidSet.contains($0.manifest.gid) } + ) + } + + private func deduplicatedDownloadIndex( + from records: [DownloadFolderRecord] + ) -> [String: DownloadFolderRecord] { + records.reduce(into: [:]) { index, record in + let gid = record.manifest.gid + guard let currentRecord = index[gid] else { + index[gid] = record + return + } + if record.displayDate > currentRecord.displayDate { + index[gid] = record + } + } + } + + private func downloadedGallery( + from record: DownloadFolderRecord + ) -> DownloadedGallery { + let gid = record.manifest.gid + return DownloadedGallery( + manifest: record.manifest, + folderURL: record.folderURL, + folderName: record.parentFolderName, + localCoverURL: record.localCoverURL, + localPageURLs: record.localPageURLs, + modificationDate: record.modificationDate, + displayStatus: displayStatus(for: record), + lastError: validationErrors[gid] ?? downloadErrors[gid] + ) + } + + private func displayStatus( + for record: DownloadFolderRecord + ) -> DownloadDisplayStatus { + let gid = record.manifest.gid + if validationErrors[gid] != nil { + return .error + } + if activeGalleryID == gid { + return .active + } + if queueStore.contains(gid) { + return .queued + } + if record.manifest.isComplete, + updatedGalleryIDs.contains(gid) { + return .updateAvailable + } + if record.manifest.isComplete { + return .completed + } + if downloadErrors[gid] != nil { + return .error + } + return .inactive + } + + private func sortDownloadsByDisplayStatus( + _ lhs: DownloadedGallery, + _ rhs: DownloadedGallery + ) -> Bool { + if lhs.displayStatus != rhs.displayStatus { + return lhs.displayStatus.sortPriority < rhs.displayStatus.sortPriority + } + return (lhs.lastDownloadedDate ?? .distantPast) + > (rhs.lastDownloadedDate ?? .distantPast) + } +} + +private extension DownloadFolderRecord { + var displayDate: Date { + modificationDate ?? .distantPast + } +} + +// MARK: - Store Operations +extension DownloadCoordinator { + public func fetchDownload( + gid: String + ) async -> DownloadedGallery? { + return await indexedDownload(gid: gid) + } + + public func fetchDownloadsFromStore() async -> [DownloadedGallery] { + return await reloadDownloadIndex() + } + + @discardableResult + public func reloadDownloadRecord(gid: String, token: String) async -> DownloadedGallery? { + let records = storage.galleryFolderRecords(gid: gid, token: token) + guard let record = deduplicatedDownloadIndex(from: records).values.first else { + downloadIndex[gid] = nil + return nil + } + downloadIndex[gid] = record + if !userFolders.contains(record.parentFolderName) { + userFolders.append(record.parentFolderName) + userFolders.sort { + $0.localizedStandardCompare($1) == .orderedAscending + } + } + return await indexedDownload(gid: gid) + } +} + +// MARK: - Persist Failure & Progress +extension DownloadCoordinator { + public func persistFailure( + error: AppError, + context: FailureContext + ) async { + await taskRunner.beforeFailurePersistence() + await settleDownloadFailure(gid: context.gid, error: error) + } + + /// Surfaces a download-level failure and clears its queue intent so it does not + /// auto-resume. Shared by the foreground `persistFailure` and the background/orphan + /// fatal-error paths so a fatal 509/auth/ban settles identically either way. + public func settleDownloadFailure(gid: String, error: AppError) async { + downloadErrors[gid] = DownloadFailure(error: error) + clearDownloadQueueIntent(gid: gid) + await queueStore.remove(gid) + } + + public func flushDownloadProgress( + context: ProgressFlushContext, + pendingResolvedPages: inout [PageResult], + lastFlushDate: inout Date, + force: Bool + ) async throws { + let shouldFlush = force + || pendingResolvedPages.count + >= Self.progressFlushPageInterval + || Date().timeIntervalSince(lastFlushDate) + >= Self.progressFlushMinimumInterval + guard shouldFlush else { return } + + let resolvedPages = pendingResolvedPages + try flushManifestPageProgress( + folderURL: context.folderURL, + pages: resolvedPages + ) + pendingResolvedPages + .removeAll(keepingCapacity: true) + lastFlushDate = Date() + await notifyObservers() + } + + public func flushManifestPageProgress( + folderURL: URL, + pages: [PageResult] + ) throws { + guard !pages.isEmpty else { return } + let manifestURL = folderURL + .appendingPathComponent(Defaults.FilePath.downloadManifest) + guard fileManager.operate({ + $0.fileExists(atPath: manifestURL.path) + }) else { + return + } + let pageRelativePaths = pages.reduce(into: [Int: String]()) { result, page in + result[page.index] = page.relativePath + } + let manifest = try storage.refreshManifestPageFileHashes( + folderURL: folderURL, + pageRelativePaths: pageRelativePaths + ) + updateDownloadIndex(folderURL: folderURL, manifest: manifest) + } + + public func updateDownloadIndex(folderURL: URL, manifest: DownloadManifest) { + downloadIndex[manifest.gid] = storage.galleryFolderRecord( + folderURL: folderURL, + manifest: manifest, + parentFolderName: storage.parentFolderName(forFolderURL: folderURL) ?? "" + ) + } +} diff --git a/AppPackage/Sources/DownloadClient/DownloadClient+PersistenceHelpers.swift b/AppPackage/Sources/DownloadClient/DownloadClient+PersistenceHelpers.swift new file mode 100644 index 000000000..89b8a883b --- /dev/null +++ b/AppPackage/Sources/DownloadClient/DownloadClient+PersistenceHelpers.swift @@ -0,0 +1,64 @@ +import Foundation +import AppModels + +// MARK: - Sanitization +extension DownloadCoordinator { + @discardableResult + public func sanitizeLocalFilesIfNeeded( + gid: String, + clearingLastError: Bool = false + ) async -> DownloadedGallery? { + guard let download = await fetchDownload(gid: gid) + else { return nil } + + scanCompletedFolder(download: download) + + guard clearingLastError, download.lastError != nil else { + return download + } + + downloadErrors[gid] = nil + validationErrors[gid] = nil + await notifyObservers() + + return await fetchDownload(gid: gid) + } + + private func scanCompletedFolder(download: DownloadedGallery) { + let completedFolderURL = download.folderURL + guard fileManager.operate({ + $0.fileExists(atPath: completedFolderURL.path) + }) else { return } + _ = storage.existingPageRelativePaths( + folderURL: completedFolderURL, + manifest: download.manifest + ) + _ = storage.existingCoverRelativePath( + folderURL: completedFolderURL, + manifest: download.manifest + ) + } + + public func captureTarget( + for download: DownloadedGallery, + index: Int + ) -> CaptureTargetResult? { + let completedFolderURL = download.folderURL + guard fileManager.operate({ + $0.fileExists(atPath: completedFolderURL.path) + }) + else { + return nil + } + + let completedPages = + storage.existingPageRelativePaths( + folderURL: completedFolderURL, + manifest: download.manifest + ) + return CaptureTargetResult( + folderURL: completedFolderURL, + preferredRelativePath: completedPages[index] + ) + } +} diff --git a/AppPackage/Sources/DownloadClient/DownloadClient+PersistenceNormalize.swift b/AppPackage/Sources/DownloadClient/DownloadClient+PersistenceNormalize.swift new file mode 100644 index 000000000..9853d5e17 --- /dev/null +++ b/AppPackage/Sources/DownloadClient/DownloadClient+PersistenceNormalize.swift @@ -0,0 +1,100 @@ +import Foundation +import AppModels + +// MARK: - Manifest, Folder & Normalize +extension DownloadCoordinator { + public func validatedManifest( + at folderURL: URL, + gid: String, + pageCount: Int + ) -> DownloadManifest? { + guard let manifest = try? storage + .readManifest(folderURL: folderURL), + manifest.gid == gid, + manifest.pageCount == pageCount + else { + return nil + } + return manifest + } + + public func activeInspectionFolderURL( + for download: DownloadedGallery + ) -> URL? { + let completedFolderURL = download.folderURL + let completedFolderExists = fileManager.operate { + $0.fileExists(atPath: completedFolderURL.path) + } + return completedFolderExists ? completedFolderURL : nil + } + + public func normalizeNeedsAttentionDownloads( + _ downloads: [DownloadedGallery] + ) async { + for download in downloads { + let shouldClearCancellationError = + download.lastError.map { + isCancellationLikeAppError($0.appError) + } ?? false + guard download.displayStatus == .error + || shouldClearCancellationError else { + continue + } + if shouldClearCancellationError { + downloadErrors[download.gid] = nil + } + } + } + + public func normalizeInterruptedDownloads( + _ downloads: [DownloadedGallery] + ) async { + let hasActiveTask = activeTask != nil + let activeGalleryID = activeGalleryID + for download in downloads where + download.needsInterruptedDownloadNormalization( + activeGalleryID: activeGalleryID, + hasActiveTask: hasActiveTask + ) { + if activeGalleryID == download.gid, !hasActiveTask { + self.activeGalleryID = nil + } + } + } + + public func reconcileActiveDownloadState() async { + guard activeTask != nil, + let activeGalleryID, + await fetchDownload(gid: activeGalleryID) != nil + else { return } + + downloadErrors[activeGalleryID] = nil + } + + /// User-initiated integrity check (the inspector's "validate" action): the only path + /// that re-reads page bytes and verifies them against their recorded hashes + /// (`verifiesContentHashes: true`). Routine scans and opens check file *presence* only; + /// automatic content re-validation was removed because it re-hashed whole galleries on + /// hot paths. The result is session-scoped status (`validationErrors`), not persisted. + public func validateImageData(gid: String) async -> DownloadValidationState? { + guard let download = await fetchDownload(gid: gid), + download.canValidateImageData + else { return nil } + let validation = storage.validate( + download: download, + verifiesContentHashes: true + ) + switch validation { + case .valid: + validationErrors[download.gid] = nil + + case .missingFiles(let message): + validationErrors[download.gid] = DownloadFailure( + code: .fileOperationFailed, + message: String(localized: message) + ) + } + await notifyObservers() + return validation + } +} diff --git a/AppPackage/Sources/DownloadClient/DownloadClient+PublicAPI.swift b/AppPackage/Sources/DownloadClient/DownloadClient+PublicAPI.swift new file mode 100644 index 000000000..ffe1d2fdd --- /dev/null +++ b/AppPackage/Sources/DownloadClient/DownloadClient+PublicAPI.swift @@ -0,0 +1,341 @@ +import OSLogExt +import Foundation +import AppModels +import Resources + +private let logger = Logger(category: .init(describing: DownloadCoordinator.self)) + +// MARK: - Public API +extension DownloadCoordinator { + public func observeDownloads() async -> AsyncStream<[DownloadedGallery]> { + // Let the hub pull the snapshot inside its own registration so the + // capture-then-register hop can't strand a new observer on a stale + // initial when a notify lands in the window (BUG-16). + await observerHub.observe { + await self.indexedDownloads() + } + } + + public func fetchDownloads() async -> [DownloadedGallery] { + return await indexedDownloads() + } + + public func reconcileDownloads() async { + await syncDownloadsState(scheduleNext: false) + } + + public func refreshDownloads() async { + await syncDownloadsState(scheduleNext: true) + } + + public func resumeQueue() async { + await scheduleNextIfNeeded() + } + + public func updateRemoteVersion( + gid: String, + metadata: DownloadVersionMetadata + ) async -> DownloadedGallery? { + guard let download = await fetchDownload(gid: gid) else { + return nil + } + guard downloadIndex[gid] != nil else { + return download + } + guard [.completed, .updateAvailable].contains(download.displayStatus) else { + return download + } + + let hadUpdate = updatedGalleryIDs.contains(gid) + let hasUpdate = metadata.hasUpdate(comparedTo: download) + if hasUpdate { + updatedGalleryIDs.insert(gid) + } else { + updatedGalleryIDs.remove(gid) + } + if hadUpdate != hasUpdate { + await notifyObservers() + } + return await fetchDownload(gid: gid) + } + + public func enqueue( + payload: DownloadRequestPayload + ) async -> Result { + do { + try storage.ensureRootDirectory() + // An already-known gallery keeps its current folder; only brand-new + // downloads land in the folder carried by the payload. + let parentFolderName: String + if let record = downloadIndex[payload.gallery.gid] { + parentFolderName = record.parentFolderName + } else if let normalizedName = storage.normalizedUserFolderName(payload.folderName) { + parentFolderName = normalizedName + } else { + return .failure( + .fileOperationFailed( + String(localized: .RLocalizable.downloadStoreInvalidFolderName) + ) + ) + } + try createDirectory(at: storage.userFolderURL(name: parentFolderName)) + let folderRelativePath = folderRelativePath( + for: payload, + parentFolderName: parentFolderName + ) + try writeInitialManifest( + payload: payload, + folderRelativePath: folderRelativePath + ) + await queueStore.enqueue(payload.gallery.gid) + await notifyObservers() + await scheduleNextIfNeeded() + logger.notice( + """ + Download enqueued, gid: \(payload.gallery.gid, privacy: .public), \ + title: \(payload.gallery.title, privacy: .public). + """ + ) + return .success(()) + } catch let error as AppError { + return .failure(error) + } catch { + logger.error("\(error, privacy: .public)") + return .failure(.unknown) + } + } + + private func writeInitialManifest( + payload: DownloadRequestPayload, + folderRelativePath: String + ) throws { + let folderURL = storage.folderURL(relativePath: folderRelativePath) + try createDirectory(at: folderURL) + if let existingManifest = reusableExistingManifest( + payload: payload, + folderURL: folderURL + ) { + updateDownloadIndex(folderURL: folderURL, manifest: existingManifest) + return + } + let manifest = makeInitialManifest(payload: payload) + try storage.writeManifest(manifest, folderURL: folderURL) + updateDownloadIndex(folderURL: folderURL, manifest: manifest) + } + + private func reusableExistingManifest( + payload: DownloadRequestPayload, + folderURL: URL + ) -> DownloadManifest? { + guard let manifest = try? storage.readManifest(folderURL: folderURL), + manifest.gid == payload.gallery.gid, + manifest.token == payload.gallery.token, + manifest.host == payload.host + else { + return nil + } + let expectedPageIndices = payload.galleryDetail.pageCount > 0 + ? Set(1...payload.galleryDetail.pageCount) + : Set() + guard Set(manifest.pages.keys) == expectedPageIndices else { + return nil + } + return manifest + } + + public func togglePause(gid: String) async -> Result { + guard let download = await fetchDownload(gid: gid) else { + return .failure(.notFound) + } + + if activeGalleryID == gid { + return await pause(gid: gid) + } + + if let queuedMode = queuedModes[gid] { + return await cancelQueuedWorkItem(download, mode: queuedMode) + } + + switch download.displayStatus { + case .queued, .active: + return await pause(gid: gid) + case .inactive: + return await resume(gid: gid) + case .completed, .error, .updateAvailable: + return .failure(.unknown) + } + } + + public func delete(gid: String) async -> Result { + let taskToCancel: Task? + schedulingBlockedGalleryIDs.insert(gid) + defer { + schedulingBlockedGalleryIDs.remove(gid) + } + if activeGalleryID == gid { + taskToCancel = activeTask + activeTask?.cancel() + activeTask = nil + activeGalleryID = nil + } else { + taskToCancel = nil + } + await taskToCancel?.value + guard let download = await fetchDownload(gid: gid) else { + clearDownloadSessionState(gid: gid, includeUpdateFlag: true) + await queueStore.remove(gid) + await backgroundTaskStore.removeAll(for: gid) + return .failure(.notFound) + } + do { + try removeGalleryFolders(gid: download.gid, token: download.token) + } catch let error as AppError { + await reloadDownloadRecord(gid: download.gid, token: download.token) + return .failure(error) + } catch { + logger.error("\(error, privacy: .public)") + await reloadDownloadRecord(gid: download.gid, token: download.token) + return .failure(.fileOperationFailed(error.localizedDescription)) + } + // Clear session and queue state only once the folders are gone; a failed + // removal above leaves the gallery intact and must not silently dequeue it. + clearDownloadSessionState(gid: gid, includeUpdateFlag: true) + await queueStore.remove(gid) + await backgroundTaskStore.removeAll(for: gid) + downloadIndex[gid] = nil + await notifyObservers() + await scheduleNextIfNeeded() + logger.notice("Download deleted, gid: \(gid, privacy: .public).") + return .success(()) + } + + public func loadManifest( + gid: String + ) async -> Result<(DownloadedGallery, DownloadManifest), AppError> { + guard let download = await sanitizeLocalFilesIfNeeded(gid: gid) else { + return .failure(.notFound) + } + switch storage.validate( + download: download, + verifiesContentHashes: false + ) { + case .valid: + break + case .missingFiles(let message): + return .failure(.fileOperationFailed(String(localized: message))) + } + do { + let manifest = try storage.readManifest(folderURL: download.folderURL) + return .success((download, manifest)) + } catch { + return .failure(.fileOperationFailed(error.localizedDescription)) + } + } + + public func captureCachedPage( + gid: String, + index: Int, + imageURL: URL? + ) async { + guard downloadIndex[gid] != nil else { return } + guard let download = await fetchDownload(gid: gid), + index >= 1, + index <= max(download.pageCount, 1) + else { return } + + guard let captureTarget = captureTarget( + for: download, index: index + ) else { return } + + await performCacheCapture( + gid: gid, + index: index, + imageURL: imageURL, + captureTarget: captureTarget, + download: download + ) + } + + private func performCacheCapture( + gid: String, + index: Int, + imageURL: URL?, + captureTarget: CaptureTargetResult, + download: DownloadedGallery + ) async { + let existingPages = storage.existingPageRelativePaths( + folderURL: captureTarget.folderURL, + manifest: download.manifest + ) + do { + let cacheURLs = pageImageCacheURLs(imageURL: imageURL) + let cacheSource = CacheRestoreSource( + gid: download.gid, + token: download.token, + cacheURLs: cacheURLs, + referenceURL: preferredPageReferenceURL(imageURL: imageURL), + imageURL: imageURL + ) + guard let pageResult = try await restorePageFromCache( + index: index, + source: cacheSource, + folderURL: captureTarget.folderURL, + preferredRelativePath: + captureTarget.preferredRelativePath ?? existingPages[index], + overwriteExistingFile: true + ) else { return } + let manifest = try storage.refreshManifestPageFileHash( + folderURL: captureTarget.folderURL, + pageIndex: index, + relativePath: pageResult.relativePath + ) + updateDownloadIndex(folderURL: captureTarget.folderURL, manifest: manifest) + _ = await sanitizeLocalFilesIfNeeded(gid: gid, clearingLastError: true) + } catch { + logger.error("\(error, privacy: .public)") + } + } + + public func loadInspection( + gid: String + ) async -> Result { + guard let download = await fetchDownload(gid: gid) else { + return .failure(.notFound) + } + + let activeFolderURL = activeInspectionFolderURL(for: download) + + let existingRelativePaths = activeFolderURL.map { + storage.existingPageRelativePaths( + folderURL: $0, + manifest: download.manifest + ) + } ?? [:] + let failedPages = (failedPageErrors[gid] ?? [:]) + .filter { !isCancellationLikeAppError($0.value.error) } + + let pages = buildInspectionPages( + download: download, + activeFolderURL: activeFolderURL, + existingRelativePaths: existingRelativePaths, + failedPages: failedPages + ) + + let coverURL = activeFolderURL.flatMap { folderURL in + storage.existingCoverRelativePath( + folderURL: folderURL, + manifest: download.manifest + ).map { + folderURL.appendingPathComponent($0) + } + } ?? download.coverURL + + return .success( + .init( + download: download, + coverURL: coverURL, + pages: pages + ) + ) + } +} diff --git a/AppPackage/Sources/DownloadClient/DownloadClient+PublicAPIHelpers.swift b/AppPackage/Sources/DownloadClient/DownloadClient+PublicAPIHelpers.swift new file mode 100644 index 000000000..3ae402ca2 --- /dev/null +++ b/AppPackage/Sources/DownloadClient/DownloadClient+PublicAPIHelpers.swift @@ -0,0 +1,59 @@ +import Foundation +import AppModels + +// MARK: - Private helpers for public API +extension DownloadCoordinator { + public func buildInspectionPages( + download: DownloadedGallery, + activeFolderURL: URL?, + existingRelativePaths: [Int: String], + failedPages: [Int: PageFailure] + ) -> [DownloadPageInspection] { + (1...download.pageCount).map { index -> DownloadPageInspection in + if let relativePath = existingRelativePaths[index], + let folderURL = activeFolderURL { + let fileURL = folderURL + .appendingPathComponent(relativePath) + if fileManager.operate({ $0.fileExists(atPath: fileURL.path) }) { + return .init( + index: index, + status: .downloaded, + relativePath: relativePath, + fileURL: fileURL, + failure: nil + ) + } + } + + if let failedPage = failedPages[index] { + return .init( + index: index, + status: .failed, + relativePath: failedPage.relativePath, + fileURL: nil, + failure: .init(error: failedPage.error) + ) + } + + return .init( + index: index, + status: .pending, + relativePath: nil, + fileURL: nil, + failure: nil + ) + } + } + + public func clearSelectedFailedPages( + gid: String, + selectedPageIndices: [Int], + ) { + for index in selectedPageIndices { + failedPageErrors[gid]?[index] = nil + } + if failedPageErrors[gid]?.isEmpty == true { + failedPageErrors[gid] = nil + } + } +} diff --git a/AppPackage/Sources/DownloadClient/DownloadClient+ResponseValidation.swift b/AppPackage/Sources/DownloadClient/DownloadClient+ResponseValidation.swift new file mode 100644 index 000000000..969b4227c --- /dev/null +++ b/AppPackage/Sources/DownloadClient/DownloadClient+ResponseValidation.swift @@ -0,0 +1,292 @@ +import Kanna +import OSLogExt +import AppModels +import Foundation +import ImageIO +import AppTools +import ParserFeature + +private let logger = Logger(category: .init(describing: DownloadCoordinator.self)) + +// MARK: - Response Error Detection +extension DownloadCoordinator { + public func detectResponseError( + data: Data, + response: URLResponse, + requestURL: URL?, + expectsHTML: Bool = false + ) -> AppError? { + detectResponseError( + prefixData: Data( + data.prefix(Self.responseInspectionPrefixLength) + ), + fullData: data, + response: response, + requestURL: requestURL, + expectsHTML: expectsHTML + ) + } + + public func detectResponseError( + fileURL: URL, + response: URLResponse, + requestURL: URL? + ) -> AppError? { + let prefixData = (try? readResponsePrefixData( + at: fileURL + )) ?? Data() + if let error = detectPlaceholderFileErrors( + response: response, + fileURL: fileURL, + requestURL: requestURL + ) { + return error + } + let mimeType = normalizedMimeType(response) + let shouldInspect = shouldInspectTextResponse( + mimeType: mimeType, + prefixData: prefixData + ) + guard shouldInspect else { + if statusCode(for: response) == 404 { + return .notFound + } + return nil + } + return detectResponseError( + prefixData: prefixData, + fullData: resolveFileData( + fileURL: fileURL, + mimeType: mimeType, + prefixData: prefixData, + response: response + ), + response: response, + requestURL: requestURL, + expectsHTML: false + ) + } + + private func detectPlaceholderFileErrors( + response: URLResponse, + fileURL: URL, + requestURL: URL? + ) -> AppError? { + let placeholderData = loadPlaceholderDataIfNeeded( + response: response, + fileURL: fileURL + ) + if let placeholderData { + if isAuthenticationRequiredPlaceholderImageData( + placeholderData + ) { + return .authenticationRequired + } + if isQuotaExceededAssetData(placeholderData) { + return .quotaExceeded + } + } + if isAuthenticationRequiredPlaceholderResponse( + response: response, + requestURL: requestURL + ) { + return .authenticationRequired + } + if isQuotaExceededResponse( + fullData: nil, + fileURL: fileURL, + response: response, + requestURL: requestURL + ) { + return .quotaExceeded + } + return nil + } + + private func resolveFileData( + fileURL: URL, + mimeType: String?, + prefixData: Data, + response: URLResponse + ) -> Data? { + let looksLikeHTML = responseLooksLikeHTML( + mimeType: mimeType, + prefixData: prefixData, + expectsHTML: false + ) + let placeholderData = loadPlaceholderDataIfNeeded( + response: response, + fileURL: fileURL + ) + if looksLikeHTML { + return placeholderData ?? (try? Data( + contentsOf: fileURL, + options: .mappedIfSafe + )) + } + return placeholderData + } + + public func detectResponseError( + prefixData: Data, + fullData: Data?, + response: URLResponse, + requestURL: URL?, + expectsHTML: Bool + ) -> AppError? { + if let error = detectDataErrors( + fullData: fullData, + response: response, + requestURL: requestURL + ) { + return error + } + + let mimeType = normalizedMimeType(response) + let shouldInspect = expectsHTML + || shouldInspectTextResponse( + mimeType: mimeType, + prefixData: prefixData + ) + if shouldInspect { + let inspectedData = fullData ?? prefixData + if let error = detectTextualDownloadError( + data: inspectedData, + looksLikeHTML: responseLooksLikeHTML( + mimeType: mimeType, + prefixData: prefixData, + expectsHTML: expectsHTML + ) + ) { + return error + } + } + if isAuthenticationRequiredResponse( + prefixData: prefixData, + fullData: fullData, + response: response, + requestURL: requestURL + ) { + return .authenticationRequired + } + guard shouldInspect else { return nil } + + let htmlContext = HTMLResponseContext( + prefixData: prefixData, fullData: fullData, + response: response, requestURL: requestURL, + mimeType: mimeType + ) + return detectHTMLResponseError( + context: htmlContext, expectsHTML: expectsHTML + ) + } + + private func detectDataErrors( + fullData: Data?, + response: URLResponse, + requestURL: URL? + ) -> AppError? { + if let fullData { + if isAuthenticationRequiredPlaceholderImageData( + fullData + ) { + return .authenticationRequired + } + if isQuotaExceededAssetData(fullData) { + return .quotaExceeded + } + } + if isAuthenticationRequiredPlaceholderResponse( + response: response, + requestURL: requestURL + ) { + return .authenticationRequired + } + if isQuotaExceededResponse( + fullData: fullData, + fileURL: nil, + response: response, + requestURL: requestURL + ) { + return .quotaExceeded + } + return nil + } + + private func detectHTMLResponseError( + context: HTMLResponseContext, + expectsHTML: Bool + ) -> AppError? { + let prefixData = context.prefixData + let fullData = context.fullData + let response = context.response + let requestURL = context.requestURL + let mimeType = context.mimeType + let textPrefix = String( + bytes: prefixData, + encoding: .utf8 + ) ?? "" + + guard !prefixLooksLikeJSON(prefixData) else { + return nil + } + + let looksLikeHTML = responseLooksLikeHTML( + mimeType: mimeType, + prefixData: prefixData, + expectsHTML: expectsHTML + ) + guard looksLikeHTML else { + if statusCode(for: response) == 404 { + return .notFound + } + return nil + } + + if let fullData, + let document = try? Kanna.HTML( + html: fullData.utf8InvalidCharactersRipped, + encoding: .utf8 + ), + let error = Parser.parseResponseError( + doc: document + ) { + return error + } + if expectsHTML { + if statusCode(for: response) == 404 { + return .notFound + } + return nil + } + logger.error( + """ + Download received unexpected HTML response, \ + url: \(requestURL?.absoluteString ?? ""), \ + snippet: \(String(textPrefix.prefix(240)), privacy: .public) + """ + ) + if statusCode(for: response) == 404 { + return .notFound + } + return .parseFailed + } + + private func loadPlaceholderDataIfNeeded( + response: URLResponse, + fileURL: URL + ) -> Data? { + let byteCount = responseContentLength(response) + ?? fileSize(at: fileURL) + guard let byteCount, + byteCount == ImagePlaceholderFingerprint.authenticationRequiredByteCount + || byteCount == ImagePlaceholderFingerprint.quotaExceededByteCount + else { + return nil + } + return try? Data( + contentsOf: fileURL, + options: .mappedIfSafe + ) + } +} diff --git a/AppPackage/Sources/DownloadClient/DownloadClient+ResponseValidationHelpers.swift b/AppPackage/Sources/DownloadClient/DownloadClient+ResponseValidationHelpers.swift new file mode 100644 index 000000000..35f06cc53 --- /dev/null +++ b/AppPackage/Sources/DownloadClient/DownloadClient+ResponseValidationHelpers.swift @@ -0,0 +1,357 @@ +import Kanna +import AppModels +import Foundation +import ImageIO +import AppTools +import AnimatedImageFeature +import ParserFeature + +// MARK: - Response Inspection Helpers +extension DownloadCoordinator { + public func normalizedMimeType( + _ response: URLResponse + ) -> String? { + if let mimeType = response.mimeType?.lowercased(), + !mimeType.isEmpty { + return mimeType + } + if let httpResponse = response as? HTTPURLResponse, + let contentType = httpResponse.value( + forHTTPHeaderField: "Content-Type" + )?.lowercased(), + let mimeType = contentType + .split(separator: ";").first, + !mimeType.isEmpty { + return String(mimeType) + } + return nil + } + + public func shouldInspectTextResponse( + mimeType: String?, + prefixData: Data + ) -> Bool { + if let mimeType { + if mimeType.hasPrefix("image/") { + return prefixLooksLikeHTML(prefixData) + } + if mimeType == "text/html" + || mimeType == "text/plain" { + return true + } + return prefixLooksLikeHTML(prefixData) + } + + guard !prefixData.isKnownBinaryImageFormat else { + return false + } + return true + } + + public func prefixLooksLikeHTML(_ prefixData: Data) -> Bool { + let prefix = String( + bytes: prefixData, + encoding: .utf8 + )? + .trimmingCharacters(in: .whitespacesAndNewlines) + .lowercased() ?? "" + guard !prefix.isEmpty else { return false } + + let htmlMarkers = [ + " Bool { + let prefix = String( + bytes: prefixData, + encoding: .utf8 + )? + .trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + guard let firstCharacter = prefix.first else { return false } + + return firstCharacter == "{" || firstCharacter == "[" + } + + func responseLooksLikeHTML( + mimeType: String?, + prefixData: Data, + expectsHTML: Bool + ) -> Bool { + expectsHTML + || mimeType == "text/html" + || prefixLooksLikeHTML(prefixData) + } + + func detectTextualDownloadError( + data: Data, + looksLikeHTML: Bool + ) -> AppError? { + let normalizedData = data.utf8InvalidCharactersRipped + let rawContent = String( + data: normalizedData, + encoding: .utf8 + ) ?? "" + if !looksLikeHTML { + return Parser.parseResponseError( + content: rawContent + ) + } + + if let document = try? Kanna.HTML( + html: normalizedData, + encoding: .utf8 + ), + let error = Parser.parseResponseError( + doc: document + ) { + return error + } + + guard rawContent.count <= 1024 else { + return nil + } + return Parser.parseResponseError( + content: rawContent + ) + } + + func isQuotaExceededResponse( + fullData: Data?, + fileURL: URL?, + response: URLResponse, + requestURL: URL? + ) -> Bool { + let urls = [requestURL, response.url] + .compactMap(\.self) + let lowercasedURLs = urls + .map { $0.absoluteString.lowercased() } + guard lowercasedURLs.contains(where: { url in + Self.quotaExceededImageURLSuffixes + .contains(where: url.hasSuffix) + }) else { + return false + } + + let byteCount = fullData?.count + ?? responseContentLength(response) + ?? fileSize(at: fileURL) + guard byteCount == ImagePlaceholderFingerprint.quotaExceededByteCount + else { + return false + } + + let data: Data? + if let fullData { + data = fullData + } else if let fileURL { + data = try? Data( + contentsOf: fileURL, + options: .mappedIfSafe + ) + } else { + data = nil + } + guard let data else { return false } + return isQuotaExceededAssetData(data) + } + + func isAuthenticationRequiredPlaceholderResponse( + response: URLResponse, + requestURL: URL? + ) -> Bool { + [requestURL, response.url].contains { + isAuthenticationRequiredPlaceholderURL($0) + } + } + + func isAuthenticationRequiredPlaceholderURL( + _ url: URL? + ) -> Bool { + guard let url else { return false } + let normalizedURL = url.absoluteString.lowercased() + if normalizedURL.contains("bounce_login.php") { + return true + } + return isKokomadePlaceholderURL(url) + } + + func isKokomadePlaceholderURL(_ url: URL?) -> Bool { + guard let url else { return false } + let normalizedURL = url.absoluteString.lowercased() + return isExHentaiURL(url) + && Self.kokomadeImageURLSuffixes + .contains(where: normalizedURL.hasSuffix) + } + + func isAuthenticationRequiredResponse( + prefixData: Data, + fullData: Data?, + response: URLResponse, + requestURL: URL? + ) -> Bool { + guard isExHentaiURL(requestURL) + || isExHentaiURL(response.url) else { + return false + } + guard normalizedMimeType(response) == "text/html" + else { + return false + } + guard fullData?.isEmpty ?? prefixData.isEmpty else { + return false + } + + let cookies = responseCookies( + response: response, + requestURL: requestURL + ) + let hasYay = cookies.contains { + $0.name == Defaults.Cookie.yay + && !$0.value.isEmpty + } + let hasValidIgneous = cookies.contains { + $0.name == Defaults.Cookie.igneous + && !$0.value.isEmpty + && $0.value != Defaults.Cookie.mystery + } + return hasYay && !hasValidIgneous + } + + func responseCookies( + response: URLResponse, + requestURL: URL? + ) -> [HTTPCookie] { + let urls = [ + response.url, + requestURL, + Defaults.URL.exhentai, + Defaults.URL.sexhentai + ] + .compactMap(\.self) + var uniqueURLs = [URL]() + for url in urls where !uniqueURLs.contains(url) { + uniqueURLs.append(url) + } + + var cookies = [HTTPCookie]() + if let httpResponse = response as? HTTPURLResponse, + let responseURL = httpResponse.url { + let headerFields = httpResponse.allHeaderFields + .reduce(into: [String: String]()) { partial, item in + guard let key = item.key as? String, + let value = item.value as? String + else { return } + partial[key] = value + } + cookies += HTTPCookie.cookies( + withResponseHeaderFields: headerFields, + for: responseURL + ) + } + + for url in uniqueURLs { + cookies += storedCookiesProvider(url) + } + return cookies + } + + func isExHentaiURL(_ url: URL?) -> Bool { + guard let host = url?.host?.lowercased() else { + return false + } + return host == "exhentai.org" + || host.hasSuffix(".exhentai.org") + } + + func statusCode(for response: URLResponse) -> Int? { + (response as? HTTPURLResponse)?.statusCode + } + + func responseContentLength( + _ response: URLResponse + ) -> Int? { + if response.expectedContentLength > 0 { + return Int(response.expectedContentLength) + } + if let httpResponse = response as? HTTPURLResponse, + let header = httpResponse.value( + forHTTPHeaderField: "Content-Length" + ), + let contentLength = Int(header) { + return contentLength + } + return nil + } + + func fileSize(at fileURL: URL?) -> Int? { + guard let fileURL else { return nil } + let values = try? fileURL.resourceValues( + forKeys: [.fileSizeKey] + ) + return values?.fileSize + } + + func isAuthenticationRequiredPlaceholderImageData( + _ data: Data + ) -> Bool { + ImagePlaceholderFingerprint.match(data) == .authenticationRequired + } + + func isQuotaExceededAssetData(_ data: Data) -> Bool { + ImagePlaceholderFingerprint.match(data) == .quotaExceeded + } + + func isDecodableImageData(_ data: Data) -> Bool { + guard let source = CGImageSourceCreateWithData( + data as CFData, + nil + ) else { + return false + } + return CGImageSourceGetCount(source) > 0 + } + + func shouldSuppressFailurePersistence( + for gid: String + ) -> Bool { + schedulingBlockedGalleryIDs.contains(gid) + || Task.isCancelled + } + + nonisolated static func isCancellationLikeError( + _ error: Error + ) -> Bool { + if error is CancellationError { + return true + } + + let nsError = error as NSError + if nsError.domain == NSURLErrorDomain, + nsError.code == URLError.cancelled.rawValue { + return true + } + + let message = nsError.localizedDescription + .lowercased() + return message.contains("cancellation") + || message.contains("cancelled") + || message.contains("canceled") + } + + func isCancellationLikeAppError( + _ error: AppError + ) -> Bool { + guard case .fileOperationFailed(let reason) = error + else { return false } + return Self.isCancellationLikeError(NSError( + domain: NSCocoaErrorDomain, + code: NSUserCancelledError, + userInfo: [NSLocalizedDescriptionKey: reason] + )) + } +} diff --git a/AppPackage/Sources/DownloadClient/DownloadClient+RetryHelpers.swift b/AppPackage/Sources/DownloadClient/DownloadClient+RetryHelpers.swift new file mode 100644 index 000000000..8c5261cc8 --- /dev/null +++ b/AppPackage/Sources/DownloadClient/DownloadClient+RetryHelpers.swift @@ -0,0 +1,108 @@ +import OSLogExt +import Foundation +import AppModels + +private let logger = Logger(category: .init(describing: DownloadCoordinator.self)) + +// MARK: - Retry & RetryPages +extension DownloadCoordinator { + public func retry( + gid: String, + mode: DownloadStartMode + ) async -> Result { + guard let download = await fetchDownload(gid: gid) else { + return .failure(.notFound) + } + do { + try await performRetry(gid: gid, download: download, mode: mode) + return .success(()) + } catch let error as AppError { + return .failure(error) + } catch { + logger.error("\(error, privacy: .public)") + return .failure(.unknown) + } + } + + private func performRetry( + gid: String, + download: DownloadedGallery, + mode: DownloadStartMode + ) async throws { + let resolvedMode = effectiveRetryMode( + for: download, requestedMode: mode + ) + clearDownloadSessionState(gid: gid) + queuedModes[gid] = resolvedMode + queuedPageSelections[gid] = nil + await queueStore.enqueue(gid) + await notifyObservers() + await scheduleNextIfNeeded() + } + + public func retryPages( + gid: String, + pageIndices: [Int] + ) async -> Result { + guard let download = await fetchDownload(gid: gid) else { + return .failure(.notFound) + } + let mode = resumeMode(for: download) + if mode == .update { return await retry(gid: gid, mode: .update) } + + let selectedPageIndices = Array(Set(pageIndices)).sorted() + guard !selectedPageIndices.isEmpty else { return .success(()) } + + let folderURL = download.folderURL + guard fileManager.operate({ $0.fileExists(atPath: folderURL.path) }) else { + return .failure(.notFound) + } + do { + try await performRetryPages( + gid: gid, + download: download, + mode: .repair, + selectedPageIndices: selectedPageIndices, + folderURL: folderURL + ) + return .success(()) + } catch let error as AppError { + return .failure(error) + } catch { + logger.error("\(error, privacy: .public)") + return .failure(.unknown) + } + } + + private func performRetryPages( + gid: String, + download: DownloadedGallery, + mode: DownloadStartMode, + selectedPageIndices: [Int], + folderURL: URL + ) async throws { + clearSelectedFailedPages(gid: gid, selectedPageIndices: selectedPageIndices) + clearDownloadFailureState(gid: gid, includePageFailures: false) + queuedModes[gid] = mode + queuedPageSelections[gid] = selectedPageIndices + await queueStore.enqueue(gid) + await notifyObservers() + await scheduleNextIfNeeded() + } + + public func loadLocalPageURLs( + gid: String + ) async -> Result<[Int: URL], AppError> { + guard let download = await fetchDownload(gid: gid) else { + return .failure(.notFound) + } + return .success(download.localPageURLs) + } + + public func rescanLocalPageURLs( + gid: String + ) async -> [Int: URL]? { + guard let token = downloadIndex[gid]?.manifest.token else { return nil } + return await reloadDownloadRecord(gid: gid, token: token)?.localPageURLs + } +} diff --git a/AppPackage/Sources/DownloadClient/DownloadClient+Scheduling.swift b/AppPackage/Sources/DownloadClient/DownloadClient+Scheduling.swift new file mode 100644 index 000000000..e5780a10c --- /dev/null +++ b/AppPackage/Sources/DownloadClient/DownloadClient+Scheduling.swift @@ -0,0 +1,234 @@ +import OSLogExt +import Foundation +import AppModels + +private let logger = Logger(category: .init(describing: DownloadCoordinator.self)) + +// MARK: - Observer Management & Scheduling +extension DownloadCoordinator { + public func notifyObservers() async { + let downloads = await indexedDownloads() + await observerHub.notify(downloads) + } + + public func scheduleNextIfNeeded() async { + await scheduleNextIfNeededCore() + // Reconcile on every exit path of the core (both early-return guards and the + // happy path), so the background-task assertion always matches queue state. + await reconcileBackgroundAssertion() + } + + private func scheduleNextIfNeededCore() async { + let queuedGIDs = queueStore.gids + let downloads = queuedGIDs.isEmpty + ? await indexedDownloads() + : await indexedDownloads(gids: queuedGIDs) + await taskRunner.beforeActiveTaskCheck() + guard activeTask == nil else { + await reconcileActiveDownloadState() + return + } + let nextDownload = queuedGIDs.isEmpty + ? nextUnqueuedSchedulableDownload(from: downloads) + : nextQueuedDownload( + orderedGIDs: queuedGIDs, + downloads: downloads + ) + guard let nextDownload else { return } + + await taskRunner.recordScheduledGallery(nextDownload.gid) + activeTaskGeneration += 1 + let generation = activeTaskGeneration + activeGalleryID = nextDownload.gid + activeTask = Task { [weak self] in + guard let self else { return } + await self.processScheduledDownload( + gid: nextDownload.gid, + generation: generation + ) + } + } + + private func processScheduledDownload( + gid: String, + generation: Int + ) async { + let result = await taskRunner.runScheduledDownload(gid) { + await self.processDownload(gid: gid, generation: generation) + } + guard result == .skippedOperation else { return } + finishActiveTaskIfOwned( + gid: gid, + generation: generation, + schedulesNext: false + ) + } + + private func nextQueuedDownload( + orderedGIDs: [String], + downloads: [DownloadedGallery] + ) -> DownloadedGallery? { + let downloadsByGID = Dictionary( + uniqueKeysWithValues: downloads.map { ($0.gid, $0) } + ) + return orderedGIDs + .compactMap { downloadsByGID[$0] } + .first { isSchedulableDownload($0) } + } + + private func nextUnqueuedSchedulableDownload( + from downloads: [DownloadedGallery] + ) -> DownloadedGallery? { + // Some transient actor state, such as an interrupted active download or + // selected page retry, can be schedulable before it is reflected in the + // persisted queue. + downloads + .filter(isSchedulableDownload) + .sorted { lhs, rhs in + let lhsIsDownloading = lhs.displayStatus == .active + let rhsIsDownloading = rhs.displayStatus == .active + if lhsIsDownloading != rhsIsDownloading { + return lhsIsDownloading + } + return (lhs.lastDownloadedDate ?? .distantPast) + < (rhs.lastDownloadedDate ?? .distantPast) + } + .first + } + + private func isSchedulableDownload( + _ download: DownloadedGallery + ) -> Bool { + !schedulingBlockedGalleryIDs.contains(download.gid) + && shouldSchedule(download: download) + } + + public func shouldSchedule(download: DownloadedGallery) -> Bool { + if download.displayStatus == .active || download.isQueuedWorkItem { + return true + } + + guard download.displayStatus == .inactive, download.isIncomplete else { + return false + } + + return queuedPageSelections[download.gid]?.isEmpty == false + } + + public func syncDownloadsState(scheduleNext: Bool) async { + let downloads = await fetchDownloadsFromStore() + await normalizeNeedsAttentionDownloads(downloads) + await normalizeInterruptedDownloads(downloads) + + do { + try storage.ensureRootDirectory() + } catch { + logger.error("\(error, privacy: .public)") + } + await reconcileActiveDownloadState() + await notifyObservers() + guard scheduleNext else { return } + await scheduleNextIfNeeded() + } +} + +// MARK: - Pause & Resume +extension DownloadCoordinator { + public func pause(gid: String) async -> Result { + do { + schedulingBlockedGalleryIDs.insert(gid) + defer { + schedulingBlockedGalleryIDs.remove(gid) + } + guard let currentDownload = await fetchDownload(gid: gid) + else { + return .failure(.notFound) + } + guard [.queued, .active] + .contains(currentDownload.displayStatus) + else { + await notifyObservers() + await scheduleNextIfNeeded() + return .success(()) + } + let taskToCancel = try await writeInitialPauseRecord( + gid: gid, + download: currentDownload + ) + await taskToCancel?.value + try await writeSettledPauseRecord( + gid: gid, + download: currentDownload + ) + await notifyObservers() + await scheduleNextIfNeeded() + logger.notice("Download paused, gid: \(gid, privacy: .public).") + return .success(()) + } catch let error as AppError { + return .failure(error) + } catch { + logger.error("\(error, privacy: .public)") + return .failure(.unknown) + } + } + + private func writeInitialPauseRecord( + gid: String, + download: DownloadedGallery + ) async throws -> Task? { + clearDownloadSessionState(gid: gid, includeUpdateFlag: true) + await queueStore.remove(gid) + await backgroundTaskStore.removeAll(for: gid) + await notifyObservers() + if activeGalleryID == gid { + let task = activeTask + activeTask?.cancel() + activeTask = nil + activeGalleryID = nil + return task + } + return nil + } + + private func writeSettledPauseRecord( + gid: String, + download: DownloadedGallery + ) async throws { + clearDownloadSessionState(gid: gid, includeUpdateFlag: true) + await queueStore.remove(gid) + await backgroundTaskStore.removeAll(for: gid) + } + + public func cancelQueuedWorkItem( + _ download: DownloadedGallery, + mode: DownloadStartMode + ) async -> Result { + switch mode { + case .initial: + return await pause(gid: download.gid) + case .redownload, .update, .repair: + break + } + + clearDownloadQueueIntent(gid: download.gid) + await queueStore.remove(download.gid) + await notifyObservers() + return .success(()) + } + + public func resume(gid: String) async -> Result { + guard let download = await fetchDownload(gid: gid) else { + return .failure(.notFound) + } + + clearDownloadFailureState(gid: gid) + queuedModes[gid] = resumeMode(for: download) + queuedPageSelections[gid] = nil + await queueStore.enqueue(gid) + await notifyObservers() + await scheduleNextIfNeeded() + logger.notice("Download resumed, gid: \(gid, privacy: .public).") + return .success(()) + } + +} diff --git a/AppPackage/Sources/DownloadClient/DownloadClient+SchedulingHelpers.swift b/AppPackage/Sources/DownloadClient/DownloadClient+SchedulingHelpers.swift new file mode 100644 index 000000000..9d2766e99 --- /dev/null +++ b/AppPackage/Sources/DownloadClient/DownloadClient+SchedulingHelpers.swift @@ -0,0 +1,78 @@ +import Foundation +import AppModels + +// MARK: - Mode Resolution +extension DownloadCoordinator { + public func queuedMode( + for download: DownloadedGallery + ) -> DownloadStartMode { + if let mode = queuedModes[download.gid] { + return effectiveRetryMode( + for: download, + requestedMode: mode + ) + } + switch download.displayStatus { + case .error where download.lastError?.code == .fileOperationFailed: + return effectiveRetryMode( + for: download, + requestedMode: .repair + ) + case .updateAvailable: + return .update + case .inactive: + return resumeMode(for: download) + case .completed: + return effectiveRetryMode( + for: download, + requestedMode: .redownload + ) + case .error, .queued, .active: + return effectiveRetryMode( + for: download, + requestedMode: interruptedWorkMode(for: download) + ) + } + } + + public func resumeMode( + for download: DownloadedGallery + ) -> DownloadStartMode { + if download.hasUpdate { + return .update + } + if download.displayStatus == .inactive, download.isIncomplete { + return effectiveRetryMode( + for: download, + requestedMode: .repair + ) + } + if case .missingFiles = storage.validate( + download: download, + verifiesContentHashes: false + ) { + return .repair + } + return .redownload + } + + // Queued, active, or errored downloads reach this fallback only when the + // in-memory queue intent is gone, typically after a relaunch interrupted + // the session; resuming in place must not discard downloaded pages, so + // anything with progress repairs instead of redownloading. + private func interruptedWorkMode( + for download: DownloadedGallery + ) -> DownloadStartMode { + download.completedPageCount == 0 ? .initial : .repair + } + + public func effectiveRetryMode( + for download: DownloadedGallery, + requestedMode: DownloadStartMode + ) -> DownloadStartMode { + guard requestedMode != .initial, download.hasUpdate else { + return requestedMode + } + return .update + } +} diff --git a/AppPackage/Sources/DownloadClient/DownloadClient+Testing.swift b/AppPackage/Sources/DownloadClient/DownloadClient+Testing.swift new file mode 100644 index 000000000..50044bdf8 --- /dev/null +++ b/AppPackage/Sources/DownloadClient/DownloadClient+Testing.swift @@ -0,0 +1,58 @@ +import Foundation +import AppModels + +#if DEBUG +extension DownloadCoordinator { + public func testingInstallActiveTask( + gid: String, + task: Task + ) { + activeTaskGeneration += 1 + activeGalleryID = gid + activeTask = task + } + + public func testingSetActiveGalleryID(_ gid: String?) { + activeGalleryID = gid + } + + public func testingSetQueuedGalleryIDs(_ gids: [String]) async { + await queueStore.removeAll() + for gid in gids { + await queueStore.enqueue(gid) + } + } + + public func testingSetDownloadError( + _ failure: DownloadFailure?, + gid: String + ) { + downloadErrors[gid] = failure + } + + public func testingSetFailedPageErrors( + _ failures: [PageFailure], + gid: String + ) { + failedPageErrors[gid] = Dictionary( + uniqueKeysWithValues: failures.map { ($0.index, $0) } + ) + } + + public func testingSetUpdatedGalleryIDs(_ gids: Set) { + updatedGalleryIDs = gids + } + + public func testingHasActiveTask() -> Bool { + activeTask != nil + } + + public func testingActiveGalleryID() -> String? { + activeGalleryID + } + + public func testingHasBackgroundAssertion() -> Bool { + backgroundAssertionToken != nil + } +} +#endif diff --git a/AppPackage/Sources/DownloadClient/DownloadClient.swift b/AppPackage/Sources/DownloadClient/DownloadClient.swift new file mode 100644 index 000000000..843407d97 --- /dev/null +++ b/AppPackage/Sources/DownloadClient/DownloadClient.swift @@ -0,0 +1,193 @@ +import Foundation +import AppModels +import ComposableArchitecture +import AppTools +import Sharing + +@DependencyClient +public struct DownloadClient: Sendable { + public var observeDownloads: @Sendable () -> AsyncStream<[DownloadedGallery]> = { AsyncStream { $0.finish() } } + public var fetchDownloads: @Sendable () async throws -> [DownloadedGallery] + public var fetchDownload: @Sendable (String) async -> DownloadedGallery? + public var reconcileDownloads: @Sendable () async -> Void + public var refreshDownloads: @Sendable () async -> Void + public var validateImageData: @Sendable (String) async -> DownloadValidationState? + public var fetchVersionMetadata: @Sendable (String, String) async -> DownloadVersionMetadata? + public var updateRemoteVersion: @Sendable (String, DownloadVersionMetadata) async -> DownloadedGallery? + public var enqueue: @Sendable (DownloadRequestPayload) async throws -> Void + public var togglePause: @Sendable (String) async throws -> Void + public var retry: @Sendable (String, DownloadStartMode) async throws -> Void + public var retryPages: @Sendable (String, [Int]) async throws -> Void + public var delete: @Sendable (String) async throws -> Void + public var loadManifest: @Sendable (String) async throws -> (DownloadedGallery, DownloadManifest) + public var loadLocalPageURLs: @Sendable (String) async -> [Int: URL]? + public var rescanLocalPageURLs: @Sendable (String) async -> [Int: URL]? + public var captureCachedPage: @Sendable (String, Int, URL?) async -> Void + public var loadInspection: @Sendable (String) async throws -> DownloadInspection + public var fetchFolders: @Sendable () async throws -> [String] + public var createFolder: @Sendable (String) async throws -> Void + public var renameFolder: @Sendable (String, String) async throws -> Void + public var deleteFolder: @Sendable (String) async throws -> Void + public var moveDownload: @Sendable (String, String) async throws -> Void + public var hasPendingWork: @Sendable () async -> Bool = { false } + public var runBackgroundProcessing: @Sendable () async -> Void +} + +extension DownloadClient { + public static func live( + rootURL: URL = FileUtil.downloadsDirectoryURL, + urlSession: URLSession = .shared, + fileManager: sending FileManager = FileManager() + ) -> Self { + let storage = DownloadStore(rootURL: rootURL, fileManager: fileManager) + // Reclaim any background-transfer files stranded by a prior process that died + // between staging and consuming them. Safe here because the background session + // does not exist yet, so the holding dir can only hold orphans. + storage.purgeBackgroundTransferHoldingDirectory() + let backgroundTaskStore = DownloadBackgroundTaskStore( + fileURL: storage.backgroundTaskRegistryURL() + ) + let completionReceiver = BackgroundPageCompletionReceiver() + let pageDownloader = DownloadPageDownloader.background( + identifier: DownloadBackgroundSessionEvents.pageSessionIdentifier, + taskStore: backgroundTaskStore, + holdingDirectory: storage.backgroundTransferHoldingDirectoryURL(), + orphanedCompletionHandler: { taskIdentifier, fileURL, response in + await completionReceiver.handleCompletion( + taskIdentifier: taskIdentifier, + fileURL: fileURL, + response: response + ) + }, + orphanedFailureHandler: { taskIdentifier, error in + await completionReceiver.handleFailure( + taskIdentifier: taskIdentifier, + error: error + ) + } + ) + let manager = DownloadCoordinator( + storage: storage, + urlSession: urlSession, + pageDownloader: pageDownloader, + backgroundTaskStore: backgroundTaskStore, + backgroundTaskClient: .live, + downloadOptionsProvider: { + @Shared(.setting) var setting + return setting.downloadRequestOptions + } + ) + Task { + await completionReceiver.setCoordinator(manager) + await manager.reconcileDownloads() + await manager.resumeQueue() + } + return makeDownloadClient(manager: manager) + } + + private static func makeObserveDownloadsStream( + manager: DownloadCoordinator + ) -> AsyncStream<[DownloadedGallery]> { + AsyncStream { continuation in + let task = Task { + let stream = await manager.observeDownloads() + for await downloads in stream { + continuation.yield(downloads) + } + continuation.finish() + } + continuation.onTermination = { _ in + task.cancel() + } + } + } + + private static func makeDownloadClient( + manager: DownloadCoordinator + ) -> Self { + .init( + observeDownloads: { makeObserveDownloadsStream(manager: manager) }, + fetchDownloads: { await manager.fetchDownloads() }, + fetchDownload: { gid in await manager.fetchDownload(gid: gid) }, + reconcileDownloads: { await manager.reconcileDownloads() }, + refreshDownloads: { await manager.refreshDownloads() }, + validateImageData: { gid in await manager.validateImageData(gid: gid) }, + fetchVersionMetadata: { gid, token in + try? await manager.fetchVersionMetadata(gid: gid, token: token).get() + }, + updateRemoteVersion: { gid, metadata in + await manager.updateRemoteVersion(gid: gid, metadata: metadata) + }, + enqueue: { payload in try await manager.enqueue(payload: payload).get() }, + togglePause: { gid in try await manager.togglePause(gid: gid).get() }, + retry: { gid, mode in try await manager.retry(gid: gid, mode: mode).get() }, + retryPages: { gid, pageIndices in + try await manager.retryPages(gid: gid, pageIndices: pageIndices).get() + }, + delete: { gid in try await manager.delete(gid: gid).get() }, + loadManifest: { gid in try await manager.loadManifest(gid: gid).get() }, + loadLocalPageURLs: { gid in try? await manager.loadLocalPageURLs(gid: gid).get() }, + rescanLocalPageURLs: { gid in await manager.rescanLocalPageURLs(gid: gid) }, + captureCachedPage: { gid, index, imageURL in + await manager.captureCachedPage(gid: gid, index: index, imageURL: imageURL) + }, + loadInspection: { gid in try await manager.loadInspection(gid: gid).get() }, + fetchFolders: { await manager.fetchFolders() }, + createFolder: { name in try await manager.createFolder(name: name).get() }, + renameFolder: { oldName, newName in + try await manager.renameFolder(oldName: oldName, newName: newName).get() + }, + deleteFolder: { name in try await manager.deleteFolder(name: name).get() }, + moveDownload: { gid, folderName in + try await manager.moveDownload(gid: gid, toFolderName: folderName).get() + }, + hasPendingWork: { await manager.hasPendingWork() }, + runBackgroundProcessing: { await manager.runQueueUntilIdle() } + ) + } +} + +// MARK: API +public enum DownloadClientKey: DependencyKey { + public static let liveValue = DownloadClient.live() + public static let previewValue = DownloadClient.noop + public static let testValue = DownloadClient() +} + +extension DependencyValues { + public var downloadClient: DownloadClient { + get { self[DownloadClientKey.self] } + set { self[DownloadClientKey.self] = newValue } + } +} + +// MARK: Preview +extension DownloadClient { + public static let noop = Self( + observeDownloads: { AsyncStream { $0.finish() } }, + fetchDownloads: { [] }, + fetchDownload: { _ in nil }, + reconcileDownloads: {}, + refreshDownloads: {}, + validateImageData: { _ in nil }, + fetchVersionMetadata: { _, _ in nil }, + updateRemoteVersion: { _, _ in nil }, + enqueue: { _ in }, + togglePause: { _ in }, + retry: { _, _ in }, + retryPages: { _, _ in }, + delete: { _ in }, + loadManifest: { _ in throw AppError.notFound }, + loadLocalPageURLs: { _ in nil }, + rescanLocalPageURLs: { _ in nil }, + captureCachedPage: { _, _, _ in }, + loadInspection: { _ in throw AppError.notFound }, + fetchFolders: { [] }, + createFolder: { _ in }, + renameFolder: { _, _ in }, + deleteFolder: { _ in }, + moveDownload: { _, _ in }, + hasPendingWork: { false }, + runBackgroundProcessing: {} + ) +} diff --git a/AppPackage/Sources/DownloadClient/DownloadFileManager.swift b/AppPackage/Sources/DownloadClient/DownloadFileManager.swift new file mode 100644 index 000000000..f89458221 --- /dev/null +++ b/AppPackage/Sources/DownloadClient/DownloadFileManager.swift @@ -0,0 +1,16 @@ +import Foundation +import Synchronization + +public final class DownloadFileManager: Sendable { + private let fileManager: Mutex + + public init(_ fileManager: sending FileManager) { + self.fileManager = Mutex(fileManager) + } + + public func operate( + _ body: (inout sending FileManager) throws -> sending T + ) rethrows -> sending T { + try fileManager.withLock(body) + } +} diff --git a/AppPackage/Sources/DownloadClient/DownloadPageDownloader.swift b/AppPackage/Sources/DownloadClient/DownloadPageDownloader.swift new file mode 100644 index 000000000..951dcf2be --- /dev/null +++ b/AppPackage/Sources/DownloadClient/DownloadPageDownloader.swift @@ -0,0 +1,434 @@ +import Foundation +import AppModels + +public struct DownloadPageTaskContext: Equatable, Sendable { + public let gid: String + public let pageIndex: Int + public init( + gid: String, + pageIndex: Int + ) { + self.gid = gid + self.pageIndex = pageIndex + } +} + +public struct DownloadPageTransfer: Sendable { + public let fileURL: URL + public let response: URLResponse + public let taskIdentifier: Int? + public init( + fileURL: URL, + response: URLResponse, + taskIdentifier: Int? = nil + ) { + self.fileURL = fileURL + self.response = response + self.taskIdentifier = taskIdentifier + } +} + +public struct DownloadPageDownloader: Sendable { + public var download: @Sendable (URLRequest, DownloadPageTaskContext) async throws -> DownloadPageTransfer + public init( + download: @escaping @Sendable (URLRequest, DownloadPageTaskContext) async throws -> DownloadPageTransfer + ) { + self.download = download + } + + public static func foreground(urlSession: URLSession) -> Self { + .init { request, _ in + let (fileURL, response) = try await urlSession.download(for: request) + return .init( + fileURL: fileURL, + response: response, + taskIdentifier: nil + ) + } + } + + public static func background( + identifier: String, + taskStore: DownloadBackgroundTaskStore, + holdingDirectory: URL, + fileManager: sending FileManager = FileManager(), + orphanedCompletionHandler: @escaping @Sendable (Int, URL, URLResponse) async -> Void = { _, _, _ in }, + orphanedFailureHandler: @escaping @Sendable (Int, AppError?) async -> Void = { _, _ in } + ) -> Self { + let session = BackgroundPageDownloadSession( + identifier: identifier, + taskStore: taskStore, + holdingDirectory: holdingDirectory, + fileManager: fileManager, + orphanedCompletionHandler: orphanedCompletionHandler, + orphanedFailureHandler: orphanedFailureHandler + ) + return .init { request, context in + try await session.download(for: request, context: context) + } + } +} + +public enum DownloadBackgroundSessionEvents { + public static let pageSessionIdentifier: String = "app.ehpanda.downloads.pages" + + @MainActor + private static var completionHandlers = [String: () -> Void]() + + @MainActor + public static func setCompletionHandler( + _ completionHandler: @escaping () -> Void, + for identifier: String + ) { + completionHandlers[identifier] = completionHandler + } + + @MainActor + public static func finishEvents(for identifier: String?) { + guard let identifier, + let completionHandler = completionHandlers.removeValue( + forKey: identifier + ) + else { return } + completionHandler() + } +} + +// A FIFO-bounded map: stash entries left behind by orphaned tasks or cancel-vs- +// complete races (no registration ever claims them) can't grow without bound over +// a long-lived session. +private struct BoundedStash { + private let capacity: Int + private var order = [Int]() + private var entries = [Int: Value]() + + init(capacity: Int) { + self.capacity = capacity + } + + mutating func insert(_ value: Value, forKey key: Int) { + if entries[key] == nil { + order.append(key) + } + entries[key] = value + while order.count > capacity { + let evictedKey = order.removeFirst() + entries[evictedKey] = nil + } + } + + mutating func removeValue(forKey key: Int) -> Value? { + guard let value = entries.removeValue(forKey: key) else { + return nil + } + if let index = order.firstIndex(of: key) { + order.remove(at: index) + } + return value + } +} + +private actor BackgroundDownloadTaskHub { + private enum Failure: Error, Sendable { + case cancelled + case app(AppError) + + var error: Error { + switch self { + case .cancelled: + return CancellationError() + case .app(let appError): + return appError + } + } + } + + private static let stashCapacity = 256 + + private var continuations = [Int: CheckedContinuation]() + private var completions = BoundedStash( + capacity: BackgroundDownloadTaskHub.stashCapacity + ) + private var failures = BoundedStash( + capacity: BackgroundDownloadTaskHub.stashCapacity + ) + + func wait( + taskIdentifier: Int, + startTask: @escaping @Sendable () -> Void, + cancelTask: @escaping @Sendable () -> Void + ) async throws -> DownloadPageTransfer { + try await withTaskCancellationHandler { + try await withCheckedThrowingContinuation { continuation in + register( + continuation, + taskIdentifier: taskIdentifier + ) + startTask() + } + } onCancel: { + cancelTask() + Task { + await self.cancel(taskIdentifier: taskIdentifier) + } + } + } + + func succeed( + taskIdentifier: Int, + transfer: DownloadPageTransfer + ) -> Bool { + if let continuation = continuations.removeValue(forKey: taskIdentifier) { + continuation.resume(returning: transfer) + return true + } + completions.insert(transfer, forKey: taskIdentifier) + return false + } + + func fail( + taskIdentifier: Int, + error: Error + ) -> Bool { + let failure = Self.failure(from: error) + if let continuation = continuations.removeValue(forKey: taskIdentifier) { + continuation.resume(throwing: failure.error) + return true + } + failures.insert(failure, forKey: taskIdentifier) + return false + } + + private func register( + _ continuation: CheckedContinuation, + taskIdentifier: Int + ) { + if let transfer = completions.removeValue(forKey: taskIdentifier) { + continuation.resume(returning: transfer) + return + } + if let failure = failures.removeValue(forKey: taskIdentifier) { + continuation.resume(throwing: failure.error) + return + } + continuations[taskIdentifier] = continuation + } + + private func cancel(taskIdentifier: Int) { + if let continuation = continuations.removeValue(forKey: taskIdentifier) { + continuation.resume(throwing: CancellationError()) + return + } + failures.insert(.cancelled, forKey: taskIdentifier) + } + + private static func failure(from error: Error) -> Failure { + if error is CancellationError { + return .cancelled + } + if let error = error as? AppError { + return .app(error) + } + if let error = error as? URLError, + error.code == .cancelled { + return .cancelled + } + if DownloadCoordinator.isCancellationLikeError(error) { + return .cancelled + } + if error is URLError { + return .app(.networkingFailed) + } + return .app(.unknown) + } + + // Classifies a delegate error for the orphaned-failure route: `nil` for a + // cancellation (clean up the task record only), the AppError otherwise. + static func orphanedFailureError(from error: Error) -> AppError? { + switch failure(from: error) { + case .cancelled: + return nil + case .app(let appError): + return appError + } + } +} + +private actor BackgroundPageDownloadSession { + private let taskStore: DownloadBackgroundTaskStore + private let hub = BackgroundDownloadTaskHub() + private let delegate: BackgroundPageDownloadDelegate + private let session: URLSession + + init( + identifier: String, + taskStore: DownloadBackgroundTaskStore, + holdingDirectory: URL, + fileManager: sending FileManager, + orphanedCompletionHandler: @escaping @Sendable (Int, URL, URLResponse) async -> Void, + orphanedFailureHandler: @escaping @Sendable (Int, AppError?) async -> Void + ) { + self.taskStore = taskStore + let delegate = BackgroundPageDownloadDelegate( + hub: hub, + taskStore: taskStore, + holdingDirectory: holdingDirectory, + fileManager: fileManager, + orphanedCompletionHandler: orphanedCompletionHandler, + orphanedFailureHandler: orphanedFailureHandler + ) + self.delegate = delegate + let configuration = URLSessionConfiguration.background(withIdentifier: identifier) + configuration.sessionSendsLaunchEvents = true + configuration.isDiscretionary = false + configuration.waitsForConnectivity = true + self.session = URLSession( + configuration: configuration, + delegate: delegate, + delegateQueue: nil + ) + } + + func download( + for request: URLRequest, + context: DownloadPageTaskContext + ) async throws -> DownloadPageTransfer { + let task = session.downloadTask(with: request) + await taskStore.record( + taskIdentifier: task.taskIdentifier, + gid: context.gid, + pageIndex: context.pageIndex + ) + do { + return try await hub.wait( + taskIdentifier: task.taskIdentifier, + startTask: { task.resume() }, + cancelTask: { task.cancel() } + ) + } catch { + await taskStore.remove(taskIdentifier: task.taskIdentifier) + throw error + } + } +} + +private final class BackgroundPageDownloadDelegate: NSObject, URLSessionDownloadDelegate { + private let hub: BackgroundDownloadTaskHub + private let taskStore: DownloadBackgroundTaskStore + private let holdingDirectory: URL + private let fileManager: DownloadFileManager + private let orphanedCompletionHandler: @Sendable (Int, URL, URLResponse) async -> Void + private let orphanedFailureHandler: @Sendable (Int, AppError?) async -> Void + + init( + hub: BackgroundDownloadTaskHub, + taskStore: DownloadBackgroundTaskStore, + holdingDirectory: URL, + fileManager: sending FileManager, + orphanedCompletionHandler: @escaping @Sendable (Int, URL, URLResponse) async -> Void, + orphanedFailureHandler: @escaping @Sendable (Int, AppError?) async -> Void + ) { + self.hub = hub + self.taskStore = taskStore + self.holdingDirectory = holdingDirectory + self.fileManager = DownloadFileManager(fileManager) + self.orphanedCompletionHandler = orphanedCompletionHandler + self.orphanedFailureHandler = orphanedFailureHandler + super.init() + } + + func urlSession( + _: URLSession, + downloadTask: URLSessionDownloadTask, + didFinishDownloadingTo location: URL + ) { + let taskIdentifier = downloadTask.taskIdentifier + guard let response = downloadTask.response else { + complete(taskIdentifier: taskIdentifier, error: AppError.notFound) + return + } + + do { + let stagedURL = try stageDownload( + at: location, + taskIdentifier: taskIdentifier + ) + let transfer = DownloadPageTransfer( + fileURL: stagedURL, + response: response, + taskIdentifier: taskIdentifier + ) + Task { + let consumed = await hub.succeed( + taskIdentifier: taskIdentifier, + transfer: transfer + ) + if !consumed { + await orphanedCompletionHandler( + taskIdentifier, + stagedURL, + response + ) + } + } + } catch { + complete(taskIdentifier: taskIdentifier, error: error) + } + } + + func urlSession( + _: URLSession, + task: URLSessionTask, + didCompleteWithError error: Error? + ) { + guard let error else { return } + complete(taskIdentifier: task.taskIdentifier, error: error) + } + + func urlSessionDidFinishEvents( + forBackgroundURLSession session: URLSession + ) { + Task { @MainActor in + DownloadBackgroundSessionEvents.finishEvents( + for: session.configuration.identifier + ) + } + } + + private func complete( + taskIdentifier: Int, + error: Error + ) { + Task { + // The in-process waiter removes its own task record when `wait` throws; + // an orphaned failure (no waiter) is routed instead, mirroring the + // orphaned-completion path, so it isn't stashed and dropped silently. + let consumed = await hub.fail( + taskIdentifier: taskIdentifier, + error: error + ) + if !consumed { + await orphanedFailureHandler( + taskIdentifier, + BackgroundDownloadTaskHub.orphanedFailureError(from: error) + ) + } + } + } + + private func stageDownload( + at location: URL, + taskIdentifier: Int + ) throws -> URL { + let stagedURL = holdingDirectory + .appendingPathComponent("\(taskIdentifier)-\(UUID().uuidString).download") + try fileManager.operate { + try $0.createDirectory( + at: holdingDirectory, + withIntermediateDirectories: true + ) + try $0.moveItem(at: location, to: stagedURL) + } + return stagedURL + } +} diff --git a/AppPackage/Sources/DownloadClient/DownloadQueueStore.swift b/AppPackage/Sources/DownloadClient/DownloadQueueStore.swift new file mode 100644 index 000000000..341f16e72 --- /dev/null +++ b/AppPackage/Sources/DownloadClient/DownloadQueueStore.swift @@ -0,0 +1,52 @@ +import OSLogExt +import ComposableArchitecture +import AppModels +import Foundation + +private let logger = Logger(category: .init(describing: DownloadQueueStore.self)) + +public struct DownloadQueueStore: Sendable { + private let identifiers: Shared<[String]> + + public init(fileURL: URL) { + identifiers = Shared(wrappedValue: [], .fileStorage(fileURL)) + } + + public var gids: [String] { + identifiers.wrappedValue + } + + public func contains(_ gid: String) -> Bool { + identifiers.wrappedValue.contains(gid) + } + + public func enqueue(_ gid: String) async { + identifiers.withLock { gids in + guard !gids.contains(gid) else { return } + gids.append(gid) + } + await save() + } + + public func remove(_ gid: String) async { + identifiers.withLock { gids in + gids.removeAll { $0 == gid } + } + await save() + } + + public func removeAll() async { + identifiers.withLock { gids in + gids.removeAll() + } + await save() + } + + private func save() async { + do { + try await identifiers.save() + } catch { + logger.error("\(error, privacy: .public)") + } + } +} diff --git a/AppPackage/Sources/DownloadClient/DownloadStore+JSONCoding.swift b/AppPackage/Sources/DownloadClient/DownloadStore+JSONCoding.swift new file mode 100644 index 000000000..b16b3a4d2 --- /dev/null +++ b/AppPackage/Sources/DownloadClient/DownloadStore+JSONCoding.swift @@ -0,0 +1,11 @@ +import Foundation + +extension DownloadStore { + public func writeJSON(_ value: T, to url: URL) throws { + try JSONEncoder().encode(value).write(to: url, options: .atomic) + } + + public func readJSON(_ type: T.Type, from url: URL) throws -> T { + try JSONDecoder().decode(type, from: Data(contentsOf: url)) + } +} diff --git a/AppPackage/Sources/DownloadClient/DownloadStore+Operations.swift b/AppPackage/Sources/DownloadClient/DownloadStore+Operations.swift new file mode 100644 index 000000000..c62f734f9 --- /dev/null +++ b/AppPackage/Sources/DownloadClient/DownloadStore+Operations.swift @@ -0,0 +1,312 @@ +import AppTools +import Foundation +import AppModels +import Resources + +extension DownloadStore { + public func linkOrCopyReadableAsset(at sourceURL: URL, to destinationURL: URL) throws { + guard sanitizeAssetFileIfNeeded(at: sourceURL) else { + throw AppError.fileOperationFailed( + String(localized: .downloadStoreAssetUnreadable(sourceURL.lastPathComponent)) + ) + } + + try fileManager.operate { + try $0.createDirectory( + at: destinationURL.deletingLastPathComponent(), + withIntermediateDirectories: true + ) + if $0.fileExists(atPath: destinationURL.path) { + try $0.removeItem(at: destinationURL) + } + } + + do { + try fileManager.operate { + try $0.linkItem(at: sourceURL, to: destinationURL) + } + } catch { + try fileManager.operate { + try $0.copyItem(at: sourceURL, to: destinationURL) + } + } + } + + public func materializeRepairSeed( + from sourceFolderURL: URL, + manifest: DownloadManifest, + to destinationFolderURL: URL + ) throws { + try fileManager.operate { + try $0.createDirectory(at: destinationFolderURL, withIntermediateDirectories: true) + } + + try linkOrCopyReadableAsset( + at: sourceFolderURL.appendingPathComponent(Defaults.FilePath.downloadManifest), + to: destinationFolderURL.appendingPathComponent(Defaults.FilePath.downloadManifest) + ) + + if let coverRelativePath = existingCoverRelativePath( + folderURL: sourceFolderURL, + manifest: manifest + ), + let sourceCoverURL = validatedChildURL(root: sourceFolderURL, relativePath: coverRelativePath), + let destCoverURL = validatedChildURL(root: destinationFolderURL, relativePath: coverRelativePath) { + if sanitizeAssetFileIfNeeded(at: sourceCoverURL) { + try linkOrCopyReadableAsset(at: sourceCoverURL, to: destCoverURL) + } + } + + let existingPages = existingPageRelativePaths( + folderURL: sourceFolderURL, + manifest: manifest + ) + for index in manifest.pages.keys.sorted() { + guard let relativePath = existingPages[index], + let sourcePageURL = validatedChildURL(root: sourceFolderURL, relativePath: relativePath), + let destPageURL = validatedChildURL(root: destinationFolderURL, relativePath: relativePath) + else { continue } + guard sanitizeAssetFileIfNeeded(at: sourcePageURL) else { continue } + try linkOrCopyReadableAsset(at: sourcePageURL, to: destPageURL) + } + } + + /// Returns the manifest with any missing page hashes filled in. Hashes are recorded at + /// write time (per page as it downloads, and on capture-restore), so finalize only + /// *merges*: it hashes a page solely when its recorded hash is empty, never re-hashing + /// the whole gallery. There is no automatic re-validation anywhere; verifying existing + /// bytes against their hashes is a user-initiated action (`validateImageData(gid:)`). + public func addingCurrentFileHashes( + to manifest: DownloadManifest, + folderURL: URL + ) throws -> DownloadManifest { + let existingPages = existingPageRelativePaths( + folderURL: folderURL, + manifest: manifest + ) + var pages = manifest.pages + for index in manifest.pages.keys.sorted() { + guard pages[index]?.isEmpty != false else { + continue + } + guard let relativePath = existingPages[index] else { + throw AppError.fileOperationFailed( + String(localized: .RLocalizable.downloadStorePageMissing(page: index)) + ) + } + pages[index] = try hashReadableAsset( + folderURL: folderURL, + relativePath: relativePath, + missingMessage: String(localized: .RLocalizable.downloadStorePageMissing(page: index)) + ) + } + + return manifest.replacing(pages: pages) + } + + @discardableResult + public func refreshManifestPageFileHash( + folderURL: URL, + pageIndex: Int, + relativePath: String? = nil + ) throws -> DownloadManifest { + let resolvedRelativePath: String? + if let relativePath { + resolvedRelativePath = relativePath + } else { + let manifest = try readManifest(folderURL: folderURL) + resolvedRelativePath = manifest.pages[pageIndex].flatMap { _ in + existingPageFileURL( + folderURL: folderURL, + gid: manifest.gid, + token: manifest.token, + index: pageIndex + )? + .lastPathComponent + } + } + guard let resolvedRelativePath else { + return try readManifest(folderURL: folderURL) + } + return try refreshManifestPageFileHashes( + folderURL: folderURL, + pageRelativePaths: [pageIndex: resolvedRelativePath] + ) + } + + @discardableResult + public func refreshManifestPageFileHashes( + folderURL: URL, + pageRelativePaths: [Int: String] + ) throws -> DownloadManifest { + let manifest = try readManifest(folderURL: folderURL) + guard !pageRelativePaths.isEmpty else { return manifest } + var pages = manifest.pages + var didUpdate = false + for index in pageRelativePaths.keys.sorted() { + guard pages[index] != nil, + let refreshedRelativePath = pageRelativePaths[index] + else { + continue + } + pages[index] = try hashReadableAsset( + folderURL: folderURL, + relativePath: refreshedRelativePath, + missingMessage: String(localized: .RLocalizable.downloadStorePageMissing(page: index)) + ) + didUpdate = true + } + + guard didUpdate else { return manifest } + + let refreshedManifest = manifest.replacing(pages: pages) + if refreshedManifest != manifest { + try writeManifest(refreshedManifest, folderURL: folderURL) + } + return refreshedManifest + } + + @discardableResult + public func refreshManifestFileHashes(folderURL: URL) throws -> DownloadManifest { + let manifest = try readManifest(folderURL: folderURL) + let hashedManifest = try addingCurrentFileHashes( + to: manifest, + folderURL: folderURL + ) + if hashedManifest != manifest { + try writeManifest(hashedManifest, folderURL: folderURL) + } + return hashedManifest + } + + public func removeFolder(relativePath: String) throws { + let targetURL = folderURL(relativePath: relativePath) + try removeFolder(at: targetURL) + } + + public func removeFolder(at folderURL: URL) throws { + let targetURL = folderURL.standardizedFileURL + guard targetURL.path.hasPrefix(rootURL.standardizedFileURL.path + "/") else { + throw AppError.fileOperationFailed(targetURL.path) + } + try fileManager.operate { + guard $0.fileExists(atPath: targetURL.path) else { return } + try $0.removeItem(at: targetURL) + } + } + + public func validate( + download: DownloadedGallery, + verifiesContentHashes: Bool + ) -> DownloadValidationState { + let folderURL = download.folderURL + guard fileManager.operate({ $0.fileExists(atPath: folderURL.path) }) else { + return .missingFiles(.downloadStoreDownloadFolderMissing) + } + let manifestURL = download.manifestURL + guard fileManager.operate({ $0.fileExists(atPath: manifestURL.path) }) else { + return .missingFiles(.downloadStoreManifestMissing) + } + guard let manifest = try? readManifest(folderURL: folderURL) else { + return .missingFiles(.RLocalizable.downloadStoreManifestCorrupted) + } + if let pageValidationFailure = validatePages( + folderURL: folderURL, + manifest: manifest, + verifiesContentHashes: verifiesContentHashes + ) { + return pageValidationFailure + } + return .valid + } + + public func validPageCount(folderURL: URL, manifest: DownloadManifest) -> Int { + let existingPages = existingPageRelativePaths( + folderURL: folderURL, + manifest: manifest + ) + return manifest.pages.keys.reduce(into: 0) { count, index in + guard let relativePath = existingPages[index], + let pageURL = validatedChildURL(root: folderURL, relativePath: relativePath) + else { return } + if sanitizeAssetFileIfNeeded(at: pageURL) { + count += 1 + } + } + } + + public func isReadableAssetFile(at url: URL) -> Bool { + sanitizeAssetFileIfNeeded(at: url) + } + + private func hashReadableAsset( + folderURL: URL, + relativePath: String, + missingMessage: String + ) throws -> String { + guard let fileURL = validatedChildURL(root: folderURL, relativePath: relativePath), + sanitizeAssetFileIfNeeded(at: fileURL) + else { + throw AppError.fileOperationFailed(missingMessage) + } + return try fileHash(at: fileURL) + } + + private func validatePages( + folderURL: URL, + manifest: DownloadManifest, + verifiesContentHashes: Bool + ) -> DownloadValidationState? { + let existingPages = existingPageRelativePaths( + folderURL: folderURL, + manifest: manifest + ) + for index in manifest.pages.keys.sorted() { + if let validationFailure = validatePage( + folderURL: folderURL, + index: index, + expectedHash: manifest.pages[index] ?? "", + existingPageRelativePaths: existingPages, + verifiesContentHash: verifiesContentHashes + ) { + return validationFailure + } + } + return nil + } + + private func validatePage( + folderURL: URL, + index: Int, + expectedHash: String, + existingPageRelativePaths: [Int: String], + verifiesContentHash: Bool + ) -> DownloadValidationState? { + guard !expectedHash.isEmpty else { + return nil + } + + guard let relativePath = existingPageRelativePaths[index], + let pageURL = validatedChildURL(root: folderURL, relativePath: relativePath), + sanitizeAssetFileIfNeeded(at: pageURL) + else { + return .missingFiles(.RLocalizable.downloadStorePageMissing(page: index)) + } + + if verifiesContentHash, (try? fileHash(at: pageURL)) != expectedHash { + return .missingFiles(.RLocalizable.downloadStorePageImageCorrupted(page: index)) + } + + return nil + } +} + +private extension DownloadManifest { + func replacing( + pages: [Int: String] + ) -> DownloadManifest { + var manifest = self + manifest.pages = pages + return manifest + } +} diff --git a/AppPackage/Sources/DownloadClient/DownloadStore.swift b/AppPackage/Sources/DownloadClient/DownloadStore.swift new file mode 100644 index 000000000..f0dc6cf24 --- /dev/null +++ b/AppPackage/Sources/DownloadClient/DownloadStore.swift @@ -0,0 +1,555 @@ +import Foundation +import AppModels +import Resources +import CryptoKit +import AppTools + +public enum DownloadValidationState: Equatable, Sendable { + case valid + case missingFiles(LocalizedStringResource) +} + +public struct DownloadFolderRecord: Equatable, Sendable { + public let relativePath: String + public let folderURL: URL + public let manifest: DownloadManifest + public let localCoverURL: URL? + public let localPageURLs: [Int: URL] + public let modificationDate: Date? + public let parentFolderName: String + public init( + relativePath: String, + folderURL: URL, + manifest: DownloadManifest, + localCoverURL: URL? = nil, + localPageURLs: [Int: URL], + modificationDate: Date? = nil, + parentFolderName: String + ) { + self.relativePath = relativePath + self.folderURL = folderURL + self.manifest = manifest + self.localCoverURL = localCoverURL + self.localPageURLs = localPageURLs + self.modificationDate = modificationDate + self.parentFolderName = parentFolderName + } +} + +public struct DownloadScanResult: Equatable, Sendable { + public let records: [DownloadFolderRecord] + public let userFolders: [String] + public init( + records: [DownloadFolderRecord], + userFolders: [String] + ) { + self.records = records + self.userFolders = userFolders + } +} + +/// Pure filesystem / manifest / hash I/O for downloads. The filesystem is the source of +/// truth (per-folder `manifest.json` + page files), so this type holds no cross-call +/// in-memory state and is race-free by construction: every method reads or writes disk and +/// returns. It is the I/O half of the download subsystem split; the `DownloadCoordinator` +/// actor owns the mutable read model and scheduling on top of it. +public struct DownloadStore: Sendable { + private static let maxFolderComponentByteCount = 255 + + public let rootURL: URL + public let fileManager: DownloadFileManager + + public init( + rootURL: URL = FileUtil.downloadsDirectoryURL, + fileManager: sending FileManager = FileManager() + ) { + self.rootURL = rootURL + self.fileManager = DownloadFileManager(fileManager) + } + + public func ensureRootDirectory() throws { + try fileManager.operate { + try $0.createDirectory(at: rootURL, withIntermediateDirectories: true) + } + var resourceValues = URLResourceValues() + resourceValues.isExcludedFromBackup = true + var mutableRootURL = rootURL + try? mutableRootURL.setResourceValues(resourceValues) + } + + public func folderURL(relativePath: String) -> URL { + rootURL.appendingPathComponent(relativePath, isDirectory: true) + } + + public func userFolderURL(name: String) -> URL { + rootURL.appendingPathComponent(name, isDirectory: true) + } + + public func rootRelativePath(forFolderURL url: URL) -> String? { + let rootPath = rootURL.standardizedFileURL.path + "/" + let path = url.standardizedFileURL.path + guard path.hasPrefix(rootPath) else { return nil } + return String(path.dropFirst(rootPath.count)) + } + + public func parentFolderName(forFolderURL url: URL) -> String? { + guard let relativePath = rootRelativePath(forFolderURL: url) else { return nil } + let components = relativePath.split(separator: "/") + guard components.count >= 2 else { return nil } + return String(components[0]) + } + + public func validatedChildURL( + root: URL, relativePath: String + ) -> URL? { + let resolved = root + .appendingPathComponent(relativePath) + .standardizedFileURL + guard resolved.path.hasPrefix(root.standardizedFileURL.path + "/") else { + return nil + } + return resolved + } + + public func manifestURL(relativePath: String) -> URL { + folderURL(relativePath: relativePath) + .appendingPathComponent(Defaults.FilePath.downloadManifest) + } + + public func queueURL() -> URL { + rootURL.appendingPathComponent(".queue.json") + } + + public func backgroundTaskRegistryURL() -> URL { + rootURL.appendingPathComponent(".background-tasks.json") + } + + public func backgroundTransferHoldingDirectoryURL() -> URL { + rootURL.appendingPathComponent(".background-downloads", isDirectory: true) + } + + /// Removes the background-transfer holding directory and everything in it. + /// + /// The holding dir is hidden, so the launch scan (`.skipsHiddenFiles`) can't see + /// it, and a process that dies between staging and consuming a file leaves it + /// stranded. Call this only while no background session exists (e.g. at `.live` + /// construction) so anything present is definitionally an orphan; the downloader + /// recreates the directory on the next stage. + public func purgeBackgroundTransferHoldingDirectory() { + let holdingDirectory = backgroundTransferHoldingDirectoryURL() + try? fileManager.operate { + guard $0.fileExists(atPath: holdingDirectory.path) else { return } + try $0.removeItem(at: holdingDirectory) + } + } + + public func existingPageRelativePaths(folderURL: URL, manifest: DownloadManifest) -> [Int: String] { + let pageIndices = Set(manifest.pages.keys) + guard !pageIndices.isEmpty else { return [:] } + + let fileURLs = existingAssetFileURLs(folderURL: folderURL) + let prefix = identityPrefix(gid: manifest.gid, token: manifest.token) + + return fileURLs.reduce(into: [:]) { result, fileURL in + let fileName = fileURL.lastPathComponent + guard fileName.hasPrefix(prefix) else { return } + let suffix = fileName.dropFirst(prefix.count) + guard let dotIndex = suffix.firstIndex(of: ".") else { return } + let indexText = String(suffix[.. [Int: URL] { + existingPageRelativePaths(folderURL: folderURL, manifest: manifest) + .reduce(into: [Int: URL]()) { result, entry in + result[entry.key] = folderURL.appendingPathComponent(entry.value) + } + } + + public func localCoverURL(folderURL: URL, manifest: DownloadManifest) -> URL? { + existingCoverFileURL( + folderURL: folderURL, + gid: manifest.gid, + token: manifest.token + ) + } + + public func existingCoverRelativePath(folderURL: URL, manifest: DownloadManifest) -> String? { + localCoverURL(folderURL: folderURL, manifest: manifest)? + .lastPathComponent + } + + /// Builds the on-disk folder name as `[gid_token] Title`. The readable title is a + /// deliberate Files-app-integration bet (the app sets `UIFileSharingEnabled` / + /// `LSSupportsOpeningDocumentsInPlace`), and the `[gid_token]` prefix keeps identity + /// resolvable from the name alone. The title is truncated to keep the whole component + /// within the filesystem's per-name byte limit. + public func makeFolderRelativePath(gid: String, token: String, title: String) -> String { + let prefix = galleryFolderNamePrefix(gid: gid, token: token) + let titleByteCount = max(Self.maxFolderComponentByteCount - prefix.utf8.count, 0) + return "\(prefix)\(normalizedFolderTitle(title, maximumUTF8ByteCount: titleByteCount))" + } + + public func galleryFolderNamePrefix(gid: String, token: String) -> String { + "[\(normalizedIdentityComponent(gid))_\(normalizedIdentityComponent(token))] " + } + + /// Finds every folder belonging to a gallery, because folder membership follows + /// filesystem location, not a stored list. A folder the user moved in the Files app is + /// still found. The `[gid_token]` name prefix is the fast path; any folder without it is + /// confirmed by reading its manifest's `gid` / `token`, so a renamed folder still matches. + public func galleryFolderURLs(gid: String, token: String) -> [URL] { + guard fileManager.operate({ $0.fileExists(atPath: rootURL.path) }) else { + return [] + } + let prefix = galleryFolderNamePrefix(gid: gid, token: token) + return directoryURLs(in: rootURL) + .flatMap { directoryURLs(in: $0) } + .filter { folderURL in + guard !folderURL.lastPathComponent.hasPrefix(prefix) else { + return true + } + guard let manifest = try? readManifest(folderURL: folderURL) else { + return false + } + return manifest.gid == gid && manifest.token == token + } + } + + public func galleryFolderRecords(gid: String, token: String) -> [DownloadFolderRecord] { + galleryFolderURLs(gid: gid, token: token).compactMap { folderURL in + guard let manifest = try? readManifest(folderURL: folderURL), + manifest.gid == gid, + manifest.token == token + else { + return nil + } + return galleryFolderRecord( + folderURL: folderURL, + manifest: manifest, + parentFolderName: parentFolderName(forFolderURL: folderURL) ?? "" + ) + } + } + + public static func isGalleryFolderLikeName(_ name: String) -> Bool { + name.range(of: #"^\[[^\]]*_[^\]]*\] "#, options: .regularExpression) != nil + } + + public static func normalizedUserFolderName(_ name: String) -> String? { + guard let limitedName = normalizedFolderName( + name, + trimsLeadingDots: true, + fallback: nil, + maximumUTF8ByteCount: maxFolderComponentByteCount + ) else { + return nil + } + guard !limitedName.isEmpty, !isGalleryFolderLikeName(limitedName) else { + return nil + } + return limitedName + } + + public func normalizedUserFolderName(_ name: String) -> String? { + Self.normalizedUserFolderName(name) + } + + private func normalizedFolderTitle( + _ title: String, + maximumUTF8ByteCount: Int + ) -> String { + Self.normalizedFolderName( + title, + trimsLeadingDots: false, + fallback: "Gallery", + maximumUTF8ByteCount: maximumUTF8ByteCount + ) ?? "Gallery" + } + + private static func normalizedFolderName( + _ name: String, + trimsLeadingDots: Bool, + fallback: String?, + maximumUTF8ByteCount: Int + ) -> String? { + let invalidCharacters = CharacterSet(charactersIn: "/\\:") + .union(.controlCharacters) + let sanitizedScalars = name + .trimmingCharacters(in: .whitespacesAndNewlines) + .unicodeScalars + .map { invalidCharacters.contains($0) ? " " : String($0) } + .joined() + let collapsedWhitespace = sanitizedScalars.replacingOccurrences( + of: "\\s+", + with: " ", + options: .regularExpression + ) + let trimPattern = trimsLeadingDots + ? "^[\\s.]+|[\\s.]+$" + : "^\\s+|[\\s.]+$" + let trimmedName = collapsedWhitespace.replacingOccurrences( + of: trimPattern, + with: "", + options: .regularExpression + ) + let limitedName = trimmedName + .truncatedToUTF8ByteCount(maximumUTF8ByteCount) + .replacingOccurrences( + of: "[\\s.]+$", + with: "", + options: .regularExpression + ) + if limitedName.isEmpty { + return fallback + } + return limitedName + } + + private func normalizedIdentityComponent(_ value: String) -> String { + let invalidCharacters = CharacterSet(charactersIn: "/\\[]:") + .union(.controlCharacters) + .union(.whitespacesAndNewlines) + let sanitized = value.unicodeScalars + .map { invalidCharacters.contains($0) ? "_" : String($0) } + .joined() + return sanitized.isEmpty ? "unknown" : sanitized + } + + private func identityPrefix(gid: String, token: String) -> String { + "\(normalizedIdentityComponent(gid))_\(normalizedIdentityComponent(token))_" + } + + public func makePageRelativePath(gid: String, token: String, index: Int, fileExtension: String) -> String { + "\(identityPrefix(gid: gid, token: token))\(index).\(fileExtension.lowercased())" + } + + public func makeCoverRelativePath(gid: String, token: String, fileExtension: String) -> String { + "\(identityPrefix(gid: gid, token: token))cover.\(fileExtension.lowercased())" + } + + public func existingPageFileURL(folderURL: URL, gid: String, token: String, index: Int) -> URL? { + existingAssetFileURL( + folderURL: folderURL, + prefix: pageFilePrefix(gid: gid, token: token, index: index) + ) + } + + public func existingCoverFileURL(folderURL: URL, gid: String, token: String) -> URL? { + existingAssetFileURL( + folderURL: folderURL, + prefix: coverFilePrefix(gid: gid, token: token) + ) + } + + private func existingAssetFileURL(folderURL: URL, prefix: String) -> URL? { + existingAssetFileURL( + in: existingAssetFileURLs(folderURL: folderURL), + prefix: prefix + ) + } + + private func existingAssetFileURLs(folderURL: URL) -> [URL] { + guard let fileURLs = try? fileManager.operate({ + try $0.contentsOfDirectory( + at: folderURL, + includingPropertiesForKeys: [.isRegularFileKey, .fileSizeKey] + ) + }) else { + return [] + } + + return fileURLs.sorted(by: { $0.lastPathComponent < $1.lastPathComponent }) + } + + private func existingAssetFileURL(in fileURLs: [URL], prefix: String) -> URL? { + fileURLs + .first(where: { + $0.lastPathComponent.hasPrefix(prefix) + && sanitizeAssetFileIfNeeded(at: $0) + }) + } + + private func pageFilePrefix(gid: String, token: String, index: Int) -> String { + "\(identityPrefix(gid: gid, token: token))\(index)." + } + + private func coverFilePrefix(gid: String, token: String) -> String { + "\(identityPrefix(gid: gid, token: token))cover." + } + + public func writeManifest(_ manifest: DownloadManifest, folderURL: URL) throws { + try writeJSON(manifest, to: folderURL.appendingPathComponent(Defaults.FilePath.downloadManifest)) + } + + public func readManifest(folderURL: URL) throws -> DownloadManifest { + let manifest = try readJSON( + DownloadManifest.self, + from: folderURL.appendingPathComponent(Defaults.FilePath.downloadManifest) + ) + try validateDecodedManifest(manifest) + return manifest + } + + private func validateDecodedManifest(_ manifest: DownloadManifest) throws { + guard manifest.pages.isEmpty == false else { + throw manifestCorruptedError() + } + guard manifest.pages.keys.sorted() == Array(1...manifest.pages.count) else { + throw manifestCorruptedError() + } + } + + private func manifestCorruptedError() -> AppError { + .fileOperationFailed(String(localized: .RLocalizable.downloadStoreManifestCorrupted)) + } + + public func scanDownloadFolders() throws -> [DownloadFolderRecord] { + try scanDownloads().records + } + + public func scanDownloads() throws -> DownloadScanResult { + guard fileManager.operate({ $0.fileExists(atPath: rootURL.path) }) else { + return .init(records: [], userFolders: []) + } + + var records = [DownloadFolderRecord]() + var userFolders = [String]() + for folderURL in directoryURLs(in: rootURL) { + let folderName = folderURL.lastPathComponent + // Gallery folders dropped directly under the root, including broken + // manifest-less ones, are invisible to the app and never become + // user folders. + guard (try? readManifest(folderURL: folderURL)) == nil else { continue } + guard !Self.isGalleryFolderLikeName(folderName) else { continue } + + userFolders.append(folderName) + for galleryFolderURL in directoryURLs(in: folderURL) { + guard let manifest = try? readManifest(folderURL: galleryFolderURL) else { + continue + } + records.append( + galleryFolderRecord( + folderURL: galleryFolderURL, + manifest: manifest, + parentFolderName: folderName + ) + ) + } + } + return .init( + records: records, + userFolders: userFolders.sorted { + $0.localizedStandardCompare($1) == .orderedAscending + } + ) + } + + private func directoryURLs(in parentURL: URL) -> [URL] { + let contents = (try? fileManager.operate { + try $0.contentsOfDirectory( + at: parentURL, + includingPropertiesForKeys: [.isDirectoryKey, .contentModificationDateKey], + options: [.skipsHiddenFiles] + ) + }) ?? [] + return contents.filter { + (try? $0.resourceValues(forKeys: [.isDirectoryKey]))?.isDirectory == true + } + } + + public func galleryFolderRecord( + folderURL: URL, + manifest: DownloadManifest, + parentFolderName: String + ) -> DownloadFolderRecord { + let resourceValues = try? folderURL.resourceValues( + forKeys: [.contentModificationDateKey] + ) + return DownloadFolderRecord( + relativePath: "\(parentFolderName)/\(folderURL.lastPathComponent)", + folderURL: folderURL, + manifest: manifest, + localCoverURL: localCoverURL(folderURL: folderURL, manifest: manifest), + localPageURLs: imageURLs(folderURL: folderURL, manifest: manifest), + modificationDate: resourceValues?.contentModificationDate, + parentFolderName: parentFolderName + ) + } + + public func fileHash(at url: URL) throws -> String { + let handle = try FileHandle(forReadingFrom: url) + defer { try? handle.close() } + + var hasher = SHA256() + while true { + let data = try handle.read(upToCount: 1024 * 1024) + guard let data, !data.isEmpty else { break } + hasher.update(data: data) + } + + let digest = hasher.finalize() + let hex = digest.map { String(format: "%02x", $0) }.joined() + return "sha256:\(hex)" + } + + @discardableResult + public func sanitizeAssetFileIfNeeded(at url: URL) -> Bool { + guard fileManager.operate({ $0.fileExists(atPath: url.path) }) else { return false } + + let attributes: [FileAttributeKey: Any] + do { + attributes = try fileManager.operate { try $0.attributesOfItem(atPath: url.path) } + } catch { + return canReadNonEmptyFile(at: url) + } + + let isRegularFile = (attributes[.type] as? FileAttributeType).map { $0 == .typeRegular } ?? true + guard isRegularFile else { + try? fileManager.operate { try $0.removeItem(at: url) } + return false + } + guard let fileSize = (attributes[.size] as? NSNumber)?.intValue else { return false } + guard fileSize > 0 else { + try? fileManager.operate { try $0.removeItem(at: url) } + return false + } + + return true + } + + private func canReadNonEmptyFile(at url: URL) -> Bool { + do { + let handle = try FileHandle(forReadingFrom: url) + defer { try? handle.close() } + return try handle.read(upToCount: 1)?.isEmpty == false + } catch { + return false + } + } +} + +private extension String { + func truncatedToUTF8ByteCount(_ maximumByteCount: Int) -> String { + guard maximumByteCount > 0 else { return "" } + var byteCount = 0 + var result = "" + for character in self { + let characterByteCount = character.utf8.count + guard byteCount + characterByteCount <= maximumByteCount else { + break + } + result.append(character) + byteCount += characterByteCount + } + return result + } +} diff --git a/AppPackage/Sources/DownloadClient/Extensions/Logger+.swift b/AppPackage/Sources/DownloadClient/Extensions/Logger+.swift new file mode 100644 index 000000000..b52977578 --- /dev/null +++ b/AppPackage/Sources/DownloadClient/Extensions/Logger+.swift @@ -0,0 +1,7 @@ +import OSLogExt + +extension Logger { + init(category: String) { + self.init(moduleName: "DownloadClient", category: category) + } +} diff --git a/AppPackage/Sources/DownloadClient/Extensions/URL+PreviewCacheCleanup.swift b/AppPackage/Sources/DownloadClient/Extensions/URL+PreviewCacheCleanup.swift new file mode 100644 index 000000000..0994927f5 --- /dev/null +++ b/AppPackage/Sources/DownloadClient/Extensions/URL+PreviewCacheCleanup.swift @@ -0,0 +1,15 @@ +import Foundation +import AppModels +import ParserFeature + +extension URL { + public func previewCacheCleanupURLs() -> [URL] { + guard let info = Parser.parsePreviewConfigs(url: self), + info.plainURL != self + else { + return [self] + } + + return [self, info.plainURL] + } +} diff --git a/AppPackage/Sources/DownloadClient/Resources/Localizable.xcstrings b/AppPackage/Sources/DownloadClient/Resources/Localizable.xcstrings new file mode 100644 index 000000000..7df21c808 --- /dev/null +++ b/AppPackage/Sources/DownloadClient/Resources/Localizable.xcstrings @@ -0,0 +1,252 @@ +{ + "sourceLanguage": "en", + "strings": { + "download_store.asset_unreadable": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Asset file is unreadable: %@" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Asset-Datei ist nicht lesbar: %@" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "アセットファイルを読み取れません: %@" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "에셋 파일을 읽을 수 없습니다: %@" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "资源文件无法读取:%@" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "資源檔案無法讀取:%@" + } + } + } + }, + "download_store.download_busy": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "The download is currently active." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Der Download läuft gerade." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ダウンロードは現在進行中です。" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "다운로드가 진행 중입니다." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "该下载正在进行中。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "此下載正在進行中。" + } + } + } + }, + "download_store.download_folder_missing": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Download folder is missing." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Download-Ordner fehlt." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ダウンロードフォルダが見つかりません。" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "다운로드 폴더가 없습니다." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "下载文件夹缺失。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "下載資料夾缺失。" + } + } + } + }, + "download_store.folder_already_exists": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "A folder with this name already exists." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Ein Ordner mit diesem Namen existiert bereits." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "同じ名前のフォルダがすでに存在します。" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "같은 이름의 폴더가 이미 있습니다." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "已存在同名文件夹。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "已存在同名的資料夾。" + } + } + } + }, + "download_store.folder_busy_downloading": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "The folder contains an active download." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Der Ordner enthält einen aktiven Download." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "フォルダに進行中のダウンロードがあります。" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "폴더에 진행 중인 다운로드가 있습니다." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "该文件夹中有正在进行的下载。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "此資料夾中有正在進行的下載。" + } + } + } + }, + "download_store.manifest_missing": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Manifest file is missing." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Manifest-Datei fehlt." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "マニフェストファイルが見つかりません。" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "매니페스트 파일이 없습니다." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "Manifest 文件缺失。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "Manifest 檔案缺失。" + } + } + } + } + }, + "version": "1.0" +} \ No newline at end of file diff --git a/AppPackage/Sources/DownloadsFeature/.swiftlint.yml b/AppPackage/Sources/DownloadsFeature/.swiftlint.yml new file mode 100644 index 000000000..1242ffcaa --- /dev/null +++ b/AppPackage/Sources/DownloadsFeature/.swiftlint.yml @@ -0,0 +1 @@ +parent_config: ../../../.swiftlint.yml diff --git a/AppPackage/Sources/DownloadsFeature/DownloadInspectorReducer.swift b/AppPackage/Sources/DownloadsFeature/DownloadInspectorReducer.swift new file mode 100644 index 000000000..22eb0f17f --- /dev/null +++ b/AppPackage/Sources/DownloadsFeature/DownloadInspectorReducer.swift @@ -0,0 +1,278 @@ +import Foundation +import AppModels +import Resources +import ComposableArchitecture +import DownloadClient +import AppComponents + +@Reducer +public struct DownloadInspectorReducer: Sendable { + private enum CancelID { + case observeDownloads + case loadInspection + } + + @ObservableState + public struct State: Equatable, Sendable { + @SharedReader(.tagTranslator) public var tagTranslator: TagTranslator + @SharedReader(.setting) public var setting: Setting + @Presents public var toast: AppAlertState? + public var gid = "" + public var inspection: DownloadInspection? + public var stableInspection: DownloadInspection? + public var loadingState: LoadingState = .loading + public var inspectionRequestID = UUID() + public var retryingPageIndices = Set() + public var isValidatingImageData = false + + public init(gid: String = "") { + self.gid = gid + loadingState = gid.isEmpty ? .idle : .loading + } + } + + public enum Action: BindableAction { + case binding(BindingAction) + case toast(PresentationAction) + case onAppear + case loadInspection + case loadInspectionDone(UUID, Result) + case observeDownloads + case observeDownloadsDone([DownloadedGallery]) + case retryPages([Int]) + case retryPagesDone(Result) + case toggleDownloadPause + case toggleDownloadPauseDone(Result) + case validateImageData + case validateImageDataDone(DownloadValidationState?) + } + + @Dependency(\.downloadClient) private var downloadClient + + public init() {} + + public var body: some Reducer { + BindingReducer() + + Reduce { state, action in + switch action { + case .binding: + return .none + + case .toast: + return .none + + case .onAppear: + guard !state.gid.isEmpty else { return .none } + return .merge( + .send(.loadInspection), + .send(.observeDownloads) + ) + + case .loadInspection: + guard !state.gid.isEmpty else { return .none } + if state.inspection == nil { + state.loadingState = .loading + } + let requestID = UUID() + state.inspectionRequestID = requestID + return .run { [gid = state.gid] send in + await send(.loadInspectionDone(requestID, .success(try await downloadClient.loadInspection(gid)))) + } catch: { error, send in + await send(.loadInspectionDone(requestID, .failure(AppError(error)))) + } + .cancellable(id: CancelID.loadInspection, cancelInFlight: true) + + case .loadInspectionDone(let requestID, let result): + guard state.inspectionRequestID == requestID else { return .none } + switch result { + case .success(let inspection): + state.stableInspection = inspection + let inspection = state.overlayRetryingPages(in: inspection) + state.inspection = inspection + state.loadingState = .idle + state.retryingPageIndices = state.reconciledRetryingPageIndices( + for: inspection + ) + case .failure(let error): + state.retryingPageIndices = .init() + if let stableInspection = state.stableInspection { + state.inspection = stableInspection + } + state.loadingState = .failed(error) + } + return .none + + case .observeDownloads: + guard !state.gid.isEmpty else { return .none } + return .run { [gid = state.gid] send in + var hadRelevantDownloads = false + for await downloads in downloadClient.observeDownloads() { + let relevantDownloads = downloads.filter { $0.gid == gid } + let hasRelevantDownloads = !relevantDownloads.isEmpty + guard hasRelevantDownloads || hadRelevantDownloads else { continue } + hadRelevantDownloads = hasRelevantDownloads + await send(.observeDownloadsDone(relevantDownloads)) + } + } + .cancellable(id: CancelID.observeDownloads, cancelInFlight: true) + + case .observeDownloadsDone(let downloads): + guard !downloads.isEmpty else { + state.inspection = nil + state.stableInspection = nil + state.retryingPageIndices = .init() + state.loadingState = .idle + return .none + } + guard let latestDownload = downloads.first else { return .none } + let previousDownload = state.inspection?.download + if let inspection = state.inspection, + state.retryingPageIndices.isEmpty || state.shouldKeepRetryPending(for: latestDownload) { + state.inspection = state.overlayRetryingPages(in: .init( + download: latestDownload, + coverURL: inspection.coverURL, + pages: inspection.pages + )) + } + guard previousDownload != latestDownload else { return .none } + return .send(.loadInspection) + + case .retryPages(let indices): + let retryingPageIndices = Set(indices) + let pageIndices = retryingPageIndices.sorted() + guard !state.gid.isEmpty, !pageIndices.isEmpty else { return .none } + state.inspectionRequestID = UUID() + state.retryingPageIndices.formUnion(retryingPageIndices) + state.stableInspection = state.inspection ?? state.stableInspection + if let inspection = state.inspection { + state.inspection = .init( + download: inspection.download, + coverURL: inspection.coverURL, + pages: inspection.pages.map { page in + guard retryingPageIndices.contains(page.index) else { return page } + return .init( + index: page.index, + status: .pending, + relativePath: page.relativePath, + fileURL: nil, + failure: nil + ) + } + ) + } + return .merge( + .cancel(id: CancelID.loadInspection), + .run { [gid = state.gid] send in + try await downloadClient.retryPages(gid, pageIndices) + await send(.retryPagesDone(.success(()))) + } catch: { error, send in + await send(.retryPagesDone(.failure(AppError(error)))) + } + ) + + case .retryPagesDone(let result): + if case .failure = result { + state.retryingPageIndices = .init() + return .send(.loadInspection) + } + return .none + + case .toggleDownloadPause: + guard let download = state.inspection?.download, + download.canTogglePause + else { return .none } + return .run { send in + try await downloadClient.togglePause(download.gid) + await send(.toggleDownloadPauseDone(.success(()))) + } catch: { error, send in + await send(.toggleDownloadPauseDone(.failure(AppError(error)))) + } + + case .toggleDownloadPauseDone(let result): + if case .failure = result { + return .send(.loadInspection) + } + return .none + + case .validateImageData: + guard !state.gid.isEmpty, + state.inspection?.canValidateImageData == true, + !state.isValidatingImageData + else { return .none } + state.isValidatingImageData = true + return .run { [gid = state.gid] send in + await send(.validateImageDataDone(await downloadClient.validateImageData(gid))) + } + + case .validateImageDataDone(let validation): + state.isValidatingImageData = false + state.toast = validation.toastConfig + return .send(.loadInspection) + } + } + .ifLet(\.$toast, action: \.toast) + } +} + +private extension Optional where Wrapped == DownloadValidationState { + var toastConfig: AppAlertState { + switch self { + case .some(.valid): + return .success(caption: .imageDataValid) + + case .some(.missingFiles(let message)): + return .error(caption: message) + + case nil: + return .error(caption: .imageDataUnavailable) + } + } +} + +extension DownloadInspectorReducer.State { + func shouldKeepRetryPending(for download: DownloadedGallery) -> Bool { + download.canPauseOrResume + || download.isQueuedWorkItem + || ( + [.inactive, .error].contains(download.displayStatus) + && download.isIncomplete + && download.lastError == nil + ) + } + + func overlayRetryingPages(in inspection: DownloadInspection) -> DownloadInspection { + guard !retryingPageIndices.isEmpty else { return inspection } + + guard shouldKeepRetryPending(for: inspection.download) else { return inspection } + + return .init( + download: inspection.download, + coverURL: inspection.coverURL, + pages: inspection.pages.map { page in + guard retryingPageIndices.contains(page.index), + page.status != .downloaded + else { + return page + } + return .init( + index: page.index, + status: .pending, + relativePath: page.relativePath, + fileURL: page.fileURL, + failure: nil + ) + } + ) + } + + func reconciledRetryingPageIndices(for inspection: DownloadInspection) -> Set { + guard !retryingPageIndices.isEmpty else { return .init() } + + guard shouldKeepRetryPending(for: inspection.download) else { return .init() } + + return retryingPageIndices.filter { index in + inspection.pages.first(where: { $0.index == index })?.status != .downloaded + } + } +} diff --git a/AppPackage/Sources/DownloadsFeature/DownloadsReducer.swift b/AppPackage/Sources/DownloadsFeature/DownloadsReducer.swift new file mode 100644 index 000000000..a685ca23e --- /dev/null +++ b/AppPackage/Sources/DownloadsFeature/DownloadsReducer.swift @@ -0,0 +1,366 @@ +import Foundation +import AppModels +import Resources +import ComposableArchitecture +import AppComponents +import AppTools +import DeviceClient +import DownloadClient +import ReadingFeature +import DetailFeature + +@Reducer +public struct DownloadsReducer: Sendable { + public enum Delegate: Equatable, Sendable { + case presentGalleryDetail(Gallery, DownloadedGallery?) + } + + @Reducer + public enum Destination { + case inspector(DownloadInspectorReducer) + case reading(ReadingReducer) + case folderManager(FolderManagerReducer) + } + + public enum Alert: Equatable, Sendable { + case confirmDelete(String) + } + + public enum Dialog: Equatable, Sendable { + case move(String, String) + } + + private enum CancelID { + case observeDownloads + case fetchFolders + } + + @ObservableState + public struct State: Equatable { + @SharedReader(.setting) public var setting: Setting + public var path = StackState() + @Presents public var destination: Destination.State? + @Presents public var alert: AppAlertState? + @Presents public var confirmationDialog: ConfirmationDialogState? + public var keyword = "" + public var folderFilter: DownloadFolderFilter = .all + public var folders = [String]() + public var downloads = [DownloadedGallery]() + public var loadingState: LoadingState = .loading + public var hasLoadedInitialDownloads = false + + public var readingRequestID = UUID() + + public init() {} + + var filteredDownloads: [DownloadedGallery] { + downloads.filter { + $0.matches(folderFilter: folderFilter) + && ( + keyword.isEmpty + || $0.searchableText.caseInsensitiveContains(keyword) + ) + } + } + } + + public enum Action: BindableAction { + case binding(BindingAction) + case delegate(Delegate) + case galleryTapped(String) + case pushGalleryDetail(DownloadedGallery) + case path(StackActionOf) + case destination(PresentationAction) + case inspectorButtonTapped(String) + case folderManagerButtonTapped + case alert(PresentationAction) + case confirmationDialog(PresentationAction) + case deleteDownloadButtonTapped(DownloadedGallery) + case moveButtonTapped(DownloadedGallery) + + case onAppear + case fetchDownloads + case fetchDownloadsDone([DownloadedGallery]) + case observeDownloads + case observeDownloadsDone([DownloadedGallery]) + case refreshDownloads + case refreshDownloadsDone + case fetchFolders + case fetchFoldersDone([String]) + case moveDownload(String, String) + case moveDownloadDone(Result) + case openReading(String) + case openReadingDone(UUID, String, Result<(DownloadedGallery, DownloadManifest), AppError>) + case toggleDownloadPause(String) + case toggleDownloadPauseDone(Result) + case updateDownload(String) + case updateDownloadDone(Result) + case deleteDownload(String) + case deleteDownloadDone(Result) + } + + @Dependency(\.downloadClient) private var downloadClient + @Dependency(\.deviceClient) private var deviceClient + + public init() {} + + public var body: some Reducer { + BindingReducer() + + Reduce { state, action in + switch action { + case .binding: + return .none + + case .galleryTapped(let gid): + // Bail if the download vanished between the observation update and this tap; routing to + // detail with `.empty` would write an unresolvable (random-gid) history entry. + guard let download = state.downloads.first(where: { $0.gid == gid }) else { return .none } + return GalleryNavigation.routeGalleryDetail( + isPad: deviceClient.isPad, + present: { .delegate(.presentGalleryDetail(download.gallery, download)) }, + push: { .pushGalleryDetail(download) } + ) + + case .pushGalleryDetail(let download): + // Seed the detail with the locally downloaded gallery/badge so it renders offline. + // The download is carried through the action, so there is no re-lookup by gid. + state.path.appendGuardingDuplicate(.detail(.init(seededFrom: download))) + return .none + + case .delegate: + return .none + + case .inspectorButtonTapped(let gid): + state.destination = .inspector(.init(gid: gid)) + return .none + + case .folderManagerButtonTapped: + state.destination = .folderManager(.init()) + return .none + + case .deleteDownloadButtonTapped(let download): + state.alert = AppAlertState { + TextState(localized: .RLocalizable.deleteDownload) + } actions: { + ButtonState(role: .destructive, action: .confirmDelete(download.gid)) { + TextState(localized: .RLocalizable.delete) + } + ButtonState(role: .cancel) { + TextState(localized: .RLocalizable.cancel) + } + } message: { + TextState( + localized: download.canTogglePause + ? .deleteActiveDownload + : .RLocalizable.deleteDownloadedGallery + ) + } + return .none + + case .moveButtonTapped(let download): + let destinations = state.folders.filter { $0 != download.folderName } + state.confirmationDialog = ConfirmationDialogState { + TextState(localized: .moveToFolder) + } actions: { + for folder in destinations { + ButtonState(action: .move(download.gid, folder)) { + TextState(folder) + } + } + ButtonState(role: .cancel) { + TextState(localized: .RLocalizable.cancel) + } + } + return .none + + case .alert(.presented(.confirmDelete(let gid))): + return .send(.deleteDownload(gid)) + + case .alert: + return .none + + case .confirmationDialog(.presented(.move(let gid, let folder))): + return .send(.moveDownload(gid, folder)) + + case .confirmationDialog: + return .none + + case .onAppear: + guard !state.hasLoadedInitialDownloads else { return .send(.fetchFolders) } + state.hasLoadedInitialDownloads = true + return .merge( + .send(.fetchDownloads), + .send(.observeDownloads), + .send(.fetchFolders) + ) + + case .fetchDownloads: + state.loadingState = .loading + return .run { send in + await send(.fetchDownloadsDone(try await downloadClient.fetchDownloads())) + } + + case .fetchDownloadsDone(let downloads), .observeDownloadsDone(let downloads): + guard state.downloads != downloads || state.loadingState != .idle else { + return .none + } + state.downloads = downloads + state.loadingState = .idle + return .none + + case .observeDownloads: + return .run { send in + for await downloads in downloadClient.observeDownloads() { + await send(.observeDownloadsDone(downloads)) + } + } + .cancellable(id: CancelID.observeDownloads, cancelInFlight: true) + + case .refreshDownloads: + return .run { send in + await downloadClient.refreshDownloads() + await send(.refreshDownloadsDone) + } + + case .refreshDownloadsDone: + return .send(.fetchFolders) + + case .fetchFolders: + return .run { send in + await send(.fetchFoldersDone(try await downloadClient.fetchFolders())) + } + .cancellable(id: CancelID.fetchFolders, cancelInFlight: true) + + case .fetchFoldersDone(let folders): + state.folders = folders + if case .folder(let name) = state.folderFilter, + !folders.contains(name) { + state.folderFilter = .all + } + return .none + + case .moveDownload(let gid, let folderName): + return .run { send in + try await downloadClient.moveDownload(gid, folderName) + await send(.moveDownloadDone(.success(()))) + } catch: { error, send in + await send(.moveDownloadDone(.failure(AppError(error)))) + } + + case .moveDownloadDone(let result): + if case .success = result { + return .send(.fetchFolders) + } + return .none + + case .openReading(let gid): + let requestID = UUID() + state.readingRequestID = requestID + return .run { send in + await send( + .openReadingDone( + requestID, + gid, + .success(try await downloadClient.loadManifest(gid)) + ) + ) + } catch: { error, send in + await send(.openReadingDone(requestID, gid, .failure(AppError(error)))) + } + + case .openReadingDone(let requestID, let gid, let result): + guard state.readingRequestID == requestID else { return .none } + var readingState: ReadingReducer.State + if case .success(let (download, manifest)) = result { + readingState = .init(gallery: download.gallery, contentSource: .local(download, manifest)) + } else { + // Local load failed; fall back to remote — but only if the download record is still + // around to seed the reader. If it vanished mid-flight there's nothing to open, so + // bail rather than push a blank (`.empty`) reader. + guard let download = state.downloads.first(where: { $0.gid == gid }) else { return .none } + readingState = .init(gallery: download.gallery, contentSource: .remote) + // A downloaded gallery has no persisted detail, so fall back to a language-agnostic + // Live Text hint rather than leaving it unset. + readingState.language = .other + } + state.destination = .reading(readingState) + return .none + + case .toggleDownloadPause(let gid): + return .run { send in + try await downloadClient.togglePause(gid) + await send(.toggleDownloadPauseDone(.success(()))) + } catch: { error, send in + await send(.toggleDownloadPauseDone(.failure(AppError(error)))) + } + + case .toggleDownloadPauseDone: + return .none + + case .updateDownload(let gid): + return .run { send in + try await downloadClient.retry(gid, .update) + await send(.updateDownloadDone(.success(()))) + } catch: { error, send in + await send(.updateDownloadDone(.failure(AppError(error)))) + } + + // List-level mutations don't surface a per-op toast: the `observeDownloads` stream is the + // user-facing feedback from the DES-3 write-through index. Failures leave the current + // observed state in place; the download client performs any targeted surprise repair. + case .updateDownloadDone: + return .none + + case .deleteDownload(let gid): + return .run { send in + try await downloadClient.delete(gid) + await send(.deleteDownloadDone(.success(()))) + } catch: { error, send in + await send(.deleteDownloadDone(.failure(AppError(error)))) + } + + case .deleteDownloadDone: + return .none + + case let .path(.element(id: _, action: .detail(.destination(.presented(.folderManager(action)))))): + switch action { + case .createFolderDone, .renameFolderDone, .deleteFolderDone: + return .send(.fetchFolders) + default: + return .none + } + + case let .path(.element(id: _, action: .comments(.delegate(.performedCommentAction(gid))))): + guard let id = state.path.detailID(forGID: gid) else { return .none } + return .send(.path(.element(id: id, action: .detail(.fetchGalleryDetail)))) + + case let .path(.element(id: _, action: elementAction)): + if let next = GalleryNavigation.nextScreen(for: elementAction) { + state.path.appendGuardingDuplicate(next) + } + return .none + + case .path: + return .none + + case .destination(.presented(.reading(.onPerformDismiss))): + return .send(.destination(.dismiss)) + + case .destination(.presented(.folderManager(.createFolderDone))), + .destination(.presented(.folderManager(.renameFolderDone))), + .destination(.presented(.folderManager(.deleteFolderDone))): + return .send(.fetchFolders) + + case .destination: + return .none + } + } + .ifLet(\.$destination, action: \.destination) + .ifLet(\.$alert, action: \.alert) + .ifLet(\.$confirmationDialog, action: \.confirmationDialog) + .forEach(\.path, action: \.path) + } +} + +extension DownloadsReducer.Destination.State: Equatable {} diff --git a/AppPackage/Sources/DownloadsFeature/DownloadsView+Subviews.swift b/AppPackage/Sources/DownloadsFeature/DownloadsView+Subviews.swift new file mode 100644 index 000000000..c7dc65d34 --- /dev/null +++ b/AppPackage/Sources/DownloadsFeature/DownloadsView+Subviews.swift @@ -0,0 +1,313 @@ +import SwiftUI +import AppModels +import TagTranslationFeature +import Resources +import SFSafeSymbols +import ComposableArchitecture +import SystemNotificationExt +import AppComponents +import GalleryListComponents + +struct DownloadInspectorView: View { + @Environment(\.dismiss) private var dismiss + @Environment(\.accessibilityReduceMotion) private var reduceMotion + + @Bindable private var store: StoreOf + private let blurRadius: Double + + init( + store: StoreOf, + blurRadius: Double + ) { + self.store = store + self.blurRadius = blurRadius + } + + var body: some View { + Group { + switch store.loadingState { + case .loading where store.inspection == nil: + LoadingView() + + case .failed(let error) where store.inspection == nil: + ErrorView(error: error, action: { store.send(.loadInspection) }) + + default: + List { + if let inspection = store.inspection { + Section { + GalleryDetailCell( + gallery: inspection.download.gallery, + coverSource: .static(inspection.coverURL), + translateAction: { + store.tagTranslator.lookup( + word: $0, + returnOriginal: !store.setting.translatesTags + ) + }, + downloadBadge: inspection.download.badge + ) + .listRowInsets(.init(top: 10, leading: 10, bottom: 10, trailing: 10)) + .listRowBackground(Color.clear) + } + + Section { + ForEach(DownloadPageStatus.inspectorSummaryOrder, id: \.self) { status in + let pages = inspection.pages.filter { $0.status == status } + DownloadInspectorPageGroupRow( + status: status, + pages: pages + ) + } + } + + let isPauseResumeDisabled = !inspection.download.canTogglePause + let isRetryFailedPagesDisabled = !inspection.canRetryFailedPages + let isValidateImageDataDisabled = + !inspection.canValidateImageData || store.isValidatingImageData + Section(.actions) { + Button { + store.send(.toggleDownloadPause) + } label: { + Label( + inspection.download.inspectorPauseResumeTitle, + systemSymbol: inspection.download.inspectorPauseResumeSymbol + ) + .disabledActionForegroundStyle(isPauseResumeDisabled) + } + .disabled(isPauseResumeDisabled) + + Button { + store.send(.retryPages(inspection.failedPageIndices)) + } label: { + Label( + .retryFailedPages, + systemSymbol: .arrowClockwise + ) + .disabledActionForegroundStyle(isRetryFailedPagesDisabled) + } + .disabled(isRetryFailedPagesDisabled) + + Button { + store.send(.validateImageData) + } label: { + DownloadInspectorValidationActionLabel( + isValidating: store.isValidatingImageData, + isDisabled: isValidateImageDataDisabled, + reduceMotion: reduceMotion + ) + } + .disabled(isValidateImageDataDisabled) + } + } + } + .listStyle(.insetGrouped) + } + } + .autoBlur(radius: blurRadius) + .toast($store.scope(state: \.toast, action: \.toast)) + .navigationTitle(.downloadStatus) + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button(role: .close, action: dismiss.callAsFunction) + } + } + .onAppear { + store.send(.onAppear) + } + } +} + +private struct DownloadInspectorValidationActionLabel: View { + let isValidating: Bool + let isDisabled: Bool + let reduceMotion: Bool + + private var title: LocalizedStringResource { + isValidating + ? .validatingImageData + : .validateImageData + } + + private var progressAnimation: Animation? { + reduceMotion ? nil : .easeInOut(duration: 0.2) + } + + var body: some View { + HStack { + Label(title, systemSymbol: .checkmarkShield) + Spacer(minLength: 12) + ZStack { + if isValidating { + ProgressView() + .controlSize(.small) + .transition( + .opacity.combined(with: .scale(scale: 0.85)) + ) + } + } + .frame(width: 20, height: 20) + } + .disabledActionForegroundStyle(isDisabled) + .animation(progressAnimation, value: isValidating) + } +} + +struct DownloadInspectorPageGroupRow: View { + @Environment(\.accessibilityReduceMotion) private var reduceMotion + + let status: DownloadPageStatus + let pages: [DownloadPageInspection] + + private var countAnimation: Animation? { + reduceMotion ? nil : .easeInOut(duration: 0.2) + } + + private var pageNumbersText: String { + let indices = pages.map(\.index).sorted() + guard !indices.isEmpty else { + return String(localized: .noPages) + } + return Self.formattedPageRanges(indices) + } + + var body: some View { + HStack(alignment: .top, spacing: 12) { + Image(systemSymbol: status.symbol) + .foregroundStyle(status.tintColor) + .font(.title3) + .labelReservedIconWidth(24) + + VStack(alignment: .leading, spacing: 3) { + Text(status.summaryTitle(count: pages.count)) + .font(.body.weight(.medium)) + .monospacedDigit() + .contentTransition(.numericText()) + .animation(countAnimation, value: pages.count) + + Text(pageNumbersText) + .font(.callout) + .monospacedDigit() + .contentTransition(.numericText()) + .foregroundStyle(pages.isEmpty ? .secondary : .primary) + .lineLimit(nil) + .textSelection(.enabled) + .animation(countAnimation, value: pageNumbersText) + } + } + .padding(.vertical, 4) + .accessibilityElement(children: .combine) + } + + private static func formattedPageRanges(_ indices: [Int]) -> String { + var ranges = [String]() + var rangeStart: Int? + var previous: Int? + + func appendCurrentRange() { + guard let start = rangeStart, + let end = previous + else { return } + ranges.append(start == end ? "\(start)" : "\(start)-\(end)") + } + + for index in indices { + if let last = previous, index == last + 1 { + previous = index + continue + } + appendCurrentRange() + rangeStart = index + previous = index + } + appendCurrentRange() + + return ranges.joined(separator: ", ") + } +} + +private extension DownloadPageStatus { + static let inspectorSummaryOrder: [Self] = [ + .downloaded, + .pending, + .failed + ] + + func summaryTitle(count: Int) -> LocalizedStringResource { + switch self { + case .pending: + return .pending(count: count) + case .downloaded: + return .downloaded(count: count) + case .failed: + return .failed(count: count) + } + } + + var symbol: SFSymbol { + switch self { + case .pending: .clock + case .downloaded: .checkmarkCircle + case .failed: .exclamationmarkCircle + } + } + + var tintColor: Color { + switch self { + case .pending: .primary + case .downloaded: .green + case .failed: .red + } + } +} + +private extension DownloadedGallery { + var inspectorPauseResumeTitle: LocalizedStringResource { + displayStatus == .inactive + ? .resume + : .pause + } + + var inspectorPauseResumeSymbol: SFSymbol { + displayStatus == .inactive ? .playFill : .pauseFill + } +} + +private extension View { + @ViewBuilder + func disabledActionForegroundStyle(_ isDisabled: Bool) -> some View { + if isDisabled { + foregroundStyle(.secondary) + } else { + self + } + } +} + +struct DownloadListRow: View { + @SharedReader(.tagTranslator) private var tagTranslator: TagTranslator + @SharedReader(.setting) private var setting: Setting + let download: DownloadedGallery + let openAction: () -> Void + + var body: some View { + HStack(spacing: 0) { + GalleryDetailCell( + gallery: download.gallery, + coverSource: .static(download.coverURL), + translateAction: { + tagTranslator.lookup(word: $0, returnOriginal: !setting.translatesTags) + }, + downloadBadge: download.badge + ) + .allowsHitTesting(false) + Spacer(minLength: 0) + } + .frame(maxWidth: .infinity, alignment: .leading) + .contentShape(.rect) + .onTapGesture(perform: openAction) + .accessibilityAddTraits(.isButton) + .accessibilityLabel(download.title) + } +} diff --git a/AppPackage/Sources/DownloadsFeature/DownloadsView.swift b/AppPackage/Sources/DownloadsFeature/DownloadsView.swift new file mode 100644 index 000000000..03fe3ba3c --- /dev/null +++ b/AppPackage/Sources/DownloadsFeature/DownloadsView.swift @@ -0,0 +1,327 @@ +import SwiftUI +import AppModels +import Resources +import SFSafeSymbols +import ComposableArchitecture +import AppTools +import AppComponents +import ReadingFeature +import DetailFeature +import SFSafeSymbolsExt + +public struct DownloadsView: View { + @Bindable private var store: StoreOf + private let blurRadius: Double + + public init( + store: StoreOf, + blurRadius: Double + ) { + self.store = store + self.blurRadius = blurRadius + } + + public var body: some View { + GalleryNavigationContainer( + store: store, + state: \.path, + action: \.path, + blurRadius: blurRadius + ) { + contentView + } + } + + private var contentView: some View { + let showsEmptyState = store.loadingState == .idle && store.filteredDownloads.isEmpty + return ZStack { + Color(.systemGroupedBackground) + .ignoresSafeArea() + + downloadsList + .allowsHitTesting(!showsEmptyState) + + if showsEmptyState { + VStack { + Spacer() + emptyStateView + Spacer() + } + .padding(.horizontal, 24) + } + } + .searchable( + text: $store.keyword, + placement: .navigationBarDrawer(displayMode: .automatic), + prompt: .searchDownloads + ) + .sheet( + item: $store.scope(state: \.destination?.inspector, action: \.destination.inspector) + ) { store in + NavigationStack { + DownloadInspectorView( + store: store, + blurRadius: blurRadius + ) + } + .autoBlur(radius: blurRadius) + } + .sheet( + item: $store.scope(state: \.destination?.folderManager, action: \.destination.folderManager) + ) { folderStore in + FolderManagerView(store: folderStore) + .accentColor(store.setting.accentColor) + .autoBlur(radius: blurRadius) + } + .fullScreenCover( + item: $store.scope(state: \.destination?.reading, action: \.destination.reading) + ) { store in + ReadingView( + store: store, + gid: store.gallery.id, + blurRadius: blurRadius + ) + .accentColor(store.setting.accentColor) + .autoBlur(radius: blurRadius) + } + .onAppear { + store.send(.onAppear) + } + .appAlert($store.scope(state: \.alert, action: \.alert)) + .confirmationDialog( + $store.scope(state: \.confirmationDialog, action: \.confirmationDialog) + ) + .navigationTitle(.RLocalizable.downloads) + .navigationBarTitleDisplayMode(.large) + .toolbar(content: toolbar) + } + +} + +// MARK: Subviews +private extension DownloadsView { + @ViewBuilder private var downloadsList: some View { + switch store.loadingState { + case .loading where store.downloads.isEmpty: + LoadingView() + + case .failed(let error) where store.downloads.isEmpty: + ErrorView(error: error, action: { store.send(.fetchDownloads) }) + + default: + List { + ForEach(store.filteredDownloads) { download in + DownloadListRow( + download: download + ) { + store.send(.openReading(download.gid)) + } + .contextMenu { + downloadContextMenu(download) + } + .swipeActions(edge: .leading, allowsFullSwipe: false) { + Button { + store.send(.inspectorButtonTapped(download.gid)) + } label: { + Label( + .inspectPages, + systemSymbol: .listBulletRectanglePortrait + ) + } + .tint(store.setting.accentColor) + + if canMove(download) { + Button { + store.send(.moveButtonTapped(download)) + } label: { + Label( + .move, + systemSymbol: .folder + ) + } + .tint(.teal) + } + } + .swipeActions(edge: .trailing, allowsFullSwipe: false) { + if download.canTriggerUpdate { + Button { + store.send(.updateDownload(download.gid)) + } label: { + Label( + .RLocalizable.update, + systemSymbol: .arrowTrianglehead2ClockwiseRotate90 + ) + } + .tint(.orange) + } + + if download.canTogglePause { + Button { + store.send(.toggleDownloadPause(download.gid)) + } label: { + Label( + download.displayStatus == .inactive + ? .resume + : .pause, + systemSymbol: download.displayStatus == .inactive + ? .playFill + : .pauseFill + ) + } + .tint(download.displayStatus == .inactive ? .green : .indigo) + } + + Button(role: .destructive) { + store.send(.deleteDownloadButtonTapped(download)) + } label: { + Label(.RLocalizable.delete, systemSymbol: .trash) + } + } + } + } + .refreshable { store.send(.refreshDownloads) } + } + } + + @ViewBuilder private func downloadContextMenu(_ download: DownloadedGallery) -> some View { + Button { + store.send(.galleryTapped(download.gid)) + } label: { + Label( + .RLocalizable.detail, + systemSymbol: .infoCircle + ) + } + + Button { + store.send(.inspectorButtonTapped(download.gid)) + } label: { + Label( + .inspectPages, + systemSymbol: .listBulletRectanglePortrait + ) + } + + if canMove(download) { + Menu { + ForEach(moveDestinations(for: download), id: \.self) { folder in + Button(folder) { + store.send(.moveDownload(download.gid, folder)) + } + } + } label: { + Label( + .moveToFolder, + systemSymbol: .folder + ) + } + } + + if download.canTriggerUpdate { + Button { + store.send(.updateDownload(download.gid)) + } label: { + Label( + .RLocalizable.update, + systemSymbol: .arrowTrianglehead2ClockwiseRotate90 + ) + } + } + + if download.canTogglePause { + Button { + store.send(.toggleDownloadPause(download.gid)) + } label: { + Label( + download.displayStatus == .inactive + ? .resume + : .pause, + systemSymbol: download.displayStatus == .inactive + ? .playFill + : .pauseFill + ) + } + } + + Button(role: .destructive) { + store.send(.deleteDownloadButtonTapped(download)) + } label: { + Label(.RLocalizable.delete, systemSymbol: .trash) + } + } + + @ViewBuilder private var emptyStateView: some View { + if store.downloads.isEmpty { + AlertView( + symbol: .squareAndArrowDown, + message: .emptyDownloads + ) { + EmptyView() + } + } else { + AlertView( + symbol: .line3HorizontalDecreaseCircle, + message: .noMatchingFilters + ) { + AlertViewButton(title: .clearFilters) { + store.keyword = "" + store.folderFilter = .all + } + } + } + } + + private func canMove(_ download: DownloadedGallery) -> Bool { + download.displayStatus != .active && !moveDestinations(for: download).isEmpty + } + + private func moveDestinations(for download: DownloadedGallery) -> [String] { + store.folders.filter { $0 != download.folderName } + } + + @ToolbarContentBuilder private func toolbar() -> some ToolbarContent { + CustomToolbarItem { + Menu { + Section { + Button { + store.send(.folderManagerButtonTapped) + } label: { + Label( + .RLocalizable.manageFolders, + systemSymbol: .folderBadgeGearshape + ) + } + } + Section { + folderFilterButton(.all) + ForEach(store.folders, id: \.self) { folder in + folderFilterButton(.folder(folder)) + } + } + } label: { + Image(systemSymbol: .dialLow) + .symbolRenderingMode(.hierarchical) + } + } + } + + private func folderFilterButton(_ filter: DownloadFolderFilter) -> some View { + Button { + store.folderFilter = filter + } label: { + Text(filter.title) + if store.folderFilter == filter { + Image(systemSymbol: .checkmark) + } + } + } +} + +struct DownloadsView_Previews: PreviewProvider { + static var previews: some View { + DownloadsView( + store: .init(initialState: .init(), reducer: DownloadsReducer.init), + blurRadius: 0 + ) + } +} diff --git a/AppPackage/Sources/DownloadsFeature/Resources/Localizable.xcstrings b/AppPackage/Sources/DownloadsFeature/Resources/Localizable.xcstrings new file mode 100644 index 000000000..198ce674f --- /dev/null +++ b/AppPackage/Sources/DownloadsFeature/Resources/Localizable.xcstrings @@ -0,0 +1,1155 @@ +{ + "sourceLanguage": "en", + "strings": { + "actions": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Actions" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Aktionen" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "操作" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "동작" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "操作" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "操作" + } + } + } + }, + "clear_filters": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Clear Filters" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Filter löschen" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "フィルターをクリア" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "필터 지우기" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "清除筛选" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "清除篩選" + } + } + } + }, + "delete_active_download": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "This will cancel the current download and remove it from this device." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Der aktuelle Download wird abgebrochen und von diesem Gerät entfernt." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "現在のダウンロードをキャンセルし、このデバイスから削除します。" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "현재 다운로드를 취소하고 이 기기에서 삭제합니다." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "这将取消当前下载并从此设备移除它。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "這將取消目前下載並從此裝置移除它。" + } + } + } + }, + "download_status": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Download Status" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Downloadstatus" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ダウンロード状況" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "다운로드 상태" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "下载状态" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "下載狀態" + } + } + } + }, + "downloaded": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Downloaded (%#@count@)" + }, + "substitutions": { + "count": { + "argNum": 1, + "formatSpecifier": "lld", + "variations": { + "plural": { + "other": { + "stringUnit": { + "state": "translated", + "value": "%arg" + } + } + } + } + } + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Heruntergeladen (%#@count@)" + }, + "substitutions": { + "count": { + "argNum": 1, + "formatSpecifier": "lld", + "variations": { + "plural": { + "other": { + "stringUnit": { + "state": "translated", + "value": "%arg" + } + } + } + } + } + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ダウンロード済み (%#@count@)" + }, + "substitutions": { + "count": { + "argNum": 1, + "formatSpecifier": "lld", + "variations": { + "plural": { + "other": { + "stringUnit": { + "state": "translated", + "value": "%arg" + } + } + } + } + } + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "다운로드됨 (%#@count@)" + }, + "substitutions": { + "count": { + "argNum": 1, + "formatSpecifier": "lld", + "variations": { + "plural": { + "other": { + "stringUnit": { + "state": "translated", + "value": "%arg" + } + } + } + } + } + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "已下载 (%#@count@)" + }, + "substitutions": { + "count": { + "argNum": 1, + "formatSpecifier": "lld", + "variations": { + "plural": { + "other": { + "stringUnit": { + "state": "translated", + "value": "%arg" + } + } + } + } + } + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "已下載 (%#@count@)" + }, + "substitutions": { + "count": { + "argNum": 1, + "formatSpecifier": "lld", + "variations": { + "plural": { + "other": { + "stringUnit": { + "state": "translated", + "value": "%arg" + } + } + } + } + } + } + } + } + }, + "empty_downloads": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Downloaded galleries will appear here." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Heruntergeladene Galerien werden hier angezeigt." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ダウンロードしたギャラリーはここに表示されます。" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "다운로드한 갤러리가 여기에 표시됩니다." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "已下载的画廊会显示在这里。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "已下載的畫廊會顯示在這裡。" + } + } + } + }, + "failed": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Failed (%#@count@)" + }, + "substitutions": { + "count": { + "argNum": 1, + "formatSpecifier": "lld", + "variations": { + "plural": { + "other": { + "stringUnit": { + "state": "translated", + "value": "%arg" + } + } + } + } + } + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Fehlgeschlagen (%#@count@)" + }, + "substitutions": { + "count": { + "argNum": 1, + "formatSpecifier": "lld", + "variations": { + "plural": { + "other": { + "stringUnit": { + "state": "translated", + "value": "%arg" + } + } + } + } + } + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "失敗 (%#@count@)" + }, + "substitutions": { + "count": { + "argNum": 1, + "formatSpecifier": "lld", + "variations": { + "plural": { + "other": { + "stringUnit": { + "state": "translated", + "value": "%arg" + } + } + } + } + } + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "실패 (%#@count@)" + }, + "substitutions": { + "count": { + "argNum": 1, + "formatSpecifier": "lld", + "variations": { + "plural": { + "other": { + "stringUnit": { + "state": "translated", + "value": "%arg" + } + } + } + } + } + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "失败 (%#@count@)" + }, + "substitutions": { + "count": { + "argNum": 1, + "formatSpecifier": "lld", + "variations": { + "plural": { + "other": { + "stringUnit": { + "state": "translated", + "value": "%arg" + } + } + } + } + } + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "失敗 (%#@count@)" + }, + "substitutions": { + "count": { + "argNum": 1, + "formatSpecifier": "lld", + "variations": { + "plural": { + "other": { + "stringUnit": { + "state": "translated", + "value": "%arg" + } + } + } + } + } + } + } + } + }, + "image_data_unavailable": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Image data could not be validated." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Bilddaten konnten nicht geprüft werden." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "画像データを検証できませんでした。" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "이미지 데이터를 검증할 수 없습니다." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "无法验证图像数据。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "無法驗證圖片資料。" + } + } + } + }, + "image_data_valid": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Image data is valid" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Bilddaten sind gültig" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "画像データは有効です" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "이미지 데이터가 유효합니다" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "图像数据有效" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "圖片資料有效" + } + } + } + }, + "inspect_pages": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Pages" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Seiten" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ページ" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "페이지" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "页面" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "頁面" + } + } + } + }, + "move": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Move" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Verschieben" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "移動" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "이동" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "移动" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "移動" + } + } + } + }, + "move_to_folder": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Move to Folder" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "In Ordner verschieben" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "フォルダに移動" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "폴더로 이동" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "移动到文件夹" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "移到資料夾" + } + } + } + }, + "no_matching_filters": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "No downloads match the current filters." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Keine Downloads entsprechen den aktuellen Filtern." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "現在のフィルターに一致するダウンロードはありません。" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "현재 필터와 일치하는 다운로드가 없습니다." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "没有下载项符合当前筛选条件。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "沒有下載項符合目前的篩選條件。" + } + } + } + }, + "no_pages": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "No pages" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Keine Seiten" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ページなし" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "페이지 없음" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "无页面" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "沒有頁面" + } + } + } + }, + "pause": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Pause" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Pausieren" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "一時停止" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "일시 정지" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "暂停" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "暫停" + } + } + } + }, + "pending": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Pending (%#@count@)" + }, + "substitutions": { + "count": { + "argNum": 1, + "formatSpecifier": "lld", + "variations": { + "plural": { + "other": { + "stringUnit": { + "state": "translated", + "value": "%arg" + } + } + } + } + } + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Ausstehend (%#@count@)" + }, + "substitutions": { + "count": { + "argNum": 1, + "formatSpecifier": "lld", + "variations": { + "plural": { + "other": { + "stringUnit": { + "state": "translated", + "value": "%arg" + } + } + } + } + } + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "待機中 (%#@count@)" + }, + "substitutions": { + "count": { + "argNum": 1, + "formatSpecifier": "lld", + "variations": { + "plural": { + "other": { + "stringUnit": { + "state": "translated", + "value": "%arg" + } + } + } + } + } + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "대기 중 (%#@count@)" + }, + "substitutions": { + "count": { + "argNum": 1, + "formatSpecifier": "lld", + "variations": { + "plural": { + "other": { + "stringUnit": { + "state": "translated", + "value": "%arg" + } + } + } + } + } + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "等待中 (%#@count@)" + }, + "substitutions": { + "count": { + "argNum": 1, + "formatSpecifier": "lld", + "variations": { + "plural": { + "other": { + "stringUnit": { + "state": "translated", + "value": "%arg" + } + } + } + } + } + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "等待中 (%#@count@)" + }, + "substitutions": { + "count": { + "argNum": 1, + "formatSpecifier": "lld", + "variations": { + "plural": { + "other": { + "stringUnit": { + "state": "translated", + "value": "%arg" + } + } + } + } + } + } + } + } + }, + "resume": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Resume" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Fortsetzen" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "再開" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "재개" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "继续" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "繼續" + } + } + } + }, + "retry_failed_pages": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Retry Failed Pages" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Fehlgeschlagene Seiten erneut versuchen" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "失敗したページを再試行" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "실패한 페이지 다시 시도" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "重试失败页面" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "重試失敗頁面" + } + } + } + }, + "search_downloads": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Search downloads" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Downloads durchsuchen" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ダウンロードを検索" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "다운로드 검색" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "搜索下载" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "搜尋下載" + } + } + } + }, + "validate_image_data": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Validate Image Data" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Bilddaten validieren" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "画像データを検証" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "이미지 데이터 검증" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "验证图片数据" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "驗證圖片資料" + } + } + } + }, + "validating_image_data": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Validating Image Data..." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Bilddaten werden geprüft..." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "画像データを検証中..." + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "이미지 데이터 검증 중..." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "正在验证图像数据..." + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "正在驗證圖片資料..." + } + } + } + } + }, + "version": "1.0" +} \ No newline at end of file diff --git a/AppPackage/Sources/FavoritesFeature/.swiftlint.yml b/AppPackage/Sources/FavoritesFeature/.swiftlint.yml new file mode 100644 index 000000000..1242ffcaa --- /dev/null +++ b/AppPackage/Sources/FavoritesFeature/.swiftlint.yml @@ -0,0 +1 @@ +parent_config: ../../../.swiftlint.yml diff --git a/AppPackage/Sources/FavoritesFeature/FavoritesReducer.swift b/AppPackage/Sources/FavoritesFeature/FavoritesReducer.swift new file mode 100644 index 000000000..1b9da2da2 --- /dev/null +++ b/AppPackage/Sources/FavoritesFeature/FavoritesReducer.swift @@ -0,0 +1,309 @@ +import AppTools +import SwiftUI +import AppModels +import IdentifiedCollections +import ComposableArchitecture +import HapticsClient +import NetworkingFeature +import DownloadClient +import DeviceClient +import DateSeekFeature +import QuickSearchFeature +import DetailFeature + +@Reducer +public struct FavoritesReducer: Sendable { + public enum Delegate: Equatable, Sendable { + case presentGalleryDetail(Gallery) + } + + private enum CancelID { + case observeDownloads + } + + @Reducer + public enum Destination { + case quickSearch(QuickSearchReducer) + case dateSeek(DateSeekReducer) + } + + @ObservableState + public struct State: Equatable { + @SharedReader(.tagTranslator) public var tagTranslator: TagTranslator + @SharedReader(.user) public var user: User + @SharedReader(.setting) public var setting: Setting + public var path = StackState() + @Presents public var destination: Destination.State? + public var keyword = "" + + public var index = -1 + public var sortOrder: FavoritesSortOrder? + + public var rawGalleries = [Int: [Gallery]]() + public var rawPageNumber = [Int: PageNumber]() + public var rawDateSeekNavigation = [Int: DateSeekNavigation]() + public var rawLoadingState = [Int: LoadingState]() + public var rawFooterLoadingState = [Int: LoadingState]() + public var downloadBadges = [String: DownloadBadge]() + + var galleries: [Gallery]? { + rawGalleries[index] + } + var pageNumber: PageNumber? { + rawPageNumber[index] + } + var dateSeekNavigation: DateSeekNavigation? { + rawDateSeekNavigation[index] + } + var loadingState: LoadingState? { + rawLoadingState[index] + } + var footerLoadingState: LoadingState? { + rawFooterLoadingState[index] + } + + public init() {} + + mutating func insertGalleries(index: Int, galleries: [Gallery]) { + galleries.forEach { gallery in + if rawGalleries[index]?.contains(gallery) == false { + rawGalleries[index]?.append(gallery) + } + } + } + } + + public enum Action: BindableAction { + case binding(BindingAction) + case onAppear + case delegate(Delegate) + case galleryTapped(Gallery) + case pushGalleryDetail(Gallery) + case path(StackActionOf) + case setFavoritesIndex(Int) + case quickSearchButtonTapped + case dateSeekButtonTapped(DateSeekNavigation) + case destination(PresentationAction) + case onNotLoginViewButtonTapped + + case fetchGalleries(String? = nil, FavoritesSortOrder? = nil) + case fetchGalleriesDone(Int, Result) + case fetchMoreGalleries + case fetchMoreGalleriesDone(Int, Result) + case observeDownloads + case observeDownloadsDone([DownloadedGallery]) + case performDateSeekDone(Int, Result) + } + + @Dependency(\.deviceClient) private var deviceClient + @Dependency(\.downloadClient) private var downloadClient + @Dependency(\.hapticsClient) private var hapticsClient + + public init() {} + + public var body: some Reducer { + BindingReducer() + + Reduce { state, action in + switch action { + case .binding: + return .none + + case .onAppear: + return .send(.observeDownloads) + + case .galleryTapped(let gallery): + return GalleryNavigation.routeGalleryDetail( + isPad: deviceClient.isPad, + present: { .delegate(.presentGalleryDetail(gallery)) }, + push: { .pushGalleryDetail(gallery) } + ) + + case .pushGalleryDetail(let gallery): + state.path.appendGuardingDuplicate(.detail(.init(gallery: gallery))) + return .none + + case .delegate: + return .none + + case let .path(.element(id: _, action: .comments(.delegate(.performedCommentAction(gid))))): + guard let id = state.path.detailID(forGID: gid) else { return .none } + return .send(.path(.element(id: id, action: .detail(.fetchGalleryDetail)))) + + case let .path(.element(id: _, action: elementAction)): + if let next = GalleryNavigation.nextScreen(for: elementAction) { + state.path.appendGuardingDuplicate(next) + } + return .none + + case .path: + return .none + + case .setFavoritesIndex(let index): + state.index = index + guard state.galleries?.isEmpty != false else { return .none } + return .send(.fetchGalleries()) + + case .quickSearchButtonTapped: + state.destination = .quickSearch(QuickSearchReducer.State()) + return .none + + case .dateSeekButtonTapped(let navigation): + state.destination = .dateSeek(.init(navigation: navigation)) + return .none + + case .onNotLoginViewButtonTapped: + return .none + + case .fetchGalleries(let keyword, let sortOrder): + guard state.loadingState != .loading else { return .none } + state.rawLoadingState[state.index] = .loading + if let keyword = keyword { + state.keyword = keyword + } + if let sortOrder = sortOrder { + state.sortOrder = sortOrder + } + if state.pageNumber == nil { + state.rawPageNumber[state.index] = PageNumber() + } else { + state.rawPageNumber[state.index]?.resetPages() + } + return .run { [index = state.index, keyword = state.keyword] send in + let response = await FavoritesGalleriesRequest( + favIndex: index, keyword: keyword, sortOrder: sortOrder + ) + .response() + await send(.fetchGalleriesDone(index, response)) + } + + case .fetchGalleriesDone(let targetFavIndex, let result): + state.rawLoadingState[targetFavIndex] = .idle + switch result { + case .success(let fetchResult): + let pageNumber = fetchResult.pageNumber + let galleries = fetchResult.galleries + guard !galleries.isEmpty else { + state.rawLoadingState[targetFavIndex] = .failed(.notFound) + guard pageNumber.hasNextPage() else { return .none } + return .send(.fetchMoreGalleries) + } + state.rawPageNumber[targetFavIndex] = pageNumber + state.rawDateSeekNavigation[targetFavIndex] = fetchResult.dateSeekNavigation + state.rawGalleries[targetFavIndex] = galleries + if let sortOrder = fetchResult.sortOrder { + state.sortOrder = sortOrder + } + return .none + case .failure(let error): + state.rawLoadingState[targetFavIndex] = .failed(error) + } + return .none + + case .fetchMoreGalleries: + let pageNumber = state.pageNumber ?? .init() + guard pageNumber.hasNextPage(), + state.footerLoadingState != .loading, + let lastID = state.galleries?.last?.id, + let lastItemTimestamp = pageNumber.lastItemTimestamp + else { return .none } + state.rawFooterLoadingState[state.index] = .loading + return .run { [index = state.index, keyword = state.keyword] send in + let response = await MoreFavoritesGalleriesRequest( + favIndex: index, + lastID: lastID, + lastTimestamp: lastItemTimestamp, + keyword: keyword + ) + .response() + await send(.fetchMoreGalleriesDone(index, response)) + } + + case .fetchMoreGalleriesDone(let targetFavIndex, let result): + state.rawFooterLoadingState[targetFavIndex] = .idle + switch result { + case .success(let fetchResult): + let pageNumber = fetchResult.pageNumber + let galleries = fetchResult.galleries + state.rawPageNumber[targetFavIndex] = pageNumber + state.rawDateSeekNavigation[targetFavIndex] = fetchResult.dateSeekNavigation + state.insertGalleries(index: targetFavIndex, galleries: galleries) + if let sortOrder = fetchResult.sortOrder { + state.sortOrder = sortOrder + } + + var effects: [Effect] = [] + if galleries.isEmpty, pageNumber.hasNextPage() { + effects.append(.send(.fetchMoreGalleries)) + } else if !galleries.isEmpty { + state.rawLoadingState[targetFavIndex] = .idle + } + return .merge(effects) + + case .failure(let error): + state.rawFooterLoadingState[targetFavIndex] = .failed(error) + } + return .none + + case .observeDownloads: + return .run { send in + for await downloads in downloadClient.observeDownloads() { + await send(.observeDownloadsDone(downloads)) + } + } + .cancellable(id: CancelID.observeDownloads, cancelInFlight: true) + + case .observeDownloadsDone(let downloads): + state.downloadBadges = Dictionary( + uniqueKeysWithValues: downloads.map { ($0.gid, $0.badge) } + ) + return .none + + case .destination(.presented(.dateSeek(.delegate(.performSeek(let url))))): + guard state.loadingState != .loading else { return .none } + state.rawLoadingState[state.index] = .loading + state.rawFooterLoadingState[state.index] = .idle + state.rawPageNumber[state.index]?.resetPages() + return .run { [index = state.index] send in + let response = await DateSeekGalleriesRequest(url: url).response() + await send(.performDateSeekDone(index, response)) + } + + case .performDateSeekDone(let targetFavIndex, let result): + state.rawLoadingState[targetFavIndex] = .idle + switch result { + case .success(let response): + let galleries = response.galleries + guard !galleries.isEmpty else { + state.rawLoadingState[targetFavIndex] = .failed(.notFound) + return .none + } + state.rawPageNumber[targetFavIndex] = response.pageNumber + state.rawDateSeekNavigation[targetFavIndex] = response.dateSeekNavigation + state.rawGalleries[targetFavIndex] = galleries + return .none + case .failure(let error): + state.rawLoadingState[targetFavIndex] = .failed(error) + } + return .none + + case .destination: + return .none + } + } + .haptics( + unwrapping: \.destination, + case: \.quickSearch, + hapticsClient: hapticsClient + ) + .haptics( + unwrapping: \.destination, + case: \.dateSeek, + hapticsClient: hapticsClient + ) + .ifLet(\.$destination, action: \.destination) + .forEach(\.path, action: \.path) + } +} + +extension FavoritesReducer.Destination.State: Equatable, Sendable {} diff --git a/AppPackage/Sources/FavoritesFeature/FavoritesView.swift b/AppPackage/Sources/FavoritesFeature/FavoritesView.swift new file mode 100644 index 000000000..04d073733 --- /dev/null +++ b/AppPackage/Sources/FavoritesFeature/FavoritesView.swift @@ -0,0 +1,130 @@ +import SwiftUI +import AppModels +import TagTranslationFeature +import Resources +import ComposableArchitecture +import AppTools +import AppComponents +import DateSeekFeature +import GalleryListComponents +import QuickSearchFeature +import DetailFeature + +public struct FavoritesView: View { + @Bindable private var store: StoreOf + private let blurRadius: Double + + public init( + store: StoreOf, + blurRadius: Double + ) { + self.store = store + self.blurRadius = blurRadius + } + + private var navigationTitle: String { + let favoriteCategory = store.user.getFavoriteCategory(index: store.index) + return (store.index == -1 ? String(localized: .RLocalizable.favorites) : favoriteCategory) + } + + public var body: some View { + GalleryNavigationContainer( + store: store, + state: \.path, + action: \.path, + blurRadius: blurRadius + ) { + ZStack { + if CookieUtil.didLogin { + GenericList( + galleries: store.galleries ?? [], + pageNumber: store.pageNumber, + loadingState: store.loadingState ?? .idle, + footerLoadingState: store.footerLoadingState ?? .idle, + fetchAction: { store.send(.fetchGalleries()) }, + fetchMoreAction: { store.send(.fetchMoreGalleries) }, + navigateAction: { store.send(.galleryTapped($0)) }, + translateAction: { + store.tagTranslator.lookup(word: $0, returnOriginal: !store.setting.translatesTags) + }, + downloadBadges: store.downloadBadges + ) + } else { + NotLoginView(action: { store.send(.onNotLoginViewButtonTapped) }) + } + } + .sheet( + item: $store.scope(state: \.destination?.quickSearch, action: \.destination.quickSearch) + ) { store in + QuickSearchView(store: store) { keyword in + self.store.send(.destination(.dismiss)) + self.store.send(.fetchGalleries(keyword)) + } + .accentColor(self.store.setting.accentColor) + .autoBlur(radius: blurRadius) + } + .sheet( + item: $store.scope(state: \.destination?.dateSeek, action: \.destination.dateSeek) + ) { store in + @Bindable var store = store + DateSeekPickerView( + selectedDate: $store.date, + navigation: store.navigation, + seekAction: { store.send(.performSeek($0)) } + ) + .accentColor(self.store.setting.accentColor) + .autoBlur(radius: blurRadius) + } + .searchable(text: $store.keyword) + .searchSuggestions { + TagSuggestionView( + keyword: $store.keyword, translations: store.tagTranslator.translations, + showsImages: store.setting.showsImagesInTags, isEnabled: store.setting.showsTagsSearchSuggestion + ) + } + .onSubmit(of: .search) { + store.send(.fetchGalleries()) + } + .onAppear { + store.send(.onAppear) + if store.galleries?.isEmpty != false && CookieUtil.didLogin { + DispatchQueue.main.async { + store.send(.fetchGalleries()) + } + } + } + .toolbar(content: toolbar) + .navigationTitle(navigationTitle) + } + } + + private func toolbar() -> some ToolbarContent { + CustomToolbarItem(tint: .primary) { + FavoritesIndexMenu(index: store.index) { index in + if index != store.index { + store.send(.setFavoritesIndex(index)) + } + } + SortOrderMenu(sortOrder: store.sortOrder) { order in + if store.sortOrder != order { + store.send(.fetchGalleries(nil, order)) + } + } + DateSeekButton(navigation: store.dateSeekNavigation) { navigation in + store.send(.dateSeekButtonTapped(navigation)) + } + QuickSearchButton(hideText: true) { + store.send(.quickSearchButtonTapped) + } + } + } +} + +struct FavoritesView_Previews: PreviewProvider { + static var previews: some View { + FavoritesView( + store: .init(initialState: .init(), reducer: FavoritesReducer.init), + blurRadius: 0 + ) + } +} diff --git a/AppPackage/Sources/FileClient/.swiftlint.yml b/AppPackage/Sources/FileClient/.swiftlint.yml new file mode 100644 index 000000000..1242ffcaa --- /dev/null +++ b/AppPackage/Sources/FileClient/.swiftlint.yml @@ -0,0 +1 @@ +parent_config: ../../../.swiftlint.yml diff --git a/AppPackage/Sources/FileClient/FileClient.swift b/AppPackage/Sources/FileClient/FileClient.swift new file mode 100644 index 000000000..56951ea25 --- /dev/null +++ b/AppPackage/Sources/FileClient/FileClient.swift @@ -0,0 +1,148 @@ +import AppModels +import Foundation +import OpenCCExt +import ComposableArchitecture + +public struct FileClient: Sendable { + public var createFile: @Sendable (String, Data?) -> Bool + public var importTagTranslator: @Sendable (URL) async -> Result + /// Decodes the raw downloaded DB JSON, applies OpenCC conversion for Traditional Chinese, caches + /// the raw bytes for a launch-time rebuild, and returns the built translator (`nil` on decode + /// failure). The raw file — not the converted dictionary — is what persists. + public var cacheAndBuildRemoteTagTranslator: @Sendable (Data, TranslatableLanguage, Date) -> TagTranslator? + /// Rebuilds the in-memory translator from the cached raw JSON described by `info` — Application + /// Support for a custom import, Caches for a remote download. `nil` if the cache is missing. + public var loadCachedTagTranslator: @Sendable (TagTranslatorInfo) -> TagTranslator? + /// Deletes the imported custom-translations file from Application Support. That directory is not + /// purgeable, so a removed import must be cleaned up explicitly rather than left on disk forever. + public var removeCustomTranslations: @Sendable () -> Void +} + +// Fixed name for a user-imported table, kept in Application Support because it cannot be +// re-downloaded (unlike a remote table, which lives in purgeable Caches). +private let customTranslationsFilename = "tagTranslations-custom.json" + +private var customTranslationsURL: URL { + .applicationSupportDirectory.appending(component: customTranslationsFilename) +} +private func remoteTranslationsURL(_ language: TranslatableLanguage) -> URL { + .cachesDirectory.appending(component: language.cachedTranslationsFilename) +} + +// Decode raw DB JSON → flatten → OpenCC-convert for Traditional Chinese. `nil` if empty/undecodable. +private func decodeTranslations( + _ data: Data, applyingChtFor language: TranslatableLanguage? +) -> [String: TagTranslation]? { + guard var translations = try? JSONDecoder() + .decode(EhTagTranslationDatabaseResponse.self, from: data).tagTranslations, + !translations.isEmpty + else { return nil } + if language == .traditionalChinese { + translations = translations.chtConverted + } + return translations +} + +private func writeTranslations(_ data: Data, to url: URL) { + try? FileManager.default.createDirectory( + at: url.deletingLastPathComponent(), withIntermediateDirectories: true + ) + try? data.write(to: url, options: .atomic) +} + +extension FileClient { + public static let live: Self = .init( + createFile: { path, data in + FileManager.default.createFile(atPath: path, contents: data, attributes: nil) + }, + importTagTranslator: { url in + await withCheckedContinuation { continuation in + // `.fileImporter` returns a security-scoped URL to the original file, which for an + // iCloud item may not be downloaded yet. A coordinated read triggers the download and + // runs the accessor only once the bytes are local. The security scope is released + // inside the accessor: `coordinate(with:queue:)` returns immediately, so a `defer` + // in this outer closure would drop the scope before the accessor ever reads. + let didAccess = url.startAccessingSecurityScopedResource() + let intent = NSFileAccessIntent.readingIntent(with: url, options: .withoutChanges) + NSFileCoordinator().coordinate(with: [intent], queue: .init()) { error in + defer { if didAccess { url.stopAccessingSecurityScopedResource() } } + guard error == nil, + let data = try? Data(contentsOf: intent.url), + let translations = decodeTranslations(data, applyingChtFor: nil) + else { + continuation.resume(returning: .failure(.parseFailed)) + return + } + // Persist the raw bytes so a launch-time rebuild can restore the import. + writeTranslations(data, to: customTranslationsURL) + continuation.resume( + returning: .success(.init(hasCustomTranslations: true, translations: translations)) + ) + } + } + }, + cacheAndBuildRemoteTagTranslator: { data, language, date in + guard let translations = decodeTranslations(data, applyingChtFor: language) else { return nil } + writeTranslations(data, to: remoteTranslationsURL(language)) + return TagTranslator(language: language, updatedDate: date, translations: translations) + }, + loadCachedTagTranslator: { info in + if info.hasCustomTranslations { + guard let data = try? Data(contentsOf: customTranslationsURL), + let translations = decodeTranslations(data, applyingChtFor: nil) + else { return nil } + return TagTranslator(hasCustomTranslations: true, translations: translations) + } + guard let language = info.language, + let data = try? Data(contentsOf: remoteTranslationsURL(language)), + let translations = decodeTranslations(data, applyingChtFor: language) + else { return nil } + return TagTranslator( + language: language, updatedDate: info.updatedDate, translations: translations + ) + }, + removeCustomTranslations: { + try? FileManager.default.removeItem(at: customTranslationsURL) + } + ) + + public func saveTorrent(hash: String, data: Data) -> URL? { + let torrentDirectory = URL.cachesDirectory.appendingPathComponent("\(hash).torrent") + return createFile(torrentDirectory.path, data) ? torrentDirectory : nil + } +} + +// MARK: API +public enum FileClientKey: DependencyKey { + public static let liveValue = FileClient.live + public static let previewValue = FileClient.noop + public static let testValue = FileClient.unimplemented +} + +extension DependencyValues { + public var fileClient: FileClient { + get { self[FileClientKey.self] } + set { self[FileClientKey.self] = newValue } + } +} + +// MARK: Test +extension FileClient { + public static let noop: Self = .init( + createFile: { _, _ in false }, + importTagTranslator: { _ in .success(.init()) }, + cacheAndBuildRemoteTagTranslator: { _, _, _ in nil }, + loadCachedTagTranslator: { _ in nil }, + removeCustomTranslations: {} + ) + + public static func placeholder() -> Result { fatalError() } + + public static let unimplemented: Self = .init( + createFile: IssueReporting.unimplemented(placeholder: placeholder()), + importTagTranslator: IssueReporting.unimplemented(placeholder: placeholder()), + cacheAndBuildRemoteTagTranslator: IssueReporting.unimplemented(placeholder: placeholder()), + loadCachedTagTranslator: IssueReporting.unimplemented(placeholder: placeholder()), + removeCustomTranslations: IssueReporting.unimplemented(placeholder: placeholder()) + ) +} diff --git a/AppPackage/Sources/FiltersFeature/.swiftlint.yml b/AppPackage/Sources/FiltersFeature/.swiftlint.yml new file mode 100644 index 000000000..1242ffcaa --- /dev/null +++ b/AppPackage/Sources/FiltersFeature/.swiftlint.yml @@ -0,0 +1 @@ +parent_config: ../../../.swiftlint.yml diff --git a/AppPackage/Sources/FiltersFeature/FiltersReducer.swift b/AppPackage/Sources/FiltersFeature/FiltersReducer.swift new file mode 100644 index 000000000..201321521 --- /dev/null +++ b/AppPackage/Sources/FiltersFeature/FiltersReducer.swift @@ -0,0 +1,137 @@ +import ComposableArchitecture +import AppModels +import Sharing +import Resources +import AppComponents + +@Reducer +public struct FiltersReducer: Sendable { + public enum Dialog: Equatable, Sendable { + case confirmReset + } + + public enum FocusedBound: Sendable { + case lower + case upper + } + + @ObservableState + public struct State: Equatable, Sendable { + @Presents public var confirmationDialog: ConfirmationDialogState? + public var filterRange: FilterRange = .search + public var focusedBound: FocusedBound? + + public var searchFilter = Filter() + public var globalFilter = Filter() + public var watchedFilter = Filter() + + public init() {} + } + + public enum Action: BindableAction, Equatable { + case binding(BindingAction) + case confirmationDialog(PresentationAction) + case resetFiltersButtonTapped + case onTextFieldSubmitted + + case syncFilter(FilterRange) + case resetFilters + case fetchFilters + } + + public init() {} + + public var body: some Reducer { + BindingReducer() + .onChange(of: \.searchFilter) { _, state in + state.searchFilter.fixInvalidData() + return .send(.syncFilter(.search)) + } + .onChange(of: \.globalFilter) { _, state in + state.globalFilter.fixInvalidData() + return .send(.syncFilter(.global)) + } + .onChange(of: \.watchedFilter) { _, state in + state.watchedFilter.fixInvalidData() + return .send(.syncFilter(.watched)) + } + + Reduce { state, action in + switch action { + case .binding: + return .none + + case .resetFiltersButtonTapped: + state.confirmationDialog = ConfirmationDialogState(titleVisibility: .hidden) { + TextState(localized: .reset) + } actions: { + ButtonState(role: .destructive, action: .confirmReset) { + TextState(localized: .reset) + } + ButtonState(role: .cancel) { + TextState(localized: .RLocalizable.cancel) + } + } message: { + TextState(localized: .resetDescription) + } + return .none + + case .confirmationDialog(.presented(.confirmReset)): + return .send(.resetFilters) + + case .confirmationDialog: + 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): + // Write-through to persisted storage; the working copies stay the edit source so the + // `BindingReducer` `.onChange` `fixInvalidData` normalization keeps running. + switch range { + case .search: + @Shared(.searchFilter) var storedFilter + $storedFilter.withLock { $0 = state.searchFilter } + case .global: + @Shared(.globalFilter) var storedFilter + $storedFilter.withLock { $0 = state.globalFilter } + case .watched: + @Shared(.watchedFilter) var storedFilter + $storedFilter.withLock { $0 = state.watchedFilter } + } + return .none + + case .resetFilters: + switch state.filterRange { + case .search: + state.searchFilter = .init() + return .send(.syncFilter(.search)) + case .global: + state.globalFilter = .init() + return .send(.syncFilter(.global)) + case .watched: + state.watchedFilter = .init() + return .send(.syncFilter(.watched)) + } + + case .fetchFilters: + // Load the persisted filters into the working copies (synchronous @Shared reads). + @Shared(.searchFilter) var searchFilter + @Shared(.globalFilter) var globalFilter + @Shared(.watchedFilter) var watchedFilter + state.searchFilter = searchFilter + state.globalFilter = globalFilter + state.watchedFilter = watchedFilter + return .none + } + } + } +} diff --git a/EhPanda/View/Support/FiltersView.swift b/AppPackage/Sources/FiltersFeature/FiltersView.swift similarity index 55% rename from EhPanda/View/Support/FiltersView.swift rename to AppPackage/Sources/FiltersFeature/FiltersView.swift index e7cdc7804..e139b020f 100644 --- a/EhPanda/View/Support/FiltersView.swift +++ b/AppPackage/Sources/FiltersFeature/FiltersView.swift @@ -1,17 +1,15 @@ -// -// FiltersView.swift -// EhPanda -// - import SwiftUI +import AppModels +import Resources import ComposableArchitecture +import AppComponents -struct FiltersView: View { +public struct FiltersView: View { @Bindable private var store: StoreOf @FocusState private var focusedBound: FiltersReducer.FocusedBound? - init(store: StoreOf) { + public init(store: StoreOf) { self.store = store } @@ -27,14 +25,13 @@ struct FiltersView: View { } // MARK: FilterView - var body: some View { - NavigationView { + public var body: some View { + NavigationStack { Form { BasicSection( - route: $store.route, filter: filter, filterRange: $store.filterRange, - resetFiltersAction: { store.send(.resetFilters) }, - resetFiltersDialogAction: { store.send(.setNavigation(.resetFilters)) } + resetFiltersDialogAction: { store.send(.resetFiltersButtonTapped) }, + confirmationDialog: $store.scope(state: \.confirmationDialog, action: \.confirmationDialog) ) AdvancedSection( filter: filter, focusedBound: $focusedBound, @@ -42,7 +39,7 @@ struct FiltersView: View { ) } .synchronize($store.focusedBound, $focusedBound) - .navigationTitle(L10n.Localizable.FiltersView.Title.filters) + .navigationTitle(.RLocalizable.filters) .onAppear { store.send(.fetchFilters) } } } @@ -50,50 +47,43 @@ struct FiltersView: View { // MARK: BasicSection private struct BasicSection: View { - @Binding private var route: FiltersReducer.Route? @Binding private var filter: Filter @Binding private var filterRange: FilterRange - private let resetFiltersAction: () -> Void private let resetFiltersDialogAction: () -> Void + private let confirmationDialog: + Binding, FiltersReducer.Dialog>?> private var categoryBindings: [Binding] { [ $filter.doujinshi, $filter.manga, $filter.artistCG, $filter.gameCG, $filter.western, $filter.nonH, $filter.imageSet, $filter.cosplay, $filter.asianPorn, $filter.misc ] } init( - route: Binding, filter: Binding, filterRange: Binding, - resetFiltersAction: @escaping () -> Void, resetFiltersDialogAction: @escaping () -> Void + filter: Binding, filterRange: Binding, + resetFiltersDialogAction: @escaping () -> Void, + confirmationDialog: + Binding, FiltersReducer.Dialog>?> ) { - _route = route _filter = filter _filterRange = filterRange - self.resetFiltersAction = resetFiltersAction self.resetFiltersDialogAction = resetFiltersDialogAction + self.confirmationDialog = confirmationDialog } var body: some View { Section { - Picker("", selection: $filterRange) { + Picker(.searchRange, selection: $filterRange) { ForEach(FilterRange.allCases) { range in Text(range.value).tag(range) } } .pickerStyle(.segmented) + .labelsHidden() CategoryView(bindings: categoryBindings) Button(action: resetFiltersDialogAction) { - Text(L10n.Localizable.FiltersView.Button.resetFilters).foregroundStyle(.red) - } - .confirmationDialog( - message: L10n.Localizable.ConfirmationDialog.Title.reset, - unwrapping: $route, - case: \.resetFilters - ) { - Button( - L10n.Localizable.ConfirmationDialog.Button.reset, - role: .destructive, action: resetFiltersAction - ) + Text(.resetFilters).foregroundStyle(.red) } - Toggle(L10n.Localizable.FiltersView.Title.advancedSettings, isOn: $filter.advanced) + .confirmationDialog(confirmationDialog) + Toggle(.advancedSettings, isOn: $filter.advanced) } } } @@ -116,24 +106,24 @@ private struct AdvancedSection: View { var body: some View { Group { - Section(L10n.Localizable.FiltersView.Section.Title.advanced) { - Toggle(L10n.Localizable.FiltersView.Title.searchGalleryName, isOn: $filter.galleryName) - Toggle(L10n.Localizable.FiltersView.Title.searchGalleryTags, isOn: $filter.galleryTags) - Toggle(L10n.Localizable.FiltersView.Title.searchGalleryDescription, isOn: $filter.galleryDesc) - Toggle(L10n.Localizable.FiltersView.Title.searchTorrentFilenames, isOn: $filter.torrentFilenames) + Section(.advanced) { + Toggle(.searchGalleryName, isOn: $filter.galleryName) + Toggle(.searchGalleryTags, isOn: $filter.galleryTags) + Toggle(.searchGalleryDescription, isOn: $filter.galleryDesc) + Toggle(.searchTorrentFilenames, isOn: $filter.torrentFilenames) Toggle( - L10n.Localizable.FiltersView.Title.onlyShowGalleriesWithTorrents, + .onlyShowGalleriesWithTorrents, isOn: $filter.onlyWithTorrents ) - Toggle(L10n.Localizable.FiltersView.Title.searchLowPowerTags, isOn: $filter.lowPowerTags) - Toggle(L10n.Localizable.FiltersView.Title.searchDownvotedTags, isOn: $filter.downvotedTags) - Toggle(L10n.Localizable.FiltersView.Title.searchExpungedGalleries, isOn: $filter.expungedGalleries) + Toggle(.searchLowPowerTags, isOn: $filter.lowPowerTags) + Toggle(.searchDownvotedTags, isOn: $filter.downvotedTags) + Toggle(.searchExpungedGalleries, isOn: $filter.expungedGalleries) } Section { - Toggle(L10n.Localizable.FiltersView.Title.setMinimumRating, isOn: $filter.minRatingActivated) + Toggle(.setMinimumRating, isOn: $filter.minRatingActivated) MinimumRatingSetter(minimum: $filter.minRating) .disabled(!filter.minRatingActivated) - Toggle(L10n.Localizable.FiltersView.Title.setPagesRange, isOn: $filter.pageRangeActivated) + Toggle(.setPagesRange, isOn: $filter.pageRangeActivated) .disabled(focusedBound.wrappedValue != nil) PagesRangeSetter( lowerBound: $filter.pageLowerBound, @@ -143,10 +133,10 @@ private struct AdvancedSection: View { ) .disabled(!filter.pageRangeActivated) } - Section(L10n.Localizable.FiltersView.Section.Title.defaultFilter) { - Toggle(L10n.Localizable.FiltersView.Title.disableLanguageFilter, isOn: $filter.disableLanguage) - Toggle(L10n.Localizable.FiltersView.Title.disableUploaderFilter, isOn: $filter.disableUploader) - Toggle(L10n.Localizable.FiltersView.Title.disableTagsFilter, isOn: $filter.disableTags) + Section(.defaultFilter) { + Toggle(.disableLanguageFilter, isOn: $filter.disableLanguage) + Toggle(.disableUploaderFilter, isOn: $filter.disableUploader) + Toggle(.disableTagsFilter, isOn: $filter.disableTags) } } .disabled(!filter.advanced) @@ -162,9 +152,9 @@ private struct MinimumRatingSetter: View { } var body: some View { - Picker(L10n.Localizable.FiltersView.Title.minimumRating, selection: $minimum) { + Picker(.minimumRating, selection: $minimum) { ForEach(Array(2...5), id: \.self) { number in - Text(L10n.Localizable.Common.Value.stars("\(number)")).tag(number) + Text(.RLocalizable.stars(count: number)).tag(number) } } .pickerStyle(.menu) @@ -192,13 +182,13 @@ private struct PagesRangeSetter: View { var body: some View { HStack { - Text(L10n.Localizable.FiltersView.Title.pagesRange) + Text(.pagesRange) Spacer() - SettingTextField(text: $lowerBound) + SettingTextField(text: $lowerBound, title: .pagesRange) .focused(focusedBound, equals: .lower) .submitLabel(.next) - Text("-") - SettingTextField(text: $upperBound) + Text(.Constant.rangeSeparator) + SettingTextField(text: $upperBound, title: .pagesRange) .focused(focusedBound, equals: .upper) .submitLabel(.done) } @@ -211,27 +201,7 @@ 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 L10n.Localizable.Enum.FilterRange.Value.search - case .global: - return L10n.Localizable.Enum.FilterRange.Value.global - case .watched: - return L10n.Localizable.Enum.FilterRange.Value.watched - } - } + let category: AppModels.Category } struct FiltersView_Previews: PreviewProvider { diff --git a/AppPackage/Sources/FiltersFeature/Resources/Constant.xcstrings b/AppPackage/Sources/FiltersFeature/Resources/Constant.xcstrings new file mode 100644 index 000000000..8d755d9a2 --- /dev/null +++ b/AppPackage/Sources/FiltersFeature/Resources/Constant.xcstrings @@ -0,0 +1,48 @@ +{ + "sourceLanguage": "en", + "strings": { + "range_separator": { + "extractionState": "manual", + "shouldTranslate": false, + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "-" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "-" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "-" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "-" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "-" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "-" + } + } + } + } + }, + "version": "1.0" +} \ No newline at end of file diff --git a/AppPackage/Sources/FiltersFeature/Resources/Localizable.xcstrings b/AppPackage/Sources/FiltersFeature/Resources/Localizable.xcstrings new file mode 100644 index 000000000..5616db5e7 --- /dev/null +++ b/AppPackage/Sources/FiltersFeature/Resources/Localizable.xcstrings @@ -0,0 +1,908 @@ +{ + "sourceLanguage": "en", + "strings": { + "advanced": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Advanced" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Erweitert" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "高度" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "고급" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "高级" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "進階" + } + } + } + }, + "advanced_settings": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Advanced settings" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Erweiterte Einstellungen" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "高度な設定" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "고급 설정" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "高级选项" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "進階選項" + } + } + } + }, + "default_filter": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Default filter" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Standardfilter" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "既定フィルター" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "기본 옵션" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "默认筛选" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "預設篩選" + } + } + } + }, + "disable_language_filter": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Disable language filter" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Gefilterte Sprachen miteinbeziehen" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "言語フィルターを無効化" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "언어 필터 끄기" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "禁用语言筛选" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "停用語言篩選" + } + } + } + }, + "disable_tags_filter": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Disable tags filter" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Gefilterte Tags miteinbeziehen" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "タグフィルターを無効化" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "태그 필터 끄기" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "禁用标签筛选" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "停用標籤篩選" + } + } + } + }, + "disable_uploader_filter": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Disable uploader filter" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Gefilterte Uploader miteinbeziehen" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "アップローダフィルターを無効化" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "업로더 필터 끄기" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "禁用上传者筛选" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "停用上傳者篩選" + } + } + } + }, + "minimum_rating": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Minimum rating" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Minimale Bewertung" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "評価の下限" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "최소 별점" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "评分下限" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "評分下限" + } + } + } + }, + "only_show_galleries_with_torrents": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Only show galleries with torrents" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Nur Galerien mit Torrents zeigen" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "トレントを含むもののみを表示" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "토렌트 있는 갤러리만 보이기" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "只显示带有种子的画廊" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "只顯示有種子的畫廊" + } + } + } + }, + "pages_range": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Pages range" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Seitenzahl-Bereich" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ページ数範囲" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "페이지 범위" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "页数范围" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "頁數範圍" + } + } + } + }, + "reset": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Reset" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Zurücksetzen" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "戻す" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "초기화" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "重置" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "重設" + } + } + } + }, + "reset_description": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Are you sure to reset?" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Bist du sicher?" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "本当に戻してもよろしいですか?" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "초기화하시겠어요?" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "确定要重置吗?" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "確定要重設嗎?" + } + } + } + }, + "reset_filters": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Reset filters" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Filter zurücksetzen" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "既定値に戻す" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "모든 필터 초기화" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "重置所有选项" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "重設所有選項" + } + } + } + }, + "search_downvoted_tags": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Search downvoted tags" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Negativ bewertete Tags miteinbeziehen" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "低評価タグを検索" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "낮은 평가의 태그를 찾아보기" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "搜索低评价标签" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "搜尋低評價標籤" + } + } + } + }, + "search_expunged_galleries": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Search expunged galleries" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Gelöschte Galerien zeigen" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "削除済みのギャラリーを表示" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "삭제된 갤러리를 보여주기" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "显示已被删除的画廊" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "顯示被刪除的畫廊" + } + } + } + }, + "search_gallery_description": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Search gallery description" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Galeriebeschreibung durchsuchen" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ギャラリー説明を検索" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "갤러리 설명을 찾아보기" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "搜索画廊描述" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "搜尋畫廊描述" + } + } + } + }, + "search_gallery_name": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Search gallery name" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Galerienamen durchsuchen" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ギャラリー名を検索" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "갤러리 이름을 찾아보기" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "搜索画廊名称" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "搜尋畫廊名稱" + } + } + } + }, + "search_gallery_tags": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Search gallery tags" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Galerietags durchsuchen" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ギャラリータグを検索" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "갤러리 태그를 찾아보기" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "搜索画廊标签" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "搜尋畫廊標籤" + } + } + } + }, + "search_low_power_tags": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Search Low-Power tags" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Low-Power Tags miteinbeziehen" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "低希望タグを検索" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "인기가 없는 태그를 찾아보기" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "搜索低期望标签" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "搜尋低期望標籤" + } + } + } + }, + "search_range": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Search range" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Suchbereich" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "検索範囲" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "검색 범위" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "搜索范围" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "搜尋範圍" + } + } + } + }, + "search_torrent_filenames": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Search torrent filenames" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Torrents durchsuchen" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "トレントファイル名を検索" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "토렌트 파일 이름을 찾아보기" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "搜索种子文件名" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "搜尋種子檔案名" + } + } + } + }, + "set_minimum_rating": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Set minimum rating" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Minimaleste Bewertung festlegen" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "評価の下限を指定" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "최소 별점 설정하기" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "设置评分下限" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "設定評分下限" + } + } + } + }, + "set_pages_range": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Set pages range" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Seitenzahl-Bereich festlegen" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ページ数範囲を指定" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "페이지 범위 설정" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "设置页数范围" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "設定頁數範圍" + } + } + } + } + }, + "version": "1.0" +} \ No newline at end of file diff --git a/AppPackage/Sources/GalleryListComponents/.swiftlint.yml b/AppPackage/Sources/GalleryListComponents/.swiftlint.yml new file mode 100644 index 000000000..1242ffcaa --- /dev/null +++ b/AppPackage/Sources/GalleryListComponents/.swiftlint.yml @@ -0,0 +1 @@ +parent_config: ../../../.swiftlint.yml diff --git a/AppPackage/Sources/GalleryListComponents/Cells/GalleryDetailCell.swift b/AppPackage/Sources/GalleryListComponents/Cells/GalleryDetailCell.swift new file mode 100644 index 000000000..f0af1eddd --- /dev/null +++ b/AppPackage/Sources/GalleryListComponents/Cells/GalleryDetailCell.swift @@ -0,0 +1,165 @@ +import SwiftUI +import Sharing +import SFSafeSymbols +import AppModels +import TagTranslationFeature +import AppComponents +import Kingfisher +import AppTools + +public struct GalleryDetailCell: View { + public enum CoverSource { + case dynamic + case `static`(URL?) + } + + @Environment(\.colorScheme) private var colorScheme + + private let gallery: Gallery + private let coverSource: CoverSource + private let translateAction: ((String) -> (String, TagTranslation?))? + private let downloadBadge: DownloadBadge? + + public init( + gallery: Gallery, + coverSource: CoverSource = .dynamic, + translateAction: ((String) -> (String, TagTranslation?))? = nil, + downloadBadge: DownloadBadge? = nil + ) { + self.gallery = gallery + self.coverSource = coverSource + self.translateAction = translateAction + self.downloadBadge = downloadBadge + } + + private var resolvedCoverURL: URL? { + switch coverSource { + case .dynamic: + gallery.coverURL + case .static(let url): + url + } + } + + public var body: some View { + GalleryDetailCellContent( + gallery: gallery, + resolvedCoverURL: resolvedCoverURL, + colorScheme: colorScheme, + translateAction: translateAction, + downloadBadge: downloadBadge + ) + } +} + +private struct GalleryDetailCellContent: View { + @SharedReader(.setting) private var setting: Setting + + private let gallery: Gallery + private let resolvedCoverURL: URL? + private let colorScheme: ColorScheme + private let translateAction: ((String) -> (String, TagTranslation?))? + private let downloadBadge: DownloadBadge? + + init( + gallery: Gallery, + resolvedCoverURL: URL?, + colorScheme: ColorScheme, + translateAction: ((String) -> (String, TagTranslation?))?, + downloadBadge: DownloadBadge? + ) { + self.gallery = gallery + self.resolvedCoverURL = resolvedCoverURL + self.colorScheme = colorScheme + self.translateAction = translateAction + self.downloadBadge = downloadBadge + } + + private var tagColor: Color { + colorScheme == .light ? Color(.systemGray5) : Color(.systemGray4) + } + + private var displayTitle: String { + setting.displaysJapaneseTitle ? gallery.titleJpn ?? gallery.title : gallery.title + } + + var body: some View { + HStack(spacing: 10) { + KFImage(resolvedCoverURL) + .placeholder { Placeholder(style: .activity(ratio: Defaults.ImageSize.rowAspect)) } + .defaultModifier().scaledToFit().frame(width: Defaults.ImageSize.rowW, height: Defaults.ImageSize.rowH) + VStack(alignment: .leading, spacing: 5) { + Text(displayTitle) + .lineLimit(downloadBadge == nil ? 3 : 2) + .font(.headline) + .foregroundStyle(.primary) + .fixedSize(horizontal: false, vertical: true) + + if gallery.uploader != nil || gallery.language != nil { + HStack { + gallery.uploader.map(Text.init) + + Spacer(minLength: 8) + + (gallery.language?.value).map(Text.init) + } + .foregroundStyle(.secondary) + .font(.subheadline) + .lineLimit(1) + } + + let tagContents = gallery.tagContents(maximum: setting.listTagsNumberMaximum) + if setting.showsTagsInList, !tagContents.isEmpty { + TagCloudView(data: tagContents) { content in + let translation = translateAction?(content.rawNamespace + content.text).1 + TagCloudCell( + text: translation?.displayValue ?? content.text, + imageURL: translation?.valueImageURL, + showsImages: setting.showsImagesInTags, + font: .caption2, padding: .init(top: 2, leading: 4, bottom: 2, trailing: 4), + textColor: content.backgroundColor != nil ? content.textColor ?? .secondary : .secondary, + backgroundColor: content.backgroundColor ?? tagColor + ) + } + } + HStack { + let ratingColor: Color = gallery.hasRated ? .green : .yellow + RatingView(rating: gallery.rating).font(.caption).foregroundStyle(ratingColor) + + Spacer(minLength: 8) + + if gallery.isFavorite, let index = gallery.favoriteTagIndex { + Image(systemSymbol: .heartFill) + .foregroundStyle(Defaults.FavoriteColor.colors[index]) + } + + if let downloadBadge { + DownloadBadgeLabel(badge: downloadBadge) + } else { + HStack(spacing: 2) { + Image(systemSymbol: .photoOnRectangleAngled) + Text(String(gallery.pageCount)) + } + .lineLimit(1).font(.footnote).foregroundStyle(.secondary).minimumScaleFactor(0.75) + } + } + HStack(alignment: .bottom) { + CategoryLabel(text: gallery.category.value, color: gallery.color(host: AppUtil.galleryHost)) + Spacer() + Text(gallery.formattedDateString).lineLimit(1).font(.footnote) + .foregroundStyle(.secondary).minimumScaleFactor(0.75) + .strikethrough(gallery.isExpunged) + } + .padding(.top, 1) + } + .drawingGroup() + } + .padding(.vertical, 5).padding(.leading, -10).padding(.trailing, -5) + } +} + +struct GalleryDetailCell_Previews: PreviewProvider { + static var previews: some View { + GalleryDetailCell(gallery: .preview) + } +} diff --git a/EhPanda/View/Support/Components/Cells/GalleryThumbnailCell.swift b/AppPackage/Sources/GalleryListComponents/Cells/GalleryThumbnailCell.swift similarity index 55% rename from EhPanda/View/Support/Components/Cells/GalleryThumbnailCell.swift rename to AppPackage/Sources/GalleryListComponents/Cells/GalleryThumbnailCell.swift index 784ffab3c..b5f80e9b6 100644 --- a/EhPanda/View/Support/Components/Cells/GalleryThumbnailCell.swift +++ b/AppPackage/Sources/GalleryListComponents/Cells/GalleryThumbnailCell.swift @@ -1,22 +1,28 @@ -// -// GalleryThumbnailCell.swift -// EhPanda -// - import SwiftUI +import Sharing +import SFSafeSymbols +import AppModels +import TagTranslationFeature +import AppComponents import Kingfisher +import AppTools -struct GalleryThumbnailCell: View { +public struct GalleryThumbnailCell: View { @Environment(\.colorScheme) private var colorScheme + @SharedReader(.setting) private var setting: Setting private let gallery: Gallery - private let setting: Setting private let translateAction: ((String) -> (String, TagTranslation?))? + private let downloadBadge: DownloadBadge? - init(gallery: Gallery, setting: Setting, translateAction: ((String) -> (String, TagTranslation?))? = nil) { + public init( + gallery: Gallery, + translateAction: ((String) -> (String, TagTranslation?))? = nil, + downloadBadge: DownloadBadge? = nil + ) { self.gallery = gallery - self.setting = setting self.translateAction = translateAction + self.downloadBadge = downloadBadge } private var backgroundColor: Color { @@ -25,8 +31,11 @@ struct GalleryThumbnailCell: View { private var tagColor: Color { colorScheme == .light ? Color(.systemGray5) : Color(.systemGray4) } + private var displayTitle: String { + setting.displaysJapaneseTitle ? gallery.titleJpn ?? gallery.title : gallery.title + } - var body: some View { + public var body: some View { VStack(alignment: .leading, spacing: 0) { KFImage(gallery.coverURL) .placeholder { Placeholder(style: .activity(ratio: Defaults.ImageSize.rowAspect)) } @@ -34,21 +43,21 @@ struct GalleryThumbnailCell: View { minAspect: Defaults.ImageSize.webtoonMinAspect, idealAspect: Defaults.ImageSize.webtoonIdealAspect )) - .fade(duration: 0.25).resizable().scaledToFit().overlay { - VStack { - HStack { - Spacer() - CategoryLabel( - text: gallery.category.value, color: gallery.color, - insets: .init(top: 3, leading: 6, bottom: 3, trailing: 6), - cornerRadius: 15, corners: .bottomLeft - ) - } - Spacer() - } + .fade(duration: 0.25) + .resizable() + .scaledToFit() + .overlay { + CategoryLabel( + text: gallery.category.value, color: gallery.color(host: AppUtil.galleryHost), + insets: .init(top: 3, leading: 6, bottom: 3, trailing: 6), + cornerRadius: 15, corners: .bottomLeft + ) + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topTrailing) } VStack(alignment: .leading, spacing: 5) { - Text(gallery.title).font(.callout.bold()).lineLimit(3) + Text(displayTitle) + .font(.callout.bold()) + .lineLimit(downloadBadge == nil ? 3 : 2) let tagContents = gallery.tagContents(maximum: setting.listTagsNumberMaximum) if setting.showsTagsInList, !tagContents.isEmpty { TagCloudView(data: tagContents) { content in @@ -64,16 +73,30 @@ struct GalleryThumbnailCell: View { } } HStack(spacing: 10) { + if gallery.isFavorite, let index = gallery.favoriteTagIndex { + Image(systemSymbol: .heartFill) + .foregroundStyle(Defaults.FavoriteColor.colors[index]) + } + + if let downloadBadge { + DownloadBadgeLabel(badge: downloadBadge) + } else { + HStack(spacing: 2) { + Image(systemSymbol: .photoOnRectangleAngled) + Text(String(gallery.pageCount)) + } + } + + Spacer(minLength: 8) + if let language = gallery.language { Text(language.value) } - HStack(spacing: 2) { - Image(systemSymbol: .photoOnRectangleAngled) - Text(String(gallery.pageCount)) - } } .lineLimit(1).font(.footnote).foregroundStyle(.secondary) - RatingView(rating: gallery.rating).foregroundColor(.yellow).font(.caption) + + let ratingColor: Color = gallery.hasRated ? .green : .yellow + RatingView(rating: gallery.rating).foregroundColor(ratingColor).font(.caption) } .padding() } @@ -83,7 +106,7 @@ struct GalleryThumbnailCell: View { struct GalleryThumbnailCell_Previews: PreviewProvider { static var previews: some View { - GalleryThumbnailCell(gallery: .preview, setting: Setting()) + GalleryThumbnailCell(gallery: .preview) .preferredColorScheme(.dark) } } diff --git a/AppPackage/Sources/GalleryListComponents/DownloadBadgeLabel.swift b/AppPackage/Sources/GalleryListComponents/DownloadBadgeLabel.swift new file mode 100644 index 000000000..926102ed2 --- /dev/null +++ b/AppPackage/Sources/GalleryListComponents/DownloadBadgeLabel.swift @@ -0,0 +1,56 @@ +import SwiftUI +import SFSafeSymbols +import AppModels + +public struct DownloadBadgeLabel: View { + private let badge: DownloadBadge + + public init(badge: DownloadBadge) { + self.badge = badge + } + + public var body: some View { + HStack(spacing: 4) { + Image(systemSymbol: badge.symbol) + .font(.caption.bold()) + Text(progressText) + .font(.caption.bold().monospacedDigit()) + .lineLimit(1) + } + .foregroundStyle(badge.color) + .padding(.horizontal, 8) + .padding(.vertical, 4) + .background(badge.color.opacity(0.15)) + .clipShape(.capsule) + .accessibilityElement(children: .ignore) + .accessibilityLabel(accessibilityText) + } + + private var progressText: LocalizedStringResource { + .downloadBadgeProgress( + completed: badge.progress.displayCompletedPageCount, + total: badge.progress.displayPageCount + ) + } + + private var statusText: LocalizedStringResource { + switch badge.status { + case .queued: + return .downloadBadgeQueued + case .active: + return .downloadBadgeDownloading + case .inactive: + return .downloadBadgePaused + case .completed: + return .downloadBadgeDownloaded + case .updateAvailable: + return .downloadBadgeUpdateAvailable + case .error: + return .downloadBadgeNeedsAttention + } + } + + private var accessibilityText: String { + [String(localized: statusText), String(localized: progressText)].joined(separator: " ") + } +} diff --git a/EhPanda/View/Support/Components/GenericList.swift b/AppPackage/Sources/GalleryListComponents/GenericList.swift similarity index 61% rename from EhPanda/View/Support/Components/GenericList.swift rename to AppPackage/Sources/GalleryListComponents/GenericList.swift index 3014dffeb..e6534e519 100644 --- a/EhPanda/View/Support/Components/GenericList.swift +++ b/AppPackage/Sources/GalleryListComponents/GenericList.swift @@ -1,57 +1,66 @@ -// -// GenericList.swift -// EhPanda -// - import SwiftUI +import Sharing +import SFSafeSymbols +import AppModels +import AppComponents import WaterfallGrid -import ComposableArchitecture +import AppTools + +public struct GenericList: View { + @SharedReader(.setting) private var setting: Setting -struct GenericList: View { private let galleries: [Gallery] - private let setting: Setting + private let downloadBadges: [String: DownloadBadge] private let pageNumber: PageNumber? private let loadingState: LoadingState private let footerLoadingState: LoadingState + private let notice: LocalizedStringResource? private let fetchAction: (() -> Void)? private let fetchMoreAction: (() -> Void)? - private let navigateAction: ((String) -> Void)? + private let navigateAction: ((Gallery) -> Void)? private let translateAction: ((String) -> (String, TagTranslation?))? - init( - galleries: [Gallery], setting: Setting, pageNumber: PageNumber?, + public init( + galleries: [Gallery], pageNumber: PageNumber?, loadingState: LoadingState, footerLoadingState: LoadingState, + notice: LocalizedStringResource? = nil, fetchAction: (() -> Void)? = nil, fetchMoreAction: (() -> Void)? = nil, - navigateAction: ((String) -> Void)? = nil, - translateAction: ((String) -> (String, TagTranslation?))? = nil + navigateAction: ((Gallery) -> Void)? = nil, + translateAction: ((String) -> (String, TagTranslation?))? = nil, + downloadBadges: [String: DownloadBadge] = [:] ) { self.galleries = galleries - self.setting = setting + self.downloadBadges = downloadBadges self.pageNumber = pageNumber self.loadingState = loadingState self.footerLoadingState = footerLoadingState + self.notice = notice self.fetchAction = fetchAction self.fetchMoreAction = fetchMoreAction self.navigateAction = navigateAction self.translateAction = translateAction } - var body: some View { + public 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 + galleries: galleries, pageNumber: pageNumber, + footerLoadingState: footerLoadingState, notice: notice, + fetchMoreAction: fetchMoreAction, + navigateAction: navigateAction, translateAction: translateAction, + downloadBadges: downloadBadges ) case .thumbnail: WaterfallList( - galleries: galleries, setting: setting, pageNumber: pageNumber, - footerLoadingState: footerLoadingState, fetchMoreAction: fetchMoreAction, - navigateAction: navigateAction, translateAction: translateAction + galleries: galleries, pageNumber: pageNumber, + footerLoadingState: footerLoadingState, notice: notice, + fetchMoreAction: fetchMoreAction, + navigateAction: navigateAction, translateAction: translateAction, + downloadBadges: downloadBadges ) } } @@ -73,24 +82,27 @@ struct GenericList: View { // MARK: DetailList private struct DetailList: View { private let galleries: [Gallery] - private let setting: Setting + private let downloadBadges: [String: DownloadBadge] private let pageNumber: PageNumber? private let footerLoadingState: LoadingState + private let notice: LocalizedStringResource? private let fetchMoreAction: (() -> Void)? - private let navigateAction: ((String) -> Void)? + private let navigateAction: ((Gallery) -> Void)? private let translateAction: ((String) -> (String, TagTranslation?))? init( - galleries: [Gallery], setting: Setting, pageNumber: PageNumber?, - footerLoadingState: LoadingState, + galleries: [Gallery], pageNumber: PageNumber?, + footerLoadingState: LoadingState, notice: LocalizedStringResource? = nil, fetchMoreAction: (() -> Void)?, - navigateAction: ((String) -> Void)? = nil, - translateAction: ((String) -> (String, TagTranslation?))? = nil + navigateAction: ((Gallery) -> Void)? = nil, + translateAction: ((String) -> (String, TagTranslation?))? = nil, + downloadBadges: [String: DownloadBadge] = [:] ) { self.galleries = galleries - self.setting = setting + self.downloadBadges = downloadBadges self.pageNumber = pageNumber self.footerLoadingState = footerLoadingState + self.notice = notice self.fetchMoreAction = fetchMoreAction self.navigateAction = navigateAction self.translateAction = translateAction @@ -107,20 +119,28 @@ private struct DetailList: View { } 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?() + List { + notice.map(ListNoticeView.init) + + ForEach(galleries) { gallery in + Button { + navigateAction?(gallery) + } label: { + GalleryDetailCell( + gallery: gallery, + translateAction: translateAction, + downloadBadge: downloadBadges[gallery.gid] + ) + } + .foregroundColor(.primary) + .onAppear { + if gallery == galleries.last { + fetchMoreAction?() + } + } + if shouldShowFooter(gallery: gallery) { + FetchMoreFooter(loadingState: footerLoadingState, retryAction: fetchMoreAction) } - } - if shouldShowFooter(gallery: gallery) { - FetchMoreFooter(loadingState: footerLoadingState, retryAction: fetchMoreAction) } } } @@ -129,11 +149,12 @@ private struct DetailList: View { // MARK: WaterfallList private struct WaterfallList: View { private let galleries: [Gallery] - private let setting: Setting + private let downloadBadges: [String: DownloadBadge] private let pageNumber: PageNumber? private let footerLoadingState: LoadingState + private let notice: LocalizedStringResource? private let fetchMoreAction: (() -> Void)? - private let navigateAction: ((String) -> Void)? + private let navigateAction: ((Gallery) -> Void)? private let translateAction: ((String) -> (String, TagTranslation?))? private var columnsInPortrait: Int { @@ -153,16 +174,18 @@ private struct WaterfallList: View { } init( - galleries: [Gallery], setting: Setting, pageNumber: PageNumber?, - footerLoadingState: LoadingState, + galleries: [Gallery], pageNumber: PageNumber?, + footerLoadingState: LoadingState, notice: LocalizedStringResource? = nil, fetchMoreAction: (() -> Void)?, - navigateAction: ((String) -> Void)? = nil, - translateAction: ((String) -> (String, TagTranslation?))? = nil + navigateAction: ((Gallery) -> Void)? = nil, + translateAction: ((String) -> (String, TagTranslation?))? = nil, + downloadBadges: [String: DownloadBadge] = [:] ) { self.galleries = galleries - self.setting = setting + self.downloadBadges = downloadBadges self.pageNumber = pageNumber self.footerLoadingState = footerLoadingState + self.notice = notice self.fetchMoreAction = fetchMoreAction self.navigateAction = navigateAction self.translateAction = translateAction @@ -170,12 +193,21 @@ private struct WaterfallList: View { var body: some View { List { + if let notice { + Section { + ListNoticeView(notice: notice) + } + } WaterfallGrid(galleries) { gallery in Button { - navigateAction?(gallery.id) + navigateAction?(gallery) } label: { - GalleryThumbnailCell(gallery: gallery, setting: setting, translateAction: translateAction) - .tint(.primary).multilineTextAlignment(.leading) + GalleryThumbnailCell( + gallery: gallery, + translateAction: translateAction, + downloadBadge: downloadBadges[gallery.gid] + ) + .tint(.primary).multilineTextAlignment(.leading) } .buttonStyle(.borderless) } diff --git a/AppPackage/Sources/GalleryListComponents/Resources/Localizable.xcstrings b/AppPackage/Sources/GalleryListComponents/Resources/Localizable.xcstrings new file mode 100644 index 000000000..61c5420f4 --- /dev/null +++ b/AppPackage/Sources/GalleryListComponents/Resources/Localizable.xcstrings @@ -0,0 +1,473 @@ +{ + "sourceLanguage": "en", + "strings": { + "download_badge.downloaded": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Downloaded" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Heruntergeladen" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ダウンロード済み" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "다운로드됨" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "已下载" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "已下載" + } + } + } + }, + "download_badge.downloading": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Downloading" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Lädt herunter" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ダウンロード中" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "다운로드 중" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "下载中" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "下載中" + } + } + } + }, + "download_badge.needs_attention": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Needs Attention" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Benötigt Aufmerksamkeit" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "要対応" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "조치 필요" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "需处理" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "需處理" + } + } + } + }, + "download_badge.paused": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Paused" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Pausiert" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "一時停止" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "일시 정지" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "已暂停" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "已暫停" + } + } + } + }, + "download_badge.progress": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "%#@completed@/%#@total@" + }, + "substitutions": { + "completed": { + "argNum": 1, + "formatSpecifier": "lld", + "variations": { + "plural": { + "other": { + "stringUnit": { + "state": "translated", + "value": "%arg" + } + } + } + } + }, + "total": { + "argNum": 2, + "formatSpecifier": "lld", + "variations": { + "plural": { + "other": { + "stringUnit": { + "state": "translated", + "value": "%arg" + } + } + } + } + } + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "%#@completed@/%#@total@" + }, + "substitutions": { + "completed": { + "argNum": 1, + "formatSpecifier": "lld", + "variations": { + "plural": { + "other": { + "stringUnit": { + "state": "translated", + "value": "%arg" + } + } + } + } + }, + "total": { + "argNum": 2, + "formatSpecifier": "lld", + "variations": { + "plural": { + "other": { + "stringUnit": { + "state": "translated", + "value": "%arg" + } + } + } + } + } + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "%#@completed@/%#@total@" + }, + "substitutions": { + "completed": { + "argNum": 1, + "formatSpecifier": "lld", + "variations": { + "plural": { + "other": { + "stringUnit": { + "state": "translated", + "value": "%arg" + } + } + } + } + }, + "total": { + "argNum": 2, + "formatSpecifier": "lld", + "variations": { + "plural": { + "other": { + "stringUnit": { + "state": "translated", + "value": "%arg" + } + } + } + } + } + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "%#@completed@/%#@total@" + }, + "substitutions": { + "completed": { + "argNum": 1, + "formatSpecifier": "lld", + "variations": { + "plural": { + "other": { + "stringUnit": { + "state": "translated", + "value": "%arg" + } + } + } + } + }, + "total": { + "argNum": 2, + "formatSpecifier": "lld", + "variations": { + "plural": { + "other": { + "stringUnit": { + "state": "translated", + "value": "%arg" + } + } + } + } + } + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "%#@completed@/%#@total@" + }, + "substitutions": { + "completed": { + "argNum": 1, + "formatSpecifier": "lld", + "variations": { + "plural": { + "other": { + "stringUnit": { + "state": "translated", + "value": "%arg" + } + } + } + } + }, + "total": { + "argNum": 2, + "formatSpecifier": "lld", + "variations": { + "plural": { + "other": { + "stringUnit": { + "state": "translated", + "value": "%arg" + } + } + } + } + } + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "%#@completed@/%#@total@" + }, + "substitutions": { + "completed": { + "argNum": 1, + "formatSpecifier": "lld", + "variations": { + "plural": { + "other": { + "stringUnit": { + "state": "translated", + "value": "%arg" + } + } + } + } + }, + "total": { + "argNum": 2, + "formatSpecifier": "lld", + "variations": { + "plural": { + "other": { + "stringUnit": { + "state": "translated", + "value": "%arg" + } + } + } + } + } + } + } + } + }, + "download_badge.queued": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Queued" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "In Warteschlange" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "待機中" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "대기 중" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "已排队" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "已排隊" + } + } + } + }, + "download_badge.update_available": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Update Available" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Update verfügbar" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "更新あり" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "업데이트 가능" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "有可更新" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "有可更新" + } + } + } + } + }, + "version": "1.0" +} \ No newline at end of file diff --git a/AppPackage/Sources/HapticsClient/.swiftlint.yml b/AppPackage/Sources/HapticsClient/.swiftlint.yml new file mode 100644 index 000000000..1242ffcaa --- /dev/null +++ b/AppPackage/Sources/HapticsClient/.swiftlint.yml @@ -0,0 +1 @@ +parent_config: ../../../.swiftlint.yml diff --git a/EhPanda/App/Tools/Clients/HapticsClient.swift b/AppPackage/Sources/HapticsClient/HapticsClient.swift similarity index 51% rename from EhPanda/App/Tools/Clients/HapticsClient.swift rename to AppPackage/Sources/HapticsClient/HapticsClient.swift index 25e4d7d59..a60f08025 100644 --- a/EhPanda/App/Tools/Clients/HapticsClient.swift +++ b/AppPackage/Sources/HapticsClient/HapticsClient.swift @@ -1,18 +1,14 @@ -// -// HapticsClient.swift -// EhPanda -// - import SwiftUI import ComposableArchitecture +import AppTools -struct HapticsClient { - let generateFeedback: (UIImpactFeedbackGenerator.FeedbackStyle) -> Void - let generateNotificationFeedback: (UINotificationFeedbackGenerator.FeedbackType) -> Void +public struct HapticsClient: Sendable { + public let generateFeedback: @MainActor @Sendable (UIImpactFeedbackGenerator.FeedbackStyle) -> Void + public let generateNotificationFeedback: @MainActor @Sendable (UINotificationFeedbackGenerator.FeedbackType) -> Void } extension HapticsClient { - static let live: Self = .init( + public static let live: Self = .init( generateFeedback: { style in HapticsUtil.generateFeedback(style: style) }, @@ -23,14 +19,14 @@ extension HapticsClient { } // MARK: API -enum HapticsClientKey: DependencyKey { - static let liveValue = HapticsClient.live - static let previewValue = HapticsClient.noop - static let testValue = HapticsClient.unimplemented +public enum HapticsClientKey: DependencyKey { + public static let liveValue = HapticsClient.live + public static let previewValue = HapticsClient.noop + public static let testValue = HapticsClient.unimplemented } extension DependencyValues { - var hapticsClient: HapticsClient { + public var hapticsClient: HapticsClient { get { self[HapticsClientKey.self] } set { self[HapticsClientKey.self] = newValue } } @@ -38,14 +34,14 @@ extension DependencyValues { // MARK: Test extension HapticsClient { - static let noop: Self = .init( + public static let noop: Self = .init( generateFeedback: { _ in }, generateNotificationFeedback: { _ in } ) - static func placeholder() -> Result { fatalError() } + public static func placeholder() -> Result { fatalError() } - static let unimplemented: Self = .init( + public static let unimplemented: Self = .init( generateFeedback: IssueReporting.unimplemented(placeholder: placeholder()), generateNotificationFeedback: IssueReporting.unimplemented(placeholder: placeholder()) ) diff --git a/AppPackage/Sources/HapticsClient/Reducer+Haptics.swift b/AppPackage/Sources/HapticsClient/Reducer+Haptics.swift new file mode 100644 index 000000000..c55b6c1c4 --- /dev/null +++ b/AppPackage/Sources/HapticsClient/Reducer+Haptics.swift @@ -0,0 +1,32 @@ +import SwiftUI +import ComposableArchitecture + +extension Reducer { + public func haptics( + unwrapping enum: @escaping (State) -> Enum?, + case caseKeyPath: CaseKeyPath, + hapticsClient: HapticsClient, + style: UIImpactFeedbackGenerator.FeedbackStyle = .light + ) -> some Reducer { + onBecomeNonNil(unwrapping: `enum`, case: caseKeyPath) { _, _ in + .run(operation: { _ in await hapticsClient.generateFeedback(style) }) + } + } + + private func onBecomeNonNil( + unwrapping enum: @escaping (State) -> Enum?, + case caseKeyPath: CaseKeyPath, + perform additionalEffects: @escaping (inout State, Action) -> Effect + ) -> some Reducer { + Reduce { state, action in + let casePath = AnyCasePath(caseKeyPath) + let previousCase = `enum`(state).flatMap(casePath.extract(from:)) + let effects = _reduce(into: &state, action: action) + let currentCase = `enum`(state).flatMap(casePath.extract(from:)) + + return previousCase == nil && currentCase != nil + ? .merge(effects, additionalEffects(&state, action)) + : effects + } + } +} diff --git a/AppPackage/Sources/HomeFeature/.swiftlint.yml b/AppPackage/Sources/HomeFeature/.swiftlint.yml new file mode 100644 index 000000000..1242ffcaa --- /dev/null +++ b/AppPackage/Sources/HomeFeature/.swiftlint.yml @@ -0,0 +1 @@ +parent_config: ../../../.swiftlint.yml diff --git a/AppPackage/Sources/HomeFeature/Frontpage/FrontpageReducer.swift b/AppPackage/Sources/HomeFeature/Frontpage/FrontpageReducer.swift new file mode 100644 index 000000000..16edc7dc2 --- /dev/null +++ b/AppPackage/Sources/HomeFeature/Frontpage/FrontpageReducer.swift @@ -0,0 +1,206 @@ +import ComposableArchitecture +import AppModels +import Sharing +import Foundation +import AppTools +import HapticsClient +import NetworkingFeature +import FiltersFeature +import DateSeekFeature + +@Reducer +public struct FrontpageReducer: Sendable { + public enum Delegate: Equatable, Sendable { + case pushDetail(Gallery) + } + + @Reducer + public enum Destination { + case filters(FiltersReducer) + case dateSeek(DateSeekReducer) + } + + private enum CancelID { + case fetchGalleries, fetchMoreGalleries, fetchDateSeekGalleries + } + + @ObservableState + public struct State: Equatable { + @SharedReader(.globalFilter) public var globalFilter: Filter + @SharedReader(.tagTranslator) public var tagTranslator: TagTranslator + @SharedReader(.setting) public var setting: Setting + @Presents public var destination: Destination.State? + public var keyword = "" + + var filteredGalleries: [Gallery] { + guard !keyword.isEmpty else { return galleries } + return galleries.filter({ $0.title.caseInsensitiveContains(keyword) }) + } + public var galleries = [Gallery]() + public var pageNumber = PageNumber() + public var dateSeekNavigation: DateSeekNavigation? + public var loadingState: LoadingState = .idle + public var footerLoadingState: LoadingState = .idle + + public init() {} + + mutating func insertGalleries(_ galleries: [Gallery]) { + galleries.forEach { gallery in + if !self.galleries.contains(gallery) { + self.galleries.append(gallery) + } + } + } + } + + public enum Action: BindableAction { + case binding(BindingAction) + case delegate(Delegate) + case filtersButtonTapped + case dateSeekButtonTapped(DateSeekNavigation) + case destination(PresentationAction) + + case fetchGalleries + case fetchGalleriesDone(Result) + case fetchMoreGalleries + case fetchMoreGalleriesDone(Result) + case performDateSeekDone(Result) + } + + @Dependency(\.hapticsClient) private var hapticsClient + + public init() {} + + public var body: some Reducer { + BindingReducer() + + Reduce { state, action in + switch action { + case .binding: + return .none + + case .delegate: + return .none + + case .filtersButtonTapped: + state.destination = .filters(FiltersReducer.State()) + return .none + + case .dateSeekButtonTapped(let navigation): + state.destination = .dateSeek(.init(navigation: navigation)) + return .none + + case .fetchGalleries: + guard state.loadingState != .loading else { return .none } + state.loadingState = .loading + state.pageNumber.resetPages() + let filter = state.globalFilter + return .run { send in + let response = await FrontpageGalleriesRequest(filter: filter).response() + await send(.fetchGalleriesDone(response)) + } + .cancellable(id: CancelID.fetchGalleries) + + case .fetchGalleriesDone(let result): + state.loadingState = .idle + switch result { + case .success(let response): + let galleries = response.galleries + guard !galleries.isEmpty else { + state.loadingState = .failed(.notFound) + guard response.pageNumber.hasNextPage() else { return .none } + return .send(.fetchMoreGalleries) + } + state.pageNumber = response.pageNumber + state.dateSeekNavigation = response.dateSeekNavigation + state.galleries = galleries + return .none + case .failure(let error): + state.loadingState = .failed(error) + } + return .none + + case .fetchMoreGalleries: + let pageNumber = state.pageNumber + guard pageNumber.hasNextPage(), + state.footerLoadingState != .loading, + let lastID = state.galleries.last?.id + else { return .none } + state.footerLoadingState = .loading + let filter = state.globalFilter + return .run { send in + let response = await MoreFrontpageGalleriesRequest(filter: filter, lastID: lastID).response() + await send(.fetchMoreGalleriesDone(response)) + } + .cancellable(id: CancelID.fetchMoreGalleries) + + case .fetchMoreGalleriesDone(let result): + state.footerLoadingState = .idle + switch result { + case .success(let response): + let galleries = response.galleries + state.pageNumber = response.pageNumber + state.dateSeekNavigation = response.dateSeekNavigation + state.insertGalleries(galleries) + + var effects: [Effect] = [] + if galleries.isEmpty, response.pageNumber.hasNextPage() { + effects.append(.send(.fetchMoreGalleries)) + } else if !galleries.isEmpty { + state.loadingState = .idle + } + return .merge(effects) + + case .failure(let error): + state.footerLoadingState = .failed(error) + } + return .none + + case .destination(.presented(.dateSeek(.delegate(.performSeek(let url))))): + guard state.loadingState != .loading else { return .none } + state.loadingState = .loading + state.footerLoadingState = .idle + state.pageNumber.resetPages() + return .run { send in + let response = await DateSeekGalleriesRequest(url: url).response() + await send(.performDateSeekDone(response)) + } + .cancellable(id: CancelID.fetchDateSeekGalleries) + + case .performDateSeekDone(let result): + state.loadingState = .idle + switch result { + case .success(let response): + let galleries = response.galleries + guard !galleries.isEmpty else { + state.loadingState = .failed(.notFound) + return .none + } + state.pageNumber = response.pageNumber + state.dateSeekNavigation = response.dateSeekNavigation + state.galleries = galleries + return .none + case .failure(let error): + state.loadingState = .failed(error) + } + return .none + + case .destination: + return .none + } + } + .haptics( + unwrapping: \.destination, + case: \.filters, + hapticsClient: hapticsClient + ) + .haptics( + unwrapping: \.destination, + case: \.dateSeek, + hapticsClient: hapticsClient + ) + .ifLet(\.$destination, action: \.destination) + } +} + +extension FrontpageReducer.Destination.State: Equatable, Sendable {} diff --git a/AppPackage/Sources/HomeFeature/Frontpage/FrontpageView.swift b/AppPackage/Sources/HomeFeature/Frontpage/FrontpageView.swift new file mode 100644 index 000000000..ea548d0e0 --- /dev/null +++ b/AppPackage/Sources/HomeFeature/Frontpage/FrontpageView.swift @@ -0,0 +1,88 @@ +import SwiftUI +import AppModels +import TagTranslationFeature +import Resources +import ComposableArchitecture +import AppTools +import AppComponents +import DateSeekFeature +import GalleryListComponents +import FiltersFeature + +struct FrontpageView: View { + @Bindable private var store: StoreOf + private let blurRadius: Double + + init( + store: StoreOf, + blurRadius: Double + ) { + self.store = store + self.blurRadius = blurRadius + } + + var body: some View { + GenericList( + galleries: store.filteredGalleries, + pageNumber: store.pageNumber, + loadingState: store.loadingState, + footerLoadingState: store.footerLoadingState, + fetchAction: { store.send(.fetchGalleries) }, + fetchMoreAction: { store.send(.fetchMoreGalleries) }, + navigateAction: { store.send(.delegate(.pushDetail($0))) }, + translateAction: { + store.tagTranslator.lookup(word: $0, returnOriginal: !store.setting.translatesTags) + } + ) + .sheet( + item: $store.scope(state: \.destination?.filters, action: \.destination.filters) + ) { store in + FiltersView(store: store) + .autoBlur(radius: blurRadius).environment(\.inSheet, true) + } + .sheet( + item: $store.scope(state: \.destination?.dateSeek, action: \.destination.dateSeek) + ) { store in + @Bindable var store = store + DateSeekPickerView( + selectedDate: $store.date, + navigation: store.navigation, + seekAction: { store.send(.performSeek($0)) } + ) + .accentColor(self.store.setting.accentColor) + .autoBlur(radius: blurRadius) + } + .searchable(text: $store.keyword, prompt: .filter) + .onAppear { + if store.galleries.isEmpty { + DispatchQueue.main.async { + store.send(.fetchGalleries) + } + } + } + .toolbar(content: toolbar) + .navigationTitle(.frontpage) + } + + private func toolbar() -> some ToolbarContent { + CustomToolbarItem { + DateSeekButton(navigation: store.dateSeekNavigation) { navigation in + store.send(.dateSeekButtonTapped(navigation)) + } + FiltersButton(hideText: true) { + store.send(.filtersButtonTapped) + } + } + } +} + +struct FrontpageView_Previews: PreviewProvider { + static var previews: some View { + NavigationStack { + FrontpageView( + store: .init(initialState: .init(), reducer: FrontpageReducer.init), + blurRadius: 0 + ) + } + } +} diff --git a/EhPanda/View/Support/Components/Cells/GalleryCardCell.swift b/AppPackage/Sources/HomeFeature/GalleryCardCell.swift similarity index 78% rename from EhPanda/View/Support/Components/Cells/GalleryCardCell.swift rename to AppPackage/Sources/HomeFeature/GalleryCardCell.swift index 5eb828b79..a9265a38b 100644 --- a/EhPanda/View/Support/Components/Cells/GalleryCardCell.swift +++ b/AppPackage/Sources/HomeFeature/GalleryCardCell.swift @@ -1,15 +1,15 @@ -// -// GalleryCardCell.swift -// EhPanda -// - import SwiftUI +import AppModels +import AppComponents import Colorful import Kingfisher import UIImageColors +import AppTools +import Sharing -struct GalleryCardCell: View { +public struct GalleryCardCell: View { @Environment(\.colorScheme) private var colorScheme + @SharedReader(.setting) private var setting: Setting private let currentID: String private let colors: [Color] @@ -20,7 +20,7 @@ struct GalleryCardCell: View { private let animation: Animation = .interpolatingSpring(stiffness: 50, damping: 1).speed(0.2) - init( + public init( gallery: Gallery, currentID: String, colors: [Color], webImageSuccessAction: @escaping (RetrieveImageResult) -> Void ) { @@ -35,14 +35,19 @@ struct GalleryCardCell: View { return gallery.gid == currentID } private var title: String { - let trimmedTitle = gallery.trimmedTitle - guard !DeviceUtil.isPad, trimmedTitle.count > 20 else { - return gallery.title + let rawTitle = setting.displaysJapaneseTitle ? gallery.titleJpn ?? gallery.title : gallery.title + var trimmed = rawTitle + if let range = trimmed.range(of: "|") { + trimmed = String(trimmed[.. 20 else { + return rawTitle } - return trimmedTitle + return trimmed } - var body: some View { + public var body: some View { ZStack { Color.gray.opacity(0.2) ColorfulView(animated: animated, animation: animation, colors: colors) diff --git a/EhPanda/View/Support/Components/Cells/GalleryRankingCell.swift b/AppPackage/Sources/HomeFeature/GalleryRankingCell.swift similarity index 69% rename from EhPanda/View/Support/Components/Cells/GalleryRankingCell.swift rename to AppPackage/Sources/HomeFeature/GalleryRankingCell.swift index ff688ce54..171c883c5 100644 --- a/EhPanda/View/Support/Components/Cells/GalleryRankingCell.swift +++ b/AppPackage/Sources/HomeFeature/GalleryRankingCell.swift @@ -1,21 +1,25 @@ -// -// GalleryRankingCell.swift -// EhPanda -// - +import AppTools import SwiftUI +import AppModels +import AppComponents import Kingfisher +import Sharing -struct GalleryRankingCell: View { +public struct GalleryRankingCell: View { + @SharedReader(.setting) private var setting: Setting private let gallery: Gallery private let ranking: Int - init(gallery: Gallery, ranking: Int) { + public init(gallery: Gallery, ranking: Int) { self.gallery = gallery self.ranking = ranking } - var body: some View { + private var displayTitle: String { + setting.displaysJapaneseTitle ? gallery.titleJpn ?? gallery.trimmedTitle : gallery.trimmedTitle + } + + public var body: some View { HStack { KFImage(gallery.coverURL) .placeholder { Placeholder(style: .activity(ratio: Defaults.ImageSize.headerAspect)) }.defaultModifier() @@ -23,7 +27,7 @@ struct GalleryRankingCell: View { .cornerRadius(2) Text(String(ranking)).fontWeight(.medium).font(.title2).padding(.horizontal) VStack(alignment: .leading) { - Text(gallery.trimmedTitle).bold().lineLimit(2).fixedSize(horizontal: false, vertical: true) + Text(displayTitle).bold().lineLimit(2).fixedSize(horizontal: false, vertical: true) if let uploader = gallery.uploader { Text(uploader).foregroundColor(.secondary).lineLimit(1) } diff --git a/AppPackage/Sources/HomeFeature/History/HistoryReducer.swift b/AppPackage/Sources/HomeFeature/History/HistoryReducer.swift new file mode 100644 index 000000000..c569ee8b7 --- /dev/null +++ b/AppPackage/Sources/HomeFeature/History/HistoryReducer.swift @@ -0,0 +1,238 @@ +import Foundation +import SwiftUI +import AppModels +import Sharing +import Resources +import ComposableArchitecture +import AppTools +import HapticsClient +import DownloadClient +import NetworkingFeature +import AppComponents + +@Reducer +public struct HistoryReducer: Sendable { + private enum CancelID { + case observeDownloads + case fetch + } + + // The gdata endpoint takes 25 gids/call; a page is two chunks, so a History visit costs at most a + // couple of polite requests instead of refetching every (up to 1,000) entry at once. + static let pageSize = 50 + + public enum Delegate: Equatable, Sendable { + case pushDetail(Gallery) + } + + public enum Dialog: Equatable, Sendable { + case confirmClearHistory + } + + @ObservableState + public struct State: Equatable { + @SharedReader(.tagTranslator) public var tagTranslator: TagTranslator + @SharedReader(.setting) public var setting: Setting + @Presents public var confirmationDialog: ConfirmationDialogState? + public var keyword = "" + public var downloadBadges = [String: DownloadBadge]() + + // The persisted browsing history (identity + recency + resume page, most-recent-first). + // No gallery snapshot is stored, so `galleries` is the display metadata refetched on demand. + @Shared(.galleryHistory) public var galleryHistory: [GalleryHistoryEntry] + + var filteredGalleries: [Gallery] { + guard !keyword.isEmpty else { return galleries } + return galleries.filter { gallery in + gallery.title.caseInsensitiveContains(keyword) + || (gallery.titleJpn?.caseInsensitiveContains(keyword) ?? false) + } + } + public var galleries = [Gallery]() + public var loadingState: LoadingState = .idle + // Paging over the local history: `fetchedCount` is how many most-recent entries have had their + // display metadata fetched so far; `footerLoadingState` drives the "load more" spinner. + public var footerLoadingState: LoadingState = .idle + public var fetchedCount = 0 + + // More history remains to page in beyond what's already been fetched. + var hasMoreHistory: Bool { fetchedCount < galleryHistory.count } + + public init() {} + } + + public enum Action: BindableAction { + case binding(BindingAction) + case onAppear + case delegate(Delegate) + case confirmationDialog(PresentationAction) + case clearHistoryButtonTapped + case clearHistoryGalleries + + case fetchGalleries + case fetchGalleriesDone(Result<[Gallery], AppError>, endIndex: Int) + case fetchMoreGalleries + case fetchMoreGalleriesDone(Result<[Gallery], AppError>, endIndex: Int) + case observeDownloads + case observeDownloadsDone([DownloadedGallery]) + } + + @Dependency(\.downloadClient) private var downloadClient + @Dependency(\.hapticsClient) private var hapticsClient + + public init() {} + + private func mergeHistoryMetadata(_ galleries: [Gallery], entries: [GalleryHistoryEntry]) -> [Gallery] { + let byGID = Dictionary(grouping: entries, by: \.gid) + return galleries.map { gallery in + guard let entry = byGID[gallery.gid]?.first else { return gallery } + var merged = gallery + merged.hasRated = entry.hasRated + merged.favoriteTagIndex = entry.favoriteTagIndex + merged.favoriteTagName = entry.favoriteTagName + return merged + } + } + + private func applyWatchedTagColors(to galleries: [Gallery]) -> [Gallery] { + var recolored = galleries + for galleryIndex in recolored.indices { + var gallery = recolored[galleryIndex] + gallery.tags = WatchedTagsSetting.applyWatchedTagColors(to: gallery.tags) + recolored[galleryIndex] = gallery + } + return recolored + } + + public var body: some Reducer { + BindingReducer() + + Reduce { state, action in + switch action { + case .binding: + return .none + + case .onAppear: + return .send(.observeDownloads) + + case .delegate: + return .none + + case .clearHistoryButtonTapped: + state.confirmationDialog = ConfirmationDialogState(titleVisibility: .hidden) { + TextState(localized: .RLocalizable.clear) + } actions: { + ButtonState(role: .destructive, action: .confirmClearHistory) { + TextState(localized: .RLocalizable.clear) + } + ButtonState(role: .cancel) { + TextState(localized: .RLocalizable.cancel) + } + } message: { + TextState(localized: .RLocalizable.clearDescription) + } + return .none + + case .confirmationDialog(.presented(.confirmClearHistory)): + return .send(.clearHistoryGalleries) + + case .confirmationDialog: + return .none + + case .clearHistoryGalleries: + // Clearing also drops resume positions (they live on the same entries) — deliberate, + // browser-like. Cancel any in-flight fetch first so its late `.success` can't + // repopulate the list we just emptied, then drop straight to the empty state. + state.$galleryHistory.withLock { $0.removeAll() } + state.galleries = [] + state.fetchedCount = 0 + state.footerLoadingState = .idle + state.loadingState = .failed(.notFound) + return .cancel(id: CancelID.fetch) + + case .fetchGalleries: + guard state.loadingState != .loading else { return .none } + state.galleries = [] + state.fetchedCount = 0 + state.footerLoadingState = .idle + let end = min(Self.pageSize, state.galleryHistory.count) + guard end > 0 else { + state.loadingState = .failed(.notFound) + return .none + } + let pairs = state.galleryHistory[0.. + private let blurRadius: Double + + init( + store: StoreOf, + blurRadius: Double + ) { + self.store = store + self.blurRadius = blurRadius + } + + var body: some View { + GenericList( + galleries: store.filteredGalleries, + pageNumber: PageNumber(isNextButtonEnabled: store.hasMoreHistory), + loadingState: store.loadingState, + footerLoadingState: store.footerLoadingState, + // A leading list section, rather than a pinned top banner, keeps the navigation title + // intact: only the most-recent records survive the launch-time prune. + notice: .historyLimitDescription(limit: GalleryHistoryEntry.historyCap), + fetchAction: { store.send(.fetchGalleries) }, + fetchMoreAction: { store.send(.fetchMoreGalleries) }, + navigateAction: { store.send(.delegate(.pushDetail($0))) }, + translateAction: { + store.tagTranslator.lookup(word: $0, returnOriginal: !store.setting.translatesTags) + }, + downloadBadges: store.downloadBadges + ) + .searchable(text: $store.keyword, prompt: .filter) + .onAppear { + store.send(.onAppear) + if store.galleries.isEmpty { + DispatchQueue.main.async { + store.send(.fetchGalleries) + } + } + } + .toolbar(content: toolbar) + .navigationTitle(.history) + } + + private func toolbar() -> some ToolbarContent { + CustomToolbarItem { + Button { + store.send(.clearHistoryButtonTapped) + } label: { + Image(systemSymbol: .trashCircle) + } + .disabled(store.loadingState == .loading || store.galleryHistory.isEmpty) + .confirmationDialog( + $store.scope(state: \.confirmationDialog, action: \.confirmationDialog) + ) + } + } +} + +struct HistoryView_Previews: PreviewProvider { + static var previews: some View { + NavigationStack { + HistoryView( + store: .init(initialState: .init(), reducer: HistoryReducer.init), + blurRadius: 0 + ) + } + } +} diff --git a/AppPackage/Sources/HomeFeature/HomePath.swift b/AppPackage/Sources/HomeFeature/HomePath.swift new file mode 100644 index 000000000..bdb2f31af --- /dev/null +++ b/AppPackage/Sources/HomeFeature/HomePath.swift @@ -0,0 +1,49 @@ +import ComposableArchitecture +import DetailFeature + +// The Home tab's navigation stack: its five full-list screens plus the shared gallery drill-down, +// nested as a `.gallery` case so the gallery routing stays defined once in `GalleryPath`. +@Reducer +public enum HomePath { + case frontpage(FrontpageReducer) + case popular(PopularReducer) + case toplists(ToplistsReducer) + case watched(WatchedReducer) + case history(HistoryReducer) + case gallery(GalleryPath.Body = GalleryPath.body) +} + +extension HomePath.State: Equatable {} + +extension HomePath.State: GalleryRouteIdentifiable { + public var routeKey: String { + switch self { + case .frontpage: + return "frontpage" + case .popular: + return "popular" + case .toplists: + return "toplists" + case .watched: + return "watched" + case .history: + return "history" + case .gallery(let state): + return "gallery/\(state.routeKey)" + } + } +} + +extension StackState where Element == HomePath.State { + // Locate the pushed `.gallery(.detail)` element for `gid` so a comment action performed on a + // deeper `.comments` screen can refresh the detail it belongs to. + func galleryDetailID(forGID gid: String) -> StackElementID? { + for id in ids { + guard let element = self[id: id] else { continue } + if case .gallery(.detail(let state)) = element, state.gid == gid { + return id + } + } + return nil + } +} diff --git a/AppPackage/Sources/HomeFeature/HomeReducer+Body.swift b/AppPackage/Sources/HomeFeature/HomeReducer+Body.swift new file mode 100644 index 000000000..e01f5769f --- /dev/null +++ b/AppPackage/Sources/HomeFeature/HomeReducer+Body.swift @@ -0,0 +1,184 @@ +import SwiftUI +import Kingfisher +import ComposableArchitecture +import NetworkingFeature +import AppModels +import Sharing +import DetailFeature + +extension HomeReducer { + @ReducerBuilder + var reducerBody: some Reducer { + BindingReducer() + .onChange(of: \.cardPageIndex) { _, state in + guard state.cardPageIndex < state.popularGalleries.count else { return .none } + state.currentCardID = state.popularGalleries[state.cardPageIndex].gid + state.allowsCardHitTesting = false + return .run { send in + try await Task.sleep(for: .milliseconds(300)) + await send(.setAllowsCardHitTesting(true)) + } + } + + Reduce { state, action in + switch action { + case .binding: + return .none + + case .galleryTapped(let gallery), + let .path(.element(id: _, action: .frontpage(.delegate(.pushDetail(gallery))))), + let .path(.element(id: _, action: .popular(.delegate(.pushDetail(gallery))))), + let .path(.element(id: _, action: .toplists(.delegate(.pushDetail(gallery))))), + let .path(.element(id: _, action: .watched(.delegate(.pushDetail(gallery))))), + let .path(.element(id: _, action: .history(.delegate(.pushDetail(gallery))))): + return GalleryNavigation.routeGalleryDetail( + isPad: deviceClient.isPad, + present: { .delegate(.presentGalleryDetail(gallery)) }, + push: { .pushGalleryDetail(gallery) } + ) + + case .pushGalleryDetail(let gallery): + state.path.appendGuardingDuplicate(.gallery(.detail(.init(gallery: gallery)))) + return .none + + case .delegate: + return .none + + case .sectionTapped(let type): + switch type { + case .frontpage: + state.path.appendGuardingDuplicate(.frontpage(.init())) + case .toplists: + state.path.appendGuardingDuplicate(.toplists(.init())) + } + return .none + + case .miscTapped(let type): + switch type { + case .popular: + state.path.appendGuardingDuplicate(.popular(.init())) + case .watched: + state.path.appendGuardingDuplicate(.watched(.init())) + case .history: + state.path.appendGuardingDuplicate(.history(.init())) + } + return .none + + case let .path(.element(id: _, action: .gallery(.comments(.delegate(.performedCommentAction(gid)))))): + guard let id = state.path.galleryDetailID(forGID: gid) else { return .none } + return .send(.path(.element(id: id, action: .gallery(.detail(.fetchGalleryDetail))))) + + case let .path(.element(id: _, action: .gallery(galleryAction))): + if let next = GalleryNavigation.nextScreen(for: galleryAction) { + state.path.appendGuardingDuplicate(.gallery(next)) + } + return .none + + case .path: + return .none + + case .setAllowsCardHitTesting(let isAllowed): + state.allowsCardHitTesting = isAllowed + return .none + + case .fetchAllGalleries: + return .merge( + .send(.fetchPopularGalleries), + .send(.fetchFrontpageGalleries), + .send(.fetchAllToplistsGalleries) + ) + + case .fetchAllToplistsGalleries: + return .merge( + ToplistsType.allCases + .map { Action.fetchToplistsGalleries($0.categoryIndex) } + .map(Effect.send) + ) + + case .fetchPopularGalleries: + guard state.popularLoadingState != .loading else { return .none } + state.popularLoadingState = .loading + state.rawCardColors = [String: [Color]]() + let filter = state.globalFilter + return .run { send in + let response = await PopularGalleriesRequest(filter: filter).response() + await send(.fetchPopularGalleriesDone(response)) + } + + case .fetchPopularGalleriesDone(let result): + state.popularLoadingState = .idle + switch result { + case .success(let galleries): + guard !galleries.isEmpty else { + state.popularLoadingState = .failed(.notFound) + return .none + } + state.setPopularGalleries(galleries) + return .none + case .failure(let error): + state.popularLoadingState = .failed(error) + } + return .none + + case .fetchFrontpageGalleries: + guard state.frontpageLoadingState != .loading else { return .none } + state.frontpageLoadingState = .loading + let filter = state.globalFilter + return .run { send in + let response = await FrontpageGalleriesRequest(filter: filter).response() + await send(.fetchFrontpageGalleriesDone(response.map { ($0.pageNumber, $0.galleries) })) + } + + 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 .none + case .failure(let error): + state.frontpageLoadingState = .failed(error) + } + return .none + + case .fetchToplistsGalleries(let index, let pageNum): + guard state.toplistsLoadingState[index] != .loading else { return .none } + state.toplistsLoadingState[index] = .loading + return .run { send in + let response = await ToplistsGalleriesRequest(catIndex: index, pageNum: pageNum).response() + await send(.fetchToplistsGalleriesDone(index, response)) + } + + case .fetchToplistsGalleriesDone(let index, let result): + state.toplistsLoadingState[index] = .idle + switch result { + case .success(let (_, galleries)): + guard !galleries.isEmpty else { + state.toplistsLoadingState[index] = .failed(.notFound) + return .none + } + state.toplistsGalleries[index] = galleries + return .none + case .failure(let error): + state.toplistsLoadingState[index] = .failed(error) + } + return .none + + case .analyzeImageColors(let gid, let result): + guard !state.rawCardColors.keys.contains(gid) else { return .none } + return .run { send in + let colors = await libraryClient.analyzeImageColors(result.image) + await send(.analyzeImageColorsDone(gid, colors)) + } + + case .analyzeImageColorsDone(let gid, let colors): + state.rawCardColors[gid] = colors + return .none + } + } + .forEach(\.path, action: \.path) + } +} diff --git a/AppPackage/Sources/HomeFeature/HomeReducer.swift b/AppPackage/Sources/HomeFeature/HomeReducer.swift new file mode 100644 index 000000000..5b4cccff4 --- /dev/null +++ b/AppPackage/Sources/HomeFeature/HomeReducer.swift @@ -0,0 +1,87 @@ +import SwiftUI +import AppModels +import Kingfisher +import Sharing +import ComposableArchitecture +import AppTools +import LibraryClient +import DeviceClient + +@Reducer +public struct HomeReducer: Sendable { + public enum Delegate: Equatable, Sendable { + case presentGalleryDetail(Gallery) + } + + @ObservableState + public struct State: Equatable { + @SharedReader(.globalFilter) public var globalFilter: AppModels.Filter + public var path = StackState() + public var cardPageIndex = 1 + public var currentCardID = "" + public var allowsCardHitTesting = true + public var rawCardColors = [String: [Color]]() + var cardColors: [Color] { + rawCardColors[currentCardID] ?? [.clear] + } + + public var popularGalleries = [Gallery]() + public var popularLoadingState: LoadingState = .idle + public var frontpageGalleries = [Gallery]() + public var frontpageLoadingState: LoadingState = .idle + public var toplistsGalleries = [Int: [Gallery]]() + public var toplistsLoadingState = [Int: LoadingState]() + + public init() {} + + mutating func setPopularGalleries(_ galleries: [Gallery]) { + let filteredGalleries = galleries.filter { $0.rating >= 3 } + let sortedGalleries = filteredGalleries.sorted { lhs, rhs in + lhs.title.count > rhs.title.count + } + var trimmedGalleries = Array(sortedGalleries.prefix(min(sortedGalleries.count, 10))) + .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(min(galleries.count, 25))) + .removeDuplicates(by: \.trimmedTitle) + } + + } + + public enum Action: BindableAction { + case binding(BindingAction) + case delegate(Delegate) + case galleryTapped(Gallery) + case pushGalleryDetail(Gallery) + case sectionTapped(HomeSectionType) + case miscTapped(HomeMiscGridType) + case path(StackActionOf) + case setAllowsCardHitTesting(Bool) + case analyzeImageColors(String, RetrieveImageResult) + case analyzeImageColorsDone(String, [Color]?) + + case fetchAllGalleries + case fetchAllToplistsGalleries + case fetchPopularGalleries + case fetchPopularGalleriesDone(Result<[Gallery], AppError>) + case fetchFrontpageGalleries + case fetchFrontpageGalleriesDone(Result<(PageNumber, [Gallery]), AppError>) + case fetchToplistsGalleries(Int, Int? = nil) + case fetchToplistsGalleriesDone(Int, Result<(PageNumber, [Gallery]), AppError>) + } + + @Dependency(\.deviceClient) var deviceClient + @Dependency(\.libraryClient) var libraryClient + + public init() {} + + public var body: some Reducer { reducerBody } +} diff --git a/AppPackage/Sources/HomeFeature/HomeView+Sections.swift b/AppPackage/Sources/HomeFeature/HomeView+Sections.swift new file mode 100644 index 000000000..2a4502bda --- /dev/null +++ b/AppPackage/Sources/HomeFeature/HomeView+Sections.swift @@ -0,0 +1,310 @@ +import SwiftUI +import AppModels +import Resources +import Kingfisher +import SwiftUIPager +import SFSafeSymbols +import AppTools +import AppComponents + +// MARK: CardSlideSection +struct CardSlideSection: View, Equatable { + @StateObject private var page: Page = .withIndex(1) + @Binding private var pageIndex: Int + + private let galleries: [Gallery] + private let currentID: String + private let colors: [Color] + private let navigateAction: (Gallery) -> Void + private let webImageSuccessAction: (String, RetrieveImageResult) -> Void + + init( + galleries: [Gallery], pageIndex: Binding, currentID: String, + colors: [Color], + navigateAction: @escaping (Gallery) -> Void, + webImageSuccessAction: @escaping (String, RetrieveImageResult) -> Void + ) { + self.galleries = galleries + _pageIndex = pageIndex + self.currentID = currentID + self.colors = colors + self.navigateAction = navigateAction + self.webImageSuccessAction = webImageSuccessAction + } + + static func == (lhs: CardSlideSection, rhs: CardSlideSection) -> Bool { + lhs.galleries == rhs.galleries + && lhs.currentID == rhs.currentID + && lhs.colors == rhs.colors + } + + var body: some View { + Pager(page: page, data: galleries) { gallery in + Button { + navigateAction(gallery) + } label: { + GalleryCardCell( + gallery: gallery, + currentID: currentID, + colors: colors, + webImageSuccessAction: { + webImageSuccessAction(gallery.gid, $0) + } + ) + .tint(.primary) + .multilineTextAlignment(.leading) + } + } + .preferredItemSize(Defaults.FrameSize.cardCellSize) + .interactive(opacity: 0.2).itemSpacing(20) + .loopPages().pagingPriority(.high) + .synchronize($pageIndex, $page.index) + .frame(height: Defaults.FrameSize.cardCellHeight) + } +} + +// MARK: CoverWallSection +struct CoverWallSection: View { + private let galleries: [Gallery] + private let isLoading: Bool + private let navigateAction: (Gallery) -> Void + private let showAllAction: () -> Void + private let reloadAction: () -> Void + + init( + galleries: [Gallery], isLoading: Bool, + navigateAction: @escaping (Gallery) -> Void, + showAllAction: @escaping () -> Void, + reloadAction: @escaping () -> Void + ) { + self.galleries = galleries + self.isLoading = isLoading + self.navigateAction = navigateAction + self.showAllAction = showAllAction + self.reloadAction = reloadAction + } + + private var dataSource: [[Gallery]] { + var galleries = galleries + if galleries.isEmpty { + galleries = Gallery.mockGalleries(count: 25) + } + if galleries.count % 2 != 0 { galleries = galleries.dropLast() } + return stride(from: 0, to: galleries.count, by: 2).map { index in + [galleries[index], galleries[index + 1]] + } + } + + var body: some View { + SubSection( + title: .frontpage, + tint: .secondary, isLoading: isLoading, + reloadAction: reloadAction, + showAllAction: showAllAction + ) { + ScrollView(.horizontal, showsIndicators: false) { + HStack(spacing: 20) { + ForEach(dataSource, id: \.first) { + VerticalCoverStack(galleries: $0, navigateAction: navigateAction) + } + .withHorizontalSpacing(width: 0) + } + } + .frame(height: Defaults.ImageSize.rowH * 2 + 30) + } + } +} + +struct VerticalCoverStack: View { + private let galleries: [Gallery] + private let navigateAction: (Gallery) -> Void + + init(galleries: [Gallery], navigateAction: @escaping (Gallery) -> Void) { + self.galleries = galleries + self.navigateAction = navigateAction + } + + private func placeholder() -> some View { + Placeholder(style: .activity(ratio: Defaults.ImageSize.headerAspect)) + } + private func imageContainer(gallery: Gallery) -> some View { + Button { + navigateAction(gallery) + } label: { + KFImage(gallery.coverURL) + .placeholder(placeholder) + .defaultModifier() + .scaledToFill() + .frame(width: Defaults.ImageSize.rowW, height: Defaults.ImageSize.rowH).cornerRadius(2) + } + } + + var body: some View { + VStack(spacing: 20) { + ForEach(galleries, content: imageContainer) + } + } +} + +// MARK: ToplistsSection +struct ToplistsSection: View { + private let galleries: [Int: [Gallery]] + private let isLoading: Bool + private let navigateAction: (Gallery) -> Void + private let showAllAction: () -> Void + private let reloadAction: () -> Void + + init( + galleries: [Int: [Gallery]], isLoading: Bool, + navigateAction: @escaping (Gallery) -> 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 = .preview + gallery.title = "......" + gallery.uploader = "......" + let galleries = Array(repeating: gallery, count: 6) + + ToplistsType.allCases.forEach { type in + dictionary[type.categoryIndex] = galleries + } + return dictionary + } + return galleries + } + private func galleries(type: ToplistsType, range: ClosedRange) -> [Gallery] { + let galleries = dataSource[type.categoryIndex] ?? [] + guard galleries.count > range.upperBound else { return [] } + return Array(galleries[range]) + } + + var body: some View { + SubSection( + title: .toplists, + tint: .secondary, isLoading: isLoading, + reloadAction: reloadAction, + showAllAction: showAllAction + ) { + ScrollView(.horizontal, showsIndicators: false) { + HStack { + ForEach(ToplistsType.allCases, content: verticalStacks) + } + } + } + } + private func verticalStacks(type: ToplistsType) -> some View { + VStack(alignment: .leading) { + Text(type.value).font(.subheadline.bold()) + HStack { + VerticalToplistsStack( + galleries: galleries(type: type, range: 0...2), startRanking: 1, + navigateAction: navigateAction + ) + if DeviceUtil.isPad { + VerticalToplistsStack( + galleries: galleries(type: type, range: 3...5), startRanking: 4, + navigateAction: navigateAction + ) + } + } + } + .padding(.horizontal, 20).padding(.vertical, 5) + } +} + +struct VerticalToplistsStack: View { + private let galleries: [Gallery] + private let startRanking: Int + private let navigateAction: (Gallery) -> Void + + init( + galleries: [Gallery], + startRanking: Int, + navigateAction: @escaping (Gallery) -> Void + ) { + self.galleries = galleries + self.startRanking = startRanking + self.navigateAction = navigateAction + } + + var body: some View { + VStack(spacing: 10) { + ForEach(0.. Void + + init(navigateAction: @escaping (HomeMiscGridType) -> Void) { + self.navigateAction = navigateAction + } + + var body: some View { + SubSection(title: .other, showAll: false) { + ScrollView(.horizontal, showsIndicators: false) { + HStack { + let types = HomeMiscGridType.allCases + ForEach(types) { type in + Button { + navigateAction(type) + } label: { + MiscGridItem(title: type.title, symbol: type.symbol).tint(.primary) + } + .padding(.trailing, type == types.last ? 0 : 10) + } + .withHorizontalSpacing() + } + } + } + } +} + +struct MiscGridItem: View { + private let title: LocalizedStringResource + private let subTitle: LocalizedStringResource? + private let symbol: SFSymbol + + init(title: LocalizedStringResource, subTitle: LocalizedStringResource? = nil, symbol: SFSymbol) { + self.title = title + self.subTitle = subTitle + self.symbol = symbol + } + + var body: some View { + HStack { + VStack(alignment: .leading) { + Text(title).font(.title2.bold()).lineLimit(1).frame(minWidth: 100) + if let subTitle = subTitle { + Text(subTitle).font(.subheadline).foregroundColor(.secondary).lineLimit(2) + } + } + Image(systemSymbol: symbol).font(.system(size: 50, weight: .light, design: .default)) + .foregroundColor(.secondary).imageScale(.large).offset(x: 20, y: 20) + } + .padding(30).cornerRadius(15).background(Color(.systemGray6).cornerRadius(15)) + } +} diff --git a/AppPackage/Sources/HomeFeature/HomeView.swift b/AppPackage/Sources/HomeFeature/HomeView.swift new file mode 100644 index 000000000..138a4915a --- /dev/null +++ b/AppPackage/Sources/HomeFeature/HomeView.swift @@ -0,0 +1,188 @@ +import SwiftUI +import AppModels +import Resources +import Kingfisher +import SFSafeSymbols +import ComposableArchitecture +import AppTools +import AppComponents +import DetailFeature + +public struct HomeView: View { + @Bindable private var store: StoreOf + private let blurRadius: Double + + public init( + store: StoreOf, + blurRadius: Double + ) { + self.store = store + self.blurRadius = blurRadius + } + + // MARK: HomeView + public var body: some View { + NavigationStack(path: $store.scope(state: \.path, action: \.path)) { + ZStack { + ScrollView(showsIndicators: false) { + VStack { + if !store.popularGalleries.isEmpty { + CardSlideSection( + galleries: store.popularGalleries, + pageIndex: $store.cardPageIndex, + currentID: store.currentCardID, + colors: store.cardColors, + navigateAction: navigateTo(gallery:), + webImageSuccessAction: { gid, result in + store.send(.analyzeImageColors(gid, result)) + } + ) + .equatable().allowsHitTesting(store.allowsCardHitTesting) + } + Group { + if store.frontpageGalleries.count > 1 { + CoverWallSection( + galleries: store.frontpageGalleries, + isLoading: store.frontpageLoadingState == .loading, + navigateAction: navigateTo(gallery:), + showAllAction: { store.send(.sectionTapped(.frontpage)) }, + reloadAction: { store.send(.fetchFrontpageGalleries) } + ) + } + ToplistsSection( + galleries: store.toplistsGalleries, + isLoading: !store.toplistsLoadingState + .values.allSatisfy({ $0 != .loading }), + navigateAction: navigateTo(gallery:), + showAllAction: { store.send(.sectionTapped(.toplists)) }, + reloadAction: { store.send(.fetchAllToplistsGalleries) } + ) + MiscGridSection(navigateAction: navigateTo(type:)) + } + .padding(.vertical) + } + } + .opacity(store.popularGalleries.isEmpty ? 0 : 1).zIndex(2) + + LoadingView() + .opacity( + store.popularLoadingState == .loading + && store.popularGalleries.isEmpty ? 1 : 0 + ) + .zIndex(0) + + let error = store.popularLoadingState.failed + ErrorView(error: error ?? .unknown) { + store.send(.fetchAllGalleries) + } + .opacity(store.popularGalleries.isEmpty && error != nil ? 1 : 0) + .zIndex(1) + } + .animation(.default, value: store.popularLoadingState) + .onAppear { + if store.popularGalleries.isEmpty { + store.send(.fetchAllGalleries) + } + } + .toolbar(content: toolbar) + .navigationTitle(.RLocalizable.home) + } destination: { store in + switch store.case { + case .frontpage(let store): + FrontpageView( + store: store, blurRadius: blurRadius + ) + case .popular(let store): + PopularView( + store: store, blurRadius: blurRadius + ) + case .toplists(let store): + ToplistsView( + store: store, blurRadius: blurRadius + ) + case .watched(let store): + WatchedView( + store: store, blurRadius: blurRadius + ) + case .history(let store): + HistoryView( + store: store, blurRadius: blurRadius + ) + case .gallery(let store): + galleryDestination( + store, blurRadius: blurRadius + ) + } + } + } + + private func toolbar() -> some ToolbarContent { + CustomToolbarItem(tint: .primary) { + Button { + store.send(.fetchAllGalleries) + } label: { + Image(systemSymbol: .arrowCounterclockwise) + } + .opacity(store.popularLoadingState == .loading ? 0 : 1) + .overlay(ProgressView().opacity(store.popularLoadingState == .loading ? 1 : 0)) + } + } +} + +// MARK: Navigation +private extension HomeView { + func navigateTo(gallery: Gallery) { + store.send(.galleryTapped(gallery)) + } + func navigateTo(type: HomeMiscGridType) { + store.send(.miscTapped(type)) + } +} + +// MARK: Definition +public enum HomeMiscGridType: CaseIterable, Identifiable, Sendable { + public var id: String { String(localized: title) } + + case popular + case watched + case history +} + +extension HomeMiscGridType { + var title: LocalizedStringResource { + switch self { + case .popular: + return .homeMiscGridTypePopular + case .watched: + return .homeMiscGridTypeWatched + case .history: + return .homeMiscGridTypeHistory + } + } + var symbol: SFSymbol { + switch self { + case .popular: + return .flame + case .watched: + return .tagCircle + case .history: + return .clockArrowTriangleheadCounterclockwiseRotate90 + } + } +} + +public enum HomeSectionType: String, CaseIterable, Identifiable, Sendable { + public var id: String { rawValue } + + case frontpage + case toplists +} + +struct HomeView_Previews: PreviewProvider { + static var previews: some View { + HomeView( + store: .init(initialState: .init(), reducer: HomeReducer.init), + blurRadius: 0 + ) + } +} diff --git a/AppPackage/Sources/HomeFeature/Popular/PopularReducer.swift b/AppPackage/Sources/HomeFeature/Popular/PopularReducer.swift new file mode 100644 index 000000000..32efe34df --- /dev/null +++ b/AppPackage/Sources/HomeFeature/Popular/PopularReducer.swift @@ -0,0 +1,109 @@ +import ComposableArchitecture +import AppModels +import Sharing +import AppTools +import HapticsClient +import NetworkingFeature +import FiltersFeature + +@Reducer +public struct PopularReducer: Sendable { + public enum Delegate: Equatable, Sendable { + case pushDetail(Gallery) + } + + @Reducer + public enum Destination { + case filters(FiltersReducer) + } + + private enum CancelID { + case fetchGalleries + } + + @ObservableState + public struct State: Equatable { + @SharedReader(.globalFilter) public var globalFilter: Filter + @SharedReader(.tagTranslator) public var tagTranslator: TagTranslator + @SharedReader(.setting) public var setting: Setting + @Presents public var destination: Destination.State? + public var keyword = "" + + var filteredGalleries: [Gallery] { + guard !keyword.isEmpty else { return galleries } + return galleries.filter({ $0.title.caseInsensitiveContains(keyword) }) + } + public var galleries = [Gallery]() + public var loadingState: LoadingState = .idle + + public init() {} + } + + public enum Action: BindableAction { + case binding(BindingAction) + case delegate(Delegate) + case filtersButtonTapped + case destination(PresentationAction) + + case fetchGalleries + case fetchGalleriesDone(Result<[Gallery], AppError>) + } + + @Dependency(\.hapticsClient) private var hapticsClient + + public init() {} + + public var body: some Reducer { + BindingReducer() + + Reduce { state, action in + switch action { + case .binding: + return .none + + case .delegate: + return .none + + case .filtersButtonTapped: + state.destination = .filters(FiltersReducer.State()) + return .none + + case .destination: + return .none + + case .fetchGalleries: + guard state.loadingState != .loading else { return .none } + state.loadingState = .loading + let filter = state.globalFilter + return .run { send in + let response = await PopularGalleriesRequest(filter: filter).response() + await send(.fetchGalleriesDone(response)) + } + .cancellable(id: CancelID.fetchGalleries) + + 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 .none + case .failure(let error): + state.loadingState = .failed(error) + } + return .none + } + } + .haptics( + unwrapping: \.destination, + case: \.filters, + hapticsClient: hapticsClient + ) + .ifLet(\.$destination, action: \.destination) + } +} + +extension PopularReducer.Destination.State: Equatable, Sendable {} diff --git a/AppPackage/Sources/HomeFeature/Popular/PopularView.swift b/AppPackage/Sources/HomeFeature/Popular/PopularView.swift new file mode 100644 index 000000000..a2ef06463 --- /dev/null +++ b/AppPackage/Sources/HomeFeature/Popular/PopularView.swift @@ -0,0 +1,71 @@ +import SwiftUI +import AppModels +import TagTranslationFeature +import Resources +import ComposableArchitecture +import AppTools +import AppComponents +import GalleryListComponents +import FiltersFeature + +struct PopularView: View { + @Bindable private var store: StoreOf + private let blurRadius: Double + + init( + store: StoreOf, + blurRadius: Double + ) { + self.store = store + self.blurRadius = blurRadius + } + + var body: some View { + GenericList( + galleries: store.filteredGalleries, + pageNumber: nil, + loadingState: store.loadingState, + footerLoadingState: .idle, + fetchAction: { store.send(.fetchGalleries) }, + navigateAction: { store.send(.delegate(.pushDetail($0))) }, + translateAction: { + store.tagTranslator.lookup(word: $0, returnOriginal: !store.setting.translatesTags) + } + ) + .sheet( + item: $store.scope(state: \.destination?.filters, action: \.destination.filters) + ) { store in + FiltersView(store: store) + .autoBlur(radius: blurRadius).environment(\.inSheet, true) + } + .searchable(text: $store.keyword, prompt: .filter) + .onAppear { + if store.galleries.isEmpty { + DispatchQueue.main.async { + store.send(.fetchGalleries) + } + } + } + .toolbar(content: toolbar) + .navigationTitle(.popular) + } + + private func toolbar() -> some ToolbarContent { + CustomToolbarItem { + FiltersButton(hideText: true) { + store.send(.filtersButtonTapped) + } + } + } +} + +struct PopularView_Previews: PreviewProvider { + static var previews: some View { + NavigationStack { + PopularView( + store: .init(initialState: .init(), reducer: PopularReducer.init), + blurRadius: 0 + ) + } + } +} diff --git a/AppPackage/Sources/HomeFeature/Resources/Localizable.xcstrings b/AppPackage/Sources/HomeFeature/Resources/Localizable.xcstrings new file mode 100644 index 000000000..07f1f4782 --- /dev/null +++ b/AppPackage/Sources/HomeFeature/Resources/Localizable.xcstrings @@ -0,0 +1,731 @@ +{ + "sourceLanguage": "en", + "strings": { + "confirm": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Confirm" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Bestätigen" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "確認" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "확인" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "确认" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "確定" + } + } + } + }, + "filter": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Filter" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Filter" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "フィルター" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "필터" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "筛选" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "過濾" + } + } + } + }, + "frontpage": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Frontpage" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Startseite" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "フロントページ" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "프론트 페이지" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "扉页" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "首頁" + } + } + } + }, + "history": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "History" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Verlauf" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "閲覧履歴" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "읽은 목록" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "历史" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "歷程" + } + } + } + }, + "history_limit_description": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Only the latest %#@limit@ records are preserved." + }, + "substitutions": { + "limit": { + "argNum": 1, + "formatSpecifier": "lld", + "variations": { + "plural": { + "other": { + "stringUnit": { + "state": "translated", + "value": "%arg" + } + } + } + } + } + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Es werden nur die neuesten %#@limit@ Einträge aufbewahrt." + }, + "substitutions": { + "limit": { + "argNum": 1, + "formatSpecifier": "lld", + "variations": { + "plural": { + "other": { + "stringUnit": { + "state": "translated", + "value": "%arg" + } + } + } + } + } + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "最新の %#@limit@ 件のみが保存されます。" + }, + "substitutions": { + "limit": { + "argNum": 1, + "formatSpecifier": "lld", + "variations": { + "plural": { + "other": { + "stringUnit": { + "state": "translated", + "value": "%arg" + } + } + } + } + } + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "최근 %#@limit@개의 기록만 보관됩니다." + }, + "substitutions": { + "limit": { + "argNum": 1, + "formatSpecifier": "lld", + "variations": { + "plural": { + "other": { + "stringUnit": { + "state": "translated", + "value": "%arg" + } + } + } + } + } + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "仅保留最近的 %#@limit@ 条记录。" + }, + "substitutions": { + "limit": { + "argNum": 1, + "formatSpecifier": "lld", + "variations": { + "plural": { + "other": { + "stringUnit": { + "state": "translated", + "value": "%arg" + } + } + } + } + } + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "僅保留最近的 %#@limit@ 筆記錄。" + }, + "substitutions": { + "limit": { + "argNum": 1, + "formatSpecifier": "lld", + "variations": { + "plural": { + "other": { + "stringUnit": { + "state": "translated", + "value": "%arg" + } + } + } + } + } + } + } + } + }, + "home_misc_grid_type.history": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "History" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Verlauf" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "閲覧履歴" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "읽은 목록" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "历史" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "歷程" + } + } + } + }, + "home_misc_grid_type.popular": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Popular" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Beliebt" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "人気" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "인기 작품" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "热门" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "熱門" + } + } + } + }, + "home_misc_grid_type.watched": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Watched" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Meine Tags" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "タグの購読" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "주시 태그" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "标签" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "關注" + } + } + } + }, + "jump_page_description": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Enter a page number between 1 and %#@max@ to jump to." + }, + "substitutions": { + "max": { + "argNum": 1, + "formatSpecifier": "lld", + "variations": { + "plural": { + "other": { + "stringUnit": { + "state": "translated", + "value": "%arg" + } + } + } + } + } + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Geben Sie eine Seitenzahl zwischen 1 und %#@max@ ein." + }, + "substitutions": { + "max": { + "argNum": 1, + "formatSpecifier": "lld", + "variations": { + "plural": { + "other": { + "stringUnit": { + "state": "translated", + "value": "%arg" + } + } + } + } + } + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "1 ~ %#@max@ のページ番号を入力してください。" + }, + "substitutions": { + "max": { + "argNum": 1, + "formatSpecifier": "lld", + "variations": { + "plural": { + "other": { + "stringUnit": { + "state": "translated", + "value": "%arg" + } + } + } + } + } + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "1에서 %#@max@ 사이의 페이지 번호를 입력하세요." + }, + "substitutions": { + "max": { + "argNum": 1, + "formatSpecifier": "lld", + "variations": { + "plural": { + "other": { + "stringUnit": { + "state": "translated", + "value": "%arg" + } + } + } + } + } + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "请输入 1 到 %#@max@ 之间的页码。" + }, + "substitutions": { + "max": { + "argNum": 1, + "formatSpecifier": "lld", + "variations": { + "plural": { + "other": { + "stringUnit": { + "state": "translated", + "value": "%arg" + } + } + } + } + } + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "請輸入 1 到 %#@max@ 之間的頁碼。" + }, + "substitutions": { + "max": { + "argNum": 1, + "formatSpecifier": "lld", + "variations": { + "plural": { + "other": { + "stringUnit": { + "state": "translated", + "value": "%arg" + } + } + } + } + } + } + } + } + }, + "other": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Other" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Sonstiges" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "その他" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "기타" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "其它" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "其他" + } + } + } + }, + "popular": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Popular" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Beliebt" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "人気" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "인기 작품" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "热门" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "熱門" + } + } + } + }, + "toplists": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Toplists" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Toplisten" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ランキング" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "상위 목록" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "排行" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "排行" + } + } + } + }, + "watched": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Watched" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Meine Tags" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "タグの購読" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "주시 태그" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "标签" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "關注" + } + } + } + } + }, + "version": "1.0" +} \ No newline at end of file diff --git a/EhPanda/View/Home/Toplists/ToplistsReducer.swift b/AppPackage/Sources/HomeFeature/Toplists/ToplistsReducer.swift similarity index 66% rename from EhPanda/View/Home/Toplists/ToplistsReducer.swift rename to AppPackage/Sources/HomeFeature/Toplists/ToplistsReducer.swift index b36ca8fe7..defc668a5 100644 --- a/EhPanda/View/Home/Toplists/ToplistsReducer.swift +++ b/AppPackage/Sources/HomeFeature/Toplists/ToplistsReducer.swift @@ -1,40 +1,44 @@ -// -// ToplistsReducer.swift -// EhPanda -// - import ComposableArchitecture +import AppModels +import AppTools +import AppComponents +import Resources +import HapticsClient +import NetworkingFeature @Reducer -struct ToplistsReducer { - @dynamicMemberLookup @CasePathable - enum Route: Equatable { - case detail(String) +public struct ToplistsReducer: Sendable { + public enum Delegate: Equatable, Sendable { + case pushDetail(Gallery) + } + + public enum Alert: Equatable, Sendable { + case performJumpPage } - private enum CancelID: CaseIterable { + private enum CancelID { case fetchGalleries, fetchMoreGalleries } @ObservableState - struct State: Equatable { - var route: Route? - var keyword = "" - var jumpPageIndex = "" - var jumpPageAlertFocused = false - var jumpPageAlertPresented = false + public struct State: Equatable { + @SharedReader(.tagTranslator) public var tagTranslator: TagTranslator + @SharedReader(.setting) public var setting: Setting + public var keyword = "" + public var jumpPageIndex = "" + @Presents public var alert: AppAlertState? - var type: ToplistsType = .yesterday + public var type: ToplistsType = .yesterday var filteredGalleries: [Gallery]? { guard !keyword.isEmpty else { return galleries } return galleries?.filter({ $0.title.caseInsensitiveContains(keyword) }) } - var rawGalleries = [ToplistsType: [Gallery]]() - var rawPageNumber = [ToplistsType: PageNumber]() - var rawLoadingState = [ToplistsType: LoadingState]() - var rawFooterLoadingState = [ToplistsType: LoadingState]() + public var rawGalleries = [ToplistsType: [Gallery]]() + public var rawPageNumber = [ToplistsType: PageNumber]() + public var rawLoadingState = [ToplistsType: LoadingState]() + public var rawFooterLoadingState = [ToplistsType: LoadingState]() var galleries: [Gallery]? { rawGalleries[type] @@ -49,11 +53,7 @@ struct ToplistsReducer { rawFooterLoadingState[type] } - var detailState: Heap - - init() { - detailState = .init(.init()) - } + public init() {} mutating func insertGalleries(type: ToplistsType, galleries: [Gallery]) { galleries.forEach { gallery in @@ -64,78 +64,74 @@ struct ToplistsReducer { } } - enum Action: BindableAction { + public enum Action: BindableAction { case binding(BindingAction) - case setNavigation(Route?) + case delegate(Delegate) case setToplistsType(ToplistsType) - case clearSubStates - case performJumpPage + case alert(PresentationAction) 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(DetailReducer.Action) } - @Dependency(\.databaseClient) private var databaseClient @Dependency(\.hapticsClient) private var hapticsClient - var body: some Reducer { + public init() {} + + public var body: some Reducer { BindingReducer() - .onChange(of: \.route) { _, newValue in - Reduce({ _, _ in newValue == nil ? .send(.clearSubStates) : .none }) - } - .onChange(of: \.jumpPageAlertPresented) { _, newValue in - Reduce { state, _ in - if !newValue { - state.jumpPageAlertFocused = false - } - return .none - } - } Reduce { state, action in switch action { case .binding: return .none - case .setNavigation(let route): - state.route = route - return route == nil ? .send(.clearSubStates) : .none + case .delegate: + return .none case .setToplistsType(let type): state.type = type guard state.galleries?.isEmpty != false else { return .none } return .send(.fetchGalleries()) - case .clearSubStates: - state.detailState.wrappedValue = .init() - return .send(.detail(.teardown)) - - case .performJumpPage: + case .alert(.presented(.performJumpPage)): guard let index = Int(state.jumpPageIndex), let pageNumber = state.pageNumber, index > 0, index <= pageNumber.maximum + 1 else { - return .run(operation: { _ in hapticsClient.generateNotificationFeedback(.error) }) + return .run(operation: { _ in await hapticsClient.generateNotificationFeedback(.error) }) } return .send(.fetchGalleries(index - 1)) - case .presentJumpPageAlert: - state.jumpPageAlertPresented = true - return .run(operation: { _ in hapticsClient.generateFeedback(.light) }) - - case .setJumpPageAlertFocused(let isFocused): - state.jumpPageAlertFocused = isFocused + case .alert: return .none - case .teardown: - return .merge(CancelID.allCases.map(Effect.cancel(id:))) + case .presentJumpPageAlert: + let maximumPage = (state.pageNumber?.maximum ?? 0) + 1 + state.alert = AppAlertState( + title: { + TextState(localized: .RLocalizable.jumpPage) + }, + textField: .init( + placeholder: TextState(localized: .RLocalizable.jumpPage), + keyboard: .numberPad + ), + actions: { + ButtonState(action: .performJumpPage) { + TextState(localized: .confirm) + } + ButtonState(role: .cancel) { + TextState(localized: .RLocalizable.cancel) + } + }, + message: { + TextState(localized: .jumpPageDescription(max: maximumPage)) + } + ) + return .run(operation: { _ in await hapticsClient.generateFeedback(.light) }) case .fetchGalleries(let pageNum): guard state.loadingState != .loading else { return .none } @@ -165,7 +161,7 @@ struct ToplistsReducer { } state.rawPageNumber[type] = pageNumber state.rawGalleries[type] = galleries - return .run(operation: { _ in await databaseClient.cacheGalleries(galleries) }) + return .none case .failure(let error): state.rawLoadingState[type] = .failed(error) } @@ -194,9 +190,7 @@ struct ToplistsReducer { state.rawPageNumber[type] = pageNumber state.insertGalleries(type: type, galleries: galleries) - var effects: [Effect] = [ - .run(operation: { _ in await databaseClient.cacheGalleries(galleries) }) - ] + var effects: [Effect] = [] if galleries.isEmpty, pageNumber.hasNextPage() { effects.append(.send(.fetchMoreGalleries)) } else if !galleries.isEmpty { @@ -208,12 +202,8 @@ struct ToplistsReducer { state.rawFooterLoadingState[type] = .failed(error) } return .none - - case .detail: - return .none } } - - Scope(state: \.detailState.wrappedValue!, action: \.detail, child: DetailReducer.init) + .ifLet(\.$alert, action: \.alert) } } diff --git a/AppPackage/Sources/HomeFeature/Toplists/ToplistsView.swift b/AppPackage/Sources/HomeFeature/Toplists/ToplistsView.swift new file mode 100644 index 000000000..4e4587650 --- /dev/null +++ b/AppPackage/Sources/HomeFeature/Toplists/ToplistsView.swift @@ -0,0 +1,77 @@ +import SwiftUI +import AppModels +import TagTranslationFeature +import Resources +import ComposableArchitecture +import AppTools +import AppComponents +import GalleryListComponents + +struct ToplistsView: View { + @Bindable private var store: StoreOf + private let blurRadius: Double + + init( + store: StoreOf, + blurRadius: Double + ) { + self.store = store + self.blurRadius = blurRadius + } + + private var navigationTitle: String { + [String(localized: .toplists), String(localized: store.type.value)].joined(separator: " - ") + } + + var body: some View { + GenericList( + galleries: store.filteredGalleries ?? [], + pageNumber: store.pageNumber, + loadingState: store.loadingState ?? .idle, + footerLoadingState: store.footerLoadingState ?? .idle, + fetchAction: { store.send(.fetchGalleries()) }, + fetchMoreAction: { store.send(.fetchMoreGalleries) }, + navigateAction: { store.send(.delegate(.pushDetail($0))) }, + translateAction: { + store.tagTranslator.lookup(word: $0, returnOriginal: !store.setting.translatesTags) + } + ) + .searchable(text: $store.keyword, prompt: .filter) + .appAlert($store.scope(state: \.alert, action: \.alert), text: $store.jumpPageIndex) + .onAppear { + if store.galleries?.isEmpty != false { + DispatchQueue.main.async { + store.send(.fetchGalleries()) + } + } + } + .toolbar(content: toolbar) + .navigationTitle(navigationTitle) + } + + private func toolbar() -> some ToolbarContent { + CustomToolbarItem(disabled: store.alert != nil) { + ToplistsTypeMenu(type: store.type) { type in + if type != store.type { + store.send(.setToplistsType(type)) + } + } + if AppUtil.galleryHost == .ehentai { + JumpPageButton(pageNumber: store.pageNumber ?? .init(), hideText: true) { + store.send(.presentJumpPageAlert) + } + } + } + } +} + +struct ToplistsView_Previews: PreviewProvider { + static var previews: some View { + NavigationStack { + ToplistsView( + store: .init(initialState: .init(), reducer: ToplistsReducer.init), + blurRadius: 0 + ) + } + } +} diff --git a/AppPackage/Sources/HomeFeature/Watched/WatchedReducer.swift b/AppPackage/Sources/HomeFeature/Watched/WatchedReducer.swift new file mode 100644 index 000000000..24c0295f4 --- /dev/null +++ b/AppPackage/Sources/HomeFeature/Watched/WatchedReducer.swift @@ -0,0 +1,246 @@ +import AppTools +import ComposableArchitecture +import AppModels +import Sharing +import HapticsClient +import NetworkingFeature +import DownloadClient +import FiltersFeature +import DateSeekFeature +import QuickSearchFeature + +@Reducer +public struct WatchedReducer: Sendable { + public enum Delegate: Equatable, Sendable { + case pushDetail(Gallery) + } + + @Reducer + public enum Destination { + case filters(FiltersReducer) + case quickSearch(QuickSearchReducer) + case dateSeek(DateSeekReducer) + } + + private enum CancelID { + case fetchGalleries, fetchMoreGalleries, observeDownloads, fetchDateSeekGalleries + } + + @ObservableState + public struct State: Equatable { + @SharedReader(.watchedFilter) public var watchedFilter: Filter + @SharedReader(.tagTranslator) public var tagTranslator: TagTranslator + @SharedReader(.setting) public var setting: Setting + @Presents public var destination: Destination.State? + public var keyword = "" + + public var galleries = [Gallery]() + public var pageNumber = PageNumber() + public var dateSeekNavigation: DateSeekNavigation? + public var loadingState: LoadingState = .idle + public var footerLoadingState: LoadingState = .idle + public var downloadBadges = [String: DownloadBadge]() + + public init() {} + + mutating func insertGalleries(_ galleries: [Gallery]) { + galleries.forEach { gallery in + if !self.galleries.contains(gallery) { + self.galleries.append(gallery) + } + } + } + } + + public enum Action: BindableAction { + case binding(BindingAction) + case onAppear + case delegate(Delegate) + case filtersButtonTapped + case quickSearchButtonTapped + case dateSeekButtonTapped(DateSeekNavigation) + case destination(PresentationAction) + case onNotLoginViewButtonTapped + + case fetchGalleries(String? = nil) + case fetchGalleriesDone(Result) + case fetchMoreGalleries + case fetchMoreGalleriesDone(Result) + case observeDownloads + case observeDownloadsDone([DownloadedGallery]) + case performDateSeekDone(Result) + } + + @Dependency(\.downloadClient) private var downloadClient + @Dependency(\.hapticsClient) private var hapticsClient + + public init() {} + + public var body: some Reducer { + BindingReducer() + + Reduce { state, action in + switch action { + case .binding: + return .none + + case .onAppear: + return .send(.observeDownloads) + + case .delegate: + return .none + + case .filtersButtonTapped: + state.destination = .filters(FiltersReducer.State()) + return .none + + case .quickSearchButtonTapped: + state.destination = .quickSearch(QuickSearchReducer.State()) + return .none + + case .dateSeekButtonTapped(let navigation): + state.destination = .dateSeek(.init(navigation: navigation)) + return .none + + case .onNotLoginViewButtonTapped: + return .none + + case .fetchGalleries(let keyword): + guard state.loadingState != .loading else { return .none } + if let keyword = keyword { + state.keyword = keyword + } + state.loadingState = .loading + state.pageNumber.resetPages() + let filter = state.watchedFilter + return .run { [keyword = state.keyword] send in + let response = await WatchedGalleriesRequest(filter: filter, keyword: keyword).response() + await send(.fetchGalleriesDone(response)) + } + .cancellable(id: CancelID.fetchGalleries) + + case .fetchGalleriesDone(let result): + state.loadingState = .idle + switch result { + case .success(let response): + let galleries = response.galleries + guard !galleries.isEmpty else { + state.loadingState = .failed(.notFound) + guard response.pageNumber.hasNextPage() else { return .none } + return .send(.fetchMoreGalleries) + } + state.pageNumber = response.pageNumber + state.dateSeekNavigation = response.dateSeekNavigation + state.galleries = galleries + return .none + case .failure(let error): + state.loadingState = .failed(error) + } + return .none + + case .fetchMoreGalleries: + let pageNumber = state.pageNumber + guard pageNumber.hasNextPage(), + state.footerLoadingState != .loading, + let lastID = state.galleries.last?.id + else { return .none } + state.footerLoadingState = .loading + let filter = state.watchedFilter + return .run { [keyword = state.keyword] send in + let response = await MoreWatchedGalleriesRequest( + filter: filter, lastID: lastID, keyword: keyword + ) + .response() + await send(.fetchMoreGalleriesDone(response)) + } + .cancellable(id: CancelID.fetchMoreGalleries) + + case .fetchMoreGalleriesDone(let result): + state.footerLoadingState = .idle + switch result { + case .success(let response): + let galleries = response.galleries + state.pageNumber = response.pageNumber + state.dateSeekNavigation = response.dateSeekNavigation + state.insertGalleries(galleries) + + var effects: [Effect] = [] + if galleries.isEmpty, response.pageNumber.hasNextPage() { + effects.append(.send(.fetchMoreGalleries)) + } else if !galleries.isEmpty { + state.loadingState = .idle + } + return .merge(effects) + + case .failure(let error): + state.footerLoadingState = .failed(error) + } + return .none + + case .observeDownloads: + return .run { send in + for await downloads in downloadClient.observeDownloads() { + await send(.observeDownloadsDone(downloads)) + } + } + .cancellable(id: CancelID.observeDownloads, cancelInFlight: true) + + case .observeDownloadsDone(let downloads): + state.downloadBadges = Dictionary( + uniqueKeysWithValues: downloads.map { ($0.gid, $0.badge) } + ) + return .none + + case .destination(.presented(.dateSeek(.delegate(.performSeek(let url))))): + guard state.loadingState != .loading else { return .none } + state.loadingState = .loading + state.footerLoadingState = .idle + state.pageNumber.resetPages() + return .run { send in + let response = await DateSeekGalleriesRequest(url: url).response() + await send(.performDateSeekDone(response)) + } + .cancellable(id: CancelID.fetchDateSeekGalleries) + + case .performDateSeekDone(let result): + state.loadingState = .idle + switch result { + case .success(let response): + let galleries = response.galleries + guard !galleries.isEmpty else { + state.loadingState = .failed(.notFound) + return .none + } + state.pageNumber = response.pageNumber + state.dateSeekNavigation = response.dateSeekNavigation + state.galleries = galleries + return .none + case .failure(let error): + state.loadingState = .failed(error) + } + return .none + + case .destination: + return .none + } + } + .haptics( + unwrapping: \.destination, + case: \.quickSearch, + hapticsClient: hapticsClient + ) + .haptics( + unwrapping: \.destination, + case: \.filters, + hapticsClient: hapticsClient + ) + .haptics( + unwrapping: \.destination, + case: \.dateSeek, + hapticsClient: hapticsClient + ) + .ifLet(\.$destination, action: \.destination) + } +} + +extension WatchedReducer.Destination.State: Equatable, Sendable {} diff --git a/AppPackage/Sources/HomeFeature/Watched/WatchedView.swift b/AppPackage/Sources/HomeFeature/Watched/WatchedView.swift new file mode 100644 index 000000000..c2345934e --- /dev/null +++ b/AppPackage/Sources/HomeFeature/Watched/WatchedView.swift @@ -0,0 +1,121 @@ +import SwiftUI +import AppModels +import TagTranslationFeature +import Resources +import ComposableArchitecture +import AppTools +import AppComponents +import DateSeekFeature +import GalleryListComponents +import FiltersFeature +import QuickSearchFeature + +struct WatchedView: View { + @Bindable private var store: StoreOf + private let blurRadius: Double + + init( + store: StoreOf, + blurRadius: Double + ) { + self.store = store + self.blurRadius = blurRadius + } + + var body: some View { + ZStack { + if CookieUtil.didLogin { + GenericList( + galleries: store.galleries, + pageNumber: store.pageNumber, + loadingState: store.loadingState, + footerLoadingState: store.footerLoadingState, + fetchAction: { store.send(.fetchGalleries()) }, + fetchMoreAction: { store.send(.fetchMoreGalleries) }, + navigateAction: { store.send(.delegate(.pushDetail($0))) }, + translateAction: { + store.tagTranslator.lookup(word: $0, returnOriginal: !store.setting.translatesTags) + }, + downloadBadges: store.downloadBadges + ) + } else { + NotLoginView(action: { store.send(.onNotLoginViewButtonTapped) }) + } + } + .sheet( + item: $store.scope(state: \.destination?.quickSearch, action: \.destination.quickSearch) + ) { store in + QuickSearchView(store: store) { keyword in + self.store.send(.destination(.dismiss)) + self.store.send(.fetchGalleries(keyword)) + } + .accentColor(self.store.setting.accentColor) + .autoBlur(radius: blurRadius) + } + .sheet( + item: $store.scope(state: \.destination?.filters, action: \.destination.filters) + ) { store in + FiltersView(store: store) + .autoBlur(radius: blurRadius).environment(\.inSheet, true) + } + .sheet( + item: $store.scope(state: \.destination?.dateSeek, action: \.destination.dateSeek) + ) { store in + @Bindable var store = store + DateSeekPickerView( + selectedDate: $store.date, + navigation: store.navigation, + seekAction: { store.send(.performSeek($0)) } + ) + .accentColor(self.store.setting.accentColor) + .autoBlur(radius: blurRadius) + } + .searchable(text: $store.keyword) + .searchSuggestions { + TagSuggestionView( + keyword: $store.keyword, translations: store.tagTranslator.translations, + showsImages: store.setting.showsImagesInTags, isEnabled: store.setting.showsTagsSearchSuggestion + ) + } + .onSubmit(of: .search) { + store.send(.fetchGalleries()) + } + .onAppear { + store.send(.onAppear) + if store.galleries.isEmpty && CookieUtil.didLogin { + DispatchQueue.main.async { + store.send(.fetchGalleries()) + } + } + } + .toolbar(content: toolbar) + .navigationTitle(.watched) + } + + private func toolbar() -> some ToolbarContent { + CustomToolbarItem { + ToolbarFeaturesMenu { + DateSeekButton(navigation: store.dateSeekNavigation) { navigation in + store.send(.dateSeekButtonTapped(navigation)) + } + FiltersButton { + store.send(.filtersButtonTapped) + } + QuickSearchButton { + store.send(.quickSearchButtonTapped) + } + } + } + } +} + +struct WatchedView_Previews: PreviewProvider { + static var previews: some View { + NavigationStack { + WatchedView( + store: .init(initialState: .init(), reducer: WatchedReducer.init), + blurRadius: 0 + ) + } + } +} diff --git a/AppPackage/Sources/ImageClient/.swiftlint.yml b/AppPackage/Sources/ImageClient/.swiftlint.yml new file mode 100644 index 000000000..1242ffcaa --- /dev/null +++ b/AppPackage/Sources/ImageClient/.swiftlint.yml @@ -0,0 +1 @@ +parent_config: ../../../.swiftlint.yml diff --git a/AppPackage/Sources/ImageClient/ImageClient.swift b/AppPackage/Sources/ImageClient/ImageClient.swift new file mode 100644 index 000000000..6a708c490 --- /dev/null +++ b/AppPackage/Sources/ImageClient/ImageClient.swift @@ -0,0 +1,197 @@ +import Photos +import AppModels +import SwiftUI +import Combine +import ComposableArchitecture +import AppTools +import AnimatedImageFeature + +public struct ImageClient: Sendable { + public struct ImageAsset: Sendable { + public let image: UIImage + public let data: Data + + /// Render / export routing for this asset: `true` → SDWebImage (animated), + /// `false` → Kingfisher / `UIImage` (still). Decided from the actual bytes, not the + /// request URL. See `Data.isAnimatedImageData`. + public var isAnimated: Bool { + data.isAnimatedImageData || image.hasAnimatedFrames + } + } + + public let prefetchImages: @Sendable ([URL]) -> Void + public let saveImageDataToPhotoLibrary: @Sendable (Data) async -> Bool + public var dataCache: DataCache = .shared + public var urlSession: URLSession = .shared +} + +extension ImageClient { + public static let live: Self = .init( + prefetchImages: { urls in + Task { + await withTaskGroup(of: Void.self) { group in + for url in urls { + group.addTask { + _ = try? await ImageClient.readerImageData( + url: url, dataCache: .shared, urlSession: .shared + ) + } + } + } + } + }, + saveImageDataToPhotoLibrary: { data in + await Self.saveImageDataToPhotoLibrary(data) + } + ) + + public static func saveImageDataToPhotoLibrary(_ data: Data) async -> Bool { + await withCheckedContinuation { continuation in + PHPhotoLibrary.shared().performChanges { + let request = PHAssetCreationRequest.forAsset() + request.addResource(with: .photo, data: data, options: nil) + } completionHandler: { isSuccess, _ in + continuation.resume(returning: isSuccess) + } + } + } + + // Exports read the same owned bytes the reader display caches, so save/share/copy + // route by image content (DES-1) instead of the request URL's extension. + public func fetchImageAsset(url: URL) async -> Result { + do { + let data = try await Self.readerImageData( + url: url, dataCache: dataCache, urlSession: urlSession + ) + guard let image = data.decodedImage else { + return .failure(AppError.parseFailed) + } + return .success(.init(image: image, data: data)) + } catch { + return .failure(error) + } + } + + public func fetchReaderImageAsset( + url: URL, + onProgress: (@MainActor @Sendable (Double) -> Void)? = nil + ) async -> ImageAsset? { + guard let data = try? await Self.readerImageData( + url: url, dataCache: dataCache, urlSession: urlSession, onProgress: onProgress + ), let image = data.decodedImage else { + return nil + } + return .init(image: image, data: data) + } + + public static func readerImageData( + url: URL, + dataCache: DataCache, + urlSession: URLSession, + onProgress: (@MainActor @Sendable (Double) -> Void)? = nil + ) async throws -> Data { + if url.isFileURL { + return try Data(contentsOf: url) + } + let cacheKeys = url.imageCacheKeys + if let data = await dataCache.data(forKeys: cacheKeys) { + // A known placeholder (quota/login wall) that was cached before this + // guard existed must not be re-served; purge it and re-fetch so a + // since-lifted limit recovers on its own. + if ImagePlaceholderFingerprint.match(data) == nil { + return data + } + try? await dataCache.removeData(forKeys: cacheKeys) + } + let data = try await downloadReaderData( + url: url, urlSession: urlSession, onProgress: onProgress + ) + // E-H serves quota/auth placeholders as valid 200 images; reject them as the + // errors they are (matching the download pipeline) so they're never cached, + // displayed, or exported as the real page. + if let placeholder = ImagePlaceholderFingerprint.match(data) { + throw placeholder.error + } + // Only cache decodable image bytes so a 200 carrying an HTML/error body + // (e.g. an unexpected notice page) can't poison the key until expiry. + guard data.decodedImage != nil else { + throw AppError.parseFailed + } + // Store under the primary key only. Reads check the stable alias first and + // it's non-nil for ~all page URLs, so also writing the absolute-URL alias + // doubled disk + memory for an entry that retrieval never reaches. + if let primaryKey = cacheKeys.first { + try? await dataCache.store(data, forKey: primaryKey) + } + return data + } + + // Streams the body via `bytes(for:)` to drive per-image progress when a + // handler is supplied; otherwise the single-shot `data(for:)` keeps the + // progress-less prefetch path fast. + private static func downloadReaderData( + url: URL, + urlSession: URLSession, + onProgress: (@MainActor @Sendable (Double) -> Void)? + ) async throws -> Data { + guard let onProgress else { + let (data, response) = try await urlSession.data(for: URLRequest(url: url)) + try validateReaderResponse(response) + return data + } + let (bytes, response) = try await urlSession.bytes(for: URLRequest(url: url)) + try validateReaderResponse(response) + let expectedLength = response.expectedContentLength + var data = Data() + if expectedLength > 0 { + data.reserveCapacity(Int(expectedLength)) + } + var lastReportedFraction = 0.0 + for try await byte in bytes { + data.append(byte) + guard expectedLength > 0 else { continue } + let fraction = min(Double(data.count) / Double(expectedLength), 1) + if fraction - lastReportedFraction >= 0.01 { + lastReportedFraction = fraction + await onProgress(fraction) + } + } + return data + } + + private static func validateReaderResponse(_ response: URLResponse) throws { + guard let httpResponse = response as? HTTPURLResponse, + (200..<300).contains(httpResponse.statusCode) else { + throw AppError.networkingFailed + } + } +} + +// MARK: API +public enum ImageClientKey: DependencyKey { + public static let liveValue = ImageClient.live + public static let previewValue = ImageClient.noop + public static let testValue = ImageClient.unimplemented +} + +extension DependencyValues { + public var imageClient: ImageClient { + get { self[ImageClientKey.self] } + set { self[ImageClientKey.self] = newValue } + } +} + +// MARK: Test +extension ImageClient { + public static let noop: Self = .init( + prefetchImages: { _ in }, + saveImageDataToPhotoLibrary: { _ in false } + ) + + public static func placeholder() -> Result { fatalError() } + + public static let unimplemented: Self = .init( + prefetchImages: IssueReporting.unimplemented(placeholder: placeholder()), + saveImageDataToPhotoLibrary: IssueReporting.unimplemented(placeholder: placeholder()) + ) +} diff --git a/AppPackage/Sources/LibraryClient/.swiftlint.yml b/AppPackage/Sources/LibraryClient/.swiftlint.yml new file mode 100644 index 000000000..1242ffcaa --- /dev/null +++ b/AppPackage/Sources/LibraryClient/.swiftlint.yml @@ -0,0 +1 @@ +parent_config: ../../../.swiftlint.yml diff --git a/AppPackage/Sources/LibraryClient/LibraryClient.swift b/AppPackage/Sources/LibraryClient/LibraryClient.swift new file mode 100644 index 000000000..d4ed4f69c --- /dev/null +++ b/AppPackage/Sources/LibraryClient/LibraryClient.swift @@ -0,0 +1,263 @@ +import SwiftUI +import AppModels +import Combine +import Foundation +import Kingfisher +import SDWebImage +import SDWebImageWebPCoder +import UIImageColors +import ComposableArchitecture +import AppTools +import AnimatedImageFeature + +public struct LibraryClient: Sendable { + public let initializeWebImage: @Sendable () -> Void + public let removeAllCachedImages: @Sendable () async -> Void + public let cachedImage: @Sendable (String) async -> UIImage? + public let cachedImageData: @Sendable (String) async -> Data? + public let removeCachedImage: @Sendable (String) async -> Void + public let isCached: @Sendable (String) -> Bool + public let analyzeImageColors: @Sendable (UIImage) async -> [Color]? + public let calculateWebImageDiskCacheSize: @Sendable () async -> UInt? + + public init( + initializeWebImage: @escaping @Sendable () -> Void, + removeAllCachedImages: @escaping @Sendable () async -> Void, + cachedImage: @escaping @Sendable (String) async -> UIImage?, + cachedImageData: @escaping @Sendable (String) async -> Data?, + removeCachedImage: @escaping @Sendable (String) async -> Void, + isCached: @escaping @Sendable (String) -> Bool, + analyzeImageColors: @escaping @Sendable (UIImage) async -> [Color]?, + calculateWebImageDiskCacheSize: @escaping @Sendable () async -> UInt? + ) { + self.initializeWebImage = initializeWebImage + self.removeAllCachedImages = removeAllCachedImages + self.cachedImage = cachedImage + self.cachedImageData = cachedImageData + self.removeCachedImage = removeCachedImage + self.isCached = isCached + self.analyzeImageColors = analyzeImageColors + self.calculateWebImageDiskCacheSize = calculateWebImageDiskCacheSize + } +} + +extension LibraryClient { + public static let live: Self = .init( + initializeWebImage: { + let config = KingfisherManager.shared.downloader.sessionConfiguration + config.httpCookieStorage = HTTPCookieStorage.shared + KingfisherManager.shared.downloader.sessionConfiguration = config + KingfisherManager.shared.cache.diskStorage.config.sizeLimit = 1024 * 1024 * 1024 + + let sdConfig = URLSessionConfiguration.default + sdConfig.httpCookieStorage = HTTPCookieStorage.shared + SDWebImageDownloaderConfig.default.sessionConfiguration = sdConfig + SDWebImageDownloader.shared.setValue( + "image/webp,image/apng,image/png,image/gif,image/*,*/*;q=0.8", + forHTTPHeaderField: "Accept" + ) + SDImageCodersManager.shared.addCoder(SDImageWebPCoder.shared) + + DataCache.installSystemPurgeObservers() + }, + removeAllCachedImages: { + KingfisherManager.shared.cache.clearMemoryCache() + SDImageCache.shared.clearMemory() + async let dataCacheClear: Void = DataCache.shared.removeAll() + async let kingfisherClear: Void = withCheckedContinuation { continuation in + KingfisherManager.shared.cache.clearDiskCache { + continuation.resume() + } + } + async let sdWebImageClear: Void = withCheckedContinuation { continuation in + SDImageCache.shared.clearDisk { + continuation.resume() + } + } + _ = try? await (dataCacheClear, kingfisherClear, sdWebImageClear) + }, + cachedImage: { key in + if let image = await kingfisherCachedImage(forKey: key) { + return image + } + return await sdWebImageCachedImage(forKey: key) + }, + cachedImageData: { key in + if let data = await kingfisherCachedImageData(forKey: key) { + return data + } + return await sdWebImageCachedImageData(forKey: key) + }, + removeCachedImage: { key in + async let kingfisherRemove: Void = withCheckedContinuation { continuation in + KingfisherManager.shared.cache.removeImage(forKey: key) { + continuation.resume() + } + } + async let sdWebImageRemove: Void = withCheckedContinuation { continuation in + SDImageCache.shared.removeImage(forKey: key) { + continuation.resume() + } + } + _ = await (kingfisherRemove, sdWebImageRemove) + }, + isCached: { key in + KingfisherManager.shared.cache.isCached(forKey: key) + || SDImageCache.shared.imageFromMemoryCache(forKey: key) != nil + || SDImageCache.shared.diskImageDataExists(withKey: key) + }, + analyzeImageColors: { image in + await withCheckedContinuation { continuation in + image.getColors(quality: .lowest) { colors in + continuation.resume( + returning: colors.map { + [ + $0.primary, $0.secondary, + $0.detail, $0.background + ] + .map(Color.init) + } + ) + } + } + }, + calculateWebImageDiskCacheSize: { + async let kingfisherSize: UInt? = withCheckedContinuation { continuation in + KingfisherManager.shared.cache.calculateDiskStorageSize { + continuation.resume(returning: try? $0.get()) + } + } + async let sdWebImageSize: UInt? = withCheckedContinuation { continuation in + SDImageCache.shared.calculateSize { _, totalSize in + continuation.resume(returning: UInt(totalSize)) + } + } + async let dataCacheSize = try? DataCache.shared.totalSize() + return await (kingfisherSize ?? 0) + (sdWebImageSize ?? 0) + UInt(dataCacheSize ?? 0) + } + ) +} + +private func kingfisherCachedImage(forKey key: String) async -> UIImage? { + if let image = KingfisherManager.shared.cache + .retrieveImageInMemoryCache(forKey: key) { + return image + } + + return await withCheckedContinuation { continuation in + KingfisherManager.shared.cache + .retrieveImage(forKey: key) { result in + switch result { + case .success(let value): + continuation.resume(returning: value.image) + + case .failure: + continuation.resume(returning: nil) + } + } + } +} + +private func kingfisherCachedImageData(forKey key: String) async -> Data? { + if let image = KingfisherManager.shared.cache + .retrieveImageInMemoryCache(forKey: key), + let data = image.kf.data(format: .unknown) { + return data + } + + if let data = try? KingfisherManager.shared.cache + .diskStorage.value(forKey: key) { + return data + } + + return await withCheckedContinuation { continuation in + KingfisherManager.shared.cache + .retrieveImage(forKey: key) { result in + switch result { + case .success(let value): + continuation.resume( + returning: value.image.flatMap { + $0.kf.data(format: .unknown) + } + ) + + case .failure: + continuation.resume(returning: nil) + } + } + } +} + +private func sdWebImageCachedImage(forKey key: String) async -> UIImage? { + if let image = SDImageCache.shared.imageFromCache(forKey: key) { + return image + } + + guard let data = await sdWebImageCachedImageData(forKey: key) else { + return nil + } + return image(from: data) +} + +private func sdWebImageCachedImageData(forKey key: String) async -> Data? { + if let image = SDImageCache.shared.imageFromMemoryCache(forKey: key), + let data = image.animatedSourceData ?? image.sd_imageData() { + return data + } + + if let data = SDImageCache.shared.diskImageData(forKey: key) { + return data + } + + return await withCheckedContinuation { continuation in + SDImageCache.shared.diskImageDataQuery(forKey: key) { data in + continuation.resume(returning: data) + } + } +} + +private func image(from data: Data) -> UIImage? { + data.decodedImage +} + +// MARK: API +public enum LibraryClientKey: DependencyKey { + public static let liveValue = LibraryClient.live + public static let previewValue = LibraryClient.noop + public static let testValue = LibraryClient.unimplemented +} + +extension DependencyValues { + public var libraryClient: LibraryClient { + get { self[LibraryClientKey.self] } + set { self[LibraryClientKey.self] = newValue } + } +} + +// MARK: Test +extension LibraryClient { + public static let noop: Self = .init( + initializeWebImage: {}, + removeAllCachedImages: {}, + cachedImage: { _ in nil }, + cachedImageData: { _ in nil }, + removeCachedImage: { _ in }, + isCached: { _ in false }, + analyzeImageColors: { _ in .none }, + calculateWebImageDiskCacheSize: { .none } + ) + + public static func placeholder() -> Result { fatalError() } + + public static let unimplemented: Self = .init( + initializeWebImage: IssueReporting.unimplemented(placeholder: placeholder()), + removeAllCachedImages: IssueReporting.unimplemented(placeholder: placeholder()), + cachedImage: IssueReporting.unimplemented(placeholder: placeholder()), + cachedImageData: IssueReporting.unimplemented(placeholder: placeholder()), + removeCachedImage: IssueReporting.unimplemented(placeholder: placeholder()), + isCached: IssueReporting.unimplemented(placeholder: placeholder()), + analyzeImageColors: IssueReporting.unimplemented(placeholder: placeholder()), + calculateWebImageDiskCacheSize: + IssueReporting.unimplemented(placeholder: placeholder()) + ) +} diff --git a/AppPackage/Sources/LogsClient/.swiftlint.yml b/AppPackage/Sources/LogsClient/.swiftlint.yml new file mode 100644 index 000000000..1242ffcaa --- /dev/null +++ b/AppPackage/Sources/LogsClient/.swiftlint.yml @@ -0,0 +1 @@ +parent_config: ../../../.swiftlint.yml diff --git a/AppPackage/Sources/LogsClient/Logger+.swift b/AppPackage/Sources/LogsClient/Logger+.swift new file mode 100644 index 000000000..b65f8984d --- /dev/null +++ b/AppPackage/Sources/LogsClient/Logger+.swift @@ -0,0 +1,7 @@ +import OSLogExt + +extension Logger { + init(category: String) { + self.init(moduleName: "LogsClient", category: category) + } +} diff --git a/AppPackage/Sources/LogsClient/LogsClient.swift b/AppPackage/Sources/LogsClient/LogsClient.swift new file mode 100644 index 000000000..600c94c29 --- /dev/null +++ b/AppPackage/Sources/LogsClient/LogsClient.swift @@ -0,0 +1,141 @@ +import OSLogExt +import AppTools +import AppModels +import Foundation +import ComposableArchitecture + +private let logger = Logger(category: .init(describing: LogsClient.self)) + +public struct LogsClient: Sendable { + /// Reads activity-log entries emitted by this process since `after` + /// (or since boot when `after` is `nil`), sorted oldest-first. + public var fetchNewEntries: @Sendable (_ after: Date?) async throws -> [AppActivityLog] + /// Appends entries to a per-run jsonl file, creating it (and the logs directory) when needed. + public var appendToRunFile: @Sendable (_ logs: [AppActivityLog], _ url: URL) async throws -> Void + /// Reads back a previously written per-run jsonl file. + public var readRunFile: @Sendable (_ url: URL) async throws -> [AppActivityLog] + /// Lists the persisted per-run log files, newest run first. + public var listRunFiles: @Sendable () async -> [RunLogFile] + /// Derives the next run count for the given day from the existing log files + /// (`max + 1` among that day's files, or `1` — so the count resets each new day). + public var nextRunCount: @Sendable (_ date: Date) async -> Int + /// The jsonl file URL for a given run. + public var currentRunFileURL: @Sendable (_ runCount: Int, _ date: Date) -> URL +} + +extension LogsClient { + public static let live: Self = .init( + fetchNewEntries: { after in + let store = try OSLogStore(scope: .currentProcessIdentifier) + let position = after.map(store.position(date:)) + ?? store.position(timeIntervalSinceLatestBoot: .zero) + let predicate = NSPredicate(format: "subsystem BEGINSWITH %@", Defaults.App.identifier) + let entries = Array(try store.getEntries(at: position, matching: predicate)) + let logEntries = entries.compactMap { $0 as? OSLogEntryLog } + if logEntries.count != entries.count { + logger.warning(""" + Some log entries could not be read as OSLogEntryLog. \ + Read \(logEntries.count, privacy: .public) of \(entries.count, privacy: .public). + """) + } + let logs = logEntries + .filter { $0.subsystem.caseInsensitiveContains(Defaults.App.identifier) } + .map(AppActivityLog.init(osLog:)) + .sorted { $0.date < $1.date } + guard let after else { return logs } + return logs.filter { $0.date > after } + }, + appendToRunFile: { logs, url in + guard !logs.isEmpty else { return } + let directory = url.deletingLastPathComponent() + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + + let encoder = JSONEncoder() + var payload = Data() + for log in logs { + payload.append(try encoder.encode(log)) + payload.append(0x0A) + } + + if FileManager.default.fileExists(atPath: url.path) { + let handle = try FileHandle(forWritingTo: url) + defer { try? handle.close() } + try handle.seekToEnd() + try handle.write(contentsOf: payload) + } else { + try payload.write(to: url, options: .atomic) + } + }, + readRunFile: { url in + let data = try Data(contentsOf: url) + let decoder = JSONDecoder() + return data.split(separator: 0x0A).compactMap { line in + try? decoder.decode(AppActivityLog.self, from: Data(line)) + } + }, + listRunFiles: { + let directory = FileUtil.logsDirectoryURL + guard let names = try? FileManager.default.contentsOfDirectory(atPath: directory.path) else { + return [] + } + return names + .compactMap { RunLogFile(fileURL: directory.appendingPathComponent($0)) } + // Newest first across days: counts reset daily, so order by day then count. + .sorted { $0.date != $1.date ? $0.date > $1.date : $0.runCount > $1.runCount } + }, + nextRunCount: { date in + let directory = FileUtil.logsDirectoryURL + guard let names = try? FileManager.default.contentsOfDirectory(atPath: directory.path) else { + return 1 + } + let today = RunLogFile.dayString(for: date) + let todayCounts = names + .compactMap { RunLogFile(fileURL: directory.appendingPathComponent($0)) } + .filter { RunLogFile.dayString(for: $0.date) == today } + .map(\.runCount) + return (todayCounts.max() ?? 0) + 1 + }, + currentRunFileURL: { runCount, date in + FileUtil.logsDirectoryURL.appendingPathComponent( + RunLogFile.fileName(date: date, runCount: runCount) + ) + } + ) +} + +// MARK: API +public enum LogsClientKey: DependencyKey { + public static let liveValue = LogsClient.live + public static let previewValue = LogsClient.noop + public static let testValue = LogsClient.unimplemented +} + +extension DependencyValues { + public var logsClient: LogsClient { + get { self[LogsClientKey.self] } + set { self[LogsClientKey.self] = newValue } + } +} + +// MARK: Test +extension LogsClient { + public static let noop: Self = .init( + fetchNewEntries: { _ in [] }, + appendToRunFile: { _, _ in }, + readRunFile: { _ in [] }, + listRunFiles: { [] }, + nextRunCount: { _ in 1 }, + currentRunFileURL: { _, _ in FileUtil.logsDirectoryURL } + ) + + public static func placeholder() -> Result { fatalError() } + + public static let unimplemented: Self = .init( + fetchNewEntries: IssueReporting.unimplemented(placeholder: placeholder()), + appendToRunFile: IssueReporting.unimplemented(placeholder: placeholder()), + readRunFile: IssueReporting.unimplemented(placeholder: placeholder()), + listRunFiles: IssueReporting.unimplemented(placeholder: placeholder()), + nextRunCount: IssueReporting.unimplemented(placeholder: placeholder()), + currentRunFileURL: IssueReporting.unimplemented(placeholder: placeholder()) + ) +} diff --git a/AppPackage/Sources/NetworkingFeature/.swiftlint.yml b/AppPackage/Sources/NetworkingFeature/.swiftlint.yml new file mode 100644 index 000000000..1242ffcaa --- /dev/null +++ b/AppPackage/Sources/NetworkingFeature/.swiftlint.yml @@ -0,0 +1 @@ +parent_config: ../../../.swiftlint.yml diff --git a/EhPanda/Network/DFExtensions.swift b/AppPackage/Sources/NetworkingFeature/DFExtensions.swift similarity index 77% rename from EhPanda/Network/DFExtensions.swift rename to AppPackage/Sources/NetworkingFeature/DFExtensions.swift index 1171a2d82..1cbf440ae 100644 --- a/EhPanda/Network/DFExtensions.swift +++ b/AppPackage/Sources/NetworkingFeature/DFExtensions.swift @@ -1,50 +1,23 @@ -// -// DFExtensions.swift -// EhPanda -// - import Foundation +import AppModels +import OSLogExt import DeprecatedAPI +import AppTools + +private let logger = Logger(category: "DFExtensions") // MARK: Global private func forceDowncast(object: Any) -> T! { if let downcastedValue = object as? T { return downcastedValue } - Logger.error( - "Failed in force downcasting...", - context: [ - "type": T.self - ] - ) + logger.error("Failed in force downcasting to type: \(String(describing: T.self), privacy: .public)") return nil } -// MARK: URL -extension URL { - func modifyComponent(for url: URL, commitChanges: (inout URLComponents) -> Void) -> URL? { - guard var components = URLComponents( - url: self, resolvingAgainstBaseURL: false - ) - else { return nil } - commitChanges(&components) - return components.url - } - func replaceHost(to newHost: String?) -> URL? { - modifyComponent(for: self) { components in - components.host = newHost - } - } - func replaceScheme(to newScheme: String?) -> URL? { - modifyComponent(for: self) { components in - components.scheme = newScheme - } - } -} - // MARK: URLRequest extension URLRequest { - var urlContainsImageURL: Bool { + public var urlContainsImageURL: Bool { var containsTarget = false ["jpg", "jpeg", "png", "gif", "bmp"].forEach { type in if url?.absoluteString.contains(type) == true { @@ -57,7 +30,7 @@ extension URLRequest { // MARK: URLSessionConfiguration extension URLSessionConfiguration { - static var domainFronting: URLSessionConfiguration { + public static var domainFronting: URLSessionConfiguration { let config = URLSessionConfiguration.default config.protocolClasses = [DFURLProtocol.self] return config @@ -66,18 +39,18 @@ extension URLSessionConfiguration { // MARK: CFHTTPMessage extension CFHTTPMessage { - var isCompleted: Bool { + public var isCompleted: Bool { CFHTTPMessageIsHeaderComplete(self) } - var url: URL? { + public var url: URL? { CFHTTPMessageCopyRequestURL(self)?.autorelease() .takeUnretainedValue() as URL? } - var allHeaderFields: [String: String] { + public var allHeaderFields: [String: String] { CFHTTPMessageCopyAllHeaderFields(self)?.autorelease() .takeUnretainedValue() as? [String: String] ?? [String: String]() } - func httpResponse() -> HTTPURLResponse? { + public func httpResponse() -> HTTPURLResponse? { guard let url = url as URL? else { return nil } let version = CFHTTPMessageCopyVersion(self) .autorelease().takeUnretainedValue() as String @@ -94,12 +67,12 @@ extension CFHTTPMessage { // MARK: URLRequest extension URLRequest { - var isHTTPS: Bool { url?.scheme == "https" } - var hasHostField: Bool { hostKey?.count ?? 0 > 0 } - var hostKey: Dictionary.Keys.Element? { + public var isHTTPS: Bool { url?.scheme == "https" } + public var hasHostField: Bool { hostKey?.count ?? 0 > 0 } + public var hostKey: Dictionary.Keys.Element? { allHTTPHeaderFields?.keys.first(where: { $0.lowercased() == "host" }) } - var domain: String? { + public var domain: String? { var domain: String? = url?.host if let allFields = allHTTPHeaderFields, let hostKey = hostKey { @@ -108,14 +81,14 @@ extension URLRequest { return domain } - var domainWithScheme: String? { + public var domainWithScheme: String? { if let scheme = url?.scheme, let domain = domain { return scheme + "://" + domain } else { return nil } } - func domainIPReplaced() -> URLRequest { + public func domainIPReplaced() -> URLRequest { var request: URLRequest = self guard let domain = domain, @@ -133,7 +106,7 @@ extension URLRequest { } return request } - func HTTPBody() -> Data? { + public func HTTPBody() -> Data? { if httpMethod != "POST" || httpBody != nil { return httpBody } @@ -159,10 +132,10 @@ extension URLRequest { if readSize > 0 { body.append(buffer, count: readSize) } else if readSize == 0 { - Logger.verbose("HTTPBodyStream read EOF.") + logger.debug("HTTPBodyStream read EOF.") } else { if let error = stream.streamError as Error? { - Logger.error("HTTPBodyStream read Error: \(error).") + logger.error("HTTPBodyStream read Error: \(error, privacy: .public)") } } } while readSize > 0 @@ -173,18 +146,18 @@ extension URLRequest { // MARK: InputStream extension InputStream { - enum CreateStreamError: Error { + public enum CreateStreamError: Error { case methodNotFound(msg: String) case urlNotFound(msg: String) case createStream(msg: String) } - var trust: SecTrust? { + public var trust: SecTrust? { let key = Stream.PropertyKey(kCFStreamPropertySSLPeerTrust as String) guard let value = property(forKey: key) else { return nil } return forceDowncast(object: value) as SecTrust } - func invalidatesCertChain(for host: String) { + public func invalidatesCertChain(for host: String) { guard host.count > 0 else { return } let settings: [AnyHashable: Any] = [ kCFStreamSSLValidatesCertificateChain: kCFBooleanFalse as Any @@ -193,7 +166,7 @@ extension InputStream { let key = kCFStreamPropertySSLSettings as String setProperty(settings, forKey: Stream.PropertyKey(key)) } - func httpMessage() -> CFHTTPMessage? { + public func httpMessage() -> CFHTTPMessage? { let stream = self as CFReadStream let key = "kCFStreamPropertyHTTPResponseHeader" as CFString @@ -204,7 +177,7 @@ extension InputStream { return forceDowncast(object: value) as CFHTTPMessage } - static func create(from request: URLRequest) -> Result { + public static func create(from request: URLRequest) -> Result { guard let method = request.httpMethod as CFString? else { return .failure(.methodNotFound( msg: "HTTPMethod not found: \(request.httpMethod ?? "nil")." diff --git a/EhPanda/Network/DFRequest.swift b/AppPackage/Sources/NetworkingFeature/DFRequest.swift similarity index 79% rename from EhPanda/Network/DFRequest.swift rename to AppPackage/Sources/NetworkingFeature/DFRequest.swift index cdfc8579f..fbc7d7c48 100644 --- a/EhPanda/Network/DFRequest.swift +++ b/AppPackage/Sources/NetworkingFeature/DFRequest.swift @@ -1,18 +1,17 @@ -// -// DFRequest.swift -// EhPanda -// - import Foundation +import AppModels +import OSLogExt + +private let logger = Logger(category: .init(describing: DFRequest.self)) -struct DFRequest { - var request: URLRequest +public struct DFRequest { + public var request: URLRequest private let stream: InputStream private(set) weak var delegate: DFRequestDelegate? private lazy var streamHandler: DFStreamEventHandler? = DFStreamEventHandler(request: self) - init?( + public init?( _ req: URLRequest, delegate: DFRequestDelegate? = nil ) { @@ -20,7 +19,7 @@ struct DFRequest { request = req.domainIPReplaced() if let url = req.url, - let cookies = HTTPCookieStorage + let cookies = HTTPCookieStorage .shared.cookies(for: url) { request.allHTTPHeaderFields = HTTPCookie .requestHeaderFields(with: cookies) @@ -41,9 +40,10 @@ struct DFRequest { } } - mutating func resume() { + public mutating func resume() { if !request.urlContainsImageURL { - Logger.verbose("Request from: \(request.url?.absoluteString ?? "")") + let urlString = request.url?.absoluteString ?? "" + logger.debug("Request from: \(urlString)") } stream.schedule(in: RunLoop.current, forMode: .common) @@ -51,7 +51,7 @@ struct DFRequest { stream.open() } - mutating func stop() { + public mutating func stop() { stream.delegate = nil streamHandler = nil stream.close() @@ -60,7 +60,7 @@ struct DFRequest { } // MARK: DFRequestDelegate -protocol DFRequestDelegate: AnyObject { +public protocol DFRequestDelegate: AnyObject { func dfRequestDidFinishLoading(_ request: DFRequest) func dfRequest(_ request: DFRequest, didLoad data: Data) func dfRequest(_ request: URLRequest, didFailWithError error: Error) diff --git a/EhPanda/Network/DFStreamHandler.swift b/AppPackage/Sources/NetworkingFeature/DFStreamHandler.swift similarity index 84% rename from EhPanda/Network/DFStreamHandler.swift rename to AppPackage/Sources/NetworkingFeature/DFStreamHandler.swift index cd7404ab7..e1318589d 100644 --- a/EhPanda/Network/DFStreamHandler.swift +++ b/AppPackage/Sources/NetworkingFeature/DFStreamHandler.swift @@ -1,16 +1,15 @@ -// -// DFStreamEventHandler.swift -// EhPanda -// - import Foundation +import AppModels +import OSLogExt + +private let logger = Logger(category: .init(describing: DFStreamEventHandler.self)) -class DFStreamEventHandler: NSObject { +public class DFStreamEventHandler: NSObject { private var request: DFRequest private var receivedResponse = false private var hasEvaluated = false - init(request: DFRequest) { + public init(request: DFRequest) { self.request = request } } @@ -81,7 +80,7 @@ private extension DFStreamEventHandler { if SecTrustEvaluateWithError(serverTrust, &error) { return true } else { - Logger.error(error as Any) + logger.error("\(String(describing: error), privacy: .public)") return false } } @@ -89,9 +88,9 @@ private extension DFStreamEventHandler { // MARK: StreamDelegate extension DFStreamEventHandler: StreamDelegate { - func stream(_ aStream: Stream, handle eventCode: Stream.Event) { + public func stream(_ aStream: Stream, handle eventCode: Stream.Event) { guard let input = aStream as? InputStream else { - Logger.error("Unexpected stream, should be a InputStream, but \(aStream).") + logger.error("Unexpected stream, should be a InputStream, but \(aStream, privacy: .public).") return } @@ -116,14 +115,14 @@ private extension DFStreamEventHandler { func openCompleted() { if !request.request.urlContainsImageURL { let urlString = request.request.url?.absoluteString ?? "" - Logger.verbose("Stream open completed for: \(urlString).") + logger.debug("Stream open completed for: \(urlString).") } } func endEncountered(_ stream: InputStream) { if !request.request.urlContainsImageURL { let urlString = request.request.url?.absoluteString ?? "" - Logger.verbose("Stream end off for: \(urlString).") + logger.debug("Stream end off for: \(urlString).") } let message = stream.httpMessage() @@ -134,7 +133,7 @@ private extension DFStreamEventHandler { } else { if !self.request.request.urlContainsImageURL { let urlString = self.request.request.url?.absoluteString ?? "" - Logger.verbose("Request loading finished for: \(urlString).") + logger.debug("Request loading finished for: \(urlString).") } self.request.delegate?.dfRequestDidFinishLoading(self.request) } @@ -160,12 +159,11 @@ private extension DFStreamEventHandler { if ["/", "/popular", "/watched"].contains(url.absoluteString) || ["/?f_search"].contains(where: url.absoluteString.contains), let domain = request.request.domainWithScheme, - let originalURL = URL(string: domain) - { + let originalURL = URL(string: domain) { url = originalURL.appendingPathComponent(url.absoluteString) } - Logger.warning("Request redirected to: \(url.absoluteString).") + logger.warning("Request redirected to: \(url.absoluteString).") var req = URLRequest(url: url) req.httpMethod = "GET" @@ -184,7 +182,7 @@ private extension DFStreamEventHandler { if let err = stream.streamError as NSError? { if !request.request.urlContainsImageURL { let urlString = request.request.url?.absoluteString ?? "" - Logger.error("\(stream) Occurred error: \(err) for: \(urlString).") + logger.error("\(stream, privacy: .public) Occurred error: \(err, privacy: .public) for: \(urlString).") } request.delegate?.dfRequest( request.request, @@ -195,6 +193,6 @@ private extension DFStreamEventHandler { } func defaultHandle(event: Stream.Event) { - Logger.error("An unexpected Evnet: \(event) occurred.") + logger.error("An unexpected event: \(String(describing: event), privacy: .public) occurred.") } } diff --git a/EhPanda/Network/DFURLProtocol.swift b/AppPackage/Sources/NetworkingFeature/DFURLProtocol.swift similarity index 62% rename from EhPanda/Network/DFURLProtocol.swift rename to AppPackage/Sources/NetworkingFeature/DFURLProtocol.swift index 30d798775..9555bcc83 100644 --- a/EhPanda/Network/DFURLProtocol.swift +++ b/AppPackage/Sources/NetworkingFeature/DFURLProtocol.swift @@ -1,30 +1,30 @@ -// -// DFURLProtocol.swift -// EhPanda -// - +import AppTools import Foundation +import AppModels +import OSLogExt + +private let logger = Logger(category: .init(describing: DFURLProtocol.self)) -class DFURLProtocol: URLProtocol { +public class DFURLProtocol: URLProtocol { private var dfRequest: DFRequest? - static let requestIdentifier = "DomainFrontingRequest" + public static let requestIdentifier = "DomainFrontingRequest" - override class func canonicalRequest( + public override class func canonicalRequest( for request: URLRequest) -> URLRequest { request } - override class func canInit(with request: URLRequest) -> Bool { + public override class func canInit(with request: URLRequest) -> Bool { if property(forKey: requestIdentifier, in: request) != nil { - Logger.error("URLRequest has been initialized.") + logger.error("URLRequest has been initialized.") return false } if !["http", "https"].contains(request.url?.scheme) { let scheme = request.url?.scheme ?? "nil" - Logger.error("URL scheme \"\(scheme)\" is not supported.") + logger.error("URL scheme \"\(scheme, privacy: .public)\" is not supported.") return false } return true } - override func startLoading() { + public override func startLoading() { dfRequest = DFRequest(request, delegate: self) let request = request as? NSMutableURLRequest DFURLProtocol.setProperty( @@ -35,7 +35,7 @@ class DFURLProtocol: URLProtocol { dfRequest?.resume() } - override func stopLoading() { + public override func stopLoading() { dfRequest?.stop() dfRequest = nil } @@ -43,22 +43,22 @@ class DFURLProtocol: URLProtocol { // MARK: DFRequestDelegate extension DFURLProtocol: DFRequestDelegate { - func dfRequestDidFinishLoading(_ request: DFRequest) { + public func dfRequestDidFinishLoading(_ request: DFRequest) { client?.urlProtocolDidFinishLoading(self) } - func dfRequest(_ request: DFRequest, didLoad data: Data) { + public func dfRequest(_ request: DFRequest, didLoad data: Data) { client?.urlProtocol(self, didLoad: data) } - func dfRequest(_ request: URLRequest, didFailWithError error: Error) { + public func dfRequest(_ request: URLRequest, didFailWithError error: Error) { client?.urlProtocol(self, didFailWithError: error) } - func dfRequest( + public func dfRequest( _ request: DFRequest, wasRedirectedTo urlRequest: URLRequest, redirectResponse: URLResponse ) { client?.urlProtocol(self, wasRedirectedTo: urlRequest, redirectResponse: redirectResponse) } - func dfRequest( + public func dfRequest( _ request: DFRequest, didReceive response: URLResponse, cacheStoragePolicy policy: URLCache.StoragePolicy ) { diff --git a/EhPanda/Network/DomainResolver.swift b/AppPackage/Sources/NetworkingFeature/DomainResolver.swift similarity index 93% rename from EhPanda/Network/DomainResolver.swift rename to AppPackage/Sources/NetworkingFeature/DomainResolver.swift index 02a20e75e..ca80d891c 100644 --- a/EhPanda/Network/DomainResolver.swift +++ b/AppPackage/Sources/NetworkingFeature/DomainResolver.swift @@ -1,15 +1,10 @@ -// -// DomainResolver.swift -// EhPanda -// - -struct DomainResolver { - static func resolve(domain: String) -> String? { +public struct DomainResolver { + public static func resolve(domain: String) -> String? { ResolvableDomain(rawValue: domain)?.ipPool.randomElement() } } -enum ResolvableDomain: String { +public enum ResolvableDomain: String { case ehgt = "ehgt.org" case ehgt0 = "gt0.ehgt.org" case ehgt1 = "gt1.ehgt.org" diff --git a/AppPackage/Sources/NetworkingFeature/Logger+.swift b/AppPackage/Sources/NetworkingFeature/Logger+.swift new file mode 100644 index 000000000..e93b73183 --- /dev/null +++ b/AppPackage/Sources/NetworkingFeature/Logger+.swift @@ -0,0 +1,7 @@ +import OSLogExt + +extension Logger { + init(category: String) { + self.init(moduleName: "NetworkingFeature", category: category) + } +} diff --git a/AppPackage/Sources/NetworkingFeature/Request+Account.swift b/AppPackage/Sources/NetworkingFeature/Request+Account.swift new file mode 100644 index 000000000..162535062 --- /dev/null +++ b/AppPackage/Sources/NetworkingFeature/Request+Account.swift @@ -0,0 +1,502 @@ +import Kanna +import AppModels +import Combine +import Foundation +import AppTools +import ParserFeature + +// MARK: Account Ops +public struct LoginRequest: Request { + public init( + username: String, + password: String + ) { + self.username = username + self.password = password + } + public let username: String + public let password: String + + public var publisher: AnyPublisher { + let params: [String: String] = [ + "b": "d", + "bt": "1-1", + "CookieDate": "1", + "UserName": username, + "PassWord": password, + "ipb_login_submit": "Login!" + ] + + var request = URLRequest(url: Defaults.URL.login) + request.httpMethod = "POST" + request.httpBody = params.dictString().urlEncoded.data(using: .utf8) + request.setURLEncodedContentType() + + return URLSession.shared.dataTaskPublisher(for: request) + .genericRetry() + .map { $0.response as? HTTPURLResponse } + .mapError(mapAppError) + .eraseToAnyPublisher() + } +} + +public struct IgneousRequest: Request { + public init() {} + + public var publisher: AnyPublisher { + URLSession.shared.dataTaskPublisher(for: Defaults.URL.exhentai) + .genericRetry() + .compactMap { $0.response as? HTTPURLResponse } + .mapError(mapAppError) + .eraseToAnyPublisher() + } +} + +public struct VerifyEhProfileRequest: Request { + public init() {} + + public var publisher: AnyPublisher { + URLSession.shared.dataTaskPublisher(for: Defaults.URL.uConfig) + .genericRetry() + .tryMap { try htmlDocument(data: $0.data) } + .tryMap { try parseResponse(doc: $0, Parser.parseProfileIndex) } + .mapError(mapAppError) + .eraseToAnyPublisher() + } +} + +public struct EhProfileRequest: Request { + public init( + action: EhProfileAction? = nil, + name: String? = nil, + set: Int? = nil + ) { + self.action = action + self.name = name + self.set = set + } + public var action: EhProfileAction? + public var name: String? + public var set: Int? + + public var publisher: AnyPublisher { + var params = [String: String]() + + if let action = action { + params["profile_action"] = action.rawValue + } + if let name = name { + params["profile_name"] = name + } + if let set = set { + params["profile_set"] = "\(set)" + } + + var request = URLRequest(url: Defaults.URL.uConfig) + request.httpMethod = "POST" + request.httpBody = params.dictString().urlEncoded.data(using: .utf8) + request.setURLEncodedContentType() + + return URLSession.shared.dataTaskPublisher(for: request) + .genericRetry() + .tryMap { try htmlDocument(data: $0.data) } + .tryMap { try parseResponse(doc: $0, Parser.parseEhSetting) } + .mapError(mapAppError) + .eraseToAnyPublisher() + } +} + +public struct EhSettingRequest: Request { + public init() {} + + public var publisher: AnyPublisher { + URLSession.shared.dataTaskPublisher(for: Defaults.URL.uConfig) + .genericRetry() + .tryMap { try htmlDocument(data: $0.data) } + .tryMap { try parseResponse(doc: $0, Parser.parseEhSetting) } + .mapError(mapAppError) + .eraseToAnyPublisher() + } +} + +public struct SubmitEhSettingChangesRequest: Request { + public init( + ehSetting: EhSetting + ) { + self.ehSetting = ehSetting + } + public let ehSetting: EhSetting + + public var publisher: AnyPublisher { + let url = Defaults.URL.uConfig + var params: [String: String] = [ + "uh": String(ehSetting.loadThroughHathSetting.rawValue), + "co": ehSetting.browsingCountry.rawValue, + "xr": String(ehSetting.imageResolution.rawValue), + "rx": String(Int(ehSetting.imageSizeWidth)), + "ry": String(Int(ehSetting.imageSizeHeight)), + "tl": String(ehSetting.galleryName.rawValue), + "ar": String(ehSetting.archiverBehavior.rawValue), + "dm": String(ehSetting.displayMode.rawValue), + "pp": ehSetting.showSearchRangeIndicator ? "0" : "1", + "fs": String(ehSetting.favoritesSortOrder.rawValue), + "ru": ehSetting.ratingsColor, + "ft": String(Int(ehSetting.tagFilteringThreshold)), + "wt": String(Int(ehSetting.tagWatchingThreshold)), + "tf": ehSetting.showFilteredRemovalCount ? "0" : "1", + "xu": ehSetting.excludedUploaders, + "rc": String(ehSetting.searchResultCount.rawValue), + "lt": String(ehSetting.thumbnailLoadTiming.rawValue), + "tr": String(ehSetting.thumbnailConfigRows.rawValue), + "tp": String(Int(ehSetting.coverScaleFactor)), + "vp": String(Int(ehSetting.viewportVirtualWidth)), + "cs": String(ehSetting.commentsSortOrder.rawValue), + "sc": String(ehSetting.commentVotesShowTiming.rawValue), + "tb": String(ehSetting.tagsSortOrder.rawValue), + "pn": String(ehSetting.galleryPageNumbering.rawValue), + "apply": "Apply" + ] + + if ehSetting.enableGalleryThumbnailSelector { + params["xn_0"] = "on" + } + + switch ehSetting.thumbnailConfigSize { + case .auto: params["ts"] = "0" + case .normal: params["ts"] = "1" + case .small: params["ts"] = "2" + default: break + } + + EhSetting.categoryNames.enumerated().forEach { index, name in + params["ct_\(name)"] = ehSetting.disabledCategories[index] ? "1" : "0" + } + Array(0...9).forEach { index in + params["favorite_\(index)"] = ehSetting.favoriteCategories[index] + } + ehSetting.excludedLanguages.enumerated().forEach { index, value in + if value { + params["xl_\(EhSetting.languageValues[index])"] = "on" + } + } + + if let useOriginalImages = ehSetting.useOriginalImages { + params["oi"] = useOriginalImages ? "1" : "0" + } + if let useMultiplePageViewer = ehSetting.useMultiplePageViewer { + params["qb"] = useMultiplePageViewer ? "1" : "0" + } + if let multiplePageViewerStyle = ehSetting.multiplePageViewerStyle { + params["ms"] = String(multiplePageViewerStyle.rawValue) + } + if let multiplePageViewerShowThumbnailPane = ehSetting.multiplePageViewerShowThumbnailPane { + params["mt"] = multiplePageViewerShowThumbnailPane ? "0" : "1" + } + + var request = URLRequest(url: url) + request.httpMethod = "POST" + request.httpBody = params.dictString().urlEncoded.data(using: .utf8) + request.setURLEncodedContentType() + + return URLSession.shared.dataTaskPublisher(for: request) + .genericRetry() + .tryMap { try htmlDocument(data: $0.data) } + .tryMap { try parseResponse(doc: $0, Parser.parseEhSetting) } + .mapError(mapAppError) + .eraseToAnyPublisher() + } +} + +public struct FavorGalleryRequest: Request { + public init( + gid: String, + token: String, + favIndex: Int + ) { + self.gid = gid + self.token = token + self.favIndex = favIndex + } + public let gid: String + public let token: String + public let favIndex: Int + + public var publisher: AnyPublisher { + let url = URLUtil.addFavorite(gid: gid, token: token) + let params: [String: String] = [ + "favcat": "\(favIndex)", + "favnote": "", + "apply": "Add to Favorites", + "update": "1" + ] + + var request = URLRequest(url: url) + request.httpMethod = "POST" + request.httpBody = params.dictString().urlEncoded.data(using: .utf8) + request.setURLEncodedContentType() + + return URLSession.shared.dataTaskPublisher(for: request) + .genericRetry() + .map { _ in () } + .mapError(mapAppError) + .eraseToAnyPublisher() + } +} + +public struct UnfavorGalleryRequest: Request { + public init( + gid: String + ) { + self.gid = gid + } + public let gid: String + + public var publisher: AnyPublisher { + let params: [String: String] = [ + "ddact": "delete", + "modifygids[]": gid, + "apply": "Apply" + ] + + var request = URLRequest(url: Defaults.URL.favorites) + request.httpMethod = "POST" + request.httpBody = params.dictString().urlEncoded.data(using: .utf8) + request.setURLEncodedContentType() + + return URLSession.shared.dataTaskPublisher(for: request) + .genericRetry() + .map { _ in () } + .mapError(mapAppError) + .eraseToAnyPublisher() + } +} + +public struct SendDownloadCommandRequest: Request { + public init( + archiveURL: URL, + resolution: String + ) { + self.archiveURL = archiveURL + self.resolution = resolution + } + public let archiveURL: URL + public let resolution: String + + public var publisher: AnyPublisher { + let params: [String: String] = [ + "hathdl_xres": resolution + ] + + var request = URLRequest(url: archiveURL) + request.httpMethod = "POST" + request.httpBody = params.dictString().urlEncoded.data(using: .utf8) + request.setURLEncodedContentType() + + return URLSession.shared.dataTaskPublisher(for: request) + .genericRetry() + .tryMap { try htmlDocument(data: $0.data) } + .tryMap { + try parseResponse(doc: $0, Parser.parseDownloadCommandResponse) + } + .mapError(mapAppError) + .eraseToAnyPublisher() + } +} + +public struct RateGalleryRequest: Request { + public init( + apiuid: Int, + apikey: String, + gid: Int, + token: String, + rating: Int + ) { + self.apiuid = apiuid + self.apikey = apikey + self.gid = gid + self.token = token + self.rating = rating + } + public let apiuid: Int + public let apikey: String + public let gid: Int + public let token: String + public let rating: Int + + public var publisher: AnyPublisher { + let params: [String: Any] = [ + "method": "rategallery", + "apiuid": apiuid, + "apikey": apikey, + "gid": gid, + "token": token, + "rating": rating + ] + + var request = URLRequest(url: Defaults.URL.api) + request.httpMethod = "POST" + request.httpBody = try? JSONSerialization.data(withJSONObject: params, options: []) + + return URLSession.shared.dataTaskPublisher(for: request) + .genericRetry() + .map { _ in () } + .mapError(mapAppError) + .eraseToAnyPublisher() + } +} + +public struct CommentGalleryRequest: Request { + public init( + content: String, + galleryURL: URL + ) { + self.content = content + self.galleryURL = galleryURL + } + public let content: String + public let galleryURL: URL + + public var publisher: AnyPublisher { + let fixedContent = content.replacingOccurrences(of: "\n", with: "%0A") + let params: [String: String] = [ + "commenttext_new": fixedContent + ] + + var request = URLRequest(url: galleryURL) + request.httpMethod = "POST" + request.httpBody = params.dictString().urlEncoded.data(using: .utf8) + request.setURLEncodedContentType() + + return URLSession.shared.dataTaskPublisher(for: request) + .genericRetry() + .map { _ in () } + .mapError(mapAppError) + .eraseToAnyPublisher() + } +} + +public struct EditGalleryCommentRequest: Request { + public init( + commentID: String, + content: String, + galleryURL: URL + ) { + self.commentID = commentID + self.content = content + self.galleryURL = galleryURL + } + public let commentID: String + public let content: String + public let galleryURL: URL + + public var publisher: AnyPublisher { + let fixedContent = content.replacingOccurrences(of: "\n", with: "%0A") + let params: [String: String] = [ + "edit_comment": commentID, + "commenttext_edit": fixedContent + ] + + var request = URLRequest(url: galleryURL) + request.httpMethod = "POST" + request.httpBody = params.dictString().urlEncoded.data(using: .utf8) + request.setURLEncodedContentType() + + return URLSession.shared.dataTaskPublisher(for: request) + .genericRetry() + .map { _ in () } + .mapError(mapAppError) + .eraseToAnyPublisher() + } +} + +public struct VoteGalleryCommentRequest: Request { + public init( + apiuid: Int, + apikey: String, + gid: Int, + token: String, + commentID: Int, + commentVote: Int + ) { + self.apiuid = apiuid + self.apikey = apikey + self.gid = gid + self.token = token + self.commentID = commentID + self.commentVote = commentVote + } + public let apiuid: Int + public let apikey: String + public let gid: Int + public let token: String + public let commentID: Int + public let commentVote: Int + + public var publisher: AnyPublisher { + let params: [String: Any] = [ + "method": "votecomment", + "apiuid": apiuid, + "apikey": apikey, + "gid": gid, + "token": token, + "comment_id": commentID, + "comment_vote": commentVote + ] + + var request = URLRequest(url: Defaults.URL.api) + request.httpMethod = "POST" + request.httpBody = try? JSONSerialization.data(withJSONObject: params, options: []) + + return URLSession.shared.dataTaskPublisher(for: request) + .genericRetry() + .map { _ in () } + .mapError(mapAppError) + .eraseToAnyPublisher() + } +} + +public struct VoteGalleryTagRequest: Request { + public init( + apiuid: Int, + apikey: String, + gid: Int, + token: String, + tag: String, + vote: Int + ) { + self.apiuid = apiuid + self.apikey = apikey + self.gid = gid + self.token = token + self.tag = tag + self.vote = vote + } + public let apiuid: Int + public let apikey: String + public let gid: Int + public let token: String + public let tag: String + public let vote: Int + + public var publisher: AnyPublisher { + let params: [String: Any] = [ + "method": "taggallery", + "apiuid": apiuid, + "apikey": apikey, + "gid": gid, + "token": token, + "tags": tag, + "vote": vote + ] + + var request = URLRequest(url: Defaults.URL.api) + request.httpMethod = "POST" + request.httpBody = try? JSONSerialization.data(withJSONObject: params, options: []) + + return URLSession.shared.dataTaskPublisher(for: request) + .genericRetry() + .map { _ in () } + .mapError(mapAppError) + .eraseToAnyPublisher() + } +} diff --git a/AppPackage/Sources/NetworkingFeature/Request+Detail.swift b/AppPackage/Sources/NetworkingFeature/Request+Detail.swift new file mode 100644 index 000000000..19f0cdd8d --- /dev/null +++ b/AppPackage/Sources/NetworkingFeature/Request+Detail.swift @@ -0,0 +1,331 @@ +import Kanna +import AppModels +import Combine +import Foundation +import AppTools +import ParserFeature + +// MARK: Response Types +public struct GalleryDetailResponse: Sendable { + public init( + galleryDetail: GalleryDetail, + galleryState: GalleryState, + apiKey: String, + greeting: Greeting? = nil + ) { + self.galleryDetail = galleryDetail + self.galleryState = galleryState + self.apiKey = apiKey + self.greeting = greeting + } + public let galleryDetail: GalleryDetail + public let galleryState: GalleryState + public let apiKey: String + public let greeting: Greeting? +} + +// MARK: Fetch others +public struct GalleryDetailRequest: Request { + public init( + gid: String, + galleryURL: URL, + urlSession: URLSession = .shared, + allowsCellular: Bool = true + ) { + self.gid = gid + self.galleryURL = galleryURL + self.urlSession = urlSession + self.allowsCellular = allowsCellular + } + public let gid: String + public let galleryURL: URL + public var urlSession: URLSession = .shared + public var allowsCellular = true + + public var publisher: AnyPublisher { + urlSession.dataTaskPublisher( + for: urlRequest( + url: URLUtil.galleryDetail(url: galleryURL), + allowsCellular: allowsCellular + ) + ) + .genericRetry() + .tryMap { try htmlDocumentWithUTF8Fallback(data: $0.data) } + .tryMap { doc in + try parseResponse(doc: doc) { + let (detail, state) = try Parser.parseGalleryDetail( + doc: $0, + gid: gid + ) + return (doc, detail, state, try Parser.parseAPIKey(doc: $0)) + } + } + .mapError(mapAppError) + .map { doc, detail, state, apiKey in + GalleryDetailResponse( + galleryDetail: detail, + galleryState: state, + apiKey: apiKey, + greeting: try? Parser.parseGreeting(doc: doc) + ) + } + .eraseToAnyPublisher() + } +} + +private struct GalleryVersionMetadata: Decodable { + let gid: Int + let token: String + let currentGID: Int? + let currentKey: String? + let parentGID: Int? + let parentKey: String? + let firstGID: Int? + let firstKey: String? + + enum CodingKeys: String, CodingKey { + case gid + case token + case currentGID = "current_gid" + case currentKey = "current_key" + case parentGID = "parent_gid" + case parentKey = "parent_key" + case firstGID = "first_gid" + case firstKey = "first_key" + } + + var versionMetadata: DownloadVersionMetadata { + DownloadVersionMetadata( + gid: String(gid), + token: token, + currentGID: currentGID.map(String.init), + currentKey: currentKey, + parentGID: parentGID.map(String.init), + parentKey: parentKey, + firstGID: firstGID.map(String.init), + firstKey: firstKey + ) + } +} + +private struct GalleryVersionMetadataAPIResponse: Decodable { + let gmetadata: [GalleryVersionMetadata] +} + +public struct GalleryVersionMetadataRequest: Request { + public let gid: String + public let token: String + public let urlSession: URLSession + + public init(gid: String, token: String, urlSession: URLSession = .shared) { + self.gid = gid + self.token = token + self.urlSession = urlSession + } + + public var publisher: AnyPublisher { + guard let gid = Int(gid) else { + return Fail(error: AppError.notFound) + .eraseToAnyPublisher() + } + return gdataPublisher(gidlist: [[gid, token]], urlSession: urlSession) { + let response = try JSONDecoder() + .decode(GalleryVersionMetadataAPIResponse.self, from: $0) + guard let metadata = response.gmetadata.first?.versionMetadata else { + throw AppError.notFound + } + return metadata + } + } +} + +public struct GalleryReverseRequest: Request { + public init( + url: URL, + isGalleryImageURL: Bool + ) { + self.url = url + self.isGalleryImageURL = isGalleryImageURL + } + public let url: URL + public let isGalleryImageURL: Bool + + public func getGallery(from detail: GalleryDetail?, and url: URL) -> Gallery? { + if let detail = detail { + return Gallery( + gid: url.pathComponents[2], + token: url.pathComponents[3], + title: detail.title, + rating: detail.rating, + tags: [], + category: detail.category, + uploader: detail.uploader, + pageCount: detail.pageCount, + postedDate: detail.postedDate, + coverURL: detail.coverURL, + galleryURL: url, + isExpunged: false, + hasRated: false, + favoriteTagIndex: detail.favoriteTagIndex, + favoriteTagName: detail.favoriteTagName + ) + } else { + return nil + } + } + + public var publisher: AnyPublisher { + galleryURL(url: url) + .genericRetry() + .flatMap(gallery) + .eraseToAnyPublisher() + } + + public func galleryURL(url: URL) -> AnyPublisher { + switch isGalleryImageURL { + case true: + return URLSession.shared.dataTaskPublisher(for: url) + .tryMap { try htmlDocument(data: $0.data) } + .tryMap { try parseResponse(doc: $0, Parser.parseGalleryURL) } + .mapError(mapAppError) + .eraseToAnyPublisher() + + case false: + return Just(url) + .setFailureType(to: AppError.self) + .eraseToAnyPublisher() + } + } + + public func gallery(url: URL) -> AnyPublisher { + URLSession.shared.dataTaskPublisher(for: url) + .tryMap { try htmlDocument(data: $0.data) } + .tryMap { doc in + try parseResponse(doc: doc) { + let (detail, _) = try Parser.parseGalleryDetail( + doc: $0, + gid: url.pathComponents[2] + ) + guard let gallery = getGallery(from: detail, and: url) + else { throw AppError.parseFailed } + return gallery + } + } + .mapError(mapAppError) + .eraseToAnyPublisher() + } +} + +public struct GalleryArchiveRequest: Request { + public init( + archiveURL: URL + ) { + self.archiveURL = archiveURL + } + public let archiveURL: URL + + public var publisher: AnyPublisher { + URLSession.shared.dataTaskPublisher(for: archiveURL) + .genericRetry() + .tryMap { try htmlDocument(data: $0.data) } + .tryMap { (html: HTMLDocument) -> (HTMLDocument, GalleryArchive) in + try parseResponse(doc: html) { + let archive = try Parser.parseGalleryArchive(doc: $0) + return (html, archive) + } + } + .map { html, archive in + guard let (currentGP, currentCredits) = try? Parser.parseCurrentFunds(doc: html) + else { return GalleryArchiveResponse(archive: archive, galleryPoints: nil, credits: nil) } + return GalleryArchiveResponse(archive: archive, galleryPoints: currentGP, credits: currentCredits) + } + .mapError(mapAppError) + .eraseToAnyPublisher() + } +} + +public struct GalleryArchiveFundsRequest: Request { + public init( + gid: String, + galleryURL: URL + ) { + self.gid = gid + self.galleryURL = galleryURL + } + public let gid: String + public let galleryURL: URL + + public var publisher: AnyPublisher<(String, String), AppError> { + archiveURL(url: galleryURL) + .genericRetry() + .flatMap(funds) + .eraseToAnyPublisher() + } + + public func archiveURL(url: URL) -> AnyPublisher { + URLSession.shared.dataTaskPublisher(for: url) + .tryMap { try htmlDocument(data: $0.data) } + .tryMap { doc in + try parseResponse(doc: doc) { + guard let archiveURL = try Parser + .parseGalleryDetail(doc: $0, gid: gid) + .0 + .archiveURL + else { throw AppError.parseFailed } + return archiveURL + } + } + .mapError(mapAppError) + .eraseToAnyPublisher() + } + + public func funds(url: URL) -> AnyPublisher<(String, String), AppError> { + URLSession.shared.dataTaskPublisher(for: url) + .tryMap { try htmlDocument(data: $0.data) } + .tryMap { try parseResponse(doc: $0, Parser.parseCurrentFunds) } + .mapError(mapAppError) + .eraseToAnyPublisher() + } +} + +public struct GalleryTorrentsRequest: Request { + public init( + gid: String, + token: String + ) { + self.gid = gid + self.token = token + } + public let gid: String + public let token: String + + public var publisher: AnyPublisher<[GalleryTorrent], AppError> { + URLSession.shared.dataTaskPublisher(for: URLUtil.galleryTorrents(gid: gid, token: token)) + .genericRetry() + .tryMap { try htmlDocument(data: $0.data) } + .map(Parser.parseGalleryTorrents) + .mapError(mapAppError) + .eraseToAnyPublisher() + } +} + +public struct GalleryPreviewURLsRequest: Request { + public init( + galleryURL: URL, + pageNum: Int + ) { + self.galleryURL = galleryURL + self.pageNum = pageNum + } + public let galleryURL: URL + public let pageNum: Int + + public var publisher: AnyPublisher<[Int: URL], AppError> { + URLSession.shared.dataTaskPublisher(for: URLUtil.detailPage(url: galleryURL, pageNum: pageNum)) + .genericRetry() + .tryMap { try htmlDocument(data: $0.data) } + .tryMap { try parseResponse(doc: $0, Parser.parsePreviewURLs) } + .mapError(mapAppError) + .eraseToAnyPublisher() + } +} diff --git a/AppPackage/Sources/NetworkingFeature/Request+GData.swift b/AppPackage/Sources/NetworkingFeature/Request+GData.swift new file mode 100644 index 000000000..24c631371 --- /dev/null +++ b/AppPackage/Sources/NetworkingFeature/Request+GData.swift @@ -0,0 +1,36 @@ +import AppModels +import Combine +import Foundation +import AppTools + +extension Request { + /// Shared plumbing for the `gdata` API (`api.php` `method=gdata`): builds the POST body from a list + /// of `[gid, token]` pairs, retries transient failures, hands the raw payload to `decode`, and maps + /// errors uniformly. Both the batch `GalleriesMetadataRequest` and the single-gallery + /// `GalleryVersionMetadataRequest` are thin wrappers over this, so a future gdata contract change + /// (params, retry policy, error mapping) lands in exactly one place. + /// + /// The endpoint accepts at most 25 pairs per call; callers that may exceed that must chunk before + /// calling and cap their own in-flight fan-out. + func gdataPublisher( + gidlist: [[Any]], + urlSession: URLSession, + decode: @escaping (Data) throws -> T + ) -> AnyPublisher { + let params: [String: Any] = [ + "method": "gdata", + "gidlist": gidlist, + "namespace": 1 + ] + var request = URLRequest(url: Defaults.URL.api) + request.httpMethod = "POST" + request.httpBody = try? JSONSerialization.data(withJSONObject: params, options: []) + + return urlSession.dataTaskPublisher(for: request) + .genericRetry() + .map(\.data) + .tryMap { data in try parseResponse(data: data, decode) } + .mapError(mapAppError) + .eraseToAnyPublisher() + } +} diff --git a/AppPackage/Sources/NetworkingFeature/Request+GalleriesMetadata.swift b/AppPackage/Sources/NetworkingFeature/Request+GalleriesMetadata.swift new file mode 100644 index 000000000..792b1c71a --- /dev/null +++ b/AppPackage/Sources/NetworkingFeature/Request+GalleriesMetadata.swift @@ -0,0 +1,190 @@ +import AppModels +import Combine +import Foundation +import AppTools + +// MARK: Response types +private struct GalleriesMetadataAPIResponse: Decodable { + let gmetadata: [GalleryMetadata] +} + +/// One `gmetadata` entry from the `gdata` API. Every display field is optional because the API +/// returns a bare `{ gid, error }` object for gids it can't resolve (expunged/removed galleries); +/// `gallery` yields `nil` for those so a single bad entry never fails the whole batch. A *resolved* +/// entry still missing any field the display needs (token, title, posted, category, rating, cover, +/// pageCount) is likewise dropped rather than defaulted, mirroring the HTML list parser's +/// all-or-nothing row policy. +private struct GalleryMetadata: Decodable { + let gid: Int + let token: String? + let error: String? + let title: String? + let titleJpn: String? + let category: String? + let thumb: String? + let uploader: String? + let posted: String? + let filecount: String? + let rating: String? + let tags: [String]? + let expunged: Bool? + + enum CodingKeys: String, CodingKey { + case gid + case token + case error + case title + case titleJpn = "title_jpn" + case category + case thumb + case uploader + case posted + case filecount + case rating + case tags + case expunged + } + + var gallery: Gallery? { + guard error == nil, + let token, + let title, + let posted, let postedInterval = TimeInterval(posted), + let category = category.flatMap(AppModels.Category.init(rawValue:)), + let rating = rating.flatMap(Float.init), + let coverURL = thumb.flatMap({ URL(string: $0) }), + let pageCount = filecount.flatMap(Int.init) + else { return nil } + return Gallery( + gid: String(gid), + token: token, + title: title.htmlEntitiesDecoded, + titleJpn: titleJpn?.htmlEntitiesDecoded, + rating: rating, + tags: Self.parseTags(tags ?? []), + category: category, + uploader: uploader, + pageCount: pageCount, + postedDate: Date(timeIntervalSince1970: postedInterval), + coverURL: coverURL, + galleryURL: Defaults.URL.host + .appendingPathComponent("g") + .appendingPathComponent(String(gid)) + .appendingPathComponent(token), + isExpunged: expunged ?? false + ) + } + + /// Groups the flat `"namespace:content"` tag list (returned because the request sets + /// `namespace: 1`) into `GalleryTag`s. A tag without a namespace falls under `misc`. + private static func parseTags(_ raw: [String]) -> [GalleryTag] { + var tags = [GalleryTag]() + for entry in raw { + let parts = entry.split(separator: ":", maxSplits: 1).map(String.init) + let namespace = parts.count == 2 ? parts[0] : "misc" + let text = parts.count == 2 ? parts[1] : entry + let content = GalleryTag.Content( + rawNamespace: namespace, text: text, isVotedUp: false, isVotedDown: false + ) + if let index = tags.firstIndex(where: { $0.rawNamespace == namespace }) { + tags[index] = .init(rawNamespace: namespace, contents: tags[index].contents + [content]) + } else { + tags.append(.init(rawNamespace: namespace, contents: [content])) + } + } + return tags + } +} + +// MARK: Request +/// Resolves display metadata for a set of galleries via the `gdata` API. The app persists no +/// gallery snapshots, so the History screen and "recently seen" suggestions rebuild their cells +/// from this on demand. The `gdata` endpoint accepts at most 25 gid/token pairs per call, so the +/// input is chunked and the chunks are fetched concurrently, then reassembled in input order +/// (unresolved gids are dropped). +public struct GalleriesMetadataRequest: Request { + public let gidList: [(gid: String, token: String)] + public let urlSession: URLSession + + public init(gidList: [(gid: String, token: String)], urlSession: URLSession = .shared) { + self.gidList = gidList + self.urlSession = urlSession + } + + public var publisher: AnyPublisher<[Gallery], AppError> { + let order = gidList.map(\.gid) + let chunks = gidList.chunked(into: 25) + guard !chunks.isEmpty else { + return Just([]).setFailureType(to: AppError.self).eraseToAnyPublisher() + } + // Cap in-flight POSTs at 2 so a large gid list can't fire an unbounded burst against the + // flood-controlled `api.php`. Result order is reconstructed from `order` below, so chunk + // completion order is irrelevant. + return Publishers.Sequence(sequence: chunks) + .flatMap(maxPublishers: .max(2)) { chunkPublisher($0) } + .collect() + .map { pages in + let byGID = Dictionary( + pages.flatMap { $0 }.map { ($0.gid, $0) }, + uniquingKeysWith: { first, _ in first } + ) + return order.compactMap { byGID[$0] } + } + .eraseToAnyPublisher() + } + + private func chunkPublisher(_ chunk: [(gid: String, token: String)]) -> AnyPublisher<[Gallery], AppError> { + let gidlist = chunk.compactMap { pair -> [Any]? in + guard let gid = Int(pair.gid) else { return nil } + return [gid, pair.token] + } + guard !gidlist.isEmpty else { + return Just([]).setFailureType(to: AppError.self).eraseToAnyPublisher() + } + return gdataPublisher(gidlist: gidlist, urlSession: urlSession) { + try Self.galleries(fromResponseData: $0) + } + } + + /// Decodes a raw `gdata` payload into galleries, silently dropping every unresolvable + /// `{ gid, error }` (or tokenless) entry. `token` is optional precisely so one such entry can't + /// fail `JSONDecoder` for the whole array — a single bad gid must never blank the History batch. + /// Exposed at `internal` access so tests can assert that per-entry tolerance directly. + static func galleries(fromResponseData data: Data) throws -> [Gallery] { + try JSONDecoder().decode(GalleriesMetadataAPIResponse.self, from: data).gmetadata.compactMap(\.gallery) + } +} + +// MARK: Helpers +private extension Array { + func chunked(into size: Int) -> [[Element]] { + stride(from: 0, to: count, by: size).map { + Array(self[$0.." + case "quot": return "\"" + case "apos": return "'" + default: return String(match.0) + } + } + } +} diff --git a/AppPackage/Sources/NetworkingFeature/Request+Gallery.swift b/AppPackage/Sources/NetworkingFeature/Request+Gallery.swift new file mode 100644 index 000000000..937cd17a5 --- /dev/null +++ b/AppPackage/Sources/NetworkingFeature/Request+Gallery.swift @@ -0,0 +1,365 @@ +import Kanna +import AppModels +import Combine +import Foundation +import AppTools +import ParserFeature + +// MARK: Fetch ListItems +public struct SearchGalleriesRequest: Request { + public init( + keyword: String, + filter: Filter + ) { + self.keyword = keyword + self.filter = filter + } + public let keyword: String + public let filter: Filter + + public var publisher: AnyPublisher { + URLSession.shared.dataTaskPublisher( + for: URLUtil.searchList(keyword: keyword, filter: filter) + ) + .genericRetry() + .tryMap { try htmlDocument(data: $0.data) } + .tryMap { + try parseResponse(doc: $0) { + GalleriesResult( + pageNumber: Parser.parsePageNum(doc: $0), + dateSeekNavigation: Parser.parseDateSeekNavigation(doc: $0), + galleries: try Parser.parseGalleries(doc: $0) + ) + } + } + .mapError(mapAppError) + .eraseToAnyPublisher() + } +} + +public struct MoreSearchGalleriesRequest: Request { + public init( + keyword: String, + filter: Filter, + lastID: String + ) { + self.keyword = keyword + self.filter = filter + self.lastID = lastID + } + public let keyword: String + public let filter: Filter + public let lastID: String + + public var publisher: AnyPublisher { + URLSession.shared.dataTaskPublisher( + for: URLUtil.moreSearchList(keyword: keyword, filter: filter, lastID: lastID) + ) + .genericRetry() + .tryMap { try htmlDocument(data: $0.data) } + .tryMap { + try parseResponse(doc: $0) { + GalleriesResult( + pageNumber: Parser.parsePageNum(doc: $0), + dateSeekNavigation: Parser.parseDateSeekNavigation(doc: $0), + galleries: try Parser.parseGalleries(doc: $0) + ) + } + } + .mapError(mapAppError) + .eraseToAnyPublisher() + } +} + +public struct DateSeekGalleriesRequest: Request { + public init( + url: URL + ) { + self.url = url + } + public let url: URL + + public var publisher: AnyPublisher { + URLSession.shared.dataTaskPublisher(for: url) + .genericRetry() + .tryMap { try htmlDocument(data: $0.data) } + .tryMap { + try parseResponse(doc: $0) { + GalleriesResult( + pageNumber: Parser.parsePageNum(doc: $0), + dateSeekNavigation: Parser.parseDateSeekNavigation(doc: $0), + galleries: try Parser.parseGalleries(doc: $0) + ) + } + } + .mapError(mapAppError) + .eraseToAnyPublisher() + } +} + +public struct FrontpageGalleriesRequest: Request { + public init( + filter: Filter + ) { + self.filter = filter + } + public let filter: Filter + + public var publisher: AnyPublisher { + URLSession.shared.dataTaskPublisher(for: URLUtil.frontpageList(filter: filter)) + .genericRetry() + .tryMap { try htmlDocument(data: $0.data) } + .tryMap { + try parseResponse(doc: $0) { + GalleriesResult( + pageNumber: Parser.parsePageNum(doc: $0), + dateSeekNavigation: Parser.parseDateSeekNavigation(doc: $0), + galleries: try Parser.parseGalleries(doc: $0) + ) + } + } + .mapError(mapAppError) + .eraseToAnyPublisher() + } +} + +public struct MoreFrontpageGalleriesRequest: Request { + public init( + filter: Filter, + lastID: String + ) { + self.filter = filter + self.lastID = lastID + } + public let filter: Filter + public let lastID: String + + public var publisher: AnyPublisher { + URLSession.shared.dataTaskPublisher(for: URLUtil.moreFrontpageList(filter: filter, lastID: lastID)) + .genericRetry() + .tryMap { try htmlDocument(data: $0.data) } + .tryMap { + try parseResponse(doc: $0) { + GalleriesResult( + pageNumber: Parser.parsePageNum(doc: $0), + dateSeekNavigation: Parser.parseDateSeekNavigation(doc: $0), + galleries: try Parser.parseGalleries(doc: $0) + ) + } + } + .mapError(mapAppError) + .eraseToAnyPublisher() + } +} + +public struct PopularGalleriesRequest: Request { + public init( + filter: Filter + ) { + self.filter = filter + } + public let filter: Filter + + public var publisher: AnyPublisher<[Gallery], AppError> { + URLSession.shared.dataTaskPublisher(for: URLUtil.popularList(filter: filter)) + .genericRetry() + .tryMap { try htmlDocument(data: $0.data) } + .tryMap { try parseResponse(doc: $0, Parser.parseGalleries) } + .mapError(mapAppError) + .eraseToAnyPublisher() + } +} + +public struct WatchedGalleriesRequest: Request { + public init( + filter: Filter, + keyword: String + ) { + self.filter = filter + self.keyword = keyword + } + public let filter: Filter + public let keyword: String + + public var publisher: AnyPublisher { + URLSession.shared.dataTaskPublisher(for: URLUtil.watchedList(filter: filter, keyword: keyword)) + .genericRetry() + .tryMap { try htmlDocument(data: $0.data) } + .tryMap { + try parseResponse(doc: $0) { + GalleriesResult( + pageNumber: Parser.parsePageNum(doc: $0), + dateSeekNavigation: Parser.parseDateSeekNavigation(doc: $0), + galleries: try Parser.parseGalleries(doc: $0) + ) + } + } + .mapError(mapAppError) + .eraseToAnyPublisher() + } +} + +public struct MoreWatchedGalleriesRequest: Request { + public init( + filter: Filter, + lastID: String, + keyword: String + ) { + self.filter = filter + self.lastID = lastID + self.keyword = keyword + } + public let filter: Filter + public let lastID: String + public let keyword: String + + public var publisher: AnyPublisher { + URLSession.shared.dataTaskPublisher( + for: URLUtil.moreWatchedList(filter: filter, lastID: lastID, keyword: keyword) + ) + .genericRetry() + .tryMap { try htmlDocument(data: $0.data) } + .tryMap { + try parseResponse(doc: $0) { + GalleriesResult( + pageNumber: Parser.parsePageNum(doc: $0), + dateSeekNavigation: Parser.parseDateSeekNavigation(doc: $0), + galleries: try Parser.parseGalleries(doc: $0) + ) + } + } + .mapError(mapAppError) + .eraseToAnyPublisher() + } +} + +public struct FavoritesGalleriesRequest: Request { + public init( + favIndex: Int, + keyword: String, + sortOrder: FavoritesSortOrder? = nil + ) { + self.favIndex = favIndex + self.keyword = keyword + self.sortOrder = sortOrder + } + public let favIndex: Int + public let keyword: String + public var sortOrder: FavoritesSortOrder? + + public var publisher: AnyPublisher { + URLSession.shared.dataTaskPublisher( + for: URLUtil.favoritesList(favIndex: favIndex, keyword: keyword, sortOrder: sortOrder) + ) + .genericRetry() + .tryMap { try htmlDocument(data: $0.data) } + .tryMap { doc in + try parseResponse(doc: doc) { + FavoritesGalleriesResult( + pageNumber: Parser.parsePageNum(doc: $0), + dateSeekNavigation: Parser.parseDateSeekNavigation(doc: $0), + sortOrder: Parser.parseFavoritesSortOrder(doc: $0), + galleries: try Parser.parseGalleries(doc: $0) + ) + } + } + .mapError(mapAppError) + .eraseToAnyPublisher() + } +} + +public struct MoreFavoritesGalleriesRequest: Request { + public init( + favIndex: Int, + lastID: String, + lastTimestamp: String, + keyword: String + ) { + self.favIndex = favIndex + self.lastID = lastID + self.lastTimestamp = lastTimestamp + self.keyword = keyword + } + public let favIndex: Int + public let lastID: String + public var lastTimestamp: String + public let keyword: String + + public var publisher: AnyPublisher { + URLSession.shared.dataTaskPublisher( + for: URLUtil.moreFavoritesList( + favIndex: favIndex, lastID: lastID, lastTimestamp: lastTimestamp, keyword: keyword + ) + ) + .genericRetry() + .tryMap { try htmlDocument(data: $0.data) } + .tryMap { doc in + try parseResponse(doc: doc) { + FavoritesGalleriesResult( + pageNumber: Parser.parsePageNum(doc: $0), + dateSeekNavigation: Parser.parseDateSeekNavigation(doc: $0), + sortOrder: Parser.parseFavoritesSortOrder(doc: $0), + galleries: try Parser.parseGalleries(doc: $0) + ) + } + } + .mapError(mapAppError) + .eraseToAnyPublisher() + } +} + +public struct ToplistsGalleriesRequest: Request { + public init( + catIndex: Int, + pageNum: Int? = nil + ) { + self.catIndex = catIndex + self.pageNum = pageNum + } + public let catIndex: Int + public var pageNum: Int? + + public var publisher: AnyPublisher<(PageNumber, [Gallery]), AppError> { + URLSession.shared.dataTaskPublisher( + for: URLUtil.toplistsList(catIndex: catIndex, pageNum: pageNum) + ) + .genericRetry() + .tryMap { try htmlDocument(data: $0.data) } + .tryMap { + try parseResponse(doc: $0) { + (Parser.parsePageNum(doc: $0), try Parser.parseGalleries(doc: $0)) + } + } + .mapError(mapAppError) + .eraseToAnyPublisher() + } +} + +public struct MoreToplistsGalleriesRequest: Request { + public init( + catIndex: Int, + pageNum: Int + ) { + self.catIndex = catIndex + self.pageNum = pageNum + } + public let catIndex: Int + public let pageNum: Int + + public var publisher: AnyPublisher<(PageNumber, [Gallery]), AppError> { + URLSession.shared.dataTaskPublisher( + for: URLUtil.moreToplistsList( + catIndex: catIndex, pageNum: pageNum + ) + ) + .genericRetry() + .tryMap { try htmlDocument(data: $0.data) } + .tryMap { + try parseResponse(doc: $0) { + (Parser.parsePageNum(doc: $0), try Parser.parseGalleries(doc: $0)) + } + } + .mapError(mapAppError) + .eraseToAnyPublisher() + } +} diff --git a/AppPackage/Sources/NetworkingFeature/Request+Image.swift b/AppPackage/Sources/NetworkingFeature/Request+Image.swift new file mode 100644 index 000000000..e6fc85893 --- /dev/null +++ b/AppPackage/Sources/NetworkingFeature/Request+Image.swift @@ -0,0 +1,381 @@ +import Kanna +import AppModels +import Combine +import Foundation +import AppTools +import ParserFeature + +// MARK: Response Types +public struct GalleryMPVImageURLResponse: Sendable { + public init( + imageURL: URL, + originalImageURL: URL? = nil, + skipServerIdentifier: String + ) { + self.imageURL = imageURL + self.originalImageURL = originalImageURL + self.skipServerIdentifier = skipServerIdentifier + } + public let imageURL: URL + public let originalImageURL: URL? + public let skipServerIdentifier: String +} + +// MARK: Image Requests +public struct MPVKeysRequest: Request { + public init( + mpvURL: URL, + urlSession: URLSession = .shared, + allowsCellular: Bool = true + ) { + self.mpvURL = mpvURL + self.urlSession = urlSession + self.allowsCellular = allowsCellular + } + public let mpvURL: URL + public var urlSession: URLSession = .shared + public var allowsCellular = true + + public var publisher: AnyPublisher<(String, [Int: String]), AppError> { + urlSession.dataTaskPublisher( + for: urlRequest(url: mpvURL, allowsCellular: allowsCellular) + ) + .genericRetry() + .tryMap { try htmlDocument(data: $0.data) } + .tryMap { try parseResponse(doc: $0, Parser.parseMPVKeys) } + .mapError(mapAppError) + .eraseToAnyPublisher() + } +} + +public struct ThumbnailURLsRequest: Request { + public init( + galleryURL: URL, + pageNum: Int, + urlSession: URLSession = .shared, + allowsCellular: Bool = true + ) { + self.galleryURL = galleryURL + self.pageNum = pageNum + self.urlSession = urlSession + self.allowsCellular = allowsCellular + } + public let galleryURL: URL + public let pageNum: Int + public var urlSession: URLSession = .shared + public var allowsCellular = true + + public var publisher: AnyPublisher<[Int: URL], AppError> { + urlSession.dataTaskPublisher( + for: urlRequest( + url: URLUtil.detailPage(url: galleryURL, pageNum: pageNum), + allowsCellular: allowsCellular + ) + ) + .genericRetry() + .tryMap { try htmlDocument(data: $0.data) } + .tryMap { try parseResponse(doc: $0, Parser.parseThumbnailURLs) } + .mapError(mapAppError) + .eraseToAnyPublisher() + } +} + +public struct GalleryNormalImageURLsRequest: Request { + public init( + thumbnailURLs: [Int: URL], + urlSession: URLSession = .shared, + allowsCellular: Bool = true + ) { + self.thumbnailURLs = thumbnailURLs + self.urlSession = urlSession + self.allowsCellular = allowsCellular + } + public let thumbnailURLs: [Int: URL] + public var urlSession: URLSession = .shared + public var allowsCellular = true + + public var publisher: AnyPublisher<([Int: URL], [Int: URL]), AppError> { + thumbnailURLs.publisher + .flatMap { index, url in + urlSession.dataTaskPublisher( + for: urlRequest( + url: url, + allowsCellular: allowsCellular + ) + ) + .genericRetry() + .tryMap { try htmlDocument(data: $0.data) } + .tryMap { doc in + try parseResponse(doc: doc) { + try Parser.parseGalleryNormalImageURL( + doc: $0, + index: index + ) + } + } + } + .collect() + .map { infos in + var imageURLs = [Int: URL]() + var originalImageURLs = [Int: URL]() + for info in infos { + imageURLs[info.index] = info.imageURL + originalImageURLs[info.index] = info.originalImageURL + } + return (imageURLs, originalImageURLs) + } + .mapError(mapAppError) + .eraseToAnyPublisher() + } +} + +public struct ImageURLRefetchResult: Sendable { + public init( + imageURL: URL, + anotherImageURL: URL, + response: HTTPURLResponse? = nil + ) { + self.imageURL = imageURL + self.anotherImageURL = anotherImageURL + self.response = response + } + public let imageURL: URL + public let anotherImageURL: URL + public let response: HTTPURLResponse? +} + +public struct GalleryNormalImageURLRefetchRequest: Request { + public init( + index: Int, + pageNum: Int, + galleryURL: URL, + thumbnailURL: URL? = nil, + storedImageURL: URL, + urlSession: URLSession = .shared, + allowsCellular: Bool = true + ) { + self.index = index + self.pageNum = pageNum + self.galleryURL = galleryURL + self.thumbnailURL = thumbnailURL + self.storedImageURL = storedImageURL + self.urlSession = urlSession + self.allowsCellular = allowsCellular + } + public let index: Int + public let pageNum: Int + public let galleryURL: URL + public let thumbnailURL: URL? + public let storedImageURL: URL + public var urlSession: URLSession = .shared + public var allowsCellular = true + + public var publisher: AnyPublisher<([Int: URL], HTTPURLResponse?), AppError> { + storedThumbnailURL() + .flatMap(renewThumbnailURL) + .flatMap(imageURL) + .genericRetry() + .map { result in + ( + [index: result.imageURL != storedImageURL + ? result.imageURL : result.anotherImageURL], + result.response + ) + } + .eraseToAnyPublisher() + } + + public func storedThumbnailURL() -> AnyPublisher { + if let thumbnailURL = thumbnailURL { + return Just(thumbnailURL) + .setFailureType(to: AppError.self) + .eraseToAnyPublisher() + } else { + return urlSession.dataTaskPublisher( + for: urlRequest( + url: URLUtil.detailPage(url: galleryURL, pageNum: pageNum), + allowsCellular: allowsCellular + ) + ) + .tryMap { try htmlDocument(data: $0.data) } + .tryMap { try parseResponse(doc: $0, Parser.parseThumbnailURLs) } + .compactMap({ thumbnailURLs in thumbnailURLs[index] }) + .mapError(mapAppError) + .eraseToAnyPublisher() + } + } + + public func renewThumbnailURL(stored: URL) + -> AnyPublisher<(URL, URL), AppError> { + urlSession.dataTaskPublisher( + for: urlRequest(url: stored, allowsCellular: allowsCellular) + ) + .tryMap { try htmlDocument(data: $0.data) } + .tryMap { doc in + try parseResponse(doc: doc) { + let identifier = try Parser.parseSkipServerIdentifier(doc: $0) + let imageURL = try Parser.parseGalleryNormalImageURL( + doc: $0, index: index + ).imageURL + return ( + stored.appending( + queryItems: [.skipServerIdentifier: identifier] + ), + imageURL + ) + } + } + .mapError(mapAppError) + .eraseToAnyPublisher() + } + + public func imageURL(thumbnailURL: URL, anotherImageURL: URL) + -> AnyPublisher { + urlSession.dataTaskPublisher( + for: urlRequest(url: thumbnailURL, allowsCellular: allowsCellular) + ) + .tryMap { + ( + try htmlDocument(data: $0.data), + $0.response as? HTTPURLResponse + ) + } + .tryMap { html, response in + try parseResponse(doc: html) { + ( + try Parser.parseGalleryNormalImageURL( + doc: $0, + index: index + ), + response + ) + } + } + .map { info, response in + ImageURLRefetchResult( + imageURL: anotherImageURL, + anotherImageURL: info.imageURL, + response: response + ) + } + .mapError(mapAppError) + .eraseToAnyPublisher() + } +} + +public struct GalleryMPVImageURLRequest: Request { + public init( + gid: Int, + index: Int, + mpvKey: String, + mpvImageKey: String, + skipServerIdentifier: String? = nil, + apiURL: URL = Defaults.URL.api, + urlSession: URLSession = .shared, + allowsCellular: Bool = true, + requiresSkipServerIdentifier: Bool = true + ) { + self.gid = gid + self.index = index + self.mpvKey = mpvKey + self.mpvImageKey = mpvImageKey + self.skipServerIdentifier = skipServerIdentifier + self.apiURL = apiURL + self.urlSession = urlSession + self.allowsCellular = allowsCellular + self.requiresSkipServerIdentifier = requiresSkipServerIdentifier + } + public let gid: Int + public let index: Int + public let mpvKey: String + public let mpvImageKey: String + public let skipServerIdentifier: String? + public var apiURL: URL = Defaults.URL.api + public var urlSession: URLSession = .shared + public var allowsCellular = true + public var requiresSkipServerIdentifier = true + + public var publisher: AnyPublisher { + var params: [String: Any] = [ + "method": "imagedispatch", + "gid": gid, + "page": index, + "imgkey": mpvImageKey, + "mpvkey": mpvKey + ] + if let skipServerIdentifier = skipServerIdentifier { + params["nl"] = skipServerIdentifier + } + + var request = urlRequest( + url: apiURL, + allowsCellular: allowsCellular + ) + request.httpMethod = "POST" + request.httpBody = try? JSONSerialization.data( + withJSONObject: params, options: [] + ) + + return urlSession.dataTaskPublisher(for: request) + .genericRetry() + .map(\.data) + .tryMap { data in + try parseResponse(data: data) { + guard let dict = try JSONSerialization + .jsonObject(with: $0) as? [String: Any], + let imageURLString = dict["i"] as? String, + let imageURL = URL(string: imageURLString) + else { throw AppError.parseFailed } + + var skipServerIdentifier: String? + + if let integerIdentifier = dict["s"] as? Int { + skipServerIdentifier = integerIdentifier.description + } else if let stringIdentifier = dict["s"] as? String { + skipServerIdentifier = stringIdentifier + } + + if skipServerIdentifier == nil, + requiresSkipServerIdentifier { + throw AppError.parseFailed + } + + if let originalSlice = dict["lf"] as? String { + let originalImageURL = Defaults.URL.host + .appendingPathComponent(originalSlice) + return GalleryMPVImageURLResponse( + imageURL: imageURL, + originalImageURL: originalImageURL, + skipServerIdentifier: skipServerIdentifier ?? "" + ) + } else { + return GalleryMPVImageURLResponse( + imageURL: imageURL, + originalImageURL: nil, + skipServerIdentifier: skipServerIdentifier ?? "" + ) + } + } + } + .mapError(mapAppError) + .eraseToAnyPublisher() + } +} + +// MARK: Tool +public struct DataRequest: Request { + public init( + url: URL + ) { + self.url = url + } + public let url: URL + + public var publisher: AnyPublisher { + URLSession.shared.dataTaskPublisher(for: url) + .genericRetry() + .map(\.data) + .mapError(mapAppError) + .eraseToAnyPublisher() + } +} diff --git a/AppPackage/Sources/NetworkingFeature/Request+MyTags.swift b/AppPackage/Sources/NetworkingFeature/Request+MyTags.swift new file mode 100644 index 000000000..26b3518bd --- /dev/null +++ b/AppPackage/Sources/NetworkingFeature/Request+MyTags.swift @@ -0,0 +1,42 @@ +import Kanna +import SwiftUI +import AppModels +import Foundation +import AppTools +import Combine +import ParserFeature + +public struct MyTagsRequest: Request { + public init( + tagSetNo: Int = 1, + urlSession: URLSession = .shared, + allowsCellular: Bool = true + ) { + self.tagSetNo = tagSetNo + self.urlSession = urlSession + self.allowsCellular = allowsCellular + } + public let tagSetNo: Int + public let urlSession: URLSession + public let allowsCellular: Bool + + public var publisher: AnyPublisher { + guard var components = URLComponents( + url: Defaults.URL.myTags, resolvingAgainstBaseURL: true + ) else { + return Fail(error: AppError.parseFailed).eraseToAnyPublisher() + } + components.queryItems = [URLQueryItem(name: "tagset", value: String(tagSetNo))] + guard let url = components.url else { + return Fail(error: AppError.parseFailed).eraseToAnyPublisher() + } + return urlSession.dataTaskPublisher( + for: urlRequest(url: url, allowsCellular: allowsCellular) + ) + .genericRetry() + .tryMap { try htmlDocument(data: $0.data) } + .tryMap { try parseResponse(doc: $0, Parser.parseMyTagsPage) } + .mapError(mapAppError) + .eraseToAnyPublisher() + } +} diff --git a/AppPackage/Sources/NetworkingFeature/Request.swift b/AppPackage/Sources/NetworkingFeature/Request.swift new file mode 100644 index 000000000..424fd5b56 --- /dev/null +++ b/AppPackage/Sources/NetworkingFeature/Request.swift @@ -0,0 +1,341 @@ +import Kanna +import AppModels +import Combine +import Foundation +import ComposableArchitecture +import AppTools +import ParserFeature + +public protocol Request { + associatedtype Response: Sendable + + var publisher: AnyPublisher { get } +} + +private struct ResponseParsingError: Error { + let underlyingError: Error + let responseError: AppError? +} + +extension Request { + public func response() async -> Result { + await publisher.receive(on: DispatchQueue.main).async() + } + + public func urlRequest( + url: URL, + allowsCellular: Bool + ) -> URLRequest { + var request = URLRequest(url: url) + request.allowsCellularAccess = allowsCellular + return request + } + + public func htmlDocument(data: Data) throws -> HTMLDocument { + do { + return try Kanna.HTML(html: data, encoding: .utf8) + } catch { + let content = String( + data: data.utf8InvalidCharactersRipped, + encoding: .utf8 + ) + throw ResponseParsingError( + underlyingError: error, + responseError: content.flatMap( + Parser.parseResponseError(content:) + ) + ) + } + } + + public func htmlDocumentWithUTF8Fallback(data: Data) throws -> HTMLDocument { + do { + return try Kanna.HTML(html: data, encoding: .utf8) + } catch { + guard let parseError = error as? ParseError, + parseError == .EncodingMismatch, + let htmlDocument = try? Kanna.HTML( + html: data.utf8InvalidCharactersRipped, + encoding: .utf8 + ) + else { + let content = String( + data: data.utf8InvalidCharactersRipped, + encoding: .utf8 + ) + throw ResponseParsingError( + underlyingError: error, + responseError: content.flatMap( + Parser.parseResponseError(content:) + ) + ) + } + return htmlDocument + } + } + + public func parseResponse( + doc: HTMLDocument, + _ parser: (HTMLDocument) throws -> T + ) throws -> T { + do { + return try parser(doc) + } catch { + throw ResponseParsingError( + underlyingError: error, + responseError: Parser.parseResponseError(doc: doc) + ) + } + } + + public func parseResponse( + data: Data, + _ parser: (Data) throws -> T + ) throws -> T { + do { + return try parser(data) + } catch { + let content = String( + data: data.utf8InvalidCharactersRipped, + encoding: .utf8 + ) + throw ResponseParsingError( + underlyingError: error, + responseError: content.flatMap( + Parser.parseResponseError(content:) + ) + ) + } + } + + public func mapAppError(error: Error) -> AppError { + if let responseParsingError = error as? ResponseParsingError { + if let responseError = parsedResponseError( + from: responseParsingError + ) { + return responseError + } + return mapAppError( + error: responseParsingError.underlyingError + ) + } + + switch error { + case is ParseError: + return .parseFailed + + case is URLError: + return .networkingFailed + + case is DecodingError: + return .parseFailed + + default: + return error as? AppError ?? .unknown + } + } + + private func parsedResponseError( + from error: ResponseParsingError + ) -> AppError? { + error.responseError + } +} + +extension Publisher { + public func genericRetry() -> Publishers.Retry { + retry(3) + } + + public func async() async -> Result where Output: Sendable, Failure == AppError { + do { + let output = try await asyncOutput() + return .success(output) + } catch { + return .failure(error as? AppError ?? .unknown) + } + } + + private func asyncOutput() async throws -> Output where Output: Sendable, Failure == AppError { + try await withCheckedThrowingContinuation { continuation in + var cancellable: AnyCancellable? + var finishedWithoutValue = true + cancellable = first() + .sink { result in + switch result { + case .finished: + if finishedWithoutValue { + continuation.resume(throwing: AppError.unknown) + } + case let .failure(error): + continuation.resume(throwing: error) + } + cancellable?.cancel() + } receiveValue: { value in + finishedWithoutValue = false + continuation.resume(returning: value) + } + } + } +} +extension URLRequest { + public mutating func setURLEncodedContentType() { + setValue("application/x-www-form-urlencoded", forHTTPHeaderField: "Content-Type") + } +} +extension Dictionary where Key == String, Value == String { + public func dictString() -> String { + var array = [String]() + keys.forEach { key in + array.append(key + "=" + self[key].forceUnwrapped) + } + return array.joined(separator: "&") + } +} + +private extension URL { + var galleryToken: String? { + let filteredComponents = pathComponents.filter { $0 != "/" && !$0.isEmpty } + guard filteredComponents.count >= 3 else { return nil } + return filteredComponents[2] + } +} + +// MARK: - Response Types + +public struct GalleriesResult: Sendable { + public init( + pageNumber: PageNumber, + dateSeekNavigation: DateSeekNavigation? = nil, + galleries: [Gallery] + ) { + self.pageNumber = pageNumber + self.dateSeekNavigation = dateSeekNavigation + self.galleries = galleries + } + public let pageNumber: PageNumber + public let dateSeekNavigation: DateSeekNavigation? + public let galleries: [Gallery] +} + +public struct FavoritesGalleriesResult: Sendable { + public init( + pageNumber: PageNumber, + dateSeekNavigation: DateSeekNavigation? = nil, + sortOrder: FavoritesSortOrder? = nil, + galleries: [Gallery] + ) { + self.pageNumber = pageNumber + self.dateSeekNavigation = dateSeekNavigation + self.sortOrder = sortOrder + self.galleries = galleries + } + public let pageNumber: PageNumber + public let dateSeekNavigation: DateSeekNavigation? + public let sortOrder: FavoritesSortOrder? + public let galleries: [Gallery] +} + +public struct GalleryArchiveResponse: Sendable { + public init( + archive: GalleryArchive, + galleryPoints: String? = nil, + credits: String? = nil + ) { + self.archive = archive + self.galleryPoints = galleryPoints + self.credits = credits + } + public let archive: GalleryArchive + public let galleryPoints: String? + public let credits: String? +} + +// MARK: Routine +public struct GreetingRequest: Request { + public init() {} + + public var publisher: AnyPublisher { + URLSession.shared.dataTaskPublisher(for: Defaults.URL.news) + .genericRetry() + .tryMap { try htmlDocument(data: $0.data) } + .tryMap { try parseResponse(doc: $0, Parser.parseGreeting) } + .mapError(mapAppError) + .eraseToAnyPublisher() + } +} + +public struct UserInfoRequest: Request { + public init( + uid: String + ) { + self.uid = uid + } + public let uid: String + + public var publisher: AnyPublisher { + URLSession.shared.dataTaskPublisher(for: URLUtil.userInfo(uid: uid)) + .genericRetry() + .tryMap { try htmlDocument(data: $0.data) } + .tryMap { try parseResponse(doc: $0, Parser.parseUserInfo) } + .mapError(mapAppError) + .eraseToAnyPublisher() + } +} + +public struct FavoriteCategoriesRequest: Request { + public init() {} + + public var publisher: AnyPublisher<[Int: String], AppError> { + URLSession.shared.dataTaskPublisher(for: Defaults.URL.uConfig) + .genericRetry() + .tryMap { try htmlDocument(data: $0.data) } + .tryMap { try parseResponse(doc: $0, Parser.parseFavoriteCategories) } + .mapError(mapAppError) + .eraseToAnyPublisher() + } +} + +public struct TagTranslatorRequest: Request { + public init( + language: TranslatableLanguage, + updatedDate: Date + ) { + self.language = language + self.updatedDate = updatedDate + } + public let language: TranslatableLanguage + public let updatedDate: Date + + public var dateFormatter: DateFormatter { + let formatter = DateFormatter() + formatter.dateFormat = Defaults.DateFormat.github + formatter.timeZone = TimeZone(secondsFromGMT: 0) + formatter.locale = Locale(identifier: "en_US_POSIX") + return formatter + } + + // Returns the untouched DB JSON bytes plus the release date; decoding, OpenCC conversion, and + // caching are done downstream by `FileClient` so this layer stays purely network. + public var publisher: AnyPublisher { + URLSession.shared.dataTaskPublisher(for: URLUtil.githubAPI(repoName: language.repoName)) + .genericRetry().tryMap { data, _ -> Date in + guard let dict = try JSONSerialization.jsonObject(with: data) as? [String: Any], + let postedDateString = dict["published_at"] as? String, + let postedDate = dateFormatter.date(from: postedDateString) + else { throw AppError.parseFailed } + + guard postedDate > updatedDate + else { throw AppError.noUpdates } + return postedDate + } + .flatMap { date in + URLSession.shared.dataTaskPublisher( + for: URLUtil.githubDownload(repoName: language.repoName, fileName: language.remoteFilename) + ) + .tryMap { data, _ in TagTranslatorPayload(data: data, updatedDate: date) } + } + .mapError(mapAppError) + .eraseToAnyPublisher() + } +} diff --git a/AppPackage/Sources/OSLogExt/.swiftlint.yml b/AppPackage/Sources/OSLogExt/.swiftlint.yml new file mode 100644 index 000000000..1242ffcaa --- /dev/null +++ b/AppPackage/Sources/OSLogExt/.swiftlint.yml @@ -0,0 +1 @@ +parent_config: ../../../.swiftlint.yml diff --git a/AppPackage/Sources/OSLogExt/Logger+.swift b/AppPackage/Sources/OSLogExt/Logger+.swift new file mode 100644 index 000000000..d4b9d7b84 --- /dev/null +++ b/AppPackage/Sources/OSLogExt/Logger+.swift @@ -0,0 +1,11 @@ +@_exported import OSLog +import AppTools + +public extension Logger { + init(moduleName: String, category: String) { + self.init( + subsystem: [Defaults.App.identifier, moduleName].joined(separator: "."), + category: category + ) + } +} diff --git a/AppPackage/Sources/OpenCCExt/.swiftlint.yml b/AppPackage/Sources/OpenCCExt/.swiftlint.yml new file mode 100644 index 000000000..1242ffcaa --- /dev/null +++ b/AppPackage/Sources/OpenCCExt/.swiftlint.yml @@ -0,0 +1 @@ +parent_config: ../../../.swiftlint.yml diff --git a/AppPackage/Sources/OpenCCExt/TagTranslation+ChtConverted.swift b/AppPackage/Sources/OpenCCExt/TagTranslation+ChtConverted.swift new file mode 100644 index 000000000..5fc83b5e2 --- /dev/null +++ b/AppPackage/Sources/OpenCCExt/TagTranslation+ChtConverted.swift @@ -0,0 +1,36 @@ +import AppModels +import Foundation +import OpenCC + +extension Dictionary where Value == TagTranslation { + public var chtConverted: Self { + func customConversion(text: String) -> String { + switch text { + case "full color": + return "全彩" + default: + return text + } + } + + guard let preferredLanguage = Locale.preferredLanguages.first else { return self } + + var options: ChineseConverter.Options = [.traditionalize] + if preferredLanguage.contains("HK") { + options = [.traditionalize, .hkStandard] + } else if preferredLanguage.contains("TW") { + options = [.traditionalize, .twStandard, .twIdiom] + } + + guard let converter = try? ChineseConverter(options: options) else { return self } + var dictionary = self + dictionary.forEach { (key, value) in + dictionary[key] = TagTranslation( + namespace: value.namespace, key: value.key, + value: customConversion(text: converter.convert(value.value)), + description: value.description, linksString: value.linksString + ) + } + return dictionary + } +} diff --git a/AppPackage/Sources/ParserFeature/.swiftlint.yml b/AppPackage/Sources/ParserFeature/.swiftlint.yml new file mode 100644 index 000000000..1242ffcaa --- /dev/null +++ b/AppPackage/Sources/ParserFeature/.swiftlint.yml @@ -0,0 +1 @@ +parent_config: ../../../.swiftlint.yml diff --git a/AppPackage/Sources/ParserFeature/Logger+.swift b/AppPackage/Sources/ParserFeature/Logger+.swift new file mode 100644 index 000000000..01a45773a --- /dev/null +++ b/AppPackage/Sources/ParserFeature/Logger+.swift @@ -0,0 +1,7 @@ +import OSLogExt + +extension Logger { + init(category: String) { + self.init(moduleName: "ParserFeature", category: category) + } +} diff --git a/AppPackage/Sources/ParserFeature/Parser+Archive.swift b/AppPackage/Sources/ParserFeature/Parser+Archive.swift new file mode 100644 index 000000000..d7cbf5a88 --- /dev/null +++ b/AppPackage/Sources/ParserFeature/Parser+Archive.swift @@ -0,0 +1,108 @@ +import AppTools +import Kanna +import AppModels +import Foundation + +extension Parser { + public static func parseGalleryArchive(doc: HTMLDocument) throws -> GalleryArchive { + guard let node = doc.at_xpath("//table") + else { throw AppError.parseFailed } + + var hathArchives = [GalleryArchive.HathArchive]() + for link in node.xpath("//td") { + var tmpResolution: ArchiveResolution? + var tmpFileSize: String? + var tmpGPPrice: String? + + for pLink in link.xpath("//p") { + if let pText = pLink.text { + if let res = ArchiveResolution(rawValue: pText) { + tmpResolution = res + } + if pText.contains("N/A") { + tmpFileSize = "N/A" + tmpGPPrice = "N/A" + + if tmpResolution != nil { + break + } + } else { + if pText.contains("KiB") + || pText.contains("MiB") + || pText.contains("GiB") { + tmpFileSize = pText + } else { + tmpGPPrice = pText + } + } + } + } + + guard let resolution = tmpResolution, + let fileSize = tmpFileSize, + let gpPrice = tmpGPPrice + else { continue } + + hathArchives.append( + GalleryArchive.HathArchive( + resolution: resolution, + fileSize: fileSize, + gpPrice: gpPrice + ) + ) + } + + return GalleryArchive(hathArchives: hathArchives) + } + + public static func parseDownloadCommandResponse(doc: HTMLDocument) throws -> String { + guard let dbNode = doc.at_xpath("//div [@id='db']") + else { throw AppError.parseFailed } + + var response = [String]() + for pLink in dbNode.xpath("//p") { + if let pText = pLink.text { + response.append(pText) + } + } + + var respString = response.joined(separator: " ") + + if let rangeA = respString.range(of: "A ") ?? respString.range(of: "An "), + let rangeB = respString.range(of: "resolution"), + let rangeC = respString.range(of: "client"), + let rangeD = respString.range(of: "Downloads") { + let resp = String(respString[rangeA.upperBound.. " + clientName + } else { + respString = resp + } + } + } + + return respString + } + + static func parseArchiveURL(node: XMLElement) throws -> URL { + var archiveURL: URL? + if let aLink = node.at_xpath("//a"), + aLink.text?.contains("Archive Download") == true, let onClick = aLink["onclick"], + let rangeA = onClick.range(of: "popUp('"), let rangeB = onClick.range(of: "',") { + archiveURL = URL(string: .init(onClick[rangeA.upperBound.. [GalleryComment] { + var comments = [GalleryComment]() + for link in doc.xpath("//div [@id='cdiv']") { + for c1Link in link.xpath("//div [@class='c1']") { + guard let c3Node = c1Link.at_xpath("//div [@class='c3']")?.text, + let c6Node = c1Link.at_xpath("//div [@class='c6']"), + let commentID = c6Node["id"]? + .replacingOccurrences(of: "comment_", with: ""), + let rangeA = c3Node.range(of: "Posted on "), + let rangeB = c3Node.range(of: " by:   ") + else { continue } + + var score: String? + if let c5Node = c1Link.at_xpath("//div [@class='c5 nosel']") { + score = c5Node.at_xpath("//span")?.text + } + let author = String(c3Node[rangeB.upperBound...]) + let commentTime = String(c3Node[rangeA.upperBound.. [CommentContent] { + var contents = [CommentContent]() + + for div in node.xpath("//div") { + node.removeChild(div) + } + for span in node.xpath("span") { + node.removeChild(span) + } + + guard var rawContent = node.innerHTML? + .replacingOccurrences(of: "
", with: "\n") + .replacingOccurrences(of: "", with: "") + else { return [] } + + while (node.xpath("//a").count + + node.xpath("//img").count) > 0 { + var tmpLink: XMLElement? + + let links = [ + node.at_xpath("//a"), + node.at_xpath("//img") + ] + .compactMap({ $0 }) + + links.forEach { newLink in + if tmpLink == nil { + tmpLink = newLink + } else { + if let tmpHTML = tmpLink?.toHTML, + let newHTML = newLink.toHTML, + let tmpBound = rawContent.range(of: tmpHTML)?.lowerBound, + let newBound = rawContent.range(of: newHTML)?.lowerBound, + newBound < tmpBound { + tmpLink = newLink + } + } + } + + guard let link = tmpLink, + let html = link.toHTML? + .replacingOccurrences(of: "
", with: "\n") + .replacingOccurrences(of: "", with: ""), + let range = rawContent.range(of: html) + else { continue } + + let text = String(rawContent[.. URL { + guard let galleryURLString = doc.at_xpath("//div [@class='sb']")?.at_xpath("//a")?["href"], + let galleryURL = URL(string: galleryURLString) else { throw AppError.parseFailed } + return galleryURL + } + + // swiftlint:disable:next function_body_length + public static func parseGalleryDetail(doc: HTMLDocument, gid: String) throws -> (GalleryDetail, GalleryState) { + var tmpGalleryDetail: GalleryDetail? + var tmpGalleryState: GalleryState? + for link in doc.xpath("//div [@class='gm']") { + guard tmpGalleryDetail == nil, tmpGalleryState == nil, + let gd3Node = link.at_xpath("//div [@id='gd3']"), + let gd4Node = link.at_xpath("//div [@id='gd4']"), + let gd5Node = link.at_xpath("//div [@id='gd5']"), + let gddNode = gd3Node.at_xpath("//div [@id='gdd']"), + let gdrNode = gd3Node.at_xpath("//div [@id='gdr']"), + let gdfNode = gd3Node.at_xpath("//div [@id='gdf']"), + let coverURL = try? parseCoverURL(node: link), + let tags = try? parseGalleryTags(node: gd4Node), + let previewURLs = try? parsePreviewURLs(doc: doc), + let arcAndTor = try? parseArcAndTor(node: gd5Node), + let infoPanel = try? parseInfoPanel(node: gddNode), + let visibility = try? parseVisibility(value: infoPanel["visible"] ?? ""), + let sizeCount = Float(infoPanel["size"] ?? ""), + let pageCount = Int(infoPanel["length"] ?? ""), + let favoritedCount = Int(infoPanel["favorited"] ?? ""), + let language = Language(rawValue: infoPanel["language"] ?? ""), + let engTitle = link.at_xpath("//h1 [@id='gn']")?.text, + let uploader = try? parseUploader(node: gd3Node), + let ratingResult = try? parseRating(node: gdrNode), + let ratingCount = Int(gdrNode.at_xpath("//span [@id='rating_count']")?.text ?? ""), + let category = AppModels.Category(rawValue: gd3Node.at_xpath("//div [@id='gdc']")?.text ?? ""), + let postedDate = try? parseDate(time: infoPanel["posted"] ?? "", format: Defaults.DateFormat.publish) + else { continue } + + let isFavorited = gdfNode + .at_xpath(".//div[@id='fav']//div[@class='i']") != nil + var favoriteTagIndex: Int? + var favoriteTagName: String? + if let favoritelink = gdfNode.at_xpath(".//a[@id='favoritelink']") { + favoriteTagName = favoritelink.text + } + let gjText = link.at_xpath("//h1 [@id='gj']")?.text + let jpnTitle = gjText?.isEmpty != false ? nil : gjText + let parentURLString = (infoPanel["parent"] ?? "").isValidURL ? infoPanel["parent"] ?? "" : "" + + tmpGalleryDetail = GalleryDetail( + gid: gid, + title: engTitle, + jpnTitle: jpnTitle, + isFavorited: isFavorited, + visibility: visibility, + rating: ratingResult.containsUserRating ? ratingResult.textRating ?? 0.0 : ratingResult.imgRating, + userRating: ratingResult.containsUserRating ? ratingResult.imgRating : 0.0, + ratingCount: ratingCount, + category: category, + language: language, + uploader: uploader, + postedDate: postedDate, + coverURL: coverURL, + archiveURL: arcAndTor.0, + parentURL: URL(string: parentURLString), + favoritedCount: favoritedCount, + pageCount: pageCount, + sizeCount: sizeCount, + sizeType: infoPanel["sizeUnit"] ?? "", + torrentCount: arcAndTor.1, + favoriteTagIndex: favoriteTagIndex, + favoriteTagName: favoriteTagName + ) + tmpGalleryState = GalleryState( + gid: gid, + tags: tags, + previewURLs: previewURLs, + previewConfig: try? parsePreviewConfig(doc: doc), + comments: parseComments(doc: doc) + ) + break + } + + guard let galleryDetail = tmpGalleryDetail, + let galleryState = tmpGalleryState + else { + if let reason = doc.at_xpath("//div [@class='d']")?.at_xpath("//p")?.text { + if let rangeA = reason.range(of: "copyright claim by "), + let rangeB = reason.range(of: ".Sorry about that.") { + let owner = String(reason[rangeA.upperBound.. String { + if doc.at_xpath("//div [@class='gt100']") != nil { + return "gt100" + } else if doc.at_xpath("//div [@class='gt200']") != nil { + return "gt200" + } else { + throw AppError.parseFailed + } + } + + static func parsePreviewConfig(doc: HTMLDocument) throws -> PreviewConfig { + guard let previewMode = try? parsePreviewMode(doc: doc), + let gpcText = doc.at_xpath("//p [@class='gpc']")?.text, + let rangeA = gpcText.range(of: "Showing 1 - "), + let rangeB = gpcText.range(of: " of "), + let singlePageCount = Int(gpcText[rangeA.upperBound.. URL { + guard let coverHTML = node?.at_xpath("//div [@id='gd1']")?.innerHTML, + let rangeA = coverHTML.range(of: "url("), let rangeB = coverHTML.range(of: ")"), + let url = URL(string: .init(coverHTML[rangeA.upperBound.. [GalleryTag] { + var tags = [GalleryTag]() + for link in node.xpath("//tr") { + guard let tcText = link.at_xpath("//td [@class='tc']")?.text else { continue } + let namespace = String(tcText.dropLast()) + var contents = [GalleryTag.Content]() + for divLink in link.xpath("//div") { + guard var text = divLink.text, let aClass = divLink.at_xpath("//a")?.className else { continue } + if let range = text.range(of: " | ") { + text = .init(text[.. (URL?, Int) { + guard let node = node else { throw AppError.parseFailed } + + var archiveURL: URL? + for g2gspLink in node.xpath("//p [@class='g2 gsp']") { + if archiveURL == nil { + archiveURL = try? parseArchiveURL(node: g2gspLink) + } else { + break + } + } + + var tmpTorrentCount: Int? + for g2Link in node.xpath("//p [@class='g2']") { + if let aText = g2Link.at_xpath("//a")?.text, + let rangeA = aText.range(of: "Torrent Download ("), + let rangeB = aText.range(of: ")") { + tmpTorrentCount = Int(aText[rangeA.upperBound.. [String: String] { + guard let object = node?.xpath("//tr") + else { throw AppError.parseFailed } + + var infoPanel = [String: String]() + for gddLink in object { + guard let gdt1Text = gddLink.at_xpath("//td [@class='gdt1']")?.text, + let gdt2Text = gddLink.at_xpath("//td [@class='gdt2']")?.text + else { continue } + let aHref = gddLink.at_xpath("//td [@class='gdt2']")?.at_xpath("//a")?["href"] + + let key = gdt1Text.trimmingCharacters(in: .whitespaces) + if key.contains("Posted") { + infoPanel["posted"] = gdt2Text + } + if key.contains("Parent") { + infoPanel["parent"] = aHref ?? "None" + } + if key.contains("Visible") { + infoPanel["visible"] = gdt2Text + } + if key.contains("Language") { + let words = gdt2Text.split(separator: " ") + if !words.isEmpty { + infoPanel["language"] = String(words[0]).trimmingCharacters(in: .whitespaces) + } + } + if key.contains("File Size") { + infoPanel["size"] = gdt2Text + .replacingOccurrences(of: " KiB", with: "") + .replacingOccurrences(of: " MiB", with: "") + .replacingOccurrences(of: " GiB", with: "") + + if gdt2Text.contains("KiB") { infoPanel["sizeUnit"] = "KiB" } + if gdt2Text.contains("MiB") { infoPanel["sizeUnit"] = "MiB" } + if gdt2Text.contains("GiB") { infoPanel["sizeUnit"] = "GiB" } + } + if key.contains("Length") { + infoPanel["length"] = gdt2Text.replacingOccurrences(of: " pages", with: "") + } + if key.contains("Favorited") { + infoPanel["favorited"] = gdt2Text + .replacingOccurrences(of: " times", with: "") + .replacingOccurrences(of: "Never", with: "0") + .replacingOccurrences(of: "Once", with: "1") + } + } + + return infoPanel + } + + static func parseVisibility(value: String) throws -> GalleryVisibility { + guard value != "Yes" else { return .yes } + guard let rangeA = value.range(of: "("), + let rangeB = value.range(of: ")") + else { throw AppError.parseFailed } + + let reason = String(value[rangeA.upperBound.. String { + guard let gdnNode = node?.at_xpath("//div [@id='gdn']") else { + throw AppError.parseFailed + } + + if let aText = gdnNode.at_xpath("//a")?.text { + return aText + } else if let gdnText = gdnNode.text { + return gdnText + } else { + throw AppError.parseFailed + } + } +} diff --git a/AppPackage/Sources/ParserFeature/Parser+Favorite.swift b/AppPackage/Sources/ParserFeature/Parser+Favorite.swift new file mode 100644 index 000000000..0a9db2655 --- /dev/null +++ b/AppPackage/Sources/ParserFeature/Parser+Favorite.swift @@ -0,0 +1,37 @@ +import Kanna +import AppModels + +extension Parser { + public static func parseFavoritesSortOrder(doc: HTMLDocument) -> FavoritesSortOrder? { + guard let select = doc.at_xpath("//div [@class='searchnav']//select[contains(@onchange, 'inline_set=fs_')]"), + let selectedValue = select.at_xpath(".//option[@selected]")?["value"] + else { return nil } + + switch selectedValue { + case "p": return .lastUpdateTime + case "f": return .favoritedTime + default: return nil + } + } + + public static func parseFavoriteCategories(doc: HTMLDocument) throws -> [Int: String] { + var favoriteCategories = [Int: String]() + + for link in doc.xpath("//div [@id='favsel']") { + for inputLink in link.xpath("//input") { + guard let name = inputLink["name"], + let value = inputLink["value"], + let type = FavoritesType(rawValue: name) + else { continue } + + favoriteCategories[type.index] = value + } + } + + if !favoriteCategories.isEmpty { + return favoriteCategories + } else { + throw AppError.parseFailed + } + } +} diff --git a/AppPackage/Sources/ParserFeature/Parser+Greeting.swift b/AppPackage/Sources/ParserFeature/Parser+Greeting.swift new file mode 100644 index 000000000..4d31ab81d --- /dev/null +++ b/AppPackage/Sources/ParserFeature/Parser+Greeting.swift @@ -0,0 +1,82 @@ +import Kanna +import AppModels +import Foundation + +extension Parser { + // swiftlint:disable:next cyclomatic_complexity + public static func parseGreeting(doc: HTMLDocument) throws -> Greeting { + guard let node = doc.at_xpath("//div [@id='eventpane']") + else { throw AppError.parseFailed } + + var greeting = Greeting() + for link in node.xpath("//p") { + guard var text = link.text, + text.contains("You gain") == true + else { continue } + var gainedTypes = [String]() + var gainedValues = [String]() + for strongLink in link.xpath("//strong") { + if let strongText = strongLink.text { + gainedValues.append(strongText) + } + } + for value in gainedValues { + guard let range = text.range(of: value) else { break } + let removeText = String(text[.. String? { + if string.contains("EXP") { + return "EXP" + } else if string.contains("Credits") { + return "Credits" + } else if string.contains("GP") { + return "GP" + } else if string.contains("Hath") { + return "Hath" + } else { + return nil + } + } + + static func trim(int: String) -> Int? { + Int(int.replacingOccurrences(of: ",", with: "") + .replacingOccurrences(of: " ", with: "")) + } +} diff --git a/AppPackage/Sources/ParserFeature/Parser+Image.swift b/AppPackage/Sources/ParserFeature/Parser+Image.swift new file mode 100644 index 000000000..37f27fe11 --- /dev/null +++ b/AppPackage/Sources/ParserFeature/Parser+Image.swift @@ -0,0 +1,85 @@ +import Kanna +import AppModels +import Foundation + +extension Parser { + // MARK: ImageURL + public static func parseThumbnailURLs(doc: HTMLDocument) throws -> [Int: URL] { + var thumbnailURLs = [Int: URL]() + + guard let gdtNode = doc.at_xpath("//div [@id='gdt']") + else { throw AppError.parseFailed } + + for aLink in gdtNode.xpath("a") { + guard let href = aLink["href"], + let thumbnailURL = URL(string: href), + let divNode = aLink.at_xpath(".//div[@title and @style]"), + let title = divNode["title"], + let index = parseGTX00IndexFromTitle(from: title) + else { continue } + + thumbnailURLs[index] = thumbnailURL + } + + return thumbnailURLs + } + + public static func parseGalleryNormalImageURL(doc: HTMLDocument, index: Int) throws -> GalleryNormalImageInfo { + guard let i3Node = doc.at_xpath("//div [@id='i3']"), + let imageURLString = i3Node.at_css("img")?["src"], + let imageURL = URL(string: imageURLString) + else { throw AppError.parseFailed } + + guard let i7Node = doc.at_xpath("//div [@id='i7']"), + let originalImageURLString = i7Node.at_xpath("//a")?["href"], + let originalImageURL = URL(string: originalImageURLString) + else { + return GalleryNormalImageInfo( + index: index, + imageURL: imageURL, + originalImageURL: nil + ) + } + + return GalleryNormalImageInfo( + index: index, + imageURL: imageURL, + originalImageURL: originalImageURL + ) + } + + public static func parseMPVKeys(doc: HTMLDocument) throws -> (String, [Int: String]) { + var tmpMPVKey: String? + var imgKeys = [Int: String]() + + for link in doc.xpath("//script [@type='text/javascript']") { + guard let text = link.text, + let rangeA = text.range(of: "mpvkey = \""), + let rangeB = text.range(of: "\";\nvar imagelist = "), + let rangeC = text.range(of: "\"}]") + else { continue } + + tmpMPVKey = String(text[rangeA.upperBound.. [Gallery] { + let galleries: [Gallery] + switch try? parseDisplayMode(doc: doc) { + case "Minimal": + galleries = (try? parseMinimalModeGalleries(doc: doc, parsesTags: false)) ?? [] + case "Minimal+": + galleries = (try? parseMinimalModeGalleries(doc: doc, parsesTags: true)) ?? [] + case "Compact": + galleries = (try? parseCompactModeGalleries(doc: doc)) ?? [] + case "Extended": + galleries = (try? parseExtendedModeGalleries(doc: doc)) ?? [] + case "Thumbnail": + galleries = (try? parseThumbnailModeGalleries(doc: doc)) ?? [] + default: + // Toplists doesn't have a display mode selector and it's compact mode + galleries = (try? parseCompactModeGalleries(doc: doc)) ?? [] + } + + if galleries.isEmpty, let error = parseResponseError(doc: doc) { + throw error + } + return galleries + } +} + +// MARK: DisplayMode +private extension Parser { + static func parseDisplayMode(doc: HTMLDocument) throws -> String { + guard let containerNode = doc.at_xpath("//div [@id='dms']") ?? doc.at_xpath("//div [@class='searchnav']") + else { throw AppError.parseFailed } + + var dmsNode: XMLElement? + for select in containerNode.xpath("//select") where select["onchange"]?.contains("inline_set=dm_") == true { + dmsNode = select + break + } + guard let dmsNode else { throw AppError.parseFailed } + + for option in dmsNode.xpath("//option") where option["selected"] == "selected" { + if let displayMode = option.text { + return displayMode + } + } + throw AppError.parseFailed + } + + static func parseMinimalModeGalleries(doc: HTMLDocument, parsesTags: Bool) throws -> [Gallery] { + var galleries = [Gallery]() + for link in doc.xpath("//tr") { + let gltmNode = link.at_xpath("//div [@class='gltm']") + let tags = (try? parseGalleryTags(node: gltmNode)) ?? [] + guard let gl2mNode = link.at_xpath("//td [@class='gl2m']"), + let gl3mNode = link.at_xpath("//td [@class='gl3m glname']"), + let panelInfo = try? parseThumbnailPanel(node: gl2mNode), + let (galleryTitle, galleryURL) = try? parseGalleryTitle(node: gl3mNode) + else { continue } + let isExpunged = parseIsExpunged(for: link) + galleries.append( + .init( + gid: galleryURL.pathComponents[2], + token: galleryURL.pathComponents[3], + title: galleryTitle, + rating: panelInfo.rating, + tags: parsesTags ? tags : [], + category: panelInfo.category, + uploader: try? parseUploader(node: link), + pageCount: panelInfo.pageCount, + postedDate: panelInfo.publishedDate, + coverURL: panelInfo.coverURL, + galleryURL: galleryURL, + isExpunged: isExpunged, + hasRated: panelInfo.hasRated, + favoriteTagIndex: panelInfo.favoriteTagIndex, + favoriteTagName: panelInfo.favoriteTagName + ) + ) + } + return galleries + } + + static func parseCompactModeGalleries(doc: HTMLDocument) throws -> [Gallery] { + var galleries = [Gallery]() + for link in doc.xpath("//tr") { + guard let gl2cNode = link.at_xpath("//td [@class='gl2c']"), + let gl3cNode = link.at_xpath("//td [@class='gl3c glname']"), + let panelInfo = try? parseThumbnailPanel(node: gl2cNode), + let (galleryTitle, galleryURL) = try? parseGalleryTitle(node: gl3cNode) + else { continue } + let isExpunged = parseIsExpunged(for: link) + galleries.append( + .init( + gid: galleryURL.pathComponents[2], + token: galleryURL.pathComponents[3], + title: galleryTitle, + rating: panelInfo.rating, + tags: (try? parseGalleryTags(node: gl3cNode)) ?? [], + category: panelInfo.category, + uploader: try? parseUploader(node: link), + pageCount: panelInfo.pageCount, + postedDate: panelInfo.publishedDate, + coverURL: panelInfo.coverURL, + galleryURL: galleryURL, + isExpunged: isExpunged, + hasRated: panelInfo.hasRated, + favoriteTagIndex: panelInfo.favoriteTagIndex, + favoriteTagName: panelInfo.favoriteTagName + ) + ) + } + + return galleries + } + + static func parseExtendedModeGalleries(doc: HTMLDocument) throws -> [Gallery] { + var galleries = [Gallery]() + for link in doc.xpath("//tr") { + guard let gl3eSiblingNode = link.at_xpath("//div [@class='gl3e']")?.nextSibling, + let panelInfo = try? parseThumbnailPanel(node: link), + let (galleryTitle, galleryURL) = try? parseGalleryTitle(node: gl3eSiblingNode) + else { continue } + let isExpunged = parseIsExpunged(for: link) + galleries.append( + .init( + gid: galleryURL.pathComponents[2], + token: galleryURL.pathComponents[3], + title: galleryTitle, + rating: panelInfo.rating, + tags: (try? parseGalleryTags(node: gl3eSiblingNode)) ?? [], + category: panelInfo.category, + uploader: panelInfo.uploader, + pageCount: panelInfo.pageCount, + postedDate: panelInfo.publishedDate, + coverURL: panelInfo.coverURL, + galleryURL: galleryURL, + isExpunged: isExpunged, + hasRated: panelInfo.hasRated, + favoriteTagIndex: panelInfo.favoriteTagIndex, + favoriteTagName: panelInfo.favoriteTagName + ) + ) + } + return galleries + } + + static func parseThumbnailModeGalleries(doc: HTMLDocument) throws -> [Gallery] { + var galleries = [Gallery]() + for link in doc.xpath("//div [@class='gl1t']") { + let gl6tNode = link.at_xpath("//div [@class='gl6t']") + guard let panelInfo = try? parseThumbnailPanel(node: link), + let (galleryTitle, galleryURL) = try? parseGalleryTitle(node: link) + else { continue } + let isExpunged = parseIsExpunged(for: link) + galleries.append( + .init( + gid: galleryURL.pathComponents[2], + token: galleryURL.pathComponents[3], + title: galleryTitle, + rating: panelInfo.rating, + tags: (try? parseGalleryTags(node: gl6tNode)) ?? [], + category: panelInfo.category, + pageCount: panelInfo.pageCount, + postedDate: panelInfo.publishedDate, + coverURL: panelInfo.coverURL, + galleryURL: galleryURL, + isExpunged: isExpunged, + hasRated: panelInfo.hasRated, + favoriteTagIndex: panelInfo.favoriteTagIndex, + favoriteTagName: panelInfo.favoriteTagName + ) + ) + } + return galleries + } +} + +// MARK: Helpers +private extension Parser { + static func parseThumbnailPanel(node: XMLElement) throws -> ThumbnailPanelInfo { + var tmpCoverURL: URL? + var tmpCategory: AppModels.Category? + var tmpPublishedDate: Date? + var tmpPageCount: Int? + var uploader: String? + var tmpHasRated = false + var tmpFavoriteTagIndex: Int? + var tmpFavoriteTagName: String? + + for div in node.xpath("//div") { + if let imgNode = div.at_css("img"), + let urlString = imgNode["data-src"] ?? imgNode["src"], let url = URL(string: urlString), + [Defaults.URL.torrentDownload, Defaults.URL.torrentDownloadInvalid].map(\.absoluteString) + .contains(where: { $0 == urlString }) == false, imgNode["alt"] != "T" { + tmpCoverURL = url + } + if let rawValue = div.text, let category = AppModels.Category(rawValue: rawValue) { + tmpCategory = category + } + if let onClick = div["onclick"], !onClick.isEmpty, let dateString = div.text, + let date = try? parseDate(time: dateString, format: Defaults.DateFormat.publish) { + tmpPublishedDate = date + } + if let text = div.text, + text.localizedCaseInsensitiveContains("page") { + let nsRange = NSRange(text.startIndex..., in: text) + guard let match = try? NSRegularExpression(pattern: #"\d+"#).firstMatch(in: text, range: nsRange), + let range = Range(match.range, in: text), + let pageCount = Int(text[range]) + else { continue } + tmpPageCount = pageCount + } + if let aLink = div.at_xpath("//a"), aLink["href"]?.contains("uploader") == true { + uploader = aLink.text + } else if div.text == "(Disowned)" { + uploader = div.text + } + } + + guard let coverURL = tmpCoverURL, + let category = tmpCategory, + let ratingResult = try? parseRating(node: node), + let publishedDate = tmpPublishedDate, + let pageCount = tmpPageCount + else { throw AppError.parseFailed } + + let hasRated = node.xpath(".//div[contains(@class, 'ir')]").contains { + guard let classes = $0.className?.split(separator: " ") else { return false } + return classes.contains("irb") + } + + let (favoriteTagIndex, favoriteTagName) = parseFavoriteInfo(for: node) + + return ThumbnailPanelInfo( + coverURL: coverURL, + category: category, + rating: ratingResult.imgRating, + publishedDate: publishedDate, + pageCount: pageCount, + uploader: uploader, + hasRated: hasRated, + favoriteTagIndex: favoriteTagIndex, + favoriteTagName: favoriteTagName + ) + } + + static func parseGalleryTitle(node: XMLElement) throws -> (String, URL) { + func findTitle(glink: XMLElement) throws -> (String, URL) { + guard let glinkParentNode = glink.parent, + let glinkGrandParentNode = glinkParentNode.parent, + let title = glink.text, + let urlString = glinkParentNode["href"] ?? glinkGrandParentNode["href"], + let url = URL(string: urlString), + url.pathComponents.count >= 4 + else { throw AppError.parseFailed } + return (title, url) + } + + for glink in node.xpath("//div") where glink.className?.contains("glink") == true { + if let result = try? findTitle(glink: glink) { + return result + } + } + for glink in node.xpath("//span") where glink.className?.contains("glink") == true { + if let result = try? findTitle(glink: glink) { + return result + } + } + throw AppError.parseFailed + } + + static func parseGalleryTags(node: XMLElement?) throws -> [GalleryTag] { + guard let node = node else { throw AppError.parseFailed } + var tags = [GalleryTag]() + for tagLink in node.xpath("//div") + where ["gt", "gtl"].contains(tagLink.className) && tagLink["title"]?.isEmpty == false { + guard let titleComponents = tagLink["title"]?.split(separator: ":"), + titleComponents.count == 2 + else { continue } + var contentTextColor: Color? + var contentBackgroundColor: Color? + let namespace = String(titleComponents[0]) + let contentText = String(titleComponents[1]) + if let style = tagLink["style"], let rangeB = style.range(of: ",#"), + let rangeA = style.range(of: "background:radial-gradient(#") { + let hex = String(style[rangeA.upperBound.. 151 { + contentTextColor = .secondary + } else { + contentTextColor = .white + } + } + } + if let index = tags.firstIndex(where: { $0.rawNamespace == namespace }) { + let contents = tags[index].contents + let galleryTagContent = GalleryTag.Content( + rawNamespace: namespace, text: contentText, + isVotedUp: false, isVotedDown: false, + textColor: contentTextColor, + backgroundColor: contentBackgroundColor + ) + let newContents = contents + [galleryTagContent] + tags[index] = .init(rawNamespace: namespace, contents: newContents) + } else { + let galleryTagContent = GalleryTag.Content( + rawNamespace: namespace, text: contentText, + isVotedUp: false, isVotedDown: false, + textColor: contentTextColor, + backgroundColor: contentBackgroundColor + ) + tags.append(.init(rawNamespace: namespace, contents: [galleryTagContent])) + } + } + return tags + } + + static func parseUploader(node: XMLElement) throws -> String { + var tmpUploader: String? + for link in node.xpath("//td") where link.className?.contains("glhide") == true { + for divLink in link.xpath("//div") + where ["page", "pages"].contains(where: { divLink.text?.contains($0) != false }) == false { + if let aLink = divLink.at_xpath("//a"), + aLink["href"]?.contains("uploader") == true, + let aText = aLink.text { + tmpUploader = aText + } else if divLink.text == "(Disowned)" { + tmpUploader = divLink.text + } + } + } + guard let uploader = tmpUploader else { throw AppError.parseFailed } + return uploader + } + + static func parseIsExpunged(for node: XMLElement) -> Bool { + node.xpath(".//s").count > 0 + } + + static func parseFavoriteInfo(for node: XMLElement) -> (tagIndex: Int?, tagName: String?) { + guard let postedDiv = node.at_xpath( + ".//div[@id][@style][@title][contains(@onclick, 'popUp')]" + ) else { return (nil, nil) } + let title = postedDiv["title"] + let style = postedDiv["style"] ?? "" + let color = extractBorderColor(from: style) + let tagIndex = color.flatMap { favoriteTagIndexMap[$0] } + return (tagIndex, title) + } + + static func extractBorderColor(from style: String) -> String? { + guard let rangeA = style.range(of: "border-color:#"), + let rangeB = style.range(of: ";") else { return nil } + let color = String(style[rangeA.upperBound.. String { + guard let text = doc.at_xpath("//div [@id='i6']")?.at_xpath("//a [@id='loadfail']")?["onclick"], + let rangeA = text.range(of: "nl('"), let rangeB = text.range(of: "')") + else { throw AppError.parseFailed } + return .init(text[rangeA.upperBound.. String { + var tmpKey: String? + + for link in doc.xpath("//script [@type='text/javascript']") { + guard let script = link.text, script.contains("apikey"), + let rangeA = script.range(of: ";\nvar apikey = \""), + let rangeB = script.range(of: "\";\nvar average_rating") + else { continue } + + tmpKey = String(script[rangeA.upperBound.. PageNumber { + var current = 0 + var maximum = 0 + + guard let link = doc.at_xpath("//table [@class='ptt']"), + let currentStr = link.at_xpath("//td [@class='ptds']")?.text + else { + if let link = doc.at_xpath("//div [@class='searchnav']") { + var timestamp: String? + var isEnabled = false + + for aLink in link.xpath("//a") where aLink.text?.contains("Next") == true { + timestamp = aLink["href"] + .map(URLComponents.init)?? + .queryItems? + .first(where: { $0.name == "next" })? + .value? + .split(separator: "-") + .last + .map(String.init) + + isEnabled = true + break + } + + return PageNumber( + lastItemTimestamp: timestamp, + isNextButtonEnabled: isEnabled + ) + } else { + return PageNumber(isNextButtonEnabled: false) + } + } + + if let range = currentStr.range(of: "-") { + current = (Int(currentStr[range.upperBound...]) ?? 1) - 1 + } else { + current = (Int(currentStr) ?? 1) - 1 + } + for aLink in link.xpath("//a") { + if let num = Int(aLink.text ?? "") { + maximum = num - 1 + } + } + return PageNumber(current: current, maximum: maximum) + } +} diff --git a/AppPackage/Sources/ParserFeature/Parser+MyTags.swift b/AppPackage/Sources/ParserFeature/Parser+MyTags.swift new file mode 100644 index 000000000..cdb610240 --- /dev/null +++ b/AppPackage/Sources/ParserFeature/Parser+MyTags.swift @@ -0,0 +1,81 @@ +import Kanna +import SwiftUI +import AppModels +import Foundation + +extension Parser { + public static func parseMyTagsPage(doc: HTMLDocument) throws -> MyTagsResponse { + let options = doc.xpath("//*[@id='tagset_outer']/div/select/option") + let tagSets = options.compactMap { option -> (Int, String)? in + guard let valueString = option["value"], let number = Int(valueString) else { return nil } + let name = option.text ?? "" + return (number, name) + } + + let tagSetEnable = doc.at_xpath("//*[@id='tagset_enable']")?["checked"] != nil + + let tagSetBackgroundColor: Color? = { + guard let input = doc.at_xpath("//*[@id='tagcolor']"), + let value = input["value"], + !value.isEmpty + else { + return nil + } + + return Color(hex: value) + }() + + let tagDivs = doc.xpath("//*[@id='usertags_outer']/div") + let tags = tagDivs.compactMap { div -> WatchedTag? in + guard let divId = div["id"], divId != "usertag_0" else { return nil } + let tagId = divId.replacingOccurrences(of: "usertag_", with: "") + + guard let title = div.at_xpath(".//div[@id='tagpreview_\(tagId)']")?["title"] else { return nil } + let pair = title + let list = pair.split(separator: ":", maxSplits: 1, omittingEmptySubsequences: false).map(String.init) + let namespace = list.count == 2 && !list[0].isEmpty ? list[0] : "temp" + let key = list.count == 2 ? list[1] : list[0] + + let watched = div.at_xpath(".//input[@id='tagwatch_\(tagId)']")?["checked"] != nil + let hidden = div.at_xpath(".//input[@id='taghide_\(tagId)']")?["checked"] != nil + let backgroundColor: Color? = { + guard let input = div.at_xpath(".//input[@id='tagcolor_\(tagId)']"), + let value = input["value"], + !value.isEmpty + else { + return nil + } + + return Color(hex: value) + }() + + let weightString = div.at_xpath(".//input[@id='tagweight_\(tagId)']")?["value"] ?? "0" + let weight = Int(weightString) ?? 0 + + return WatchedTag( + namespace: namespace, + key: key, + watched: watched, + hidden: hidden, + backgroundColor: backgroundColor, + weight: weight + ) + } + + let scriptText = doc.at_xpath("//*[@id='outer']/script[1]")?.text ?? "" + let apikeyMatch = scriptText.range(of: #"apikey = "([^"]+)""#, options: .regularExpression) + let apikey = apikeyMatch.flatMap { match in + let start = match.upperBound + let end = scriptText.index(start, offsetBy: 32, limitedBy: scriptText.endIndex) ?? scriptText.endIndex + return String(scriptText[start.. [Int: URL] { + guard let gdtNode = doc.at_xpath("//div [@id='gdt']") + else { throw AppError.parseFailed } + + let combinedURLs = parseCombinedPreviewURLs(node: gdtNode) + return combinedURLs.isEmpty ? parseStandalonePreviewURLs(node: gdtNode) : combinedURLs + } + + public static func parsePreviewConfigs(url: URL) -> PreviewConfigInfo? { + guard var components = URLComponents( + url: url, resolvingAgainstBaseURL: false + ), + let queryItems = components.queryItems + else { return nil } + + let keys = [ + Defaults.URL.Component.Key.ehpandaWidth, + Defaults.URL.Component.Key.ehpandaHeight, + Defaults.URL.Component.Key.ehpandaOffset + ] + let configs = keys.map(\.rawValue).compactMap { key in + queryItems.filter({ $0.name == key }).first?.value + } + .compactMap(Int.init) + + components.queryItems = nil + guard configs.count == keys.count, + let plainURL = components.url + else { return nil } + + let size = CGSize(width: configs[0], height: configs[1]) + return PreviewConfigInfo( + plainURL: plainURL, + size: size, + offset: CGSize(width: configs[2], height: 0) + ) + } +} + +private extension Parser { + static func parseCombinedPreviewURLs(node: XMLElement) -> [Int: URL] { + var previewURLs = [Int: URL]() + + for link in node.xpath("//a") { + if let divNode = link.at_xpath(".//div[@title and @style]"), + let style = divNode["style"], + let rangeA = style.range(of: "width:"), + let rangeB = style.range(of: "px;height:"), + let rangeC = style.range(of: "px;background"), + let rangeD = style.range(of: "url("), + let rangeE = style.range(of: ") -"), + let rangeF = style[rangeE.upperBound...].range(of: "px "), + let urlString = style[rangeD.upperBound.. [Int: URL] { + var previewURLs = [Int: URL]() + + for link in node.xpath("//a") { + if let divNode = link.at_xpath(".//div[@title and @style]"), + let style = divNode["style"], + let rangeA = style.range(of: "url("), + let rangeB = style.range(of: ")"), + let urlString = style[rangeA.upperBound.. VerifyEhProfileResponse { + var profileNotFound = true + var profileValue: Int? + + let selector = doc.at_xpath("//select [@name='profile_set']") + let options = selector?.xpath("//option") + + guard let options = options, options.count >= 1 + else { throw AppError.parseFailed } + + for link in options where EhSetting.verifyEhPandaProfileName(with: link.text) { + profileNotFound = false + profileValue = Int(link["value"] ?? "") + } + + return .init(profileValue: profileValue, isProfileNotFound: profileNotFound) + } + + // swiftlint:disable:next cyclomatic_complexity function_body_length + public static func parseEhSetting(doc: HTMLDocument) throws -> EhSetting { + var tmpForm: XMLElement? + for link in doc.xpath("//form [@method='post']") + where link["id"] == nil { + tmpForm = link + } + guard let profileOuter = doc.at_xpath("//div [@id='profile_outer']"), + let form = tmpForm else { throw AppError.parseFailed } + + // swiftlint:disable line_length + var ehProfiles = [EhProfile](); var isCapableOfCreatingNewProfile: Bool?; var capableLoadThroughHathSetting: EhSetting.LoadThroughHathSetting?; var capableImageResolution: EhSetting.ImageResolution?; var capableSearchResultCount: EhSetting.SearchResultCount?; var capableThumbnailConfigSizes = [EhSetting.ThumbnailSize](); var capableThumbnailConfigRowCount: EhSetting.ThumbnailRowCount?; var loadThroughHathSetting: EhSetting.LoadThroughHathSetting?; var browsingCountry: EhSetting.BrowsingCountry?; var imageResolution: EhSetting.ImageResolution?; var imageSizeWidth: Float?; var imageSizeHeight: Float?; var galleryName: EhSetting.GalleryName?; var literalBrowsingCountry: String?; var archiverBehavior: EhSetting.ArchiverBehavior?; var displayMode: EhSetting.DisplayMode?; var showSearchRangeIndicator: Bool?; var enableGalleryThumbnailSelector: Bool?; var disabledCategories = [Bool](); var favoriteCategories = [String](); var favoritesSortOrder: EhSetting.FavoritesSortOrder?; var ratingsColor: String?; var tagFilteringThreshold: Float?; var tagWatchingThreshold: Float?; var showFilteredRemovalCount: Bool?; var excludedLanguages = [Bool](); var excludedUploaders: String?; var searchResultCount: EhSetting.SearchResultCount?; var thumbnailLoadTiming: EhSetting.ThumbnailLoadTiming?; var thumbnailConfigSize: EhSetting.ThumbnailSize?; var thumbnailConfigRows: EhSetting.ThumbnailRowCount?; var coverScaleFactor: Float?; var viewportVirtualWidth: Float?; var commentsSortOrder: EhSetting.CommentsSortOrder?; var commentVotesShowTiming: EhSetting.CommentVotesShowTiming?; var tagsSortOrder: EhSetting.TagsSortOrder?; var galleryPageNumbers: EhSetting.GalleryPageNumbering?; var useOriginalImages: Bool?; var useMultiplePageViewer: Bool?; var multiplePageViewerStyle: EhSetting.MultiplePageViewerStyle?; var multiplePageViewerShowThumbnailPane: Bool? + // swiftlint:enable line_length + + ehProfiles = parseSelections(node: profileOuter, name: "profile_set") + .compactMap { option in + guard let value = Int(option.value) else { return nil } + return EhProfile(value: value, name: option.name, isSelected: option.isSelected) + } + + isCapableOfCreatingNewProfile = false + for button in profileOuter.xpath("//input [@type='button']") { + if button["value"] == "Create New" { + isCapableOfCreatingNewProfile = true + break + } + } + + for optouter in form.xpath("//div [@class='optouter']") { + if optouter.at_xpath("//input [@name='uh']") != nil { + loadThroughHathSetting = parseEnum(node: optouter, name: "uh") + capableLoadThroughHathSetting = parseCapability(node: optouter, name: "uh") + } + if optouter.at_xpath("//select [@name='co']") != nil { + var value = parseSelections(node: optouter, name: "co") + .filter(\.isSelected) + .first? + .value + + if value == "" { value = "-" } + browsingCountry = EhSetting.BrowsingCountry(rawValue: value ?? "") + + literalBrowsingCountry = optouter.at_xpath(".//p/strong")?.text ?? "" + } + if optouter.at_xpath("//input [@name='xr']") != nil { + imageResolution = parseEnum(node: optouter, name: "xr") + capableImageResolution = parseCapability(node: optouter, name: "xr") + } + if optouter.at_xpath("//input [@name='rx']") != nil { + imageSizeWidth = Float(parseString(node: optouter, name: "rx") ?? "0") + if imageSizeWidth == nil { imageSizeWidth = 0 } + } + if optouter.at_xpath("//input [@name='ry']") != nil { + imageSizeHeight = Float(parseString(node: optouter, name: "ry") ?? "0") + if imageSizeHeight == nil { imageSizeHeight = 0 } + } + if optouter.at_xpath("//input [@name='tl']") != nil { + galleryName = parseEnum(node: optouter, name: "tl") + } + if optouter.at_xpath("//input [@name='ar']") != nil { + archiverBehavior = parseEnum(node: optouter, name: "ar") + } + if optouter.at_xpath("//input [@name='dm']") != nil { + displayMode = parseEnum(node: optouter, name: "dm") + } + if optouter.at_xpath("//input [@name='pp']") != nil { + showSearchRangeIndicator = parseInt(node: optouter, name: "pp") == 0 + } + if optouter.at_xpath("//input [@name='xn_0']") != nil { + enableGalleryThumbnailSelector = parseCheckBoxBool(node: optouter, name: "xn_0") + } + if optouter.at_xpath("//div [@id='catsel']") != nil { + disabledCategories = EhSetting.categoryNames.map { name in + parseBool(node: optouter, name: "ct_\(name)") ?? false + } + } + if optouter.at_xpath("//div [@id='favsel']") != nil { + var index = 0 + while let value = parseString(node: optouter, name: "favorite_\(index)") { + favoriteCategories.append(value) + index += 1 + } + while favoriteCategories.count < EhSetting.favoriteCategoryCount { + favoriteCategories.append("") + } + } + if optouter.at_xpath("//input [@name='fs']") != nil { + favoritesSortOrder = parseEnum(node: optouter, name: "fs") + } + if optouter.at_xpath("//input [@name='ru']") != nil { + ratingsColor = parseString(node: optouter, name: "ru") ?? "" + } + if optouter.at_xpath("//input [@name='ft']") != nil { + tagFilteringThreshold = Float(parseString(node: optouter, name: "ft") ?? "0") + if tagFilteringThreshold == nil { tagFilteringThreshold = 0 } + } + if optouter.at_xpath("//input [@name='wt']") != nil { + tagWatchingThreshold = Float(parseString(node: optouter, name: "wt") ?? "0") + if tagWatchingThreshold == nil { tagWatchingThreshold = 0 } + } + if optouter.at_xpath("//input [@name='tf']") != nil { + showFilteredRemovalCount = parseInt(node: optouter, name: "tf") == 0 + } + if optouter.at_xpath("//div [@id='xlasel']") != nil { + for langId in EhSetting.languageValues { + if let checked = parseCheckBoxBool(node: optouter, name: "xl_\(langId)") { + excludedLanguages.append(checked) + } else { + excludedLanguages.append(false) + } + } + } + if optouter.at_xpath("//textarea [@name='xu']") != nil { + excludedUploaders = parseTextEditorString(node: optouter, name: "xu") ?? "" + } + if optouter.at_xpath("//input [@name='rc']") != nil { + searchResultCount = parseEnum(node: optouter, name: "rc") + capableSearchResultCount = parseCapability(node: optouter, name: "rc") + } + if optouter.at_xpath("//input [@name='lt']") != nil { + thumbnailLoadTiming = parseEnum(node: optouter, name: "lt") + } + if optouter.at_xpath("//input [@name='ts']") != nil { + var options = [ThumbnailSizeOption]() + for link in optouter.xpath("//input [@name='ts']") { + if let valueString = link["value"], let value = Int(valueString) { + let isEnabled = link["disabled"] != "disabled" + let isSelected = link["checked"] == "checked" + options.append( + ThumbnailSizeOption( + value: value, + isEnabled: isEnabled, + isSelected: isSelected + ) + ) + } + } + let thumbnailSize: (Int) -> EhSetting.ThumbnailSize? = { + switch $0 { + case 0: .auto + case 1: .normal + case 2: .small + default: nil + } + } + for option in options where option.isEnabled { + if let size = thumbnailSize(option.value) { + capableThumbnailConfigSizes.append(size) + } + } + if let selectedSize = (options.first(where: \.isSelected)?.value).flatMap(thumbnailSize) { + thumbnailConfigSize = selectedSize + } + } + if optouter.at_xpath("//input [@name='tr']") != nil { + thumbnailConfigRows = parseEnum(node: optouter, name: "tr") + capableThumbnailConfigRowCount = parseCapability(node: optouter, name: "tr") + } + if optouter.at_xpath("//input [@name='tp']") != nil { + coverScaleFactor = Float(parseString(node: optouter, name: "tp") ?? "100") + if coverScaleFactor == nil { coverScaleFactor = 100 } + } + if optouter.at_xpath("//input [@name='vp']") != nil { + viewportVirtualWidth = Float(parseString(node: optouter, name: "vp") ?? "0") + if viewportVirtualWidth == nil { viewportVirtualWidth = 0 } + } + if optouter.at_xpath("//input [@name='cs']") != nil { + commentsSortOrder = parseEnum(node: optouter, name: "cs") + } + if optouter.at_xpath("//input [@name='sc']") != nil { + commentVotesShowTiming = parseEnum(node: optouter, name: "sc") + } + if optouter.at_xpath("//input [@name='tb']") != nil { + tagsSortOrder = parseEnum(node: optouter, name: "tb") + } + if optouter.at_xpath("//input [@name='pn']") != nil { + galleryPageNumbers = parseEnum(node: optouter, name: "pn") + } + if optouter.at_xpath("//input [@name='oi']") != nil { + useOriginalImages = parseInt(node: optouter, name: "oi") == 1 + } + if optouter.at_xpath("//input [@name='qb']") != nil { + useMultiplePageViewer = parseInt(node: optouter, name: "qb") == 1 + } + if optouter.at_xpath("//input [@name='ms']") != nil { + multiplePageViewerStyle = parseEnum(node: optouter, name: "ms") + } + if optouter.at_xpath("//input [@name='mt']") != nil { + multiplePageViewerShowThumbnailPane = parseInt(node: optouter, name: "mt") == 0 + } + } + + // swiftlint:disable line_length + guard !ehProfiles.filter(\.isSelected).isEmpty, let isCapableOfCreatingNewProfile, let capableLoadThroughHathSetting, let capableImageResolution, let capableSearchResultCount, !capableThumbnailConfigSizes.isEmpty, let capableThumbnailConfigRowCount, let loadThroughHathSetting, let browsingCountry, let literalBrowsingCountry, let imageResolution, let imageSizeWidth, let imageSizeHeight, let galleryName, let archiverBehavior, let displayMode, let showSearchRangeIndicator, let enableGalleryThumbnailSelector, let favoritesSortOrder, let ratingsColor, let tagFilteringThreshold, let tagWatchingThreshold, let showFilteredRemovalCount, let excludedUploaders, let searchResultCount, let thumbnailLoadTiming, let thumbnailConfigSize, let thumbnailConfigRows, let coverScaleFactor, let viewportVirtualWidth, let commentsSortOrder, let commentVotesShowTiming, let tagsSortOrder, let galleryPageNumbers + else { throw AppError.parseFailed } + + return EhSetting(ehProfiles: ehProfiles.sorted(), isCapableOfCreatingNewProfile: isCapableOfCreatingNewProfile, capableLoadThroughHathSetting: capableLoadThroughHathSetting, capableImageResolution: capableImageResolution, capableSearchResultCount: capableSearchResultCount, capableThumbnailConfigRowCount: capableThumbnailConfigRowCount, capableThumbnailConfigSizes: capableThumbnailConfigSizes, loadThroughHathSetting: loadThroughHathSetting, browsingCountry: browsingCountry, literalBrowsingCountry: literalBrowsingCountry, imageResolution: imageResolution, imageSizeWidth: imageSizeWidth, imageSizeHeight: imageSizeHeight, galleryName: galleryName, archiverBehavior: archiverBehavior, displayMode: displayMode, showSearchRangeIndicator: showSearchRangeIndicator, enableGalleryThumbnailSelector: enableGalleryThumbnailSelector, disabledCategories: disabledCategories, favoriteCategories: favoriteCategories, favoritesSortOrder: favoritesSortOrder, ratingsColor: ratingsColor, tagFilteringThreshold: tagFilteringThreshold, tagWatchingThreshold: tagWatchingThreshold, showFilteredRemovalCount: showFilteredRemovalCount, excludedLanguages: excludedLanguages, excludedUploaders: excludedUploaders, searchResultCount: searchResultCount, thumbnailLoadTiming: thumbnailLoadTiming, thumbnailConfigSize: thumbnailConfigSize, thumbnailConfigRows: thumbnailConfigRows, coverScaleFactor: coverScaleFactor, viewportVirtualWidth: viewportVirtualWidth, commentsSortOrder: commentsSortOrder, commentVotesShowTiming: commentVotesShowTiming, tagsSortOrder: tagsSortOrder, galleryPageNumbering: galleryPageNumbers, useOriginalImages: useOriginalImages, useMultiplePageViewer: useMultiplePageViewer, multiplePageViewerStyle: multiplePageViewerStyle, multiplePageViewerShowThumbnailPane: multiplePageViewerShowThumbnailPane + ) + // swiftlint:enable line_length + } +} + +// MARK: Helpers +private extension Parser { + + static func parseInt(node: XMLElement, name: String) -> Int? { + var value: Int? + for link in node.xpath("//input [@name='\(name)']") + where link["checked"] == "checked" { + value = Int(link["value"] ?? "") + } + return value + } + + static func parseEnum(node: XMLElement, name: String) -> T? where T.RawValue == Int { + guard let rawValue = parseInt( + node: node, name: name + ) else { return nil } + return T(rawValue: rawValue) + } + + static func parseString(node: XMLElement, name: String) -> String? { + node.at_xpath("//input [@name='\(name)']")?["value"] + } + + static func parseTextEditorString(node: XMLElement, name: String) -> String? { + node.at_xpath("//textarea [@name='\(name)']")?.text + } + + static func parseBool(node: XMLElement, name: String) -> Bool? { + switch parseString(node: node, name: name) { + case "0": return false + case "1": return true + default: return nil + } + } + + static func parseCheckBoxBool(node: XMLElement, name: String) -> Bool? { + node.at_xpath("//input [@name='\(name)']")?["checked"] == "checked" + } + + static func parseCapability(node: XMLElement, name: String) -> T? where T.RawValue == Int { + var maxValue: Int? + for link in node.xpath("//input [@name='\(name)']") where link["disabled"] != "disabled" { + let value = Int(link["value"] ?? "") ?? 0 + if maxValue == nil { + maxValue = value + } else if maxValue ?? 0 < value { + maxValue = value + } + } + return T(rawValue: maxValue ?? 0) + } + + static func parseSelections(node: XMLElement, name: String) -> [SelectionOption] { + guard let select = node.at_xpath("//select [@name='\(name)']") + else { return [] } + + var selections = [SelectionOption]() + for link in select.xpath("//option") { + guard let name = link.text, + let value = link["value"] + else { continue } + + selections.append( + SelectionOption( + name: name, + value: value, + isSelected: link["selected"] == "selected" + ) + ) + } + + return selections + } +} diff --git a/AppPackage/Sources/ParserFeature/Parser+ResponseError.swift b/AppPackage/Sources/ParserFeature/Parser+ResponseError.swift new file mode 100644 index 000000000..e7b7d7fa4 --- /dev/null +++ b/AppPackage/Sources/ParserFeature/Parser+ResponseError.swift @@ -0,0 +1,115 @@ +import Kanna +import AppModels +import Resources + +extension Parser { + public static func parseResponseError(doc: HTMLDocument) -> AppError? { + if let banInterval = parseBanInterval(doc: doc) { + return .ipBanned(banInterval) + } + // Ex login failures commonly surface as a kokomade placeholder wall when `igneous` is missing. + // Reference: https://github.com/OpportunityLiu/E-Viewer/issues/124 + if doc.at_xpath("//img[contains(@src, 'kokomade.jpg')]") != nil { + return .authenticationRequired + } + + for candidate in responseErrorCandidates(doc: doc) { + if let error = parseResponseError(content: candidate) { + return error + } + } + return nil + } + + public static func parseResponseError(content: String) -> AppError? { + let normalizedContent = content.lowercased() + guard !normalizedContent.isEmpty else { return nil } + + // Ex login failures commonly surface as a kokomade placeholder wall when `igneous` is missing. + // Reference: https://github.com/OpportunityLiu/E-Viewer/issues/124 + if normalizedContent.contains("kokomade.jpg") + || normalizedContent.contains("access to exhentai.org is restricted") { + return .authenticationRequired + } + // JDownloader matches these image-limit texts to distinguish quota exhaustion from generic HTML failures. + // Reference: https://github.com/mirror/jdownloader/blob/master/src/jd/plugins/hoster/EHentaiOrg.java + if normalizedContent.contains("you have exceeded your image viewing limits") + || normalizedContent.contains( + "you have reached the image limit, and do not have sufficient gp to buy a download quota" + ) { + return .quotaExceeded + } + // `Gallery Not Available` is intentionally not mapped to `.expunged` in the download parser. + // gallery-dl treats `404 + Gallery Not Available` as an authorization-like unavailable state: + // https://github.com/mikf/gallery-dl/blob/master/gallery_dl/extractor/exhentai.py + if normalizedContent.contains("gallery not available") + || normalizedContent.contains(String(localized: .RConstant.responseGalleryUnavailable).lowercased()) { + return nil + } + // JDownloader treats `bounce_login.php` as an account / re-login required signal for EH/EX. + // Reference: https://github.com/mirror/jdownloader/blob/master/src/jd/plugins/hoster/EHentaiOrg.java + if normalizedContent.contains("bounce_login.php"), + !looksLikeGalleryDetailMarkup(normalizedContent) { + return .authenticationRequired + } + // gallery-dl treats `Key missing` and `Gallery not found` as gallery-level not-found conditions. + // Reference: https://github.com/mikf/gallery-dl/blob/master/gallery_dl/extractor/exhentai.py + if normalizedContent.contains("gallery not found") + || normalizedContent.contains("key missing") { + return .notFound + } + // gallery-dl treats `Invalid page` and `Keep trying` as image-page not-found conditions. + // Reference: https://github.com/mikf/gallery-dl/blob/master/gallery_dl/extractor/exhentai.py + if normalizedContent.contains("invalid page") + || normalizedContent.contains("keep trying") { + return .notFound + } + return nil + } +} + +// MARK: Helpers +private extension Parser { + static func responseErrorCandidates(doc: HTMLDocument) -> [String] { + var candidates = [String]() + + let directCandidates = [ + doc.at_xpath("//title")?.text, + doc.at_xpath("//h1")?.text, + doc.at_xpath("//div[@class='d']//p")?.text + ] + for candidate in directCandidates.compactMap(\.self) { + let trimmedCandidate = candidate.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmedCandidate.isEmpty, !candidates.contains(trimmedCandidate) else { continue } + candidates.append(trimmedCandidate) + } + + if let bodyText = doc.body?.text?.trimmingCharacters(in: .whitespacesAndNewlines), + !bodyText.isEmpty, + bodyText.count <= 1024, + !candidates.contains(bodyText) { + candidates.append(bodyText) + } + + if let bodyContent = doc.body?.innerHTML?.trimmingCharacters(in: .whitespacesAndNewlines), + !bodyContent.isEmpty, + bodyContent.count <= 2048, + !candidates.contains(bodyContent) { + candidates.append(bodyContent) + } + + return candidates + } + + static func looksLikeGalleryDetailMarkup(_ normalizedContent: String) -> Bool { + normalizedContent.contains(#"id="gd1""#) + || normalizedContent.contains(#"id='gd1'"#) + || normalizedContent.contains(#"id="gdt""#) + || normalizedContent.contains(#"id='gdt'"#) + || normalizedContent.contains(#"id="taglist""#) + || normalizedContent.contains(#"id='taglist'"#) + || normalizedContent.contains("gallerypopups.php") + || normalizedContent.contains("api.e-hentai.org/api.php") + || normalizedContent.contains("api.exhentai.org/api.php") + } +} diff --git a/AppPackage/Sources/ParserFeature/Parser+Shared.swift b/AppPackage/Sources/ParserFeature/Parser+Shared.swift new file mode 100644 index 000000000..f71a91b49 --- /dev/null +++ b/AppPackage/Sources/ParserFeature/Parser+Shared.swift @@ -0,0 +1,199 @@ +import Kanna +import AppModels +import OSLogExt +import Foundation +import AppTools + +private let logger = Logger(category: .init(describing: Parser.self)) + +extension Parser { + static func parseGTX00IndexFromTitle(from title: String) -> Int? { + // The probable format of page title is "Page [Number]: filename" + ( + title + .components(separatedBy: ":") + .first? + .replacingOccurrences(of: "Page ", with: "") + .trimmingCharacters(in: .whitespaces) + ) + .flatMap(Int.init) + } + + static func parseDate(time: String, format: String) throws -> Date { + let formatter = DateFormatter() + formatter.dateFormat = format + formatter.timeZone = TimeZone(secondsFromGMT: 0) + formatter.locale = Locale(identifier: "en_US_POSIX") + + guard let date = formatter.date(from: time) + else { throw AppError.parseFailed } + + return date + } + + static func parseScriptVariable(name: String, doc: HTMLDocument) -> String? { + let escapedName = NSRegularExpression.escapedPattern(for: name) + let pattern = #"var\s+\#(escapedName)\s*=\s*["']([^"']*)["']\s*;"# + guard let regex = try? NSRegularExpression(pattern: pattern) else { return nil } + + for script in doc.xpath("//script") { + guard let text = script.text else { continue } + let range = NSRange(text.startIndex..., in: text) + guard let match = regex.firstMatch(in: text, range: range), + let valueRange = Range(match.range(at: 1), in: text) + else { continue } + + return String(text[valueRange]) + } + return nil + } + + static func parseScriptURL(name: String, doc: HTMLDocument, host: URL) -> URL? { + guard var value = parseScriptVariable(name: name, doc: doc)?.trimmingCharacters(in: .whitespacesAndNewlines), + !value.isEmpty + else { return nil } + value = value + .replacingOccurrences(of: "&", with: "&") + .replacingOccurrences(of: "\\u0026", with: "&") + + let baseURL = host + let parsedURL: URL? + if let url = URL(string: value), url.scheme != nil { + parsedURL = url + } else { + parsedURL = URL(string: value, relativeTo: baseURL)?.absoluteURL + } + + guard let parsedURL else { return nil } + guard var components = URLComponents(url: parsedURL, resolvingAgainstBaseURL: false) else { + return parsedURL + } + + let knownGalleryHosts = [ + Defaults.URL.ehentai.host, + Defaults.URL.exhentai.host, + Defaults.URL.sexhentai.host + ] + .compactMap { $0?.lowercased() } + + if let host = components.host?.lowercased(), + knownGalleryHosts.contains(host), + let baseComponents = URLComponents(url: baseURL, resolvingAgainstBaseURL: false) + { + components.scheme = baseComponents.scheme + components.host = baseComponents.host + } + return components.url + } + + static func parseScriptDate(name: String, doc: HTMLDocument) -> Date? { + guard let value = parseScriptVariable(name: name, doc: doc), !value.isEmpty else { return nil } + return try? parseDate(time: value, format: "yyyy-MM-dd") + } + + public static func parseDateSeekNavigation( + doc: HTMLDocument, host: URL = Defaults.URL.host + ) -> DateSeekNavigation? { + guard let minimumDate = parseScriptDate(name: "mindate", doc: doc), + let maximumDate = parseScriptDate(name: "maxdate", doc: doc), + let directions = DateSeekNavigation.Directions( + newer: parseScriptURL(name: "prevurl", doc: doc, host: host), + older: parseScriptURL(name: "nexturl", doc: doc, host: host) + ) + else { return nil } + return DateSeekNavigation(directions: directions, minimumDate: minimumDate, maximumDate: maximumDate) + } + + // swiftlint:disable cyclomatic_complexity + /// Returns ratings parsed from stars image / text and if the return contains a userRating . + static func parseRating(node: XMLElement) throws -> RatingResult { + func parseTextRating(node: XMLElement) throws -> Float { + guard let ratingString = node + .at_xpath("//td [@id='rating_label']")?.text? + .replacingOccurrences(of: "Average: ", with: "") + .replacingOccurrences(of: "Not Yet Rated", with: "0"), + let rating = Float(ratingString) + else { throw AppError.parseFailed } + + return rating + } + + var tmpRatingString: String? + var containsUserRating = false + + for link in node.xpath("//div") where + link.className?.contains("ir") == true + && link["style"]?.isEmpty == false { + if tmpRatingString != nil { break } + tmpRatingString = link["style"] + containsUserRating = link.className != "ir" + } + + guard let ratingString = tmpRatingString + else { throw AppError.parseFailed } + + var tmpRating: Float? + if ratingString.contains("0px") { tmpRating = 5.0 } + if ratingString.contains("-16px") { tmpRating = 4.0 } + if ratingString.contains("-32px") { tmpRating = 3.0 } + if ratingString.contains("-48px") { tmpRating = 2.0 } + if ratingString.contains("-64px") { tmpRating = 1.0 } + if ratingString.contains("-80px") { tmpRating = 0.0 } + + guard var rating = tmpRating + else { throw AppError.parseFailed } + + if ratingString.contains("-21px") { rating -= 0.5 } + return RatingResult( + imgRating: rating, + textRating: try? parseTextRating(node: node), + containsUserRating: containsUserRating + ) + } + // swiftlint:enable cyclomatic_complexity + + public static func parseBanInterval(doc: HTMLDocument) -> BanInterval? { + guard let text = doc.body?.text, let range = text.range(of: "The ban expires in ") + else { return nil } + + let expireDescription = String(text[range.upperBound...]) + + if let daysRange = expireDescription.range(of: "days"), + let days = Int(expireDescription[.. [GalleryTorrent] { + var torrents = [GalleryTorrent]() + + for link in doc.xpath("//form") { + var tmpPostedTime: String? + var tmpFileSize: String? + var tmpSeedCount: Int? + var tmpPeerCount: Int? + var tmpDownloadCount: Int? + var tmpUploader: String? + var tmpFileName: String? + var tmpHash: String? + var tmpTorrentURL: URL? + + for trLink in link.xpath("//tr") { + for tdLink in trLink.xpath("//td") { + if let tdText = tdLink.text { + if tdText.contains("Posted: ") { + tmpPostedTime = tdText.replacingOccurrences(of: "Posted: ", with: "") + } + if tdText.contains("Size: ") { + tmpFileSize = tdText.replacingOccurrences(of: "Size: ", with: "") + } + if tdText.contains("Seeds: ") { + tmpSeedCount = Int(tdText.replacingOccurrences(of: "Seeds: ", with: "")) + } + if tdText.contains("Peers: ") { + tmpPeerCount = Int(tdText.replacingOccurrences(of: "Peers: ", with: "")) + } + if tdText.contains("Downloads: ") { + tmpDownloadCount = Int(tdText.replacingOccurrences(of: "Downloads: ", with: "")) + } + if tdText.contains("Uploader: ") { + tmpUploader = tdText.replacingOccurrences(of: "Uploader: ", with: "") + } + } + if let aLink = tdLink.at_xpath("//a"), + let aHref = aLink["href"], + let aText = aLink.text, + let aURL = URL(string: aHref), + let range = aURL.lastPathComponent.range(of: ".torrent") { + tmpHash = String(aURL.lastPathComponent[.. User { + var displayName: String? + var avatarURL: URL? + + for ipbLink in doc.xpath("//table [@class='ipbtable']") { + guard let profileName = ipbLink.at_xpath("//div [@id='profilename']")?.text + else { continue } + + displayName = profileName + + for imgLink in ipbLink.xpath("//img") { + guard let imgURLString = imgLink["src"], + imgURLString.contains("forums.e-hentai.org/uploads"), + let imgURL = URL(string: imgURLString) + else { continue } + + avatarURL = imgURL + } + } + if displayName != nil { + return User(displayName: displayName, avatarURL: avatarURL) + } else { + throw AppError.parseFailed + } + } + + public static func parseCurrentFunds(doc: HTMLDocument) throws -> (String, String) { + var tmpGP: String? + var tmpCredits: String? + + for element in doc.xpath("//p") { + if let text = element.text, + let rangeA = text.range(of: "GP"), + let rangeB = text.range(of: "[?]"), + let rangeC = text.range(of: "Credits") { + tmpGP = String(text[..? + public var focusedField: FocusField? + public var editingWord: QuickSearchWord = .empty + public var listEditMode: EditMode = .inactive + public var isListEditing: Bool { + get { listEditMode == .active } + set { listEditMode = newValue ? .active : .inactive } + } + + @Shared(.quickSearchWords) public var quickSearchWords: [QuickSearchWord] + @SharedReader(.tagTranslator) public var tagTranslator: TagTranslator + @SharedReader(.setting) public var setting: Setting + + public init() {} + + // The add button is disabled once this is true (see `wordLimit`). + public var isAtWordLimit: Bool { quickSearchWords.count >= QuickSearchReducer.wordLimit } + } + + public enum Action: BindableAction, Equatable { + case binding(BindingAction) + case confirmationDialog(PresentationAction) + case deleteWordButtonTapped(QuickSearchWord) + case newWordButtonTapped + case editWordButtonTapped(QuickSearchWord) + + case toggleListEditing + + case appendWord + case editWord + case deleteWord(QuickSearchWord) + case deleteWordWithOffsets(IndexSet) + case moveWord(IndexSet, Int) + } + + public init() {} + + public var body: some Reducer { + CombineReducers { + BindingReducer() + + Reduce { state, action in + switch action { + case .binding: + return .none + + case .newWordButtonTapped: + state.editingWord = .empty + state.editKind = .new + return .none + + case .editWordButtonTapped(let word): + state.editingWord = word + state.editKind = .edit + return .none + + case .deleteWordButtonTapped(let word): + state.confirmationDialog = ConfirmationDialogState(titleVisibility: .hidden) { + TextState(localized: .RLocalizable.delete) + } actions: { + ButtonState(role: .destructive, action: .confirmDelete(word)) { + TextState(localized: .RLocalizable.delete) + } + ButtonState(role: .cancel) { + TextState(localized: .RLocalizable.cancel) + } + } message: { + TextState(localized: .RLocalizable.deleteDescription) + } + return .none + + case .confirmationDialog(.presented(.confirmDelete(let word))): + return .send(.deleteWord(word)) + + case .confirmationDialog: + return .none + + case .toggleListEditing: + state.isListEditing.toggle() + return .none + + case .appendWord: + guard !state.isAtWordLimit else { return .none } + let word = state.editingWord + state.$quickSearchWords.withLock { $0.append(word) } + state.editKind = nil + return .none + + case .editWord: + if let index = state.quickSearchWords.firstIndex(where: { $0.id == state.editingWord.id }) { + let word = state.editingWord + state.$quickSearchWords.withLock { $0[index] = word } + } + state.editKind = nil + return .none + + case .deleteWord(let word): + state.$quickSearchWords.withLock { $0.removeAll { $0 == word } } + return .none + + case .deleteWordWithOffsets(let offsets): + state.$quickSearchWords.withLock { $0.remove(atOffsets: offsets) } + return .none + + case .moveWord(let source, let destination): + state.$quickSearchWords.withLock { $0.move(fromOffsets: source, toOffset: destination) } + return .none + } + } + } + // Dismissing the editor (back-swipe or a confirmed save) resets the scratch word and focus. + .onChange(of: \.editKind) { _, state in + if state.editKind == nil { + state.focusedField = nil + state.editingWord = .empty + } + return .none + } + .ifLet(\.$confirmationDialog, action: \.confirmationDialog) + } +} diff --git a/EhPanda/View/Search/Support/QuickSearchView.swift b/AppPackage/Sources/QuickSearchFeature/QuickSearchView.swift similarity index 56% rename from EhPanda/View/Search/Support/QuickSearchView.swift rename to AppPackage/Sources/QuickSearchFeature/QuickSearchView.swift index 5c60c1601..da7544326 100644 --- a/EhPanda/View/Search/Support/QuickSearchView.swift +++ b/AppPackage/Sources/QuickSearchFeature/QuickSearchView.swift @@ -1,65 +1,58 @@ -// -// QuickSearchView.swift -// EhPanda -// - import SwiftUI +import AppModels +import Resources +import SFSafeSymbols import ComposableArchitecture +import AppComponents -struct QuickSearchView: View { +public struct QuickSearchView: View { @Bindable private var store: StoreOf private let searchAction: (String) -> Void @FocusState private var focusedField: QuickSearchReducer.FocusField? - init(store: StoreOf, searchAction: @escaping (String) -> Void) { + public init(store: StoreOf, searchAction: @escaping (String) -> Void) { self.store = store self.searchAction = searchAction } - var body: some View { - NavigationView { + public var body: some View { + NavigationStack { ZStack { List { + // A leading list section, rather than a pinned top banner, keeps the navigation + // title intact: the word list is capped and the add button disables at the limit. + ListNoticeView(notice: .wordLimitDescription(limit: QuickSearchReducer.wordLimit)) + ForEach(store.quickSearchWords) { word in Button { - searchAction(word.content) + searchAction(word.effectiveSearchText) } label: { VStack(alignment: .leading, spacing: 5) { - if !word.name.isEmpty { + if !word.name.isEmpty, !word.content.isEmpty { Text(word.name).font(.subheadline).foregroundColor(.secondary).lineLimit(1) } - Text(word.content).fontWeight(.medium).font(.title3).lineLimit(2) + Text(word.effectiveSearchText) + .fontWeight(.medium) + .font(.title3) + .lineLimit(2) } .tint(.primary) } .swipeActions(edge: .trailing) { Button { - store.send(.setNavigation(.deleteWord(word))) + store.send(.deleteWordButtonTapped(word)) } label: { Image(systemSymbol: .trash) } .tint(.red) Button { - store.send(.setEditingWord(word)) - store.send(.setNavigation(.editWord)) + store.send(.editWordButtonTapped(word)) } label: { Image(systemSymbol: .squareAndPencil) } } .withArrow(isVisible: !store.isListEditing).padding(5) - .confirmationDialog( - message: L10n.Localizable.ConfirmationDialog.Title.delete, - unwrapping: $store.route, - case: \.deleteWord, - matching: word - ) { route in - Button(L10n.Localizable.ConfirmationDialog.Button.delete, role: .destructive) { - DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) { - store.send(.deleteWord(route)) - } - } - } } .onDelete { offsets in store.send(.deleteWordWithOffsets(offsets)) @@ -68,28 +61,19 @@ struct QuickSearchView: View { store.send(.moveWord(source, destination)) } } - LoadingView().opacity( - store.loadingState == .loading - && store.quickSearchWords.isEmpty ? 1 : 0 - ) ErrorView(error: .notFound) - .opacity( - store.loadingState != .loading - && store.quickSearchWords.isEmpty ? 1 : 0 - ) + .opacity(store.quickSearchWords.isEmpty ? 1 : 0) } + .confirmationDialog( + $store.scope(state: \.confirmationDialog, action: \.confirmationDialog) + ) .synchronize($store.focusedField, $focusedField) .environment(\.editMode, $store.listEditMode) .animation(.default, value: store.quickSearchWords) .animation(.default, value: store.listEditMode) - .onAppear { - if store.quickSearchWords.isEmpty { - store.send(.fetchQuickSearchWords) - } - } .toolbar(content: toolbar) - .background(navigationLinks) - .navigationTitle(L10n.Localizable.QuickSearchView.Title.quickSearch) + .navigationDestination(item: $store.editKind) { editWordView(for: $0) } + .navigationTitle(.RLocalizable.quickSearch) } } @@ -105,11 +89,11 @@ struct QuickSearchView: View { private func toolbar() -> some ToolbarContent { CustomToolbarItem { Button { - store.send(.setEditingWord(.empty)) - store.send(.setNavigation(.newWord)) + store.send(.newWordButtonTapped) } label: { Image(systemSymbol: .plus) } + .disabled(store.isAtWordLimit) Button { store.send(.toggleListEditing) } label: { @@ -118,66 +102,66 @@ struct QuickSearchView: View { } } } - @ViewBuilder private var navigationLinks: some View { - NavigationLink(unwrapping: $store.route, case: \.newWord) { _ in - EditWordView( - title: L10n.Localizable.QuickSearchView.Title.newWord, - word: $store.editingWord, - focusedField: $focusedField, - submitAction: onTextFieldSubmitted, - confirmAction: { - store.send(.appendWord) - store.send(.setNavigation(nil)) - } - ) - } - NavigationLink(unwrapping: $store.route, case: \.editWord) { _ in - EditWordView( - title: L10n.Localizable.QuickSearchView.Title.editWord, - word: $store.editingWord, - focusedField: $focusedField, - submitAction: onTextFieldSubmitted, - confirmAction: { - store.send(.editWord) - store.send(.setNavigation(nil)) - } - ) - } + @ViewBuilder private func editWordView(for kind: QuickSearchReducer.WordEditKind) -> some View { + EditWordView( + title: kind == .new + ? .newWord + : .editWord, + word: $store.editingWord, + focusedField: $focusedField, + submitAction: onTextFieldSubmitted, + confirmAction: { + store.send(kind == .new ? .appendWord : .editWord) + }, + tagTranslator: store.tagTranslator, + setting: store.setting + ) } } extension QuickSearchView { // MARK: EditWordView struct EditWordView: View { - private let title: String + private let title: LocalizedStringResource @Binding private var word: QuickSearchWord private let focusedField: FocusState.Binding private let submitAction: () -> Void private let confirmAction: () -> Void + private let tagTranslator: TagTranslator + private let setting: Setting init( - title: String, word: Binding, + title: LocalizedStringResource, word: Binding, focusedField: FocusState.Binding, - submitAction: @escaping () -> Void, confirmAction: @escaping () -> Void + submitAction: @escaping () -> Void, confirmAction: @escaping () -> Void, + tagTranslator: TagTranslator, setting: Setting ) { self.title = title _word = word self.focusedField = focusedField self.submitAction = submitAction self.confirmAction = confirmAction + self.tagTranslator = tagTranslator + self.setting = setting } var body: some View { Form { - Section(L10n.Localizable.QuickSearchView.Title.name) { - TextField(L10n.Localizable.QuickSearchView.Placeholder.optional, text: $word.name) + Section(.name) { + TextField(.optionalPlaceholder, text: $word.name) .submitLabel(.next).focused(focusedField, equals: .name) } - Section(L10n.Localizable.QuickSearchView.Title.content) { + Section(.content) { TextEditor(text: $word.content) .disableAutocorrection(true) .textInputAutocapitalization(.never) .focused(focusedField, equals: .content) + TagSuggestionView( + keyword: $word.content, + translations: tagTranslator.translations, + showsImages: setting.showsImagesInTags, + isEnabled: setting.showsTagsSearchSuggestion + ) } } .toolbar(content: toolbar) diff --git a/AppPackage/Sources/QuickSearchFeature/Resources/Localizable.xcstrings b/AppPackage/Sources/QuickSearchFeature/Resources/Localizable.xcstrings new file mode 100644 index 000000000..a0f5e7c0f --- /dev/null +++ b/AppPackage/Sources/QuickSearchFeature/Resources/Localizable.xcstrings @@ -0,0 +1,348 @@ +{ + "sourceLanguage": "en", + "strings": { + "content": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Content" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Inhalt" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "内容" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "내용" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "内容" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "搜尋內容" + } + } + } + }, + "edit_word": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Edit word" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Suchbegriff bearbeiten" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "キーワードを編集" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "키워드 편집" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "编辑关键词" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "編輯關鍵字" + } + } + } + }, + "name": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Name" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Name" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "名前" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "이름" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "名称" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "名稱" + } + } + } + }, + "new_word": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "New word" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Neuer Suchbegriff" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "キーワードを追加" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "새 키워드" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "添加关键词" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "新關鍵字" + } + } + } + }, + "optional_placeholder": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Optional" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Optional" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "任意" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "선택 사항" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "可选" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "(可選)" + } + } + } + }, + "word_limit_description": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Up to %#@limit@ words can be saved." + }, + "substitutions": { + "limit": { + "argNum": 1, + "formatSpecifier": "lld", + "variations": { + "plural": { + "other": { + "stringUnit": { + "state": "translated", + "value": "%arg" + } + } + } + } + } + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Es können bis zu %#@limit@ Suchbegriffe gespeichert werden." + }, + "substitutions": { + "limit": { + "argNum": 1, + "formatSpecifier": "lld", + "variations": { + "plural": { + "other": { + "stringUnit": { + "state": "translated", + "value": "%arg" + } + } + } + } + } + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "最大 %#@limit@ 件のキーワードを保存できます。" + }, + "substitutions": { + "limit": { + "argNum": 1, + "formatSpecifier": "lld", + "variations": { + "plural": { + "other": { + "stringUnit": { + "state": "translated", + "value": "%arg" + } + } + } + } + } + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "최대 %#@limit@개의 키워드를 저장할 수 있습니다." + }, + "substitutions": { + "limit": { + "argNum": 1, + "formatSpecifier": "lld", + "variations": { + "plural": { + "other": { + "stringUnit": { + "state": "translated", + "value": "%arg" + } + } + } + } + } + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "最多可保存 %#@limit@ 个关键词。" + }, + "substitutions": { + "limit": { + "argNum": 1, + "formatSpecifier": "lld", + "variations": { + "plural": { + "other": { + "stringUnit": { + "state": "translated", + "value": "%arg" + } + } + } + } + } + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "最多可儲存 %#@limit@ 個關鍵字。" + }, + "substitutions": { + "limit": { + "argNum": 1, + "formatSpecifier": "lld", + "variations": { + "plural": { + "other": { + "stringUnit": { + "state": "translated", + "value": "%arg" + } + } + } + } + } + } + } + } + } + }, + "version": "1.0" +} \ No newline at end of file diff --git a/AppPackage/Sources/ReadingFeature/.swiftlint.yml b/AppPackage/Sources/ReadingFeature/.swiftlint.yml new file mode 100644 index 000000000..1242ffcaa --- /dev/null +++ b/AppPackage/Sources/ReadingFeature/.swiftlint.yml @@ -0,0 +1 @@ +parent_config: ../../../.swiftlint.yml diff --git a/AppPackage/Sources/ReadingFeature/Logger+.swift b/AppPackage/Sources/ReadingFeature/Logger+.swift new file mode 100644 index 000000000..3e9822c84 --- /dev/null +++ b/AppPackage/Sources/ReadingFeature/Logger+.swift @@ -0,0 +1,7 @@ +import OSLogExt + +extension Logger { + init(category: String) { + self.init(moduleName: "ReadingFeature", category: category) + } +} diff --git a/AppPackage/Sources/ReadingFeature/ReadingReducer+Body.swift b/AppPackage/Sources/ReadingFeature/ReadingReducer+Body.swift new file mode 100644 index 000000000..294e1996c --- /dev/null +++ b/AppPackage/Sources/ReadingFeature/ReadingReducer+Body.swift @@ -0,0 +1,209 @@ +import SwiftUI +import Kingfisher +import AppComponents +import ComposableArchitecture +import AppTools + +// MARK: - CancelID +enum ReadingCancelID { + case fetchImage + case progressFlush + case observeDownloads + case loadLocalPageURLs + case fetchPreviewURLs + case fetchThumbnailURLs + case fetchNormalImageURLs + case refetchNormalImageURLs + case fetchMPVKeys + case fetchMPVImageURL +} + +// MARK: - Reducer Body +extension ReadingReducer { + @ReducerBuilder + func makeBody() -> some Reducer { + BindingReducer() + .onChange(of: \.showsSliderPreview) { _, _ in + .run(operation: { _ in await hapticsClient.generateFeedback(.soft) }) + } + mainReducer + } + + var mainReducer: some ReducerOf { + CombineReducers { + lifecycleReducer + sessionReducer + imageFetchReducer + } + .haptics( + unwrapping: \.destination, + case: \.readingSetting, + hapticsClient: hapticsClient + ) + .haptics( + unwrapping: \.destination, + case: \.share, + hapticsClient: hapticsClient + ) + .ifLet(\.$destination, action: \.destination) + .ifLet(\.$toast, action: \.toast) + } + + var lifecycleReducer: some ReducerOf { + Reduce { state, action in + switch action { + case .binding: + return .none + + case .destination: + return .none + + case .presentShare(let shareItem): + state.destination = .share(shareItem) + return .none + + case .presentReadingSetting: + state.destination = .readingSetting(.init()) + return .none + + case .toggleShowsPanel: + state.showsPanel.toggle() + return .none + + case .setOrientationPortrait(let isPortrait): + var effects = [Effect]() + if isPortrait { + effects.append(.run(operation: { _ in await appDelegateClient.setPortraitOrientationMask() })) + effects.append(.run(operation: { _ in await appDelegateClient.setPortraitOrientation() })) + } else { + effects.append(.run(operation: { _ in await appDelegateClient.setAllOrientationMask() })) + } + return .merge(effects) + + case .onPerformDismiss: + // Flush synchronously here — this runs before the parent nils the presentation and + // cancels the pending debounce, so the last page swiped-to isn't lost on a normal close. + flushReadingProgress(state) + return .run(operation: { _ in await hapticsClient.generateFeedback(.light) }) + + case .onAppear(let gid): + var effects: [Effect] = [ + .send(.observeDownloads(gid)), + .send(.loadLocalPageURLs(gid)) + ] + if state.setting.enablesLandscape { + effects.append(.send(.setOrientationPortrait(false))) + } + return .merge(effects) + + case .onWebImageRetry(let index): + state.imageURLLoadingStates[index] = .idle + return .none + + case .onWebImageSucceeded(let index): + state.imageURLLoadingStates[index] = .idle + state.webImageLoadSuccessIndices.insert(index) + guard !state.isOffline, + state.gallery.id.isValidGID, + state.localPageURLs[index] == nil + else { + return .none + } + return .send(.captureCachedPage(index)) + + case .onWebImageFailed(let index): + state.imageURLLoadingStates[index] = .failed(.webImageFailed) + guard let url = state.localPageURLs[index], url.isFileURL, + state.gallery.id.isValidGID + else { + return .none + } + let gid = state.gallery.id + let requestID = UUID() + state.localPageRequestID = requestID + return .run { send in + let localPageURLs = await downloadClient.rescanLocalPageURLs(gid) ?? [:] + await send(.loadLocalPageURLsDone(requestID, localPageURLs)) + } + .cancellable(id: ReadingCancelID.loadLocalPageURLs, cancelInFlight: true) + + case .reloadAllWebImages: + guard state.contentSource == .remote else { + if case .local(let download, let manifest) = state.contentSource { + applyLocalSource(state: &state, download: download, manifest: manifest) + } + return .none + } + state.previewURLs = .init() + state.thumbnailURLs = .init() + state.imageURLs = .init() + state.originalImageURLs = .init() + state.mpvKey = nil + state.mpvImageKeys = .init() + state.mpvSkipServerIdentifiers = .init() + state.forceRefreshID = .init() + // URL maps live only in reducer state now; clearing them above is the whole reset. + return .none + + case .retryAllFailedWebImages: + guard !state.isOffline else { return .none } + state.imageURLLoadingStates.forEach { (index, loadingState) in + if case .failed = loadingState { + state.imageURLLoadingStates[index] = .idle + } + } + state.previewLoadingStates.forEach { (index, loadingState) in + if case .failed = loadingState { + state.previewLoadingStates[index] = .idle + } + } + return .none + + case .copyImage(let imageURL): + return .send(.fetchImage(.copy, imageURL)) + + case .saveImage(let imageURL): + return .send(.fetchImage(.save, imageURL)) + + case .saveImageDone(let isSucceeded): + state.toast = isSucceeded ? .savedToPhotoLibrary : .error() + return .none + + case .shareImage(let imageURL): + return .send(.fetchImage(.share, imageURL)) + + case .fetchImage(let action, let imageURL): + return .run { send in + let result = await imageClient.fetchImageAsset(url: imageURL) + await send(.fetchImageDone(action, result)) + } + .cancellable(id: ReadingCancelID.fetchImage) + + case .fetchImageDone(let action, let result): + if case .success(let asset) = result { + switch action { + case .copy: + state.toast = .copiedToClipboardSucceeded + return .run(operation: { _ in _ = clipboardClient.saveImageData(asset.data) }) + case .save: + return .run { send in + let success = await imageClient.saveImageDataToPhotoLibrary(asset.data) + await send(.saveImageDone(success)) + } + case .share: + let shareItem: ShareItem = asset.isAnimated + ? .data(asset.data) + : .image(asset.image) + return .send(.presentShare(.init(value: shareItem))) + } + } else { + state.toast = .error() + return .none + } + + default: + return .none + } + } + } +} diff --git a/AppPackage/Sources/ReadingFeature/ReadingReducer+ImageFetch.swift b/AppPackage/Sources/ReadingFeature/ReadingReducer+ImageFetch.swift new file mode 100644 index 000000000..b62cf1b7a --- /dev/null +++ b/AppPackage/Sources/ReadingFeature/ReadingReducer+ImageFetch.swift @@ -0,0 +1,340 @@ +import Foundation +import ComposableArchitecture +import AppTools +import NetworkingFeature + +// MARK: - Image URL Fetch Actions +extension ReadingReducer { + var imageFetchReducer: some ReducerOf { + Reduce { state, action in + switch action { + case .fetchPreviewURLs(let index): + guard !state.isOffline else { + state.previewLoadingStates[index] = .idle + return .none + } + guard state.previewLoadingStates[index] != .loading, + let galleryURL = state.gallery.galleryURL + else { return .none } + state.previewLoadingStates[index] = .loading + let pageNum = state.previewConfig.pageNumber(index: index) + return .run { send in + let response = await GalleryPreviewURLsRequest(galleryURL: galleryURL, pageNum: pageNum).response() + await send(.fetchPreviewURLsDone(index, response)) + } + .cancellable(id: ReadingCancelID.fetchPreviewURLs) + + case .fetchPreviewURLsDone(let index, let result): + 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 .none + case .failure(let error): + state.previewLoadingStates[index] = .failed(error) + } + return .none + + case .fetchImageURLs(let index): + guard !state.isOffline else { + state.imageURLLoadingStates[index] = .idle + return .none + } + guard state.localPageURLs[index] == nil else { + state.imageURLLoadingStates[index] = .idle + return .none + } + if state.mpvKey != nil { + return .send(.fetchMPVImageURL(index, false)) + } else { + return .send(.fetchThumbnailURLs(index)) + } + + case .refetchImageURLs(let index): + guard !state.isOffline else { + state.imageURLLoadingStates[index] = .idle + return .none + } + guard state.localPageURLs[index] == nil else { + state.imageURLLoadingStates[index] = .idle + return .none + } + if state.mpvKey != nil { + return .send(.fetchMPVImageURL(index, true)) + } else { + return .send(.refetchNormalImageURLs(index)) + } + + case .prefetchImages(let index, let prefetchLimit): + guard !state.isOffline else { return .none } + func getPrefetchImageURLs(range: ClosedRange) -> [URL] { + (range.lowerBound...range.upperBound).compactMap { index in + if let url = state.localPageURLs[index], !url.isFileURL { + return url + } + if let url = state.imageURLs[index] { + return url + } + return nil + } + } + func getFetchImageURLIndices(range: ClosedRange) -> [Int] { + (range.lowerBound...range.upperBound).compactMap { index in + if state.localPageURLs[index] != nil { + return nil + } + if state.imageURLs[index] == nil, + state.imageURLLoadingStates[index] != .loading { + return index + } + return nil + } + } + var prefetchImageURLs = [URL]() + var fetchImageURLIndices = [Int]() + var effects = [Effect]() + let previousUpperBound = max(index - 2, 1) + let previousLowerBound = max(previousUpperBound - prefetchLimit / 2, 1) + if previousUpperBound - previousLowerBound > 0 { + prefetchImageURLs += getPrefetchImageURLs(range: previousLowerBound...previousUpperBound) + fetchImageURLIndices += getFetchImageURLIndices(range: previousLowerBound...previousUpperBound) + } + let nextLowerBound = min(index + 2, state.gallery.pageCount) + let nextUpperBound = min(nextLowerBound + prefetchLimit / 2, state.gallery.pageCount) + if nextUpperBound - nextLowerBound > 0 { + prefetchImageURLs += getPrefetchImageURLs(range: nextLowerBound...nextUpperBound) + fetchImageURLIndices += getFetchImageURLIndices(range: nextLowerBound...nextUpperBound) + } + fetchImageURLIndices.forEach { + effects.append(.send(.fetchImageURLs($0))) + } + effects.append( + .run { [prefetchImageURLs] _ in + imageClient.prefetchImages(prefetchImageURLs) + } + ) + return .merge(effects) + + case .fetchThumbnailURLs(let index): + guard !state.isOffline else { + state.imageURLLoadingStates[index] = .idle + return .none + } + guard state.imageURLLoadingStates[index] != .loading, + let galleryURL = state.gallery.galleryURL + else { return .none } + state.previewConfig.batchRange(index: index).forEach { + state.imageURLLoadingStates[$0] = .loading + } + let pageNum = state.previewConfig.pageNumber(index: index) + return .run { send in + let response = await ThumbnailURLsRequest(galleryURL: galleryURL, pageNum: pageNum).response() + await send(.fetchThumbnailURLsDone(index, response)) + } + .cancellable(id: ReadingCancelID.fetchThumbnailURLs) + + case .fetchThumbnailURLsDone(let index, let result): + let batchRange = state.previewConfig.batchRange(index: index) + switch result { + case .success(let thumbnailURLs): + guard !thumbnailURLs.isEmpty else { + batchRange.forEach { + state.imageURLLoadingStates[$0] = .failed(.notFound) + } + return .none + } + if let url = thumbnailURLs[index], urlClient.checkIfMPVURL(url) { + return .send(.fetchMPVKeys(index, url)) + } else { + state.updateThumbnailURLs(thumbnailURLs) + return .send(.fetchNormalImageURLs(index, thumbnailURLs)) + } + case .failure(let error): + batchRange.forEach { + state.imageURLLoadingStates[$0] = .failed(error) + } + } + return .none + + case .fetchNormalImageURLs(let index, let thumbnailURLs): + guard !state.isOffline else { + state.imageURLLoadingStates[index] = .idle + return .none + } + return .run { send in + let response = await GalleryNormalImageURLsRequest(thumbnailURLs: thumbnailURLs).response() + await send(.fetchNormalImageURLsDone(index, response)) + } + .cancellable(id: ReadingCancelID.fetchNormalImageURLs) + + case .fetchNormalImageURLsDone(let index, let result): + 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 .none + case .failure(let error): + batchRange.forEach { + state.imageURLLoadingStates[$0] = .failed(error) + } + } + return .none + + case .refetchNormalImageURLs(let index): + guard !state.isOffline else { + state.imageURLLoadingStates[index] = .idle + return .none + } + guard state.imageURLLoadingStates[index] != .loading, + let galleryURL = state.gallery.galleryURL, + let imageURL = state.imageURLs[index] + else { return .none } + state.imageURLLoadingStates[index] = .loading + let pageNum = state.previewConfig.pageNumber(index: index) + return .run { [thumbnailURL = state.thumbnailURLs[index]] send in + let response = await GalleryNormalImageURLRefetchRequest( + index: index, + pageNum: pageNum, + galleryURL: galleryURL, + thumbnailURL: thumbnailURL, + storedImageURL: imageURL + ) + .response() + await send(.refetchNormalImageURLsDone(index, response)) + } + .cancellable(id: ReadingCancelID.refetchNormalImageURLs) + + case .refetchNormalImageURLsDone(let index, let result): + switch result { + case .success(let (imageURLs, response)): + var effects = [Effect]() + if let response = response { + effects.append(.run(operation: { _ in cookieClient.setSkipServer(response: response) })) + } + guard !imageURLs.isEmpty else { + state.imageURLLoadingStates[index] = .failed(.notFound) + return effects.isEmpty ? .none : .merge(effects) + } + state.imageURLLoadingStates[index] = .idle + state.updateImageURLs(imageURLs, [:]) + return effects.isEmpty ? .none : .merge(effects) + case .failure(let error): + state.imageURLLoadingStates[index] = .failed(error) + } + return .none + + case .fetchMPVKeys(let index, let mpvURL): + guard !state.isOffline else { + state.imageURLLoadingStates[index] = .idle + return .none + } + return .run { send in + let response = await MPVKeysRequest(mpvURL: mpvURL).response() + await send(.fetchMPVKeysDone(index, response)) + } + .cancellable(id: ReadingCancelID.fetchMPVKeys) + + case .fetchMPVKeysDone(let index, let result): + let batchRange = state.previewConfig.batchRange(index: index) + switch result { + case .success(let (mpvKey, mpvImageKeys)): + let pageCount = state.gallery.pageCount + guard mpvImageKeys.count == pageCount else { + batchRange.forEach { + state.imageURLLoadingStates[$0] = .failed(.notFound) + } + return .none + } + batchRange.forEach { + state.imageURLLoadingStates[$0] = .idle + } + state.mpvKey = mpvKey + state.mpvImageKeys = mpvImageKeys + return .merge( + Array(1...min(3, max(1, pageCount))).map { + .send(.fetchMPVImageURL($0, false)) + } + ) + case .failure(let error): + batchRange.forEach { + state.imageURLLoadingStates[$0] = .failed(error) + } + } + return .none + + case .fetchMPVImageURL(let index, let isRefresh): + guard !state.isOffline else { + state.imageURLLoadingStates[index] = .idle + return .none + } + guard let gidInteger = Int(state.gallery.id), let mpvKey = state.mpvKey, + let mpvImageKey = state.mpvImageKeys[index], + state.imageURLLoadingStates[index] != .loading + else { return .none } + state.imageURLLoadingStates[index] = .loading + let skipServerIdentifier = isRefresh ? state.mpvSkipServerIdentifiers[index] : nil + return .run { send in + let response = await GalleryMPVImageURLRequest( + gid: gidInteger, + index: index, + mpvKey: mpvKey, + mpvImageKey: mpvImageKey, + skipServerIdentifier: skipServerIdentifier + ) + .response() + await send(.fetchMPVImageURLDone(index, response)) + } + .cancellable(id: ReadingCancelID.fetchMPVImageURL) + + case .fetchMPVImageURLDone(let index, let result): + switch result { + case .success(let mpvResult): + let imageURLs: [Int: URL] = [index: mpvResult.imageURL] + var originalImageURLs = [Int: URL]() + if let originalImageURL = mpvResult.originalImageURL { + originalImageURLs[index] = originalImageURL + } + state.imageURLLoadingStates[index] = .idle + state.mpvSkipServerIdentifiers[index] = mpvResult.skipServerIdentifier + state.updateImageURLs(imageURLs, originalImageURLs) + return .none + case .failure(let error): + state.imageURLLoadingStates[index] = .failed(error) + } + return .none + + case .captureCachedPage(let index): + guard !state.isOffline, + state.gallery.id.isValidGID + else { + return .none + } + let gid = state.gallery.id + let imageURL = state.imageURLs[index] + return .run { _ in + await downloadClient.captureCachedPage( + gid, + index, + imageURL + ) + } + + default: + return .none + } + } + } +} diff --git a/AppPackage/Sources/ReadingFeature/ReadingReducer+Session.swift b/AppPackage/Sources/ReadingFeature/ReadingReducer+Session.swift new file mode 100644 index 000000000..c0855f858 --- /dev/null +++ b/AppPackage/Sources/ReadingFeature/ReadingReducer+Session.swift @@ -0,0 +1,145 @@ +import SwiftUI +import AppModels +import Sharing +import ComposableArchitecture +import AppTools + +// MARK: - Session Restore & Download Actions +extension ReadingReducer { + var sessionReducer: some ReducerOf { + Reduce { state, action in + switch action { + case .syncReadingProgress(let progress): + // Debounce the persist: the page index is the hottest interaction in the app (swipe, + // slider scrub, auto-play) and each write re-encodes the whole history array. Keep the + // pending page in state and flush it at most once per second — plus immediately on + // teardown/background (`.flushReadingProgress`) so a force-quit loses under a second. + state.pendingReadingProgress = progress + return .run { send in + try await clock.sleep(for: .seconds(1)) + await send(.flushReadingProgress) + } + .cancellable(id: ReadingCancelID.progressFlush, cancelInFlight: true) + + case .flushReadingProgress: + flushReadingProgress(state) + return .none + + case .observeDownloads(let gid): + return reduceObserveDownloads(gid: gid) + + case .observeDownloadsDone: + guard state.gallery.id.isValidGID else { return .none } + return .send(.loadLocalPageURLs(state.gallery.id)) + + case .loadLocalPageURLs(let gid): + return reduceLoadLocalPageURLs(state: &state, gid: gid) + + case .loadLocalPageURLsDone(let requestID, let localPageURLs): + return reduceLoadLocalPageURLsDone( + state: &state, requestID: requestID, localPageURLs: localPageURLs + ) + + default: + return .none + } + } + } + + /// Persists the latest pending page into the shared browsing history. Called from the debounced + /// `.flushReadingProgress` and — crucially — synchronously on reader dismissal (`.onPerformDismiss`), + /// which runs in this child reducer before the parent nils the presentation and cancels the pending + /// debounce. A deferred `.send` at that point would be dropped once the destination is gone. + func flushReadingProgress(_ state: State) { + // Nothing to persist for a gallery that can't be keyed into history (e.g. an unseeded reader + // dismissed immediately); bail before touching the clock or the shared store. + guard state.gallery.id.isValidGID else { return } + @Shared(.galleryHistory) var galleryHistory + $galleryHistory.withLock { + $0.updateReadingProgress( + gid: state.gallery.id, token: state.gallery.token, + progress: state.pendingReadingProgress, date: date.now + ) + } + } + + func reduceObserveDownloads(gid: String) -> Effect { + guard gid.isValidGID else { return .none } + return .run { send in + var previousRelevantDownloads = [DownloadedGallery]() + var hadRelevantDownloads = false + for await downloads in downloadClient.observeDownloads() { + let relevantDownloads = downloads.filter { $0.gid == gid } + let hasRelevantDownloads = !relevantDownloads.isEmpty + guard hasRelevantDownloads || hadRelevantDownloads else { continue } + if relevantDownloads == previousRelevantDownloads { + hadRelevantDownloads = hasRelevantDownloads + continue + } + previousRelevantDownloads = relevantDownloads + hadRelevantDownloads = hasRelevantDownloads + await send(.observeDownloadsDone(relevantDownloads)) + } + } + .cancellable(id: ReadingCancelID.observeDownloads, cancelInFlight: true) + } + + func reduceLoadLocalPageURLs(state: inout State, gid: String) -> Effect { + guard gid.isValidGID else { + state.localPageRequestID = UUID() + state.localPageURLs = .init() + return .none + } + let requestID = UUID() + state.localPageRequestID = requestID + return .run { send in + let localPageURLs = await downloadClient.loadLocalPageURLs(gid) ?? [:] + await send(.loadLocalPageURLsDone(requestID, localPageURLs)) + } + .cancellable(id: ReadingCancelID.loadLocalPageURLs, cancelInFlight: true) + } + + func reduceLoadLocalPageURLsDone( + state: inout State, requestID: UUID, localPageURLs: [Int: URL] + ) -> Effect { + guard state.localPageRequestID == requestID else { return .none } + // Local files turned up empty; fall back to remote so the gallery is still readable. + if case .local = state.contentSource, + localPageURLs.isEmpty { + state.contentSource = .remote + state.previewURLs = .init() + state.thumbnailURLs = .init() + state.imageURLs = .init() + state.originalImageURLs = .init() + state.forceRefreshID = .init() + } + state.localPageURLs = localPageURLs + localPageURLs.keys.forEach { + state.imageURLLoadingStates[$0] = .idle + state.previewLoadingStates[$0] = .idle + } + return .none + } + + /// Enters offline mode: seeds the gallery and language from the manifest (so a downloaded + /// gallery reads entirely from its local files and manifest) and makes `localPageURLs` the only + /// page source, clearing the remote URL maps that don't apply offline. + func applyLocalSource( + state: inout State, + download: DownloadedGallery, + manifest: DownloadManifest + ) { + state.gallery = download.gallery + state.language = manifest.language + state.localPageURLs = download.localPageURLs + state.previewURLs = .init() + state.thumbnailURLs = .init() + state.imageURLs = .init() + state.originalImageURLs = .init() + state.mpvKey = nil + state.mpvImageKeys = .init() + state.mpvSkipServerIdentifiers = .init() + state.imageURLLoadingStates = .init() + state.previewLoadingStates = .init() + } +} diff --git a/AppPackage/Sources/ReadingFeature/ReadingReducer.swift b/AppPackage/Sources/ReadingFeature/ReadingReducer.swift new file mode 100644 index 000000000..3098f75d0 --- /dev/null +++ b/AppPackage/Sources/ReadingFeature/ReadingReducer.swift @@ -0,0 +1,240 @@ +import AppTools +import SwiftUI +import AppModels +import Sharing +import ComposableArchitecture +import URLClient +import HapticsClient +import ImageClient +import NetworkingFeature +import DownloadClient +import ClipboardClient +import CookieClient +import DeviceClient +import AppDelegateClient +import AppComponents +import ReadingSettingFeature + +@Reducer +public struct ReadingReducer: Sendable { + @Reducer + public enum Destination { + @ReducerCaseIgnored + case share(IdentifiableBox) + case readingSetting(ReadingSettingReducer) + } + + public enum ShareItem: Equatable, Sendable { + var associatedValue: Any { + switch self { + case .data(let data): + return data + case .image(let image): + return image + } + } + case data(Data) + case image(UIImage) + } + + public enum ImageAction: Sendable { + case copy, save, share + } + + @ObservableState + public struct State: Equatable, Sendable { + @Presents public var toast: AppAlertState? + @Presents public var destination: Destination.State? + public var contentSource: ReadingContentSource = .remote + public var gallery: Gallery + public var language: Language? + + // Read-only here: the reducer only reads `setting` (page math, orientation). The reading-setting + // editor writes it through a view-owned `@Shared(.setting)` binding instead, and the mutual + // scale-factor clamp lives on the `Setting` model, so those writes stay consistent without a + // reducer round-trip. + @SharedReader(.setting) public var setting: Setting + + public var readingProgress: Int = .zero + // The latest page awaiting a debounced persist to `@Shared(.galleryHistory)`; kept separate + // from `readingProgress` (which seeds the slider) so tracking it never feeds back into the pager. + public var pendingReadingProgress: Int = .zero + public var forceRefreshID: UUID = .init() + + public var webImageLoadSuccessIndices = Set() + public var imageURLLoadingStates = [Int: LoadingState]() + public var previewLoadingStates = [Int: LoadingState]() + public var previewConfig: PreviewConfig = .normal(rows: 4) + + public var previewURLs = [Int: URL]() + /// The single source of truth for downloaded page files. It is not copied into the + /// other URL maps; both offline reads and the opportunistic "use the downloaded file + /// if present" check in remote mode resolve a page through this map alone. + public var localPageURLs = [Int: URL]() + public var localPageRequestID = UUID() + + public var thumbnailURLs = [Int: URL]() + public var imageURLs = [Int: URL]() + public var originalImageURLs = [Int: URL]() + + public var mpvKey: String? + public var mpvImageKeys = [Int: String]() + public var mpvSkipServerIdentifiers = [Int: String]() + + public var showsPanel = false + public var showsSliderPreview = false + + // `gallery` is required (no `.empty` default) so every pushing context is forced to seed it: + // a blank gallery has a `nil` galleryURL and a random-UUID gid, which bricks remote reading + // and writes junk history entries. `previewConfig`/`language` are threaded from the detail + // context so page math and Live Text language stay correct for remote sessions; offline + // sources overwrite both from the manifest on appear (see `applyLocalSource`). + public init( + gallery: Gallery, + contentSource: ReadingContentSource = .remote, + previewConfig: PreviewConfig = .normal(rows: 4), + language: Language? = nil + ) { + self.gallery = gallery + self.contentSource = contentSource + self.previewConfig = previewConfig + self.language = language + // Offline sources overwrite the gallery/language/page source from the manifest, so resolve + // them before keying the resume lookup on the final gallery id. + if case .local(let download, let manifest) = contentSource { + ReadingReducer().applyLocalSource(state: &self, download: download, manifest: manifest) + } + // Seed the resume page synchronously from the persisted browsing history so the pager is + // built at the right page from the start (no post-render jump). `pendingReadingProgress` + // mirrors it so a flush before the first page turn rewrites that position, not a stale zero. + @Shared(.galleryHistory) var galleryHistory + readingProgress = galleryHistory.readingProgress(gid: self.gallery.id) + pendingReadingProgress = readingProgress + } + + var isOffline: Bool { contentSource != .remote } + + // 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) + } + + // Image + func containerDataSource(setting: Setting, isLandscape: Bool) -> [Int] { + guard gallery.pageCount > 0 else { return [] } + + let defaultData = Array(1...gallery.pageCount) + guard isLandscape && setting.enablesDualPageMode + && setting.readingDirection != .vertical + 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 + ) -> 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 + ) + } + } + + public enum Action: BindableAction { + case binding(BindingAction) + case toast(PresentationAction) + case destination(PresentationAction) + case presentShare(IdentifiableBox) + case presentReadingSetting + + case toggleShowsPanel + case setOrientationPortrait(Bool) + case onPerformDismiss + case onAppear(String) + + case onWebImageRetry(Int) + case onWebImageSucceeded(Int) + case onWebImageFailed(Int) + case reloadAllWebImages + case retryAllFailedWebImages + + case copyImage(URL) + case saveImage(URL) + case saveImageDone(Bool) + case shareImage(URL) + case fetchImage(ImageAction, URL) + case fetchImageDone(ImageAction, Result) + + case syncReadingProgress(Int) + case flushReadingProgress + + case observeDownloads(String) + case observeDownloadsDone([DownloadedGallery]) + case loadLocalPageURLs(String) + case loadLocalPageURLsDone(UUID, [Int: URL]) + + 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) + case captureCachedPage(Int) + } + + @Dependency(\.appDelegateClient) var appDelegateClient + @Dependency(\.clipboardClient) var clipboardClient + @Dependency(\.downloadClient) var downloadClient + @Dependency(\.hapticsClient) var hapticsClient + @Dependency(\.cookieClient) var cookieClient + @Dependency(\.deviceClient) var deviceClient + @Dependency(\.imageClient) var imageClient + @Dependency(\.urlClient) var urlClient + @Dependency(\.date) var date + @Dependency(\.continuousClock) var clock + + public init() {} + + public var body: some Reducer { makeBody() } +} + +extension ReadingReducer.Destination.State: Equatable, Sendable {} +extension ReadingReducer.Destination.Action: Equatable, Sendable {} diff --git a/AppPackage/Sources/ReadingFeature/ReadingView+Gestures.swift b/AppPackage/Sources/ReadingFeature/ReadingView+Gestures.swift new file mode 100644 index 000000000..386bc92fa --- /dev/null +++ b/AppPackage/Sources/ReadingFeature/ReadingView+Gestures.swift @@ -0,0 +1,52 @@ +import SwiftUI +import AppModels + +// MARK: Gesture +extension ReadingView { + var tapGesture: some Gesture { + let singleTap = TapGesture(count: 1) + .onEnded { + gestureHandler.onSingleTapGestureEnded( + readingDirection: store.setting.readingDirection, + setPageIndexOffsetAction: { + let newValue = page.index + $0 + page.update(.new(index: newValue)) + }, + toggleShowsPanelAction: { store.send(.toggleShowsPanel) } + ) + } + let doubleTap = TapGesture(count: 2) + .onEnded { + gestureHandler.onDoubleTapGestureEnded( + scaleMaximum: store.setting.maximumScaleFactor, + doubleTapScale: store.setting.doubleTapScaleFactor + ) + } + return ExclusiveGesture(doubleTap, singleTap) + } + var magnificationGesture: some Gesture { + MagnificationGesture() + .onChanged { + gestureHandler.onMagnificationGestureChanged( + value: $0, scaleMaximum: store.setting.maximumScaleFactor + ) + } + .onEnded { + gestureHandler.onMagnificationGestureEnded( + value: $0, scaleMaximum: store.setting.maximumScaleFactor + ) + } + } + var dragGesture: some Gesture { + DragGesture(minimumDistance: .zero, coordinateSpace: .local) + .onChanged(gestureHandler.onDragGestureChanged) + .onEnded(gestureHandler.onDragGestureEnded) + } + var controlPanelDismissGesture: some Gesture { + DragGesture().onEnded { + gestureHandler.onControlPanelDismissGestureEnded( + value: $0, dismissAction: { store.send(.onPerformDismiss) } + ) + } + } +} diff --git a/AppPackage/Sources/ReadingFeature/ReadingView.swift b/AppPackage/Sources/ReadingFeature/ReadingView.swift new file mode 100644 index 000000000..dbdae4de1 --- /dev/null +++ b/AppPackage/Sources/ReadingFeature/ReadingView.swift @@ -0,0 +1,381 @@ +import SwiftUI +import Sharing +import AppModels +import OSLogExt +import Observation +import SFSafeSymbols +import SwiftUIPager +import ComposableArchitecture +import AppTools +import AnimatedImageFeature +import SystemNotificationExt +import AppComponents +import ReadingSettingFeature + +private let logger = Logger(category: .init(describing: ReadingView.self)) + +public struct ReadingView: View { + @Environment(\.colorScheme) private var colorScheme + + @Bindable var store: StoreOf + // Write handle for ControlPanel setting edits. The reading-setting sheet owns its own + // `@Shared(.setting)`; this view derives an `effectiveSetting` that forces vertical mode + // for webtoon galleries without mutating the global setting. + @Shared(.setting) private var setting: Setting + let gid: String + let blurRadius: Double + + @State private var liveTextHandler = LiveTextHandler() + @State private var autoPlayHandler = AutoPlayHandler() + @State var gestureHandler = GestureHandler() + @State private var pageHandler: PageHandler + @StateObject var page: Page + + public init( + store: StoreOf, + gid: String, blurRadius: Double + ) { + self.store = store + self.gid = gid + self.blurRadius = blurRadius + // Seed the pager and slider from the resume page the reducer computed in `State.init`, so the + // reader opens on the saved page. Seeding replaced a `.restoreSession` action that mutated + // `readingProgress` after the view had subscribed; with no post-subscribe change event, the + // pager must be positioned at construction or every session would open at page 1. + let resumePage = max(store.state.readingProgress, 1) + let handler = PageHandler() + handler.sliderValue = Float(resumePage) + let pagerIndex = handler.mapToPager(index: resumePage, setting: store.state.setting) + _pageHandler = State(wrappedValue: handler) + _page = StateObject(wrappedValue: .withIndex(pagerIndex)) + } + + private var backgroundColor: Color { + colorScheme == .light ? Color(.systemGray4) : Color(.systemGray6) + } + + private var displayPreviewURLs: [Int: URL] { + store.localPageURLs.merging(store.previewURLs, uniquingKeysWith: { local, _ in local }) + } + + private var displayImageURLs: [Int: URL] { + store.localPageURLs.merging(store.imageURLs, uniquingKeysWith: { local, _ in local }) + } + + private var displayOriginalImageURLs: [Int: URL] { + if store.contentSource == .remote { + return store.originalImageURLs + } + return store.localPageURLs.merging(store.originalImageURLs, uniquingKeysWith: { local, _ in local }) + } + + private var effectiveSetting: Setting { + var copy = store.setting + if store.gallery.hasWebtoonTag { + copy.readingDirection = .vertical + } + return copy + } + + private var effectiveSettingBinding: Binding { + Binding( + get: { effectiveSetting }, + set: { newValue in + var adjusted = newValue + if store.gallery.hasWebtoonTag { + adjusted.readingDirection = store.setting.readingDirection + } + setting = adjusted + } + ) + } + + public var body: some View { + @Bindable var bindableLiveTextHandler = liveTextHandler + @Bindable var bindablePageHandler = pageHandler + + return changeTriggers(content: { content }) + .sheet( + item: $store.scope(state: \.destination?.readingSetting, action: \.destination.readingSetting) + ) { readingSettingStore in + NavigationStack { + ReadingSettingView(store: readingSettingStore) + .toolbar { + if !DeviceUtil.isPad && DeviceUtil.isLandscape { + CustomToolbarItem(placement: .cancellationAction) { + Button { + store.send(.destination(.dismiss)) + } label: { + Image(systemSymbol: .chevronDown) + } + } + } + } + } + .accentColor(effectiveSetting.accentColor) + .tint(effectiveSetting.accentColor) + .autoBlur(radius: blurRadius) + } + .sheet(item: $store.destination.share, id: \.id) { shareItemBox in + ActivityView(activityItems: [shareItemBox.wrappedValue.associatedValue]) + .accentColor(effectiveSetting.accentColor) + .autoBlur(radius: blurRadius) + } + .toast($store.scope(state: \.toast, action: \.toast)) + + .animation(.linear(duration: 0.1), value: gestureHandler.offset) + .animation(.default, value: liveTextHandler.enablesLiveText) + .animation(.default, value: liveTextHandler.liveTextGroups) + .animation(.default, value: gestureHandler.scale) + .animation(.default, value: store.showsPanel) + .statusBar(hidden: !store.showsPanel) + .onDisappear { + liveTextHandler.cancelRequests() + setAutoPlayPolocy(.off) + } + .onAppear { + store.send(.onAppear(gid)) + } + } + + var content: some View { + @Bindable var bindableLiveTextHandler = liveTextHandler + @Bindable var bindablePageHandler = pageHandler + + return ZStack { + backgroundColor.ignoresSafeArea() + + ZStack { + if effectiveSetting.readingDirection == .vertical { + AdvancedList( + page: page, + data: store.state.containerDataSource( + setting: effectiveSetting, + isLandscape: DeviceUtil.isLandscape + ), + id: \.self, + spacing: effectiveSetting.contentDividerHeight, + gesture: SimultaneousGesture(magnificationGesture, tapGesture), + content: imageStack + ) + .scrollDisabled(gestureHandler.scale != 1) + } else { + Pager( + page: page, + data: store.state.containerDataSource( + setting: effectiveSetting, + isLandscape: DeviceUtil.isLandscape + ), + id: \.self, + content: imageStack + ) + .horizontal(effectiveSetting.readingDirection == .rightToLeft ? .endToStart : .startToEnd) + .swipeInteractionArea(.allAvailable) + .allowsDragging(gestureHandler.scale == 1) + } + } + .scaleEffect(gestureHandler.scale, anchor: gestureHandler.scaleAnchor) + .offset(gestureHandler.offset) + .highPriorityGesture( + dragGesture.simultaneously(with: tapGesture), + isEnabled: gestureHandler.scale > 1 + ) + .gesture(tapGesture, isEnabled: gestureHandler.scale == 1) + .gesture(magnificationGesture) + .ignoresSafeArea() + .id(store.forceRefreshID) + + ControlPanel( + showsPanel: $store.showsPanel, + showsSliderPreview: $store.showsSliderPreview, + sliderValue: $bindablePageHandler.sliderValue, setting: effectiveSettingBinding, + enablesLiveText: $bindableLiveTextHandler.enablesLiveText, + autoPlayPolicy: .init(get: { autoPlayHandler.policy }, set: { setAutoPlayPolocy($0) }), + range: 1...Float(store.gallery.pageCount), + previewURLs: displayPreviewURLs, + dismissGesture: controlPanelDismissGesture, + dismissAction: { store.send(.onPerformDismiss) }, + navigateSettingAction: { store.send(.presentReadingSetting) }, + reloadAllImagesAction: { store.send(.reloadAllWebImages) }, + retryAllFailedImagesAction: { store.send(.retryAllFailedWebImages) }, + fetchPreviewURLsAction: { store.send(.fetchPreviewURLs($0)) } + ) + } + } + + @ViewBuilder + private func changeTriggers(@ViewBuilder content: () -> Content) -> some View { + pageAndAutoPlayTriggers(content: content) + // LiveText + .onChange(of: liveTextHandler.enablesLiveText) { _, newValue in + if newValue { store.webImageLoadSuccessIndices.forEach(analyzeImageForLiveText) } + } + .onChange(of: store.webImageLoadSuccessIndices) { _, newValue in + if liveTextHandler.enablesLiveText { + newValue.forEach(analyzeImageForLiveText) + } + } + // Orientation + .onChange(of: effectiveSetting.enablesLandscape) { _, newValue in + store.send(.setOrientationPortrait(!newValue)) + } + } + + @ViewBuilder + private func pageAndAutoPlayTriggers(@ViewBuilder content: () -> Content) -> some View { + content() + // Page + .onChange(of: page.index) { _, newValue in + let newValue = pageHandler.mapFromPager( + index: newValue, pageCount: store.gallery.pageCount, setting: effectiveSetting + ) + pageHandler.sliderValue = .init(newValue) + store.send(.syncReadingProgress(.init(newValue))) + } + .onChange(of: pageHandler.sliderValue) { _, newValue in + if !store.showsSliderPreview { + setPageIndex(sliderValue: newValue) + } + } + .onChange(of: store.showsSliderPreview) { _, newValue in + if !newValue { + setPageIndex(sliderValue: pageHandler.sliderValue) + } else { + let currentIndex = Int(pageHandler.sliderValue) + store.send(.fetchPreviewURLs(currentIndex)) + } + setAutoPlayPolocy(.off) + } + // AutoPlay + .onChange(of: store.destination != nil) { _, isPresented in + if isPresented { + setAutoPlayPolocy(.off) + } + } + } + + @ViewBuilder private func imageStack(index: Int) -> some View { + let setting = effectiveSetting + let imageStackConfig = store.state.imageContainerConfigs( + index: index, + setting: setting, + isLandscape: DeviceUtil.isLandscape + ) + let isDualPage = setting.enablesDualPageMode + && setting.readingDirection != .vertical && DeviceUtil.isLandscape + let dataSource = store.state.containerDataSource(setting: setting, isLandscape: DeviceUtil.isLandscape) + let activeStackIndex = dataSource.indices.contains(page.index) ? dataSource[page.index] : nil + HorizontalImageStack( + index: index, + isDualPage: isDualPage, + isActive: index == activeStackIndex, + backgroundColor: backgroundColor, + config: imageStackConfig, + imageURLs: displayImageURLs, + originalImageURLs: displayOriginalImageURLs, + loadingStates: store.imageURLLoadingStates, + enablesLiveText: liveTextHandler.enablesLiveText, + liveTextGroups: liveTextHandler.liveTextGroups, + focusedLiveTextGroup: liveTextHandler.focusedLiveTextGroup, + liveTextTapAction: liveTextHandler.setFocusedLiveTextGroup, + fetchAction: { store.send(.fetchImageURLs($0)) }, + refetchAction: { store.send(.refetchImageURLs($0)) }, + prefetchAction: { store.send(.prefetchImages($0, effectiveSetting.prefetchLimit)) }, + loadRetryAction: { store.send(.onWebImageRetry($0)) }, + loadSucceededAction: { store.send(.onWebImageSucceeded($0)) }, + loadFailedAction: { store.send(.onWebImageFailed($0)) }, + copyImageAction: { store.send(.copyImage($0)) }, + saveImageAction: { store.send(.saveImage($0)) }, + shareImageAction: { store.send(.shareImage($0)) } + ) + } +} + +// MARK: Handler methods +extension ReadingView { + func setPageIndex(sliderValue: Float) { + let newValue = pageHandler.mapToPager( + index: .init(sliderValue), setting: effectiveSetting + ) + if page.index != newValue { + page.update(.new(index: newValue)) + } + } + func setAutoPlayPolocy(_ policy: AutoPlayPolicy) { + autoPlayHandler.setPolicy(policy, updatePageAction: { + page.update(.next) + }) + } + func analyzeImageForLiveText(index: Int) { + guard liveTextHandler.liveTextGroups[index] == nil else { + return + } + guard let imageURL = displayImageURLs[index] else { + logger.debug("analyzeImageForLiveText URL not found, index: \(index, privacy: .public)") + return + } + if imageURL.isFileURL { + analyzeLocalImage(at: imageURL, index: index) + return + } + Task { + await analyzeCachedImageData( + cacheKeys: imageURL.imageCacheKeys, + index: index + ) + } + } + + /// Runs Live Text over a downloaded page file. Animated images are skipped by design + /// (Live Text scans still images only), so a single non-animating frame is never lifted + /// out of an animation. + private func analyzeLocalImage(at imageURL: URL, index: Int) { + guard let data = try? Data(contentsOf: imageURL), + !data.isAnimatedImageData, + let image = data.decodedImage, + let cgImage = image.cgImage + else { + logger.debug("analyzeImageForLiveText local image not found, index: \(index, privacy: .public)") + return + } + + liveTextHandler.analyzeImage( + cgImage, size: image.size, index: index, recognitionLanguages: + store.language?.codes + ) + } + + /// Runs Live Text over a remote page's cached bytes, read from the owned `DataCache` + /// (the reader's cache, not Kingfisher's). Animated images are skipped by design + /// (Live Text scans still images only). + private func analyzeCachedImageData(cacheKeys: [String], index: Int) async { + guard let data = await DataCache.shared.data(forKeys: cacheKeys), + !data.isAnimatedImageData, + let image = data.decodedImage, + let cgImage = image.cgImage + else { + logger.debug("analyzeImageForLiveText image not found, index: \(index, privacy: .public)") + return + } + + liveTextHandler.analyzeImage( + cgImage, size: image.size, index: index, recognitionLanguages: + store.language?.codes + ) + } +} + +struct ReadingView_Previews: PreviewProvider { + static var previews: some View { + NavigationStack { + Color.clear + .fullScreenCover(isPresented: .constant(true)) { + ReadingView( + store: .init(initialState: .init(gallery: .preview), reducer: ReadingReducer.init), + gid: .init(), + blurRadius: 0 + ) + } + } + } +} diff --git a/AppPackage/Sources/ReadingFeature/ReadingViewComponents.swift b/AppPackage/Sources/ReadingFeature/ReadingViewComponents.swift new file mode 100644 index 000000000..867532fe1 --- /dev/null +++ b/AppPackage/Sources/ReadingFeature/ReadingViewComponents.swift @@ -0,0 +1,349 @@ +import SwiftUI +import AppModels +import Resources +import Kingfisher +import SDWebImage +import SDWebImageSwiftUI +import ComposableArchitecture +import AppTools +import ImageClient +import AppComponents +import SFSafeSymbols +import SFSafeSymbolsExt + +// MARK: ImageStackConfig +struct ImageStackConfig { + let firstIndex: Int + let secondIndex: Int + let isFirstAvailable: Bool + let isSecondAvailable: Bool +} + +// MARK: AutoPlayPolicy +enum AutoPlayPolicy: Int, CaseIterable, Identifiable { + var id: Int { rawValue } + + case off = -1 + case sec1 = 1 + case sec2 = 2 + case sec3 = 3 + case sec4 = 4 + case sec5 = 5 +} + +extension AutoPlayPolicy { + var value: LocalizedStringResource { + switch self { + case .off: + return .autoPlayPolicyOff + default: + return .RLocalizable.seconds(count: rawValue) + } + } +} + +// MARK: HorizontalImageStack +struct HorizontalImageStack: View { + private let index: Int + private let isDualPage: Bool + private let isActive: Bool + private let backgroundColor: Color + private let config: ImageStackConfig + private let imageURLs: [Int: URL] + private let originalImageURLs: [Int: URL] + private let loadingStates: [Int: LoadingState] + private let enablesLiveText: Bool + private let liveTextGroups: [Int: [LiveTextGroup]] + private let focusedLiveTextGroup: LiveTextGroup? + private let liveTextTapAction: (LiveTextGroup) -> Void + private let fetchAction: (Int) -> Void + private let refetchAction: (Int) -> Void + private let prefetchAction: (Int) -> Void + private let loadRetryAction: (Int) -> Void + private let loadSucceededAction: (Int) -> Void + private let loadFailedAction: (Int) -> Void + private let copyImageAction: (URL) -> Void + private let saveImageAction: (URL) -> Void + private let shareImageAction: (URL) -> Void + + init( + index: Int, isDualPage: Bool, isActive: Bool, backgroundColor: Color, + config: ImageStackConfig, imageURLs: [Int: URL], originalImageURLs: [Int: URL], + loadingStates: [Int: LoadingState], enablesLiveText: Bool, + liveTextGroups: [Int: [LiveTextGroup]], focusedLiveTextGroup: LiveTextGroup?, + liveTextTapAction: @escaping (LiveTextGroup) -> Void, + fetchAction: @escaping (Int) -> Void, + refetchAction: @escaping (Int) -> Void, prefetchAction: @escaping (Int) -> Void, + loadRetryAction: @escaping (Int) -> Void, loadSucceededAction: @escaping (Int) -> Void, + loadFailedAction: @escaping (Int) -> Void, copyImageAction: @escaping (URL) -> Void, + saveImageAction: @escaping (URL) -> Void, shareImageAction: @escaping (URL) -> Void + ) { + self.index = index + self.isDualPage = isDualPage + self.isActive = isActive + self.backgroundColor = backgroundColor + self.config = config + self.imageURLs = imageURLs + self.originalImageURLs = originalImageURLs + self.loadingStates = loadingStates + self.enablesLiveText = enablesLiveText + self.liveTextGroups = liveTextGroups + self.focusedLiveTextGroup = focusedLiveTextGroup + self.liveTextTapAction = liveTextTapAction + self.fetchAction = fetchAction + self.refetchAction = refetchAction + self.prefetchAction = prefetchAction + self.loadRetryAction = loadRetryAction + self.loadSucceededAction = loadSucceededAction + self.loadFailedAction = loadFailedAction + self.copyImageAction = copyImageAction + self.saveImageAction = saveImageAction + self.shareImageAction = shareImageAction + } + + var body: some View { + HStack(spacing: 0) { + if config.isFirstAvailable { + imageContainer(index: config.firstIndex) + } + if config.isSecondAvailable { + imageContainer(index: config.secondIndex) + } + } + } + + func imageContainer(index: Int) -> some View { + ImageContainer( + index: index, + imageURL: imageURLs[index], + loadingState: loadingStates[index] ?? .idle, + isDualPage: isDualPage, + isActive: isActive, + backgroundColor: backgroundColor, + enablesLiveText: enablesLiveText, + liveTextGroups: liveTextGroups[index] ?? [], + focusedLiveTextGroup: focusedLiveTextGroup, + liveTextTapAction: liveTextTapAction, + refetchAction: refetchAction, + loadRetryAction: loadRetryAction, + loadSucceededAction: loadSucceededAction, + loadFailedAction: loadFailedAction + ) + .onAppear { + if imageURLs[index] == nil { + fetchAction(index) + } + prefetchAction(index) + } + .contextMenu { contextMenuItems(index: index) } + } + @ViewBuilder private func contextMenuItems(index: Int) -> some View { + Button { + refetchAction(index) + } label: { + Label(.reload, systemSymbol: .arrowCounterclockwise) + } + if let imageURL = imageURLs[index] { + Button { + copyImageAction(imageURL) + } label: { + Label(.copy, systemSymbol: .plusSquareOnSquare) + } + Button { + saveImageAction(imageURL) + } label: { + Label(.save, systemSymbol: .squareAndArrowDown) + } + if let originalImageURL = originalImageURLs[index] { + Button { + saveImageAction(originalImageURL) + } label: { + Label( + .saveOriginal, + systemSymbol: .squareAndArrowDownOnSquare + ) + } + } + Button { + shareImageAction(imageURL) + } label: { + Label(.RLocalizable.share, systemSymbol: .squareAndArrowUp) + } + } + } +} + +// MARK: ImageContainer +struct ImageContainer: View { + private var width: CGFloat { + DeviceUtil.windowW / (isDualPage ? 2 : 1) + } + private var height: CGFloat { + width / Defaults.ImageSize.contentAspect + } + + private let index: Int + private let imageURL: URL? + private let loadingState: LoadingState + private let isDualPage: Bool + private let isActive: Bool + private let backgroundColor: Color + private let enablesLiveText: Bool + private let liveTextGroups: [LiveTextGroup] + private let focusedLiveTextGroup: LiveTextGroup? + private let liveTextTapAction: (LiveTextGroup) -> Void + private let refetchAction: (Int) -> Void + private let loadRetryAction: (Int) -> Void + private let loadSucceededAction: (Int) -> Void + private let loadFailedAction: (Int) -> Void + + init( + index: Int, imageURL: URL?, + loadingState: LoadingState, + isDualPage: Bool, + isActive: Bool, + backgroundColor: Color, + enablesLiveText: Bool, + liveTextGroups: [LiveTextGroup], + focusedLiveTextGroup: LiveTextGroup?, + liveTextTapAction: @escaping (LiveTextGroup) -> Void, + refetchAction: @escaping (Int) -> Void, + loadRetryAction: @escaping (Int) -> Void, + loadSucceededAction: @escaping (Int) -> Void, + loadFailedAction: @escaping (Int) -> Void + ) { + self.index = index + self.imageURL = imageURL + self.loadingState = loadingState + self.isDualPage = isDualPage + self.isActive = isActive + self.backgroundColor = backgroundColor + self.enablesLiveText = enablesLiveText + self.liveTextGroups = liveTextGroups + self.focusedLiveTextGroup = focusedLiveTextGroup + self.liveTextTapAction = liveTextTapAction + self.refetchAction = refetchAction + self.loadRetryAction = loadRetryAction + self.loadSucceededAction = loadSucceededAction + self.loadFailedAction = loadFailedAction + } + + private func placeholder(_ progress: Progress?) -> some View { + Placeholder( + style: .progress( + pageNumber: index, + progress: progress, + isDualPage: isDualPage, + backgroundColor: backgroundColor + ) + ) + .frame(width: width, height: height) + } + @ViewBuilder private func image(url: URL?) -> some View { + ByteRoutedReaderImage( + url: url, + isActive: isActive, + placeholder: { progress in placeholder(progress) }, + onSucceeded: { loadSucceededAction(index) }, + onFailed: { loadFailedAction(index) } + ) + } + + var body: some View { + if loadingState == .idle { + image(url: imageURL).scaledToFit().overlay( + LiveTextView( + liveTextGroups: liveTextGroups, + focusedLiveTextGroup: focusedLiveTextGroup, + tapAction: liveTextTapAction + ) + .opacity(enablesLiveText ? 1 : 0) + ) + } else { + ZStack { + backgroundColor + VStack { + Text(String(index)).font(.largeTitle.bold()) + .foregroundColor(.gray).padding(.bottom, 30) + ZStack { + Button(action: reloadImage) { + Image(systemSymbol: .exclamationmarkArrowTrianglehead2ClockwiseRotate90) + } + .font(.system(size: 30, weight: .medium)).foregroundColor(.gray) + .opacity(loadingState == .loading ? 0 : 1) + ProgressView().opacity(loadingState == .loading ? 1 : 0) + } + } + } + .frame(width: width, height: height) + } + } + private func reloadImage() { + if let error = loadingState.failed { + if case .webImageFailed = error { + loadRetryAction(index) + } else { + refetchAction(index) + } + } + } +} + +// Renders a reader page from bytes loaded through the owned ImageClient fetch +// (DataCache → cookied URLSession), routing animated bytes to SDWebImage and +// still bytes to UIImage so the engine decides by content, not URL extension. +private struct ByteRoutedReaderImage: View { + private static var progressUnitCount: Int64 { 10_000 } + + let url: URL? + let isActive: Bool + @ViewBuilder let placeholder: (Progress?) -> Placeholder + let onSucceeded: () -> Void + let onFailed: () -> Void + + @Dependency(\.imageClient) private var imageClient + @State private var stillImage: UIImage? + @State private var animatedData: Data? + @State private var progress: Progress? + + var body: some View { + content.task(id: url) { await load() } + } + + @ViewBuilder private var content: some View { + if let animatedData { + AnimatedImage(data: animatedData, isAnimating: .constant(isActive)) + .resizable() + } else if let stillImage { + Image(uiImage: stillImage).resizable() + } else { + placeholder(progress) + } + } + + @MainActor private func load() async { + stillImage = nil + animatedData = nil + progress = nil + guard let url else { return } + let downloadProgress = Progress(totalUnitCount: Self.progressUnitCount) + progress = downloadProgress + let asset = await imageClient.fetchReaderImageAsset(url: url) { fraction in + downloadProgress.completedUnitCount = Int64(fraction * Double(Self.progressUnitCount)) + } + progress = nil + // A cancelled `.task(id:)` (scrolled off screen, URL changed) surfaces as a + // nil asset; it is not a load failure, so report neither success nor failure. + guard !Task.isCancelled else { return } + guard let asset else { + onFailed() + return + } + if asset.isAnimated { + animatedData = asset.data + } else { + stillImage = asset.image + } + onSucceeded() + } +} diff --git a/AppPackage/Sources/ReadingFeature/Resources/Localizable.xcstrings b/AppPackage/Sources/ReadingFeature/Resources/Localizable.xcstrings new file mode 100644 index 000000000..0bae21daa --- /dev/null +++ b/AppPackage/Sources/ReadingFeature/Resources/Localizable.xcstrings @@ -0,0 +1,457 @@ +{ + "sourceLanguage": "en", + "strings": { + "auto_play": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Auto-Play" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Autoplay" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "自動再生" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "자동 재생" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "自动播放" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "自動播放" + } + } + } + }, + "auto_play_policy.off": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Off" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Aus" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "オフ" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "끔" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "不启用" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "關閉" + } + } + } + }, + "copy": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Copy" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Kopieren" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "コピー" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "복사" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "复制" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "複製" + } + } + } + }, + "dual_page_mode": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Dual-Page mode" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Doppelseitenmodus" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "デュアルページモード" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "두 장을 한 화면으로 보기" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "双页模式" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "雙頁模式" + } + } + } + }, + "except_the_cover": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Except the cover" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Außer dem Cover" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "カバーを除く" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "표지 제외하기" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "封面除外" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "封面除外" + } + } + } + }, + "reading_setting": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Reading setting" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Leseeinstellungen" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "閲覧設定" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "읽기 설정" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "阅读设置" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "閱讀設定" + } + } + } + }, + "reload": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Reload" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Neu laden" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "再読み込み" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "재시도" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "重新加载" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "重新整理" + } + } + } + }, + "reload_all_images": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Reload all images" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Alle Bilder neu laden" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "画像をすべて再読み込み" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "모든 이미지 다시 불러오기" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "重新加载所有图片" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "重新載入所有圖片" + } + } + } + }, + "retry_all_failed_images": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Retry all failed images" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Fehlgeschlagene Bilder erneut laden" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "読み込み失敗した画像をすべてリトライ" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "실패한 이미지 모두 재시도" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "重试所有读取失败图片" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "重新載入失敗圖片" + } + } + } + }, + "save": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Save" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Sichern" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "保存" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "저장" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "保存" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "儲存圖片" + } + } + } + }, + "save_original": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Save original" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Original sichern" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "オリジナルを保存" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "원본 저장" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "保存原图" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "儲存原始圖片" + } + } + } + } + }, + "version": "1.0" +} \ No newline at end of file diff --git a/EhPanda/View/Reading/Support/AdvancedList.swift b/AppPackage/Sources/ReadingFeature/Support/AdvancedList.swift similarity index 98% rename from EhPanda/View/Reading/Support/AdvancedList.swift rename to AppPackage/Sources/ReadingFeature/Support/AdvancedList.swift index 22eb2bcf4..7e83eb249 100644 --- a/EhPanda/View/Reading/Support/AdvancedList.swift +++ b/AppPackage/Sources/ReadingFeature/Support/AdvancedList.swift @@ -1,8 +1,3 @@ -// -// AdvancedList.swift -// EhPanda -// - import SwiftUI import SwiftUIPager diff --git a/EhPanda/View/Reading/Support/AutoPlayHandler.swift b/AppPackage/Sources/ReadingFeature/Support/AutoPlayHandler.swift similarity index 56% rename from EhPanda/View/Reading/Support/AutoPlayHandler.swift rename to AppPackage/Sources/ReadingFeature/Support/AutoPlayHandler.swift index 660f96d9f..b9c48b0d3 100644 --- a/EhPanda/View/Reading/Support/AutoPlayHandler.swift +++ b/AppPackage/Sources/ReadingFeature/Support/AutoPlayHandler.swift @@ -1,32 +1,34 @@ -// -// AutoPlayHandler.swift -// EhPanda -// - import SwiftUI +import AppModels +import Observation -final class AutoPlayHandler: ObservableObject { - @Published var policy: AutoPlayPolicy = .off +@Observable +@MainActor +final class AutoPlayHandler { + var policy: AutoPlayPolicy = .off + @ObservationIgnored private var timer: Timer? - deinit { + isolated deinit { invalidate() } func invalidate() { - Logger.info("invalidate") timer?.invalidate() } - func setPolicy(_ policy: AutoPlayPolicy, updatePageAction: @escaping () -> Void) { - Logger.info("setPolicy", context: ["policy": policy]) + func setPolicy(_ policy: AutoPlayPolicy, updatePageAction: @MainActor @escaping () -> Void) { self.policy = policy timer?.invalidate() let timeInterval = TimeInterval(policy.rawValue) if timeInterval > 0 { timer = .scheduledTimer( withTimeInterval: timeInterval, repeats: true, - block: { _ in updatePageAction() } + block: { _ in + Task { @MainActor in + updatePageAction() + } + } ) } } diff --git a/EhPanda/View/Reading/Support/ControlPanel.swift b/AppPackage/Sources/ReadingFeature/Support/ControlPanel.swift similarity index 90% rename from EhPanda/View/Reading/Support/ControlPanel.swift rename to AppPackage/Sources/ReadingFeature/Support/ControlPanel.swift index f0fcdf0be..fb89376d9 100644 --- a/EhPanda/View/Reading/Support/ControlPanel.swift +++ b/AppPackage/Sources/ReadingFeature/Support/ControlPanel.swift @@ -1,10 +1,9 @@ -// -// ControlPanel.swift -// EhPanda -// - import SwiftUI -import Kingfisher +import AppModels +import Resources +import AppTools +import AppComponents +import SFSafeSymbols // MARK: ControlPanel struct ControlPanel: View { @@ -152,7 +151,7 @@ private struct UpperPanel: View { Button { setting.enablesDualPageMode.toggle() } label: { - Text(L10n.Localizable.ReadingView.ToolbarItem.Title.dualPageMode) + Text(.dualPageMode) if setting.enablesDualPageMode { Image(systemSymbol: .checkmark) } @@ -160,7 +159,7 @@ private struct UpperPanel: View { Button { setting.exceptCover.toggle() } label: { - Text(L10n.Localizable.ReadingView.ToolbarItem.Title.exceptTheCover) + Text(.exceptTheCover) if setting.exceptCover { Image(systemSymbol: .checkmark) } @@ -174,7 +173,7 @@ private struct UpperPanel: View { } Menu { - Text(L10n.Localizable.ReadingView.ToolbarItem.Title.autoPlay).foregroundColor(.secondary) + Text(.autoPlay).foregroundColor(.secondary) ForEach(AutoPlayPolicy.allCases) { policy in Button { autoPlayPolicy = policy @@ -193,16 +192,16 @@ private struct UpperPanel: View { ToolbarFeaturesMenu { Button(action: retryAllFailedImagesAction) { - Image(systemSymbol: .exclamationmarkArrowTriangle2Circlepath) - Text(L10n.Localizable.ReadingView.ToolbarItem.Button.retryAllFailedImages) + Image(systemSymbol: .exclamationmarkArrowTrianglehead2ClockwiseRotate90) + Text(.retryAllFailedImages) } Button(action: reloadAllImagesAction) { Image(systemSymbol: .arrowCounterclockwise) - Text(L10n.Localizable.ReadingView.ToolbarItem.Button.reloadAllImages) + Text(.reloadAllImages) } Button(action: navigateSettingAction) { Image(systemSymbol: .gear) - Text(L10n.Localizable.ReadingView.ToolbarItem.Button.readingSetting) + Text(.readingSetting) } } .buttonStyle(.borderless) @@ -267,7 +266,7 @@ private struct LowerPanel: View { ) HStack { - Text(isReversed ? "\(Int(range.upperBound))" : "\(Int(range.lowerBound))") + Text(isReversed ? Int(range.upperBound) : Int(range.lowerBound), format: .number) .fontWeight(.medium) .font(.caption) .padding() @@ -284,7 +283,7 @@ private struct LowerPanel: View { .onChanged({ if $0 { showsSliderPreview = true } }) ) - Text(isReversed ? "\(Int(range.lowerBound))" : "\(Int(range.upperBound))") + Text(isReversed ? Int(range.lowerBound) : Int(range.upperBound), format: .number) .fontWeight(.medium) .font(.caption) .padding() @@ -326,17 +325,11 @@ private struct SliderPreivew: View { var body: some View { HStack(spacing: previewSpacing) { ForEach(previewsIndices, id: \.self) { index in - let (url, modifier) = PreviewResolver.getPreviewConfigs(originalURL: previewURLs[index]) VStack { - KFImage.url(url, cacheKey: previewURLs[index]?.absoluteString) - .placeholder({ Placeholder(style: .activity(ratio: Defaults.ImageSize.previewAspect)) }) - .fade(duration: 0.25) - .imageModifier(modifier) - .resizable() - .scaledToFit() + PreviewImageView(originalURL: previewURLs[index]) .frame(width: previewWidth, height: showsSliderPreview ? previewHeight : 0) - Text("\(index)") + Text(index, format: .number) .font(DeviceUtil.isPadWidth ? .callout : .caption) .foregroundColor(index == Int(sliderValue) ? .accentColor : .secondary) } @@ -348,6 +341,22 @@ private struct SliderPreivew: View { .opacity(checkIndex(index) ? 1 : 0) } } + .onChange(of: showsSliderPreview) { _, newValue in + if newValue { + for index in previewsIndices { + if previewURLs[index] == nil && checkIndex(index) { + fetchPreviewURLsAction(index) + } + } + } + } + .onChange(of: sliderValue) { _, _ in + for index in previewsIndices { + if previewURLs[index] == nil && checkIndex(index) { + fetchPreviewURLsAction(index) + } + } + } .opacity(showsSliderPreview ? 1 : 0) .padding(.vertical, verticalPadding) .padding(.horizontal, horizontalPadding) @@ -364,12 +373,10 @@ private extension SliderPreivew { DeviceUtil.isPadWidth ? DeviceUtil.isLandscape ? 7 : 5 : 3 } var previewsIndices: [Int] { - guard !previewURLs.isEmpty else { return [] } let currentIndex = Int(sliderValue) let distance = (previewsCount - 1) / 2 - let lowerBound = currentIndex - distance - let upperBound = currentIndex + distance - + let lowerBound = max(Int(range.lowerBound), currentIndex - distance) + let upperBound = min(Int(range.upperBound), currentIndex + distance) let indices = Array(lowerBound...upperBound) return isReversed ? indices.reversed() : indices } diff --git a/EhPanda/View/Reading/Support/GestureHandler.swift b/AppPackage/Sources/ReadingFeature/Support/GestureHandler.swift similarity index 66% rename from EhPanda/View/Reading/Support/GestureHandler.swift rename to AppPackage/Sources/ReadingFeature/Support/GestureHandler.swift index c3b88c58e..13bf7a6c4 100644 --- a/EhPanda/View/Reading/Support/GestureHandler.swift +++ b/AppPackage/Sources/ReadingFeature/Support/GestureHandler.swift @@ -1,37 +1,39 @@ -// -// GestureHandler.swift -// EhPanda -// - import SwiftUI +import AppModels +import Observation +import AppTools -final class GestureHandler: ObservableObject { - @Published var scaleAnchor: UnitPoint = .center - @Published var scale: Double = 1 - @Published var offset: CGSize = .zero - @Published private var baseScale: Double = 1 - @Published private var newOffset: CGSize = .zero +@Observable +@MainActor +final class GestureHandler { + var scaleAnchor: UnitPoint = .center + var scale: Double = 1 + var offset: CGSize = .zero + @ObservationIgnored + private var baseScale: Double = 1 + @ObservationIgnored + private var newOffset: CGSize = .zero - private func edgeWidth(x: Double) -> Double { + private func edgeWidth(xAxis: Double) -> Double { let marginW = DeviceUtil.absWindowW * (scale - 1) / 2 let leadingMargin = scaleAnchor.x / 0.5 * marginW let trailingMargin = (1 - scaleAnchor.x) / 0.5 * marginW - return min(max(x, -trailingMargin), leadingMargin) + return min(max(xAxis, -trailingMargin), leadingMargin) } - private func edgeHeight(y: Double) -> Double { + private func edgeHeight(yAxis: Double) -> Double { let marginH = DeviceUtil.absWindowH * (scale - 1) / 2 let topMargin = scaleAnchor.y / 0.5 * marginH let bottomMargin = (1 - scaleAnchor.y) / 0.5 * marginH - return min(max(y, -bottomMargin), topMargin) + return min(max(yAxis, -bottomMargin), topMargin) } private func correctOffset() { - offset.width = edgeWidth(x: offset.width) - offset.height = edgeHeight(y: offset.height) + offset.width = edgeWidth(xAxis: offset.width) + offset.height = edgeHeight(yAxis: offset.height) } private func correctScaleAnchor(point: CGPoint) { - let x = min(1, max(0, point.x / DeviceUtil.absWindowW)) - let y = min(1, max(0, point.y / DeviceUtil.absWindowH)) - scaleAnchor = .init(x: x, y: y) + let xAxis = min(1, max(0, point.x / DeviceUtil.absWindowW)) + let yAxis = min(1, max(0, point.y / DeviceUtil.absWindowH)) + scaleAnchor = .init(x: xAxis, y: yAxis) } private func setOffset(_ offset: CGSize) { self.offset = offset @@ -48,7 +50,6 @@ final class GestureHandler: ObservableObject { setPageIndexOffsetAction: @escaping (Int) -> Void, toggleShowsPanelAction: @escaping () -> Void ) { - Logger.info("onSingleTapGestureEnded", context: ["readingDirection": readingDirection]) guard readingDirection != .vertical, let pointX = TouchHandler.shared.currentPoint?.x else { @@ -66,9 +67,6 @@ final class GestureHandler: ObservableObject { } func onDoubleTapGestureEnded(scaleMaximum: Double, doubleTapScale: Double) { - Logger.info("onDoubleTapGestureEnded", context: [ - "scaleMaximum": scaleMaximum, "doubleTapScale": doubleTapScale - ]) let newScale = scale == 1 ? doubleTapScale : 1 if let point = TouchHandler.shared.currentPoint { correctScaleAnchor(point: point) @@ -78,9 +76,6 @@ final class GestureHandler: ObservableObject { } func onMagnificationGestureChanged(value: Double, scaleMaximum: Double) { - Logger.info("onMagnificationGestureChanged", context: [ - "value": value, "scaleMaximum": scaleMaximum - ]) if value == 1 { baseScale = scale } @@ -91,9 +86,6 @@ final class GestureHandler: ObservableObject { } func onMagnificationGestureEnded(value: Double, scaleMaximum: Double) { - Logger.info("onMagnificationGestureEnded", context: [ - "value": value, "scaleMaximum": scaleMaximum - ]) onMagnificationGestureChanged(value: value, scaleMaximum: scaleMaximum) if value * baseScale - 1 < 0.01 { setScale(scale: 1, maximum: scaleMaximum) @@ -102,17 +94,15 @@ final class GestureHandler: ObservableObject { } func onDragGestureChanged(value: DragGesture.Value) { - Logger.info("onDragGestureChanged", context: ["value": value]) guard scale > 1 else { return } let newX = value.translation.width + newOffset.width let newY = value.translation.height + newOffset.height - let newOffsetW = edgeWidth(x: newX) - let newOffsetH = edgeHeight(y: newY) + let newOffsetW = edgeWidth(xAxis: newX) + let newOffsetH = edgeHeight(yAxis: newY) setOffset(.init(width: newOffsetW, height: newOffsetH)) } func onDragGestureEnded(value: DragGesture.Value) { - Logger.info("onDragGestureEnded", context: ["value": value]) onDragGestureChanged(value: value) if scale > 1 { newOffset.width = offset.width @@ -121,7 +111,6 @@ final class GestureHandler: ObservableObject { } func onControlPanelDismissGestureEnded(value: DragGesture.Value, dismissAction: @escaping () -> Void) { - Logger.info("onControlPanelDismissGestureEnded", context: ["value": value]) if value.predictedEndTranslation.height > 30 { dismissAction() } diff --git a/AppPackage/Sources/ReadingFeature/Support/LiveTextHandler.swift b/AppPackage/Sources/ReadingFeature/Support/LiveTextHandler.swift new file mode 100644 index 000000000..d67e8ce74 --- /dev/null +++ b/AppPackage/Sources/ReadingFeature/Support/LiveTextHandler.swift @@ -0,0 +1,181 @@ +// reason: the reference URLs below exceed the line-length limit and cannot wrap +// swiftlint:disable line_length +// Reference +// https://www.codeproject.com/Articles/15573/2D-Polygon-Collision-Detection +// https://developer.apple.com/documentation/vision/recognizing_text_in_images +// https://github.com/TelegramMessenger/Telegram-iOS/blob/2a32c871882c4e1b1ccdecd34fccd301723b30d9/submodules/Translate/Sources/Translate.swift +// https://github.com/TelegramMessenger/Telegram-iOS/blob/0be460b147321b7455247aedca81ca819702959d/submodules/ImageContentAnalysis/Sources/ImageContentAnalysis.swift +// swiftlint:enable line_length +// + +import Vision +import AppModels +import OSLogExt +import SwiftUI +import Foundation +import Observation + +private let logger = Logger(category: .init(describing: LiveTextHandler.self)) + +@Observable +@MainActor +final class LiveTextHandler { + var enablesLiveText = false + var liveTextGroups = [Int: [LiveTextGroup]]() + private(set) var focusedLiveTextGroup: LiveTextGroup? + + @ObservationIgnored + private var analysisTasks = [Int: Task]() + + isolated deinit { + cancelRequests() + } + + func cancelRequests() { + analysisTasks.values.forEach { task in + task.cancel() + } + analysisTasks.removeAll() + } + + func setFocusedLiveTextGroup(_ group: LiveTextGroup) { + focusedLiveTextGroup = group + } + + func analyzeImage(_ cgImage: CGImage, size: CGSize, index: Int, recognitionLanguages: [String]?) { + analysisTasks[index]?.cancel() + analysisTasks[index] = Task { [weak self] in + do { + let groups = try await Self.recognizeTextGroups( + in: cgImage, + size: size, + recognitionLanguages: recognitionLanguages + ) + guard !Task.isCancelled else { return } + self?.liveTextGroups[index] = groups + } catch is CancellationError { + } catch { + logger.error("Live Text failed, index \(index, privacy: .public): \(error, privacy: .public)") + } + } + } + + @concurrent + private static func recognizeTextGroups( + in cgImage: CGImage, + size: CGSize, + recognitionLanguages: [String]? + ) async throws -> [LiveTextGroup] { + var request = RecognizeTextRequest() + request.usesLanguageCorrection = true + if let recognitionLanguages { + request.recognitionLanguages = recognitionLanguages.map { + Locale.Language(identifier: $0) + } + } + + let observations = try await request.perform(on: cgImage) + try Task.checkCancellation() + + let blocks: [LiveTextBlock] = observations.compactMap { observation in + guard let recognizedText = observation.topCandidates(1).first?.string else { return nil } + return .init( + text: recognizedText, + bounds: .init( + topLeft: observation.topLeft.cgPoint.verticalReversed, + topRight: observation.topRight.cgPoint.verticalReversed, + bottomLeft: observation.bottomLeft.cgPoint.verticalReversed, + bottomRight: observation.bottomRight.cgPoint.verticalReversed + ) + ) + } + + var groupData = [[LiveTextBlock]]() + for newItem in blocks { + try Task.checkCancellation() + + if let groupIndex = groupData.firstIndex(where: { items in + items.first { item in + let angle = abs(item.bounds.getAngle(size) - newItem.bounds.getAngle(size)) + .truncatingRemainder(dividingBy: 360.0) + let isAngleValid = angle < 5 || angle > (360 - 5) + let aHeight = item.bounds.getHeight(size) + let bHeight = newItem.bounds.getHeight(size) + let isHeightValid = abs(aHeight - bHeight) < (min(aHeight, bHeight) / 2) + + guard isAngleValid && isHeightValid else { return false } + return polygonsIntersecting( + lhs: item.bounds.expandingHalfHeight(size).edges, + rhs: newItem.bounds.expandingHalfHeight(size).edges + ) + } != nil + }) { + groupData[groupIndex].append(newItem) + } else { + groupData.append([newItem]) + } + } + + return groupData.compactMap(LiveTextGroup.init) + } + + nonisolated private static func polygonsIntersecting(lhs: [CGPoint], rhs: [CGPoint]) -> Bool { + guard !lhs.isEmpty, !rhs.isEmpty, lhs.count == rhs.count else { return false } + for points in [lhs, rhs] { + for index1 in 0.. Int { guard isLandscape && setting.enablesDualPageMode diff --git a/AppPackage/Sources/ReadingSettingFeature/.swiftlint.yml b/AppPackage/Sources/ReadingSettingFeature/.swiftlint.yml new file mode 100644 index 000000000..1242ffcaa --- /dev/null +++ b/AppPackage/Sources/ReadingSettingFeature/.swiftlint.yml @@ -0,0 +1 @@ +parent_config: ../../../.swiftlint.yml diff --git a/AppPackage/Sources/ReadingSettingFeature/ReadingSettingReducer.swift b/AppPackage/Sources/ReadingSettingFeature/ReadingSettingReducer.swift new file mode 100644 index 000000000..418ac50c0 --- /dev/null +++ b/AppPackage/Sources/ReadingSettingFeature/ReadingSettingReducer.swift @@ -0,0 +1,27 @@ +import Sharing +import AppModels +import ComposableArchitecture + +// A state-only reducer for the reading-setting editor. It carries `@Shared(.setting)` and vends the +// shared projection (`sharedSetting`) so `ReadingSettingView` binds through its own store instead of +// holding a `@Shared` itself. It runs no logic: every field write goes straight through the shared +// value, and the `Setting` model's clamps keep those writes consistent. Any orientation side effect +// stays with the host — the reader drives it from `ReadingReducer`; the Setting tab has none. +@Reducer +public struct ReadingSettingReducer: Sendable { + @ObservableState + public struct State: Equatable, Sendable { + @Shared(.setting) public var setting: Setting + public var sharedSetting: Shared { $setting } + + public init() {} + } + + public enum Action: Equatable, Sendable {} + + public init() {} + + public var body: some Reducer { + EmptyReducer() + } +} diff --git a/AppPackage/Sources/ReadingSettingFeature/ReadingSettingView.swift b/AppPackage/Sources/ReadingSettingFeature/ReadingSettingView.swift new file mode 100644 index 000000000..7199831da --- /dev/null +++ b/AppPackage/Sources/ReadingSettingFeature/ReadingSettingView.swift @@ -0,0 +1,119 @@ +import SwiftUI +import AppModels +import Sharing +import Resources +import AppTools +import ComposableArchitecture + +public struct ReadingSettingView: View { + // The reading-setting editor is shared by the Setting tab and the reader sheet. Rather than hold + // its own `@Shared`, it binds through its store, whose state vends the shared `Setting`; the model + // clamps keep every write safe. Any orientation side effect stays with the host (the reader drives + // it from `ReadingReducer`; the Setting tab has none), so this view carries no such logic. + private let store: StoreOf + + public init(store: StoreOf) { + self.store = store + } + + public var body: some View { + Form { + Section { + Picker(.direction, selection: Binding(store.sharedSetting.readingDirection)) { + ForEach(ReadingDirection.allCases) { + Text($0.value).tag($0) + } + } + .pickerStyle(.menu) + Picker(.preloadLimit, selection: Binding(store.sharedSetting.prefetchLimit)) { + ForEach(Array(stride(from: 6, through: 18, by: 4)), id: \.self) { value in + Text(.RLocalizable.pages(count: value)).tag(value) + } + } + .pickerStyle(.menu) + if !DeviceUtil.isPad { + Toggle(.enablesLandscape, isOn: Binding(store.sharedSetting.enablesLandscape)) + } + } + Section(.readingAppearance) { + Picker( + .separatorHeight, + selection: Binding(store.sharedSetting.contentDividerHeight) + ) { + ForEach(Array(stride(from: 0, through: 20, by: 5)), id: \.self) { value in + Text(.Constant.pointValue(value)).tag(Double(value)) + } + } + .pickerStyle(.menu) + .disabled(store.setting.readingDirection != .vertical) + ScaleFactorRow( + scaleFactor: Binding(store.sharedSetting.maximumScaleFactor), + labelContent: .maximumScaleFactor, + minFactor: 1.5, maxFactor: 10 + ) + ScaleFactorRow( + scaleFactor: Binding(store.sharedSetting.doubleTapScaleFactor), + labelContent: .doubleTapScaleFactor, + minFactor: 1.5, maxFactor: 5 + ) + } + } + .navigationTitle(.reading) + } +} + +private struct ScaleFactorRow: View { + @Binding private var scaleFactor: Double + private let labelContent: LocalizedStringResource + private let minFactor: Double + private let maxFactor: Double + + init( + scaleFactor: Binding, labelContent: LocalizedStringResource, + minFactor: Double, maxFactor: Double + ) { + _scaleFactor = scaleFactor + self.labelContent = labelContent + self.minFactor = minFactor + self.maxFactor = maxFactor + } + + var body: some View { + VStack { + HStack { + Text(labelContent) + Spacer() + Text(.Constant.scaleFactor(scaleFactor.roundedString())).foregroundStyle(.tint) + } + Slider( + value: $scaleFactor, in: minFactor...maxFactor, step: 0.5, + minimumValueLabel: Text(.Constant.scaleFactor(minFactor.roundedString())) + .fontWeight(.medium).font(.callout), + maximumValueLabel: Text(.Constant.scaleFactor(maxFactor.roundedString())) + .fontWeight(.medium).font(.callout), + label: EmptyView.init + ) + } + .padding(.vertical, 10) + } +} + +private extension Double { + func roundedString() -> String { + roundedString(with: 1) + } + + func roundedString(with places: Int) -> String { + String(format: "%.\(places)f", self) + } +} + +struct ReadingSettingView_Previews: PreviewProvider { + static var previews: some View { + NavigationStack { + ReadingSettingView( + store: .init(initialState: .init(), reducer: ReadingSettingReducer.init) + ) + } + } +} diff --git a/AppPackage/Sources/ReadingSettingFeature/Resources/Constant.xcstrings b/AppPackage/Sources/ReadingSettingFeature/Resources/Constant.xcstrings new file mode 100644 index 000000000..b0d8ca6f0 --- /dev/null +++ b/AppPackage/Sources/ReadingSettingFeature/Resources/Constant.xcstrings @@ -0,0 +1,90 @@ +{ + "sourceLanguage": "en", + "strings": { + "point_value": { + "extractionState": "manual", + "shouldTranslate": false, + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "%lldpt" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "%lldpt" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "%lldpt" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "%lldpt" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "%lldpt" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "%lldpt" + } + } + } + }, + "scale_factor": { + "extractionState": "manual", + "shouldTranslate": false, + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "%@x" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "%@x" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "%@x" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "%@x" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "%@x" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "%@x" + } + } + } + } + }, + "version": "1.0" +} \ No newline at end of file diff --git a/AppPackage/Sources/ReadingSettingFeature/Resources/Localizable.xcstrings b/AppPackage/Sources/ReadingSettingFeature/Resources/Localizable.xcstrings new file mode 100644 index 000000000..49a6a23a7 --- /dev/null +++ b/AppPackage/Sources/ReadingSettingFeature/Resources/Localizable.xcstrings @@ -0,0 +1,334 @@ +{ + "sourceLanguage": "en", + "strings": { + "direction": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Direction" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Leserichtung" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "方向" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "방향" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "方向" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "翻頁方向" + } + } + } + }, + "double_tap_scale_factor": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Double tap scale factor" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Doppel-Tap Skalierungsfaktor" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ダブルタップスケール係数" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "더블 탭 확대 비율" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "双击缩放系数" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "雙擊縮放值" + } + } + } + }, + "enables_landscape": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Enables landscape" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Querformat aktivieren" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "横向きを有効" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "가로 화면 사용하기" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "启用横屏" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "啟用橫向顯示" + } + } + } + }, + "maximum_scale_factor": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Maximum scale factor" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Maximaler Skalierungsfaktor" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "最大スケール係数" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "최대 확대 비율" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "最大缩放系数" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "縮放上限" + } + } + } + }, + "preload_limit": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Preload limit" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Vorausladen" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "プリロード上限数" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "페이지 미리 로딩" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "预加载数量上限" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "預先載入頁數" + } + } + } + }, + "reading": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Reading" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Am Lesen" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "閲覧" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "읽기" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "阅读" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "閱讀設定" + } + } + } + }, + "reading_appearance": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Appearance" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Oberfläche" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "外観" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "외관" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "外观" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "外觀" + } + } + } + }, + "separator_height": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Separator height" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Höhe der Teilung" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "仕切りの高さ" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "페이지 간 여백 두께" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "分隔线高度" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "頁間分隔高度" + } + } + } + } + }, + "version": "1.0" +} \ No newline at end of file diff --git a/AppPackage/Sources/Resources/.swiftlint.yml b/AppPackage/Sources/Resources/.swiftlint.yml new file mode 100644 index 000000000..1242ffcaa --- /dev/null +++ b/AppPackage/Sources/Resources/.swiftlint.yml @@ -0,0 +1 @@ +parent_config: ../../../.swiftlint.yml diff --git a/AppPackage/Sources/Resources/ResourceStringSymbols.swift b/AppPackage/Sources/Resources/ResourceStringSymbols.swift new file mode 100644 index 000000000..5fb7a933c --- /dev/null +++ b/AppPackage/Sources/Resources/ResourceStringSymbols.swift @@ -0,0 +1,292 @@ +import Foundation + +@available(macOS 13, iOS 16, tvOS 16, watchOS 9, *) +private nonisolated let resourceStringSymbolsBundleDescription = LocalizedStringResource.BundleDescription + .atURL(#bundle.bundleURL) + +@available(macOS 13, iOS 16, tvOS 16, watchOS 9, *) +public nonisolated extension LocalizedStringResource { + enum RLocalizable { + public static var cancel: LocalizedStringResource { + LocalizedStringResource( + "cancel", + table: "Localizable", + bundle: resourceStringSymbolsBundleDescription + ) + } + + public static var clear: LocalizedStringResource { + LocalizedStringResource( + "clear", + table: "Localizable", + bundle: resourceStringSymbolsBundleDescription + ) + } + + public static var clearDescription: LocalizedStringResource { + LocalizedStringResource( + "clear_description", + table: "Localizable", + bundle: resourceStringSymbolsBundleDescription + ) + } + + public static var dateSeek: LocalizedStringResource { + LocalizedStringResource( + "date_seek", + table: "Localizable", + bundle: resourceStringSymbolsBundleDescription + ) + } + + public static func days(count: Int) -> LocalizedStringResource { + LocalizedStringResource( + "days", + defaultValue: "\(count, specifier: "%lld")", + table: "Localizable", + bundle: resourceStringSymbolsBundleDescription + ) + } + + public static var delete: LocalizedStringResource { + LocalizedStringResource( + "delete", + table: "Localizable", + bundle: resourceStringSymbolsBundleDescription + ) + } + + public static var deleteDescription: LocalizedStringResource { + LocalizedStringResource( + "delete_description", + table: "Localizable", + bundle: resourceStringSymbolsBundleDescription + ) + } + + public static var deleteDownload: LocalizedStringResource { + LocalizedStringResource( + "delete_download", + table: "Localizable", + bundle: resourceStringSymbolsBundleDescription + ) + } + + public static var deleteDownloadedGallery: LocalizedStringResource { + LocalizedStringResource( + "delete_downloaded_gallery", + table: "Localizable", + bundle: resourceStringSymbolsBundleDescription + ) + } + + public static var detail: LocalizedStringResource { + LocalizedStringResource( + "detail", + table: "Localizable", + bundle: resourceStringSymbolsBundleDescription + ) + } + + public static var downloadStoreInvalidFolderName: LocalizedStringResource { + LocalizedStringResource( + "download_store.invalid_folder_name", + table: "Localizable", + bundle: resourceStringSymbolsBundleDescription + ) + } + + public static var downloadStoreManifestCorrupted: LocalizedStringResource { + LocalizedStringResource( + "download_store.manifest_corrupted", + table: "Localizable", + bundle: resourceStringSymbolsBundleDescription + ) + } + + public static func downloadStorePageImageCorrupted(page: Int) -> LocalizedStringResource { + LocalizedStringResource( + "download_store.page_image_corrupted", + defaultValue: "\(page, specifier: "%lld")", + table: "Localizable", + bundle: resourceStringSymbolsBundleDescription + ) + } + + public static func downloadStorePageMissing(page: Int) -> LocalizedStringResource { + LocalizedStringResource( + "download_store.page_missing", + defaultValue: "\(page, specifier: "%lld")", + table: "Localizable", + bundle: resourceStringSymbolsBundleDescription + ) + } + + public static var downloads: LocalizedStringResource { + LocalizedStringResource( + "downloads", + table: "Localizable", + bundle: resourceStringSymbolsBundleDescription + ) + } + + public static var favorites: LocalizedStringResource { + LocalizedStringResource( + "favorites", + table: "Localizable", + bundle: resourceStringSymbolsBundleDescription + ) + } + + public static var filters: LocalizedStringResource { + LocalizedStringResource( + "filters", + table: "Localizable", + bundle: resourceStringSymbolsBundleDescription + ) + } + + public static var home: LocalizedStringResource { + LocalizedStringResource( + "home", + table: "Localizable", + bundle: resourceStringSymbolsBundleDescription + ) + } + + public static func hours(count: Int) -> LocalizedStringResource { + LocalizedStringResource( + "hours", + defaultValue: "\(count, specifier: "%lld")", + table: "Localizable", + bundle: resourceStringSymbolsBundleDescription + ) + } + + public static var jumpPage: LocalizedStringResource { + LocalizedStringResource( + "jump_page", + table: "Localizable", + bundle: resourceStringSymbolsBundleDescription + ) + } + + public static var language: LocalizedStringResource { + LocalizedStringResource( + "language", + table: "Localizable", + bundle: resourceStringSymbolsBundleDescription + ) + } + + public static var login: LocalizedStringResource { + LocalizedStringResource( + "login", + table: "Localizable", + bundle: resourceStringSymbolsBundleDescription + ) + } + + public static var manageFolders: LocalizedStringResource { + LocalizedStringResource( + "manage_folders", + table: "Localizable", + bundle: resourceStringSymbolsBundleDescription + ) + } + + public static func minutes(count: Int) -> LocalizedStringResource { + LocalizedStringResource( + "minutes", + defaultValue: "\(count, specifier: "%lld")", + table: "Localizable", + bundle: resourceStringSymbolsBundleDescription + ) + } + + public static func pages(count: Int) -> LocalizedStringResource { + LocalizedStringResource( + "pages", + defaultValue: "\(count, specifier: "%lld")", + table: "Localizable", + bundle: resourceStringSymbolsBundleDescription + ) + } + + public static var quickSearch: LocalizedStringResource { + LocalizedStringResource( + "quick_search", + table: "Localizable", + bundle: resourceStringSymbolsBundleDescription + ) + } + + public static var retry: LocalizedStringResource { + LocalizedStringResource( + "retry", + table: "Localizable", + bundle: resourceStringSymbolsBundleDescription + ) + } + + public static var search: LocalizedStringResource { + LocalizedStringResource( + "search", + table: "Localizable", + bundle: resourceStringSymbolsBundleDescription + ) + } + + public static func seconds(count: Int) -> LocalizedStringResource { + LocalizedStringResource( + "seconds", + defaultValue: "\(count, specifier: "%lld")", + table: "Localizable", + bundle: resourceStringSymbolsBundleDescription + ) + } + + public static var setting: LocalizedStringResource { + LocalizedStringResource( + "setting", + table: "Localizable", + bundle: resourceStringSymbolsBundleDescription + ) + } + + public static var share: LocalizedStringResource { + LocalizedStringResource( + "share", + table: "Localizable", + bundle: resourceStringSymbolsBundleDescription + ) + } + + public static func stars(count: Int) -> LocalizedStringResource { + LocalizedStringResource( + "stars", + defaultValue: "\(count, specifier: "%lld")", + table: "Localizable", + bundle: resourceStringSymbolsBundleDescription + ) + } + + public static var update: LocalizedStringResource { + LocalizedStringResource( + "update", + table: "Localizable", + bundle: resourceStringSymbolsBundleDescription + ) + } + } + + enum RConstant { + public static var responseGalleryUnavailable: LocalizedStringResource { + LocalizedStringResource( + "response.gallery_unavailable", + table: "Constant", + bundle: resourceStringSymbolsBundleDescription + ) + } + } +} diff --git a/AppPackage/Sources/Resources/Resources/Constant.xcstrings b/AppPackage/Sources/Resources/Resources/Constant.xcstrings new file mode 100644 index 000000000..3e71c8c68 --- /dev/null +++ b/AppPackage/Sources/Resources/Resources/Constant.xcstrings @@ -0,0 +1,48 @@ +{ + "sourceLanguage": "en", + "strings": { + "response.gallery_unavailable": { + "extractionState": "manual", + "shouldTranslate": false, + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "This gallery has been removed or is unavailable." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "This gallery has been removed or is unavailable." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "This gallery has been removed or is unavailable." + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "This gallery has been removed or is unavailable." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "This gallery has been removed or is unavailable." + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "This gallery has been removed or is unavailable." + } + } + } + } + }, + "version": "1.0" +} \ No newline at end of file diff --git a/AppPackage/Sources/Resources/Resources/Localizable.xcstrings b/AppPackage/Sources/Resources/Resources/Localizable.xcstrings new file mode 100644 index 000000000..75678828f --- /dev/null +++ b/AppPackage/Sources/Resources/Resources/Localizable.xcstrings @@ -0,0 +1,1551 @@ +{ + "sourceLanguage" : "en", + "strings" : { + "cancel" : { + "extractionState" : "manual", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Abbrechen" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Cancel" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "キャンセル" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "취소" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "取消" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "取消" + } + } + } + }, + "clear" : { + "extractionState" : "manual", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Löschen" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Clear" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "削除" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "삭제" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "清空" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "清空" + } + } + } + }, + "clear_description" : { + "extractionState" : "manual", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bist du sicher das du das löschen möchtest?" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Are you sure to clear?" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "本当に削除してもよろしいですか?" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "삭제하시겠어요?" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "确定要清空吗?" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "確定要清空嗎?" + } + } + } + }, + "date_seek" : { + "extractionState" : "manual", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Datum aufsuchen" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Date seek" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "日付指定" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "날짜로 이동" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "日期定位" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "日期定位" + } + } + } + }, + "days" : { + "extractionState" : "manual", + "localizations" : { + "de" : { + "variations" : { + "plural" : { + "one" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld Tag" + } + }, + "other" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld Tage" + } + } + } + } + }, + "en" : { + "variations" : { + "plural" : { + "one" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld day" + } + }, + "other" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld days" + } + } + } + } + }, + "ja" : { + "variations" : { + "plural" : { + "other" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld 日" + } + } + } + } + }, + "ko" : { + "variations" : { + "plural" : { + "other" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld일" + } + } + } + } + }, + "zh-Hans" : { + "variations" : { + "plural" : { + "other" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld 天" + } + } + } + } + }, + "zh-Hant" : { + "variations" : { + "plural" : { + "other" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld 天" + } + } + } + } + } + } + }, + "delete" : { + "extractionState" : "manual", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Löschen" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Delete" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "削除" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "삭제" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "删除" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "刪除" + } + } + } + }, + "delete_description" : { + "extractionState" : "manual", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Möchtest du dieses Element wirklich löschen?" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Are you sure to delete this item?" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "本当にこれを削除してもよろしいですか?" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "이 항목을 삭제하시겠어요?" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "确定要删除吗?" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "確定要刪除?" + } + } + } + }, + "delete_download" : { + "extractionState" : "manual", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Download löschen?" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Delete Download?" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "ダウンロードを削除しますか?" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "다운로드를 삭제할까요?" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "删除下载?" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "刪除下載?" + } + } + } + }, + "delete_downloaded_gallery" : { + "extractionState" : "manual", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Die heruntergeladene Galerie wird von diesem Gerät entfernt." + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "This will remove the downloaded gallery from this device." + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "ダウンロード済みのギャラリーをこのデバイスから削除します。" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "다운로드한 갤러리를 이 기기에서 삭제합니다." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "这将从此设备移除已下载的画廊。" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "這將從此裝置移除已下載的畫廊。" + } + } + } + }, + "detail" : { + "extractionState" : "manual", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Details" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Detail" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "詳細" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "세부 정보" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "详情" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "Detail" + } + } + } + }, + "download_store.invalid_folder_name" : { + "extractionState" : "manual", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Der Ordnername ist ungültig." + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "The folder name is invalid." + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "フォルダ名が無効です。" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "폴더 이름이 올바르지 않습니다." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "文件夹名称无效。" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "資料夾名稱無效。" + } + } + } + }, + "download_store.manifest_corrupted" : { + "extractionState" : "manual", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Manifest-Datei ist beschädigt." + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Manifest file is corrupted." + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "マニフェストファイルが破損しています。" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "매니페스트 파일이 손상되었습니다." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "Manifest 文件已损坏。" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "Manifest 檔案已損壞。" + } + } + } + }, + "download_store.page_image_corrupted" : { + "extractionState" : "manual", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bilddaten von Seite %lld sind beschädigt." + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Page %lld image data is corrupted." + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "ページ %lld の画像データが破損しています。" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "페이지 %lld 이미지 데이터가 손상되었습니다." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "第 %lld 页图片数据已损坏。" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "第 %lld 頁圖片資料已損壞。" + } + } + } + }, + "download_store.page_missing" : { + "extractionState" : "manual", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Seite %lld fehlt." + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Page %lld is missing." + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "ページ %lld が見つかりません。" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "페이지 %lld가 없습니다." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "第 %lld 页缺失。" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "第 %lld 頁缺失。" + } + } + } + }, + "downloads" : { + "extractionState" : "manual", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Downloads" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Downloads" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "ダウンロード" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "다운로드" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "下载" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "下載" + } + } + } + }, + "favorites" : { + "extractionState" : "manual", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Favoriten" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Favorites" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "お気に入り" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "즐겨찾기" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "收藏" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "收藏" + } + } + } + }, + "filters" : { + "extractionState" : "manual", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Filter" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Filters" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "フィルター" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "필터" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "筛选" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "過濾" + } + } + } + }, + "home" : { + "extractionState" : "manual", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Start" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Home" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "ホーム" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "홈" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "主页" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "總覽" + } + } + } + }, + "hours" : { + "extractionState" : "manual", + "localizations" : { + "de" : { + "variations" : { + "plural" : { + "one" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld Stunde" + } + }, + "other" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld Stunden" + } + } + } + } + }, + "en" : { + "variations" : { + "plural" : { + "one" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld hour" + } + }, + "other" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld hours" + } + } + } + } + }, + "ja" : { + "variations" : { + "plural" : { + "other" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld 時間" + } + } + } + } + }, + "ko" : { + "variations" : { + "plural" : { + "other" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld시간" + } + } + } + } + }, + "zh-Hans" : { + "variations" : { + "plural" : { + "other" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld 小时" + } + } + } + } + }, + "zh-Hant" : { + "variations" : { + "plural" : { + "other" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld 小時" + } + } + } + } + } + } + }, + "jump_page" : { + "extractionState" : "manual", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Zu Seite springen" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Jump page" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "ページジャンプ" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "페이지 이동" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "页码跳转" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "跳到..." + } + } + } + }, + "language" : { + "extractionState" : "manual", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Sprache" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Language" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "言語" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "언어" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "语言" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "語言" + } + } + } + }, + "login" : { + "extractionState" : "manual", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Einloggen" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Login" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "ログイン" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "로그인" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "登录" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "登入" + } + } + } + }, + "manage_folders" : { + "extractionState" : "manual", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ordner verwalten" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Manage Folders" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "フォルダを管理" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "폴더 관리" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "管理文件夹" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "管理資料夾" + } + } + } + }, + "minutes" : { + "extractionState" : "manual", + "localizations" : { + "de" : { + "variations" : { + "plural" : { + "one" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld Minute" + } + }, + "other" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld Minuten" + } + } + } + } + }, + "en" : { + "variations" : { + "plural" : { + "one" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld minute" + } + }, + "other" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld minutes" + } + } + } + } + }, + "ja" : { + "variations" : { + "plural" : { + "other" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld 分" + } + } + } + } + }, + "ko" : { + "variations" : { + "plural" : { + "other" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld분" + } + } + } + } + }, + "zh-Hans" : { + "variations" : { + "plural" : { + "other" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld 分" + } + } + } + } + }, + "zh-Hant" : { + "variations" : { + "plural" : { + "other" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld 分" + } + } + } + } + } + } + }, + "pages" : { + "extractionState" : "manual", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld Seiten" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld pages" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld ページ" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld페이지" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld 页" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld 頁" + } + } + } + }, + "quick_search" : { + "extractionState" : "manual", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Schnellsuche" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Quick search" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "クイック検索" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "빠른 검색" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "快速搜索" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "快速搜尋" + } + } + } + }, + "retry" : { + "extractionState" : "manual", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Erneut versuchen" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Retry" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "リトライ" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "재시도" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "重试" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "重試" + } + } + } + }, + "search" : { + "extractionState" : "manual", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Suche" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Search" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "検索" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "검색" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "搜索" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "搜尋" + } + } + } + }, + "seconds" : { + "extractionState" : "manual", + "localizations" : { + "de" : { + "variations" : { + "plural" : { + "one" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld Sekunde" + } + }, + "other" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld Sekunden" + } + } + } + } + }, + "en" : { + "variations" : { + "plural" : { + "one" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld second" + } + }, + "other" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld seconds" + } + } + } + } + }, + "ja" : { + "variations" : { + "plural" : { + "other" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld 秒" + } + } + } + } + }, + "ko" : { + "variations" : { + "plural" : { + "other" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld초" + } + } + } + } + }, + "zh-Hans" : { + "variations" : { + "plural" : { + "other" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld 秒" + } + } + } + } + }, + "zh-Hant" : { + "variations" : { + "plural" : { + "other" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld 秒" + } + } + } + } + } + } + }, + "setting" : { + "extractionState" : "manual", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Einstellungen" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Setting" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "設定" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "설정" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "设置" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "設定" + } + } + } + }, + "share" : { + "extractionState" : "manual", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Teilen" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Share" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "共有" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "공유" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "分享" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "分享" + } + } + } + }, + "stars" : { + "extractionState" : "manual", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld Sterne" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld stars" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld つ星" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld별" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld 星" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld 星" + } + } + } + }, + "update" : { + "extractionState" : "manual", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Aktualisieren" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Update" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "更新" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "업데이트" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "更新" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "更新" + } + } + } + } + }, + "version" : "1.0" +} \ No newline at end of file diff --git a/AppPackage/Sources/SFSafeSymbolsExt/.swiftlint.yml b/AppPackage/Sources/SFSafeSymbolsExt/.swiftlint.yml new file mode 100644 index 000000000..1242ffcaa --- /dev/null +++ b/AppPackage/Sources/SFSafeSymbolsExt/.swiftlint.yml @@ -0,0 +1 @@ +parent_config: ../../../.swiftlint.yml diff --git a/AppPackage/Sources/SFSafeSymbolsExt/SFSafeSymbols+LocalizedStringResource.swift b/AppPackage/Sources/SFSafeSymbolsExt/SFSafeSymbols+LocalizedStringResource.swift new file mode 100644 index 000000000..d53026f0f --- /dev/null +++ b/AppPackage/Sources/SFSafeSymbolsExt/SFSafeSymbols+LocalizedStringResource.swift @@ -0,0 +1,16 @@ +import SwiftUI +import SFSafeSymbols + +public extension Label where Title == Text, Icon == Image { + /// Builds a `Label` from a `LocalizedStringResource` title and an `SFSymbol` icon, + /// composing `Text` and `Image(systemSymbol:)` directly so no system-name string + /// parameter is needed. This is the initializer the `label_text_image_shorthand` + /// lint rule expects call sites to use. + nonisolated init(_ titleResource: LocalizedStringResource, systemSymbol: SFSymbol) { + self.init { + Text(titleResource) + } icon: { + Image(systemSymbol: systemSymbol) + } + } +} diff --git a/AppPackage/Sources/SearchFeature/.swiftlint.yml b/AppPackage/Sources/SearchFeature/.swiftlint.yml new file mode 100644 index 000000000..1242ffcaa --- /dev/null +++ b/AppPackage/Sources/SearchFeature/.swiftlint.yml @@ -0,0 +1 @@ +parent_config: ../../../.swiftlint.yml diff --git a/EhPanda/View/Support/Components/Cells/GalleryHistoryCell.swift b/AppPackage/Sources/SearchFeature/GalleryHistoryCell.swift similarity index 68% rename from EhPanda/View/Support/Components/Cells/GalleryHistoryCell.swift rename to AppPackage/Sources/SearchFeature/GalleryHistoryCell.swift index 96be0877b..f34ae0d56 100644 --- a/EhPanda/View/Support/Components/Cells/GalleryHistoryCell.swift +++ b/AppPackage/Sources/SearchFeature/GalleryHistoryCell.swift @@ -1,26 +1,30 @@ -// -// GalleryHistoryCell.swift -// EhPanda -// - +import AppTools import SwiftUI +import AppModels +import AppComponents import Kingfisher +import Sharing -struct GalleryHistoryCell: View { +public struct GalleryHistoryCell: View { + @SharedReader(.setting) private var setting: Setting private let gallery: Gallery - init(gallery: Gallery) { + public init(gallery: Gallery) { self.gallery = gallery } - var body: some View { + private var displayTitle: String { + setting.displaysJapaneseTitle ? gallery.titleJpn ?? gallery.trimmedTitle : gallery.trimmedTitle + } + + public 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) + Text(displayTitle).bold().lineLimit(2).fixedSize(horizontal: false, vertical: true) if let uploader = gallery.uploader { Text(uploader).foregroundColor(.secondary).lineLimit(1) } diff --git a/AppPackage/Sources/SearchFeature/Resources/Localizable.xcstrings b/AppPackage/Sources/SearchFeature/Resources/Localizable.xcstrings new file mode 100644 index 000000000..a978d3505 --- /dev/null +++ b/AppPackage/Sources/SearchFeature/Resources/Localizable.xcstrings @@ -0,0 +1,88 @@ +{ + "sourceLanguage": "en", + "strings": { + "recently_searched": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Recently searched" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Zuletzt gesucht" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "最近検索した項目" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "최근 검색어" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "最近搜索" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "最近搜尋" + } + } + } + }, + "recently_seen": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Recently seen" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Zuletzt angesehen" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "最近閲覧した項目" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "최근 본 항목" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "最近看过" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "最近閱讀" + } + } + } + } + }, + "version": "1.0" +} \ No newline at end of file diff --git a/AppPackage/Sources/SearchFeature/SearchPath.swift b/AppPackage/Sources/SearchFeature/SearchPath.swift new file mode 100644 index 000000000..8a7a2dcd8 --- /dev/null +++ b/AppPackage/Sources/SearchFeature/SearchPath.swift @@ -0,0 +1,37 @@ +import ComposableArchitecture +import DetailFeature + +// The Search tab's navigation stack: the search-results screen plus the shared gallery drill-down, +// nested as a `.gallery` case so the gallery routing stays defined once in `GalleryPath`. +@Reducer +public enum SearchPath { + case search(SearchReducer) + case gallery(GalleryPath.Body = GalleryPath.body) +} + +extension SearchPath.State: Equatable {} + +extension SearchPath.State: GalleryRouteIdentifiable { + public var routeKey: String { + switch self { + case .search: + return "search" + case .gallery(let state): + return "gallery/\(state.routeKey)" + } + } +} + +extension StackState where Element == SearchPath.State { + // Locate the pushed `.gallery(.detail)` element for `gid` so a comment action performed on a + // deeper `.comments` screen can refresh the detail it belongs to. + func galleryDetailID(forGID gid: String) -> StackElementID? { + for id in ids { + guard let element = self[id: id] else { continue } + if case .gallery(.detail(let state)) = element, state.gid == gid { + return id + } + } + return nil + } +} diff --git a/AppPackage/Sources/SearchFeature/SearchReducer.swift b/AppPackage/Sources/SearchFeature/SearchReducer.swift new file mode 100644 index 000000000..c70958dbf --- /dev/null +++ b/AppPackage/Sources/SearchFeature/SearchReducer.swift @@ -0,0 +1,262 @@ +import AppTools +import ComposableArchitecture +import AppModels +import Sharing +import Foundation +import HapticsClient +import NetworkingFeature +import DownloadClient +import FiltersFeature +import DateSeekFeature +import QuickSearchFeature + +@Reducer +public struct SearchReducer: Sendable { + public enum Delegate: Equatable, Sendable { + case pushDetail(Gallery) + case searchPerformed(String) + } + + @Reducer + public enum Destination { + case filters(FiltersReducer) + case quickSearch(QuickSearchReducer) + case dateSeek(DateSeekReducer) + } + + private enum CancelID { + case fetchGalleries, fetchMoreGalleries, observeDownloads, fetchDateSeekGalleries + } + + @ObservableState + public struct State: Equatable { + @SharedReader(.searchFilter) public var searchFilter: Filter + @SharedReader(.tagTranslator) public var tagTranslator: TagTranslator + @SharedReader(.setting) public var setting: Setting + @Presents public var destination: Destination.State? + public var keyword = "" + public var lastKeyword = "" + + public var galleries = [Gallery]() + public var pageNumber = PageNumber() + public var dateSeekNavigation: DateSeekNavigation? + public var loadingState: LoadingState = .idle + public var footerLoadingState: LoadingState = .idle + public var downloadBadges = [String: DownloadBadge]() + + public init(keyword: String = "") { + self.keyword = keyword + lastKeyword = keyword + } + + mutating func insertGalleries(_ galleries: [Gallery]) { + galleries.forEach { gallery in + if !self.galleries.contains(gallery) { + self.galleries.append(gallery) + } + } + } + } + + public enum Action: BindableAction { + case binding(BindingAction) + case onAppear + case delegate(Delegate) + case filtersButtonTapped + case quickSearchButtonTapped + case dateSeekButtonTapped(DateSeekNavigation) + case destination(PresentationAction) + + case fetchGalleries(String? = nil) + case fetchGalleriesDone(Result) + case fetchMoreGalleries + case fetchMoreGalleriesDone(Result) + case observeDownloads + case observeDownloadsDone([DownloadedGallery]) + case performDateSeekDone(Result) + } + + @Dependency(\.downloadClient) private var downloadClient + @Dependency(\.hapticsClient) private var hapticsClient + + public init() {} + + public var body: some Reducer { + BindingReducer() + .onChange(of: \.keyword) { _, state in + if !state.keyword.isEmpty { + state.lastKeyword = state.keyword + } + return .none + } + + Reduce { state, action in + switch action { + case .binding: + return .none + + case .onAppear: + return .send(.observeDownloads) + + case .delegate: + return .none + + case .filtersButtonTapped: + state.destination = .filters(FiltersReducer.State()) + return .none + + case .quickSearchButtonTapped: + state.destination = .quickSearch(QuickSearchReducer.State()) + return .none + + case .dateSeekButtonTapped(let navigation): + state.destination = .dateSeek(.init(navigation: navigation)) + return .none + + case .fetchGalleries(let keyword): + // The performed keyword is what the host records into search history: an explicit + // keyword when provided, otherwise the current `lastKeyword`. Emit it even when a + // fetch is already in flight, matching the previous host-observes-every-fetch behavior. + let historyEffect: Effect = .send(.delegate(.searchPerformed(keyword ?? state.lastKeyword))) + guard state.loadingState != .loading else { return historyEffect } + if let keyword = keyword { + state.keyword = keyword + state.lastKeyword = keyword + } + state.loadingState = .loading + state.pageNumber.resetPages() + let filter = state.searchFilter + return .merge( + historyEffect, + .run { [lastKeyword = state.lastKeyword] send in + let response = await SearchGalleriesRequest(keyword: lastKeyword, filter: filter).response() + await send(.fetchGalleriesDone(response)) + } + .cancellable(id: CancelID.fetchGalleries) + ) + + case .fetchGalleriesDone(let result): + state.loadingState = .idle + switch result { + case .success(let response): + let galleries = response.galleries + guard !galleries.isEmpty else { + state.loadingState = .failed(.notFound) + guard response.pageNumber.hasNextPage() else { return .none } + return .send(.fetchMoreGalleries) + } + state.pageNumber = response.pageNumber + state.dateSeekNavigation = response.dateSeekNavigation + state.galleries = galleries + return .none + case .failure(let error): + state.loadingState = .failed(error) + } + return .none + + case .fetchMoreGalleries: + let pageNumber = state.pageNumber + guard pageNumber.hasNextPage(), + state.footerLoadingState != .loading, + let lastID = state.galleries.last?.id + else { return .none } + state.footerLoadingState = .loading + let filter = state.searchFilter + return .run { [lastKeyword = state.lastKeyword] send in + let response = await MoreSearchGalleriesRequest( + keyword: lastKeyword, filter: filter, lastID: lastID + ) + .response() + await send(.fetchMoreGalleriesDone(response)) + } + .cancellable(id: CancelID.fetchMoreGalleries) + + case .fetchMoreGalleriesDone(let result): + state.footerLoadingState = .idle + switch result { + case .success(let response): + let galleries = response.galleries + state.pageNumber = response.pageNumber + state.dateSeekNavigation = response.dateSeekNavigation + state.insertGalleries(galleries) + + var effects: [Effect] = [] + if galleries.isEmpty, response.pageNumber.hasNextPage() { + effects.append(.send(.fetchMoreGalleries)) + } else if !galleries.isEmpty { + state.loadingState = .idle + } + return .merge(effects) + + case .failure(let error): + state.footerLoadingState = .failed(error) + } + return .none + + case .observeDownloads: + return .run { send in + for await downloads in downloadClient.observeDownloads() { + await send(.observeDownloadsDone(downloads)) + } + } + .cancellable(id: CancelID.observeDownloads, cancelInFlight: true) + + case .observeDownloadsDone(let downloads): + state.downloadBadges = Dictionary( + uniqueKeysWithValues: downloads.map { ($0.gid, $0.badge) } + ) + return .none + + case .destination(.presented(.dateSeek(.delegate(.performSeek(let url))))): + guard state.loadingState != .loading else { return .none } + state.loadingState = .loading + state.footerLoadingState = .idle + state.pageNumber.resetPages() + return .run { send in + let response = await DateSeekGalleriesRequest(url: url).response() + await send(.performDateSeekDone(response)) + } + .cancellable(id: CancelID.fetchDateSeekGalleries) + + case .performDateSeekDone(let result): + state.loadingState = .idle + switch result { + case .success(let response): + let galleries = response.galleries + guard !galleries.isEmpty else { + state.loadingState = .failed(.notFound) + return .none + } + state.pageNumber = response.pageNumber + state.dateSeekNavigation = response.dateSeekNavigation + state.galleries = galleries + return .none + case .failure(let error): + state.loadingState = .failed(error) + } + return .none + + case .destination: + return .none + } + } + .haptics( + unwrapping: \.destination, + case: \.quickSearch, + hapticsClient: hapticsClient + ) + .haptics( + unwrapping: \.destination, + case: \.filters, + hapticsClient: hapticsClient + ) + .haptics( + unwrapping: \.destination, + case: \.dateSeek, + hapticsClient: hapticsClient + ) + .ifLet(\.$destination, action: \.destination) + } +} + +extension SearchReducer.Destination.State: Equatable, Sendable {} diff --git a/AppPackage/Sources/SearchFeature/SearchRootReducer.swift b/AppPackage/Sources/SearchFeature/SearchRootReducer.swift new file mode 100644 index 000000000..e6f3667be --- /dev/null +++ b/AppPackage/Sources/SearchFeature/SearchRootReducer.swift @@ -0,0 +1,206 @@ +import ComposableArchitecture +import AppModels +import Sharing +import AppTools +import HapticsClient +import DeviceClient +import FiltersFeature +import QuickSearchFeature +import NetworkingFeature +import DetailFeature + +@Reducer +public struct SearchRootReducer: Sendable { + private enum CancelID { + case fetchHistoryGalleries + } + + public enum Delegate: Equatable, Sendable { + case presentGalleryDetail(Gallery) + } + + @Reducer + public enum Destination { + case filters(FiltersReducer) + case quickSearch(QuickSearchReducer) + } + + @ObservableState + public struct State: Equatable { + public var path = StackState() + @Presents public var destination: Destination.State? + public var keyword = "" + public var historyGalleries = [Gallery]() + + // Persisted directly in app storage; both are also read/written by pushed Search screens + // and the QuickSearch editor, which share the same keys, so changes stay live without reloads. + @Shared(.historyKeywords) public var historyKeywords: [String] + @Shared(.quickSearchWords) public var quickSearchWords: [QuickSearchWord] + @SharedReader(.tagTranslator) public var tagTranslator: TagTranslator + @SharedReader(.setting) public var setting: Setting + + public init() {} + + 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) + ) + } + } + } + $historyKeywords.withLock { $0 = historyKeywords } + } + + mutating func removeHistoryKeyword(_ keyword: String) { + $historyKeywords.withLock { $0.removeAll { $0 == keyword } } + } + } + + public enum Action: BindableAction { + case binding(BindingAction) + case delegate(Delegate) + case pushSearch + case galleryTapped(Gallery) + case pushGalleryDetail(Gallery) + case path(StackActionOf) + case setKeyword(String) + case filtersButtonTapped + case quickSearchButtonTapped + case destination(PresentationAction) + + case appendHistoryKeyword(String) + case removeHistoryKeyword(String) + case fetchHistoryGalleries + case fetchHistoryGalleriesDone(Result<[Gallery], AppError>) + } + + @Dependency(\.deviceClient) private var deviceClient + @Dependency(\.hapticsClient) private var hapticsClient + + public init() {} + + public var body: some Reducer { + BindingReducer() + + Reduce { state, action in + switch action { + case .binding: + return .none + + case .pushSearch: + state.path.appendGuardingDuplicate(.search(.init(keyword: state.keyword))) + return .none + + case .galleryTapped(let gallery), + let .path(.element(id: _, action: .search(.delegate(.pushDetail(gallery))))): + return GalleryNavigation.routeGalleryDetail( + isPad: deviceClient.isPad, + present: { .delegate(.presentGalleryDetail(gallery)) }, + push: { .pushGalleryDetail(gallery) } + ) + + case .pushGalleryDetail(let gallery): + state.path.appendGuardingDuplicate(.gallery(.detail(.init(gallery: gallery)))) + return .none + + case .delegate: + return .none + + case let .path(.element(id: _, action: .search(.delegate(.searchPerformed(keyword))))): + state.appendHistoryKeywords([keyword]) + return .none + + case let .path(.element(id: _, action: .gallery(.comments(.delegate(.performedCommentAction(gid)))))): + guard let id = state.path.galleryDetailID(forGID: gid) else { return .none } + return .send(.path(.element(id: id, action: .gallery(.detail(.fetchGalleryDetail))))) + + case let .path(.element(id: _, action: .gallery(galleryAction))): + if let next = GalleryNavigation.nextScreen(for: galleryAction) { + state.path.appendGuardingDuplicate(.gallery(next)) + } + return .none + + case .path: + return .none + + case .setKeyword(let keyword): + state.keyword = keyword + return .none + + case .filtersButtonTapped: + state.destination = .filters(FiltersReducer.State()) + return .none + + case .quickSearchButtonTapped: + state.destination = .quickSearch(QuickSearchReducer.State()) + return .none + + case .destination: + return .none + + case .appendHistoryKeyword(let keyword): + state.appendHistoryKeywords([keyword]) + return .none + + case .removeHistoryKeyword(let keyword): + state.removeHistoryKeyword(keyword) + return .none + + case .fetchHistoryGalleries: + // "Recently seen" suggestions: the 10 most-recent history entries, metadata + // refetched on demand since no gallery snapshot is persisted. + @Shared(.galleryHistory) var galleryHistory + let pairs = galleryHistory.prefix(10).map { (gid: $0.gid, token: $0.token) } + guard !pairs.isEmpty else { + state.historyGalleries = [] + return .none + } + // Skip when the shown suggestions already reflect the 10 most-recent gids — a plain pop + // back to the Search root shouldn't re-download identical metadata. `cancelInFlight` + // stops rapid re-entry from stacking overlapping, last-writer-wins requests. + guard pairs.map(\.gid) != state.historyGalleries.map(\.gid) else { return .none } + return .run { send in + let response = await GalleriesMetadataRequest(gidList: pairs).response() + await send(.fetchHistoryGalleriesDone(response)) + } + .cancellable(id: CancelID.fetchHistoryGalleries, cancelInFlight: true) + + case .fetchHistoryGalleriesDone(let result): + if case .success(let galleries) = result { + state.historyGalleries = galleries + } + return .none + } + } + .haptics( + unwrapping: \.destination, + case: \.quickSearch, + hapticsClient: hapticsClient + ) + .haptics( + unwrapping: \.destination, + case: \.filters, + hapticsClient: hapticsClient + ) + .ifLet(\.$destination, action: \.destination) + .forEach(\.path, action: \.path) + } +} + +extension SearchRootReducer.Destination.State: Equatable, Sendable {} diff --git a/AppPackage/Sources/SearchFeature/SearchRootView+Keywords.swift b/AppPackage/Sources/SearchFeature/SearchRootView+Keywords.swift new file mode 100644 index 000000000..010d25816 --- /dev/null +++ b/AppPackage/Sources/SearchFeature/SearchRootView+Keywords.swift @@ -0,0 +1,141 @@ +import SwiftUI +import SFSafeSymbols +import AppTools + +// MARK: DoubleVerticalKeywordsStack +struct DoubleVerticalKeywordsStack: View { + private let keywords: [WrappedKeyword] + private let searchAction: (String) -> Void + private let removeAction: ((String) -> Void)? + + init( + keywords: [WrappedKeyword], + searchAction: @escaping (String) -> Void, + removeAction: ((String) -> Void)? = nil + ) { + self.keywords = keywords + self.searchAction = searchAction + self.removeAction = removeAction + } + + var singleKeywords: [WrappedKeyword] { + .init(keywords.prefix(min(keywords.count, 10))) + } + var doubleKeywords: ([WrappedKeyword], [WrappedKeyword]) { + var leadingKeywords = [WrappedKeyword]() + var trailingKeywords = [WrappedKeyword]() + keywords.enumerated().forEach { (index, keyword) in + guard index < 20 else { return } + if index % 2 == 0 { + leadingKeywords.append(keyword) + } else { + trailingKeywords.append(keyword) + } + } + return (leadingKeywords, trailingKeywords) + } + + var body: some View { + HStack(alignment: .top, spacing: 30) { + if !DeviceUtil.isPad { + VerticalKeywordsStack( + keywords: singleKeywords, + searchAction: searchAction, + removeAction: removeAction + ) + } else { + let (leadingKeywords, trailingKeywords) = doubleKeywords + VerticalKeywordsStack( + keywords: leadingKeywords, + searchAction: searchAction, + removeAction: removeAction + ) + VerticalKeywordsStack( + keywords: trailingKeywords, + searchAction: searchAction, + removeAction: removeAction + ) + } + } + .padding() + } +} + +struct VerticalKeywordsStack: View { + private let keywords: [WrappedKeyword] + private let searchAction: (String) -> Void + private let removeAction: ((String) -> Void)? + + init(keywords: [WrappedKeyword], searchAction: @escaping (String) -> Void, removeAction: ((String) -> Void)?) { + self.keywords = keywords + self.searchAction = searchAction + self.removeAction = removeAction + } + + var body: some View { + VStack(spacing: 10) { + ForEach(keywords, id: \.self) { keyword in + VStack(alignment: .leading, spacing: 10) { + KeywordCell(wrappedKeyword: keyword, searchAction: searchAction, removeAction: removeAction) + Divider().opacity(keyword == keywords.last ? 0 : 1) + } + } + } + } +} + +struct KeywordCell: View { + private let wrappedKeyword: WrappedKeyword + private let searchAction: (String) -> Void + private let removeAction: ((String) -> Void)? + + init(wrappedKeyword: WrappedKeyword, searchAction: @escaping (String) -> Void, removeAction: ((String) -> Void)?) { + self.wrappedKeyword = wrappedKeyword + self.searchAction = searchAction + self.removeAction = removeAction + } + + var title: String { + wrappedKeyword.displayText.isEmpty ? wrappedKeyword.keyword : wrappedKeyword.displayText + } + + var body: some View { + HStack(spacing: 20) { + Button { + searchAction(wrappedKeyword.keyword) + } label: { + Image(systemSymbol: .magnifyingglass) + + Text(title) + .frame(maxWidth: .infinity, alignment: .leading) + .lineLimit(1) + } + .tint(.primary) + + if removeAction != nil { + Button { + removeAction?(wrappedKeyword.keyword) + } label: { + Image(systemSymbol: .xmark) + .imageScale(.small) + .foregroundColor(.secondary) + } + } + } + } +} + +// MARK: Definition +struct WrappedKeyword: Hashable { + let keyword: String + let displayText: String + + init(keyword: String, displayText: String) { + self.keyword = keyword + self.displayText = displayText + } + + init(keyword: String) { + self.init(keyword: keyword, displayText: .init()) + } +} diff --git a/AppPackage/Sources/SearchFeature/SearchRootView.swift b/AppPackage/Sources/SearchFeature/SearchRootView.swift new file mode 100644 index 000000000..1f1b24489 --- /dev/null +++ b/AppPackage/Sources/SearchFeature/SearchRootView.swift @@ -0,0 +1,266 @@ +import SwiftUI +import AppModels +import Resources +import ComposableArchitecture +import AppTools +import AppComponents +import FiltersFeature +import QuickSearchFeature +import DetailFeature + +public struct SearchRootView: View { + @Bindable private var store: StoreOf + private let blurRadius: Double + + public init( + store: StoreOf, + blurRadius: Double + ) { + self.store = store + self.blurRadius = blurRadius + } + + public var body: some View { + NavigationStack(path: $store.scope(state: \.path, action: \.path)) { + let content = + ScrollView(showsIndicators: false) { + SuggestionsPanel( + historyKeywords: store.historyKeywords.reversed(), + historyGalleries: store.historyGalleries, + quickSearchWords: store.quickSearchWords, + navigateGalleryAction: { store.send(.galleryTapped($0)) }, + navigateQuickSearchAction: { store.send(.quickSearchButtonTapped) }, + searchKeywordAction: { keyword in + store.send(.setKeyword(keyword)) + store.send(.pushSearch) + }, + removeKeywordAction: { store.send(.removeHistoryKeyword($0)) } + ) + } + .sheet( + item: $store.scope(state: \.destination?.filters, action: \.destination.filters) + ) { store in + FiltersView(store: store) + .autoBlur(radius: blurRadius).environment(\.inSheet, true) + } + .sheet( + item: $store.scope(state: \.destination?.quickSearch, action: \.destination.quickSearch) + ) { store in + QuickSearchView(store: store) { keyword in + self.store.send(.destination(.dismiss)) + self.store.send(.setKeyword(keyword)) + DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { + self.store.send(.pushSearch) + } + } + .accentColor(self.store.setting.accentColor) + .autoBlur(radius: blurRadius) + } + .searchable(text: $store.keyword) + .searchSuggestions { + TagSuggestionView( + keyword: $store.keyword, translations: store.tagTranslator.translations, + showsImages: store.setting.showsImagesInTags, isEnabled: store.setting.showsTagsSearchSuggestion + ) + } + .onSubmit(of: .search) { + store.send(.pushSearch) + } + .onAppear { + store.send(.fetchHistoryGalleries) + } + .toolbar(content: toolbar) + .navigationTitle(.RLocalizable.search) + + // Workaround: Prevent the title disappearing issue. The blank subtitle only reserves + // layout; `verbatim` keeps Xcode from extracting it as a localizable " " key. + if store.historyKeywords.isEmpty && store.historyGalleries.isEmpty { + content + .navigationSubtitle(Text(verbatim: " ")) + } else { + content + } + } destination: { store in + switch store.case { + case .search(let store): + SearchView( + store: store, blurRadius: blurRadius + ) + case .gallery(let store): + galleryDestination( + store, blurRadius: blurRadius + ) + } + } + } + + private func toolbar() -> some ToolbarContent { + CustomToolbarItem(tint: .primary) { + ToolbarFeaturesMenu(symbolRenderingMode: .hierarchical) { + FiltersButton { + store.send(.filtersButtonTapped) + } + QuickSearchButton { + store.send(.quickSearchButtonTapped) + } + } + } + } +} + +// MARK: SuggestionsPanel +private struct SuggestionsPanel: View { + private let historyKeywords: [String] + private let historyGalleries: [Gallery] + private let quickSearchWords: [QuickSearchWord] + private let navigateGalleryAction: (Gallery) -> Void + private let navigateQuickSearchAction: () -> Void + private let searchKeywordAction: (String) -> Void + private let removeKeywordAction: (String) -> Void + + init( + historyKeywords: [String], historyGalleries: [Gallery], + quickSearchWords: [QuickSearchWord], + navigateGalleryAction: @escaping (Gallery) -> 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 { + .init( + keyword: $0.effectiveSearchText, + displayText: !$0.content.isEmpty ? $0.name : "" + ) + } + .removeDuplicates() + } + + var body: some View { + SubSection( + title: .RLocalizable.quickSearch, + 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: .recentlySearched, showAll: false) { + DoubleVerticalKeywordsStack( + keywords: keywords.map(WrappedKeyword.init), + searchAction: searchAction, + removeAction: removeAction + ) + } + } +} + +// MARK: HistoryGalleriesSection +private struct HistoryGalleriesSection: View { + private let galleries: [Gallery] + private let navigationAction: (Gallery) -> Void + + init(galleries: [Gallery], navigationAction: @escaping (Gallery) -> Void) { + self.galleries = galleries + self.navigationAction = navigationAction + } + + var body: some View { + SubSection(title: .recentlySeen, showAll: false) { + ScrollView(.horizontal, showsIndicators: false) { + HStack { + ForEach(galleries) { gallery in + Button { + navigationAction(gallery) + } label: { + GalleryHistoryCell(gallery: gallery) + .tint(.primary).multilineTextAlignment(.leading) + } + } + .withHorizontalSpacing() + } + } + } + } +} + +struct SearchRootView_Previews: PreviewProvider { + static var previews: some View { + SearchRootView( + store: .init(initialState: .init(), reducer: SearchRootReducer.init), + blurRadius: 0 + ) + } +} diff --git a/AppPackage/Sources/SearchFeature/SearchView.swift b/AppPackage/Sources/SearchFeature/SearchView.swift new file mode 100644 index 000000000..c23d4f49a --- /dev/null +++ b/AppPackage/Sources/SearchFeature/SearchView.swift @@ -0,0 +1,112 @@ +import SwiftUI +import AppModels +import TagTranslationFeature +import ComposableArchitecture +import AppTools +import AppComponents +import DateSeekFeature +import GalleryListComponents +import FiltersFeature +import QuickSearchFeature + +struct SearchView: View { + @Bindable private var store: StoreOf + private let blurRadius: Double + + init( + store: StoreOf, + blurRadius: Double + ) { + self.store = store + self.blurRadius = blurRadius + } + + var body: some View { + GenericList( + galleries: store.galleries, + pageNumber: store.pageNumber, + loadingState: store.loadingState, + footerLoadingState: store.footerLoadingState, + fetchAction: { store.send(.fetchGalleries()) }, + fetchMoreAction: { store.send(.fetchMoreGalleries) }, + navigateAction: { store.send(.delegate(.pushDetail($0))) }, + translateAction: { + store.tagTranslator.lookup(word: $0, returnOriginal: !store.setting.translatesTags) + }, + downloadBadges: store.downloadBadges + ) + .sheet( + item: $store.scope(state: \.destination?.quickSearch, action: \.destination.quickSearch) + ) { store in + QuickSearchView(store: store) { keyword in + self.store.send(.destination(.dismiss)) + self.store.send(.fetchGalleries(keyword)) + } + .accentColor(self.store.setting.accentColor) + .autoBlur(radius: blurRadius) + } + .sheet( + item: $store.scope(state: \.destination?.filters, action: \.destination.filters) + ) { store in + FiltersView(store: store) + .accentColor(self.store.setting.accentColor).autoBlur(radius: blurRadius) + } + .sheet( + item: $store.scope(state: \.destination?.dateSeek, action: \.destination.dateSeek) + ) { store in + @Bindable var store = store + DateSeekPickerView( + selectedDate: $store.date, + navigation: store.navigation, + seekAction: { store.send(.performSeek($0)) } + ) + .accentColor(self.store.setting.accentColor) + .autoBlur(radius: blurRadius) + } + .searchable(text: $store.keyword) + .searchSuggestions { + TagSuggestionView( + keyword: $store.keyword, translations: store.tagTranslator.translations, + showsImages: store.setting.showsImagesInTags, isEnabled: store.setting.showsTagsSearchSuggestion + ) + } + .onSubmit(of: .search) { + store.send(.fetchGalleries()) + } + .onAppear { + store.send(.onAppear) + if store.galleries.isEmpty { + DispatchQueue.main.async { + store.send(.fetchGalleries()) + } + } + } + .toolbar(content: toolbar) + .navigationTitle(store.lastKeyword) + } + + private func toolbar() -> some ToolbarContent { + CustomToolbarItem { + ToolbarFeaturesMenu { + DateSeekButton(navigation: store.dateSeekNavigation) { navigation in + store.send(.dateSeekButtonTapped(navigation)) + } + FiltersButton { + store.send(.filtersButtonTapped) + } + QuickSearchButton { + store.send(.quickSearchButtonTapped) + } + } + } + } +} + +struct SearchView_Previews: PreviewProvider { + static var previews: some View { + SearchView( + store: .init(initialState: .init(), reducer: SearchReducer.init), + blurRadius: 0 + ) + } +} diff --git a/AppPackage/Sources/SettingFeature/.swiftlint.yml b/AppPackage/Sources/SettingFeature/.swiftlint.yml new file mode 100644 index 000000000..1242ffcaa --- /dev/null +++ b/AppPackage/Sources/SettingFeature/.swiftlint.yml @@ -0,0 +1 @@ +parent_config: ../../../.swiftlint.yml diff --git a/AppPackage/Sources/SettingFeature/AccountSetting/AccountSettingReducer.swift b/AppPackage/Sources/SettingFeature/AccountSetting/AccountSettingReducer.swift new file mode 100644 index 000000000..d7c2fcb6b --- /dev/null +++ b/AppPackage/Sources/SettingFeature/AccountSetting/AccountSettingReducer.swift @@ -0,0 +1,144 @@ +import Foundation +import AppModels +import Resources +import ComposableArchitecture +import HapticsClient +import ClipboardClient +import CookieClient +import UserDefaultsClient +import AppTools +import AppComponents + +@Reducer +public struct AccountSettingReducer: Sendable { + @Reducer + public enum Destination { + @ReducerCaseIgnored + case webView(URL) + } + + public enum Dialog: Equatable, Sendable { + case confirmLogout + } + + // Pushes handled by SettingReducer, which owns the Setting navigation stack. + public enum Delegate: Equatable, Sendable { + case pushLogin + case pushEhSetting + } + + @ObservableState + public struct State: Equatable, Sendable { + @Presents public var destination: Destination.State? + @Presents public var confirmationDialog: ConfirmationDialogState? + @Presents public var toast: AppAlertState? + public var ehCookiesState: CookiesState = .empty(.ehentai) + public var exCookiesState: CookiesState = .empty(.exhentai) + + public init() {} + } + + public enum Action: BindableAction, Equatable { + case binding(BindingAction) + case toast(PresentationAction) + case destination(PresentationAction) + case presentWebView(URL) + case confirmationDialog(PresentationAction) + case delegate(Delegate) + case logoutButtonTapped + case onLogoutConfirmButtonTapped + case loadCookies + case copyCookies(GalleryHost) + case galleryHostChanged(GalleryHost) + } + + @Dependency(\.clipboardClient) private var clipboardClient + @Dependency(\.cookieClient) private var cookieClient + @Dependency(\.hapticsClient) private var hapticsClient + @Dependency(\.userDefaultsClient) private var userDefaultsClient + + public init() {} + + public var body: some Reducer { + BindingReducer() + .onChange(of: \.ehCookiesState) { _, state in + .run(operation: { [value = state.ehCookiesState] _ in cookieClient.setCookies(state: value) }) + } + .onChange(of: \.exCookiesState) { _, state in + .run(operation: { [value = state.exCookiesState] _ in cookieClient.setCookies(state: value) }) + } + + Reduce { state, action in + switch action { + case .binding: + return .none + + case .toast: + return .none + + case .destination: + return .none + + case .presentWebView(let url): + state.destination = .webView(url) + return .none + + case .delegate: + return .none + + case .logoutButtonTapped: + state.confirmationDialog = ConfirmationDialogState(titleVisibility: .hidden) { + TextState(localized: .logout) + } actions: { + ButtonState(role: .destructive, action: .confirmLogout) { + TextState(localized: .logout) + } + ButtonState(role: .cancel) { + TextState(localized: .RLocalizable.cancel) + } + } message: { + TextState(localized: .logoutDescription) + } + return .none + + case .confirmationDialog(.presented(.confirmLogout)): + return .send(.onLogoutConfirmButtonTapped) + + case .confirmationDialog: + return .none + + case .onLogoutConfirmButtonTapped: + return .send(.loadCookies) + + case .loadCookies: + state.ehCookiesState = cookieClient.loadCookiesState(host: .ehentai) + state.exCookiesState = cookieClient.loadCookiesState(host: .exhentai) + return .none + + case .copyCookies(let host): + state.toast = .copiedToClipboardSucceeded + let cookiesDescription = cookieClient.getCookiesDescription(host: host) + return .merge( + .run(operation: { _ in clipboardClient.saveText(cookiesDescription) }), + .run(operation: { _ in await hapticsClient.generateNotificationFeedback(.success) }) + ) + + // The picker writes `galleryHost` straight into `@Shared(.setting)`; mirror it into + // UserDefaults so the selected host survives independently of the persisted setting blob. + case .galleryHostChanged(let host): + return .run(operation: { _ in userDefaultsClient.setValue(host.rawValue, .galleryHost) }) + } + } + .haptics( + unwrapping: \.destination, + case: \.webView, + hapticsClient: hapticsClient + ) + .ifLet(\.$destination, action: \.destination) + .ifLet(\.$confirmationDialog, action: \.confirmationDialog) + .ifLet(\.$toast, action: \.toast) + } +} + +extension AccountSettingReducer.Destination.State: Equatable, Sendable {} +extension AccountSettingReducer.Destination.Action: Equatable, Sendable {} diff --git a/EhPanda/View/Setting/AccountSetting/AccountSettingView.swift b/AppPackage/Sources/SettingFeature/AccountSetting/AccountSettingView.swift similarity index 54% rename from EhPanda/View/Setting/AccountSetting/AccountSettingView.swift rename to AppPackage/Sources/SettingFeature/AccountSetting/AccountSettingView.swift index 09b2d9b95..ec391a19b 100644 --- a/EhPanda/View/Setting/AccountSetting/AccountSettingView.swift +++ b/AppPackage/Sources/SettingFeature/AccountSetting/AccountSettingView.swift @@ -1,27 +1,19 @@ -// -// AccountSettingView.swift -// EhPanda -// - import SwiftUI +import AppModels +import Sharing +import AppComponents +import Resources import ComposableArchitecture +import AppTools +import SystemNotificationExt struct AccountSettingView: View { @Bindable private var store: StoreOf - @Binding private var galleryHost: GalleryHost - @Binding private var showsNewDawnGreeting: Bool - private let bypassesSNIFiltering: Bool + @Shared(.setting) private var setting: Setting private let blurRadius: Double - init( - store: StoreOf, - galleryHost: Binding, showsNewDawnGreeting: Binding, - bypassesSNIFiltering: Bool, blurRadius: Double - ) { + init(store: StoreOf, blurRadius: Double) { self.store = store - _galleryHost = galleryHost - _showsNewDawnGreeting = showsNewDawnGreeting - self.bypassesSNIFiltering = bypassesSNIFiltering self.blurRadius = blurRadius } @@ -29,25 +21,23 @@ struct AccountSettingView: View { var body: some View { Form { Section { - Picker("", selection: $galleryHost) { + Picker(.website, selection: Binding($setting.galleryHost)) { ForEach(GalleryHost.allCases) { Text($0.rawValue).tag($0) } } .pickerStyle(.segmented) + .labelsHidden() AccountSection( - route: $store.route, - showsNewDawnGreeting: $showsNewDawnGreeting, - bypassesSNIFiltering: bypassesSNIFiltering, - loginAction: { store.send(.setNavigation(.login)) }, - logoutAction: { - DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) { - store.send(.onLogoutConfirmButtonTapped) - } - }, - logoutDialogAction: { store.send(.setNavigation(.logout)) }, - configureAccountAction: { store.send(.setNavigation(.ehSetting)) }, - manageTagsAction: { store.send(.setNavigation(.webView(Defaults.URL.myTags))) } + showsNewDawnGreeting: Binding($setting.showsNewDawnGreeting), + bypassesSNIFiltering: setting.bypassesSNIFiltering, + loginAction: { store.send(.delegate(.pushLogin)) }, + logoutDialogAction: { store.send(.logoutButtonTapped) }, + logoutConfirmationDialog: $store.scope( + state: \.confirmationDialog, action: \.confirmationDialog + ), + configureAccountAction: { store.send(.delegate(.pushEhSetting)) }, + manageTagsAction: { store.send(.presentWebView(Defaults.URL.myTags)) } ) } CookieSection( @@ -56,101 +46,72 @@ struct AccountSettingView: View { copyAction: { store.send(.copyCookies($0)) } ) } - .progressHUD( - config: store.hudConfig, - unwrapping: $store.route, - case: \.hud - ) - .sheet(item: $store.route.sending(\.setNavigation).webView, id: \.absoluteString) { url in - WebView(url: url) + .toast($store.scope(state: \.toast, action: \.toast)) + .sheet(item: $store.destination.webView, id: \.absoluteString) { url in + WebView(url: url.wrappedValue) .ignoresSafeArea(edges: .bottom) .autoBlur(radius: blurRadius) } .onAppear { store.send(.loadCookies) } - .background(navigationLinks) - .navigationTitle(L10n.Localizable.AccountSettingView.Title.account) - } -} - -// MARK: NavigationLinks -private extension AccountSettingView { - @ViewBuilder var navigationLinks: some View { - NavigationLink(unwrapping: $store.route, case: \.login) { _ in - LoginView( - store: store.scope(state: \.loginState, action: \.login), - bypassesSNIFiltering: bypassesSNIFiltering, blurRadius: blurRadius - ) - } - NavigationLink(unwrapping: $store.route, case: \.ehSetting) { _ in - EhSettingView( - store: store.scope(state: \.ehSettingState, action: \.ehSetting), - bypassesSNIFiltering: bypassesSNIFiltering, blurRadius: blurRadius - ) + .onChange(of: setting.galleryHost) { _, newValue in + store.send(.galleryHostChanged(newValue)) } + .navigationTitle(.account) } } // MARK: AccountSection private struct AccountSection: View { - @Binding private var route: AccountSettingReducer.Route? @Binding private var showsNewDawnGreeting: Bool private let bypassesSNIFiltering: Bool private let loginAction: () -> Void - private let logoutAction: () -> Void private let logoutDialogAction: () -> Void + private let logoutConfirmationDialog: + Binding, AccountSettingReducer.Dialog>?> private let configureAccountAction: () -> Void private let manageTagsAction: () -> Void init( - route: Binding, showsNewDawnGreeting: Binding, bypassesSNIFiltering: Bool, - loginAction: @escaping () -> Void, logoutAction: @escaping () -> Void, + loginAction: @escaping () -> Void, logoutDialogAction: @escaping () -> Void, + logoutConfirmationDialog: + Binding, AccountSettingReducer.Dialog>?>, configureAccountAction: @escaping () -> Void, manageTagsAction: @escaping () -> Void ) { - _route = route _showsNewDawnGreeting = showsNewDawnGreeting self.bypassesSNIFiltering = bypassesSNIFiltering self.loginAction = loginAction - self.logoutAction = logoutAction self.logoutDialogAction = logoutDialogAction + self.logoutConfirmationDialog = logoutConfirmationDialog self.configureAccountAction = configureAccountAction self.manageTagsAction = manageTagsAction } var body: some View { if !CookieUtil.didLogin { - Button(L10n.Localizable.AccountSettingView.Button.login, action: loginAction) + Button(.RLocalizable.login, action: loginAction) } else { Button( - L10n.Localizable.ConfirmationDialog.Button.logout, + .logout, role: .destructive, action: logoutDialogAction ) - .confirmationDialog( - message: L10n.Localizable.ConfirmationDialog.Title.logout, - unwrapping: $route, - case: \.logout - ) { - Button( - L10n.Localizable.ConfirmationDialog.Button.logout, - role: .destructive, action: logoutAction - ) - } + .confirmationDialog(logoutConfirmationDialog) Group { Button( - L10n.Localizable.AccountSettingView.Button.accountConfiguration, + .accountConfiguration, action: configureAccountAction ) .withArrow() if !bypassesSNIFiltering { Button( - L10n.Localizable.AccountSettingView.Button.tagsManagement, + .tagsManagement, action: manageTagsAction ) .withArrow() } - Toggle(L10n.Localizable.AccountSettingView.Title.showsNewDawnGreeting, isOn: $showsNewDawnGreeting) + Toggle(.showsNewDawnGreeting, isOn: $showsNewDawnGreeting) } .foregroundColor(.primary) } @@ -177,7 +138,7 @@ private struct CookieSection: View { Section(GalleryHost.ehentai.rawValue) { CookieRow(cookieState: $ehCookiesState.memberID) CookieRow(cookieState: $ehCookiesState.passHash) - Button(L10n.Localizable.AccountSettingView.Button.copyCookies) { + Button(.copyCookies) { copyAction(.ehentai) } .foregroundStyle(.tint).font(.subheadline) @@ -186,7 +147,7 @@ private struct CookieSection: View { CookieRow(cookieState: $exCookiesState.igneous) CookieRow(cookieState: $exCookiesState.memberID) CookieRow(cookieState: $exCookiesState.passHash) - Button(L10n.Localizable.AccountSettingView.Button.copyCookies) { + Button(.copyCookies) { copyAction(.exhentai) } .foregroundStyle(.tint).font(.subheadline) @@ -218,12 +179,9 @@ private struct CookieRow: View { struct AccountSettingView_Previews: PreviewProvider { static var previews: some View { - NavigationView { + NavigationStack { AccountSettingView( store: .init(initialState: .init(), reducer: AccountSettingReducer.init), - galleryHost: .constant(.ehentai), - showsNewDawnGreeting: .constant(false), - bypassesSNIFiltering: false, blurRadius: 0 ) } diff --git a/AppPackage/Sources/SettingFeature/AppActivityLogs/AppActivityLogsPumpReducer.swift b/AppPackage/Sources/SettingFeature/AppActivityLogs/AppActivityLogsPumpReducer.swift new file mode 100644 index 000000000..31b78d84d --- /dev/null +++ b/AppPackage/Sources/SettingFeature/AppActivityLogs/AppActivityLogsPumpReducer.swift @@ -0,0 +1,104 @@ +import OSLogExt +import Foundation +import AppModels +import Sharing +import LogsClient +import ComposableArchitecture + +private let logger = Logger(category: .init(describing: AppActivityLogsPumpReducer.self)) + +// The always-alive, view-less activity-logs pump. Owned by AppReducer so it outlives Setting +// navigation, it derives the current run once per app run, appends new OS log entries to that run's +// jsonl file every few seconds, and publishes the live current run + its logs via in-memory shared +// state to the (navigation-scoped, read-only) `AppActivityLogsReducer` screen. +@Reducer +public struct AppActivityLogsPumpReducer: Sendable { + private enum CancelID { + case pump + } + + @ObservableState + public struct State: Equatable, Sendable { + @Shared(.appActivityLogsCurrentRun) public var currentRun: RunLogFile? + @Shared(.appActivityLogsCurrentRunLogs) public var currentRunLogs: [AppActivityLog] + public var lastCursorDate: Date? + + public init() {} + } + + public enum Action: Equatable, Sendable { + case startPump + case pausePump + case setCurrentRun(RunLogFile) + case didReceiveNewEntries([AppActivityLog]) + } + + @Dependency(\.logsClient) private var logsClient + @Dependency(\.continuousClock) private var clock + @Dependency(\.date) private var date + + public init() {} + + public var body: some Reducer { + Reduce { state, action in + switch action { + case .startPump: + return .run { [existingURL = state.currentRun?.url, cursor0 = state.lastCursorDate] send in + let fileURL: URL + if let existingURL { + fileURL = existingURL + } else { + let now = date.now + let runCount = await logsClient.nextRunCount(now) + let resolvedURL = logsClient.currentRunFileURL(runCount, now) + await send(.setCurrentRun(RunLogFile(url: resolvedURL, date: now, runCount: runCount))) + fileURL = resolvedURL + let appVersion = Bundle.main + .object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String ?? "(null)" + logger.log( + """ + App activity logging started. + Run \(runCount, privacy: .public) + App Version \(appVersion, privacy: .public) + OS \(ProcessInfo.processInfo.operatingSystemVersionString, privacy: .public) + """ + ) + } + + var cursor = cursor0 + while !Task.isCancelled { + let newEntries = (try? await logsClient.fetchNewEntries(cursor)) ?? [] + if let lastDate = newEntries.last?.date { + cursor = lastDate + await send(.didReceiveNewEntries(newEntries)) + try? await logsClient.appendToRunFile(newEntries, fileURL) + } + try await clock.sleep(for: .seconds(5)) + } + } + .cancellable(id: CancelID.pump, cancelInFlight: true) + + case .pausePump: + return .merge( + .run { [cursor = state.lastCursorDate, fileURL = state.currentRun?.url] send in + guard let fileURL else { return } + let newEntries = (try? await logsClient.fetchNewEntries(cursor)) ?? [] + guard !newEntries.isEmpty else { return } + await send(.didReceiveNewEntries(newEntries)) + try? await logsClient.appendToRunFile(newEntries, fileURL) + }, + .cancel(id: CancelID.pump) + ) + + case let .setCurrentRun(run): + state.$currentRun.withLock { $0 = run } + return .none + + case .didReceiveNewEntries(let entries): + state.$currentRunLogs.withLock { $0.append(contentsOf: entries) } + state.lastCursorDate = entries.last?.date ?? state.lastCursorDate + return .none + } + } + } +} diff --git a/AppPackage/Sources/SettingFeature/AppActivityLogs/AppActivityLogsReducer.swift b/AppPackage/Sources/SettingFeature/AppActivityLogs/AppActivityLogsReducer.swift new file mode 100644 index 000000000..3a272ca96 --- /dev/null +++ b/AppPackage/Sources/SettingFeature/AppActivityLogs/AppActivityLogsReducer.swift @@ -0,0 +1,96 @@ +import Foundation +import AppModels +import Sharing +import LogsClient +import ApplicationClient +import ComposableArchitecture + +@Reducer +public struct AppActivityLogsReducer: Sendable { + @ObservableState + public struct State: Equatable, Sendable { + // Live, read-only view of the current run + its logs, published by the always-alive + // `AppActivityLogsPumpReducer`. Read-only so the screen can never write stale data back. + @SharedReader(.appActivityLogsCurrentRun) public var currentRun: RunLogFile? + @SharedReader(.appActivityLogsCurrentRunLogs) public var currentRunLogs: [AppActivityLog] + + // File-backed log files for previous runs (excludes the current run). + public var previousRuns = [RunLogFile]() + // File-backed logs for the currently selected previous run. + public var selectedRunLogs = [AppActivityLog]() + + // `nil` selects the current run (live/shared); otherwise a previous run's file URL. + public var selectedRun: URL? + public var keyword = "" + public var loadingState: LoadingState = .idle + + // Derived live from the shared current-run buffer (or the selected previous run), newest + // first. Computed rather than stored so it tracks the pump's shared writes without an action. + public var displayedLogs: [AppActivityLog] { + let source = selectedRun == nil ? currentRunLogs : selectedRunLogs + let filtered = keyword.isEmpty ? source : source.filter { log in + [log.dateDescription, String(localized: log.level.title), log.category, log.message] + .joined(separator: " ") + .caseInsensitiveContains(keyword) + } + return filtered.sorted { $0.date > $1.date } + } + + public init() {} + } + + public enum Action: Equatable, Sendable { + case refreshAvailableRuns + case availableRunsResponse([RunLogFile]) + case selectRun(URL?) + case runFileResponse([AppActivityLog]) + case queryLogs(String) + case navigateToFileApp + } + + @Dependency(\.logsClient) private var logsClient + @Dependency(\.applicationClient) private var applicationClient + + public init() {} + + public var body: some Reducer { + Reduce { state, action in + switch action { + case .refreshAvailableRuns: + return .run { send in + await send(.availableRunsResponse(await logsClient.listRunFiles())) + } + + case .availableRunsResponse(let runs): + // Exclude the current run by file (its count can repeat on earlier days). + state.previousRuns = runs.filter { $0.url != state.currentRun?.url } + return .none + + case .selectRun(let url): + guard let url, state.previousRuns.contains(where: { $0.url == url }) else { + state.selectedRun = nil + state.selectedRunLogs = [] + return .none + } + state.selectedRun = url + state.loadingState = .loading + return .run { send in + let logs = (try? await logsClient.readRunFile(url)) ?? [] + await send(.runFileResponse(logs)) + } + + case .runFileResponse(let logs): + state.loadingState = .idle + state.selectedRunLogs = logs + return .none + + case .queryLogs(let keyword): + state.keyword = keyword + return .none + + case .navigateToFileApp: + return .run { _ in await applicationClient.openFileApp() } + } + } + } +} diff --git a/AppPackage/Sources/SettingFeature/AppActivityLogs/AppActivityLogsSharedKeys.swift b/AppPackage/Sources/SettingFeature/AppActivityLogs/AppActivityLogsSharedKeys.swift new file mode 100644 index 000000000..424a28d9e --- /dev/null +++ b/AppPackage/Sources/SettingFeature/AppActivityLogs/AppActivityLogsSharedKeys.swift @@ -0,0 +1,17 @@ +import Sharing +import AppModels + +// In-memory shared keys bridging the always-alive activity-logs pump (writer, owned by AppReducer) +// to the read-only activity-logs screen (a Setting-stack path element). Domain-typed keys live in +// their owning feature rather than a generic keys module, per the shared-keys design. +extension SharedReaderKey where Self == InMemoryKey.Default { + static var appActivityLogsCurrentRun: Self { + Self[.inMemory("appActivityLogs.currentRun"), default: nil] + } +} + +extension SharedReaderKey where Self == InMemoryKey<[AppActivityLog]>.Default { + static var appActivityLogsCurrentRunLogs: Self { + Self[.inMemory("appActivityLogs.currentRunLogs"), default: []] + } +} diff --git a/AppPackage/Sources/SettingFeature/AppActivityLogs/AppActivityLogsView.swift b/AppPackage/Sources/SettingFeature/AppActivityLogs/AppActivityLogsView.swift new file mode 100644 index 000000000..0caaaee58 --- /dev/null +++ b/AppPackage/Sources/SettingFeature/AppActivityLogs/AppActivityLogsView.swift @@ -0,0 +1,237 @@ +import SwiftUI +import Resources +import AppModels +import AppComponents +import SFSafeSymbols +import ComposableArchitecture +import SFSafeSymbolsExt + +struct AppActivityLogsView: View { + @Bindable private var store: StoreOf + + @State private var keyword = "" + @State private var isRunPickerPresented = false + + init(store: StoreOf) { + self.store = store + } + + var body: some View { + ZStack { + List(store.displayedLogs) { log in + AppActivityLogRow(log: log) + } + .listStyle(.plain) + .opacity(store.displayedLogs.isEmpty ? 0 : 1) + + LoadingView() + .opacity(store.loadingState == .loading && store.displayedLogs.isEmpty ? 1 : 0) + + Text(.appActivityLogsViewNoLogs) + .foregroundColor(.secondary) + .opacity(store.loadingState != .loading && store.displayedLogs.isEmpty ? 1 : 0) + } + .searchable(text: $keyword) + .onSubmit(of: .search) { + store.send(.queryLogs(keyword)) + } + .onChange(of: keyword) { oldValue, newValue in + if !oldValue.isEmpty, newValue.isEmpty { + store.send(.queryLogs(newValue)) + } + } + .onAppear { + store.send(.refreshAvailableRuns) + } + .toolbar(content: toolbar) + .navigationTitle(.appActivityLogsViewTitle) + .navigationBarTitleDisplayMode(.large) + .sheet(isPresented: $isRunPickerPresented) { + RunPickerSheet(store: store) { isRunPickerPresented = false } + } + } + + @ToolbarContentBuilder + private func toolbar() -> some ToolbarContent { + ToolbarItem(placement: .navigationBarTrailing) { + Menu { + runMenu + } label: { + Image(systemSymbol: .clock) + } + } + ToolbarItem(placement: .navigationBarTrailing) { + Button { + store.send(.navigateToFileApp) + } label: { + Label(.appActivityLogsViewOpenInFiles, systemSymbol: .folderBadgeGearshape) + } + } + } + + @ViewBuilder + private var runMenu: some View { + Section(.appActivityLogsViewCurrent) { + RunButton( + run: store.currentRun, + isSelected: store.selectedRun == nil + ) { + store.send(.selectRun(nil)) + } + } + + // Show the latest runs including the current one: current + 4 previous = 5 rows. + ForEach(groupedRuns(Array(store.previousRuns.prefix(4))), id: \.day) { group in + Section(runDayFormatter.string(from: group.day)) { + ForEach(group.runs) { run in + RunButton( + run: run, + isSelected: store.selectedRun == run.url + ) { + store.send(.selectRun(run.url)) + } + } + } + } + + Section { + Button { + isRunPickerPresented = true + } label: { + Label(.appActivityLogsViewMoreLogs, systemSymbol: .ellipsisCalendar) + } + } + } +} + +// MARK: RunPickerSheet +private struct RunPickerSheet: View { + @Bindable var store: StoreOf + let dismissAction: () -> Void + + var body: some View { + NavigationStack { + List { + Section(.appActivityLogsViewCurrent) { + RunButton( + run: store.currentRun, + isSelected: store.selectedRun == nil + ) { + store.send(.selectRun(nil)) + dismissAction() + } + } + + ForEach(groupedRuns(store.previousRuns), id: \.day) { group in + Section(runDayFormatter.string(from: group.day)) { + ForEach(group.runs) { run in + RunButton( + run: run, + isSelected: store.selectedRun == run.url + ) { + store.send(.selectRun(run.url)) + dismissAction() + } + } + } + } + } + .navigationTitle(.appActivityLogsViewRuns) + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button(role: .cancel, action: dismissAction) + } + } + } + } +} + +// MARK: RunButton +private struct RunButton: View { + let run: RunLogFile? + let isSelected: Bool + let action: () -> Void + + var body: some View { + Button(action: action) { + if isSelected { + Label(runLabel(run), systemSymbol: .checkmark) + } else { + Text(runLabel(run)) + } + } + .foregroundStyle(.primary) + } +} + +// A nil run is the current run before its count is resolved; fall back to "Current". +private func runLabel(_ run: RunLogFile?) -> String { + guard let run else { + return String(localized: .appActivityLogsViewCurrent) + } + let title = String(localized: .appActivityLogsViewRun(count: run.runCount)) + return "\(title) (\(runTimeFormatter.string(from: run.date)))" +} + +private func groupedRuns(_ runs: [RunLogFile]) -> [(day: Date, runs: [RunLogFile])] { + Dictionary(grouping: runs) { Calendar.current.startOfDay(for: $0.date) } + .map { (day: $0.key, runs: $0.value.sorted { $0.runCount > $1.runCount }) } + .sorted { $0.day > $1.day } +} + +private let runDayFormatter: DateFormatter = { + let formatter = DateFormatter() + formatter.dateStyle = .medium + formatter.timeStyle = .none + return formatter +}() + +// 24-hour HH:mm; the day is already shown by the section header. +private let runTimeFormatter: DateFormatter = { + let formatter = DateFormatter() + formatter.dateFormat = "HH:mm" + formatter.locale = Locale(identifier: "en_US_POSIX") + return formatter +}() + +// MARK: AppActivityLogRow +private struct AppActivityLogRow: View { + let log: AppActivityLog + + var body: some View { + VStack(alignment: .leading, spacing: 4) { + HStack(alignment: .firstTextBaseline, spacing: 4) { + Image(systemSymbol: .circleFill) + .foregroundColor(log.level.color) + .font(.caption2) + Text(log.dateDescription) + if !log.category.isEmpty { + Text(log.category) + .foregroundColor(.primary) + .padding(.vertical, 2) + .padding(.horizontal, 4) + .background(Color(.systemGray5)) + .clipShape(.rect(cornerRadius: 4)) + .bold() + .lineLimit(1) + } + } + Text(log.message) + .lineLimit(30) + } + .font(.caption.monospaced()) + .padding(.vertical, 4) + .frame(maxWidth: .infinity, alignment: .leading) + } +} + +struct AppActivityLogsView_Previews: PreviewProvider { + static var previews: some View { + NavigationStack { + AppActivityLogsView( + store: .init(initialState: .init(), reducer: AppActivityLogsReducer.init) + ) + } + } +} diff --git a/AppPackage/Sources/SettingFeature/AppearanceSetting/AppIconReducer.swift b/AppPackage/Sources/SettingFeature/AppearanceSetting/AppIconReducer.swift new file mode 100644 index 000000000..87bd7ee49 --- /dev/null +++ b/AppPackage/Sources/SettingFeature/AppearanceSetting/AppIconReducer.swift @@ -0,0 +1,49 @@ +import AppModels +import Sharing +import ComposableArchitecture +import ApplicationClient + +@Reducer +public struct AppIconReducer: Sendable { + @ObservableState + public struct State: Equatable, Sendable { + @Shared(.setting) public var setting: Setting + public init() {} + } + + public enum Action: Equatable, Sendable { + // The view writes `appIconType` straight into `@Shared(.setting)`, which dispatches no action, + // so it bridges the change here; this reducer applies it to the system icon and reconciles the + // stored value back from whatever icon actually took effect. + case appIconTypeChanged(AppIconType) + case syncAppIconType + case syncAppIconTypeDone(String?) + } + + @Dependency(\.applicationClient) private var applicationClient + + public init() {} + + public var body: some Reducer { + Reduce { state, action in + switch action { + case .appIconTypeChanged(let iconType): + return .run { send in + _ = await applicationClient.setAlternateIconName(iconType.filename) + await send(.syncAppIconType) + } + + case .syncAppIconType: + return .run { send in + await send(.syncAppIconTypeDone(await applicationClient.alternateIconName())) + } + + case .syncAppIconTypeDone(let iconName): + if let iconName { + state.$setting.withLock { $0.appIconType = .matching(alternateIconName: iconName) } + } + return .none + } + } + } +} diff --git a/AppPackage/Sources/SettingFeature/AppearanceSetting/AppearanceSettingReducer.swift b/AppPackage/Sources/SettingFeature/AppearanceSetting/AppearanceSettingReducer.swift new file mode 100644 index 000000000..b12c83afd --- /dev/null +++ b/AppPackage/Sources/SettingFeature/AppearanceSetting/AppearanceSettingReducer.swift @@ -0,0 +1,39 @@ +import AppModels +import ComposableArchitecture +import ApplicationClient + +@Reducer +public struct AppearanceSettingReducer: Sendable { + // Pushes handled by SettingReducer, which owns the Setting navigation stack. + public enum Delegate: Equatable, Sendable { + case pushAppIcon + } + + @ObservableState + public struct State: Equatable, Sendable { + public init() {} + } + + public enum Action: Equatable, Sendable { + case delegate(Delegate) + // The theme picker writes `preferredColorScheme` into `@Shared(.setting)`, which dispatches no + // action, so the view bridges the change here for this reducer to apply the interface style. + case preferredColorSchemeChanged(PreferredColorScheme) + } + + @Dependency(\.applicationClient) private var applicationClient + + public init() {} + + public var body: some Reducer { + Reduce { _, action in + switch action { + case .delegate: + return .none + + case .preferredColorSchemeChanged(let colorScheme): + return .run { _ in await applicationClient.setUserInterfaceStyle(colorScheme.userInterfaceStyle) } + } + } + } +} diff --git a/AppPackage/Sources/SettingFeature/AppearanceSetting/AppearanceSettingView.swift b/AppPackage/Sources/SettingFeature/AppearanceSetting/AppearanceSettingView.swift new file mode 100644 index 000000000..4407f59af --- /dev/null +++ b/AppPackage/Sources/SettingFeature/AppearanceSetting/AppearanceSettingView.swift @@ -0,0 +1,156 @@ +import SwiftUI +import AppModels +import Sharing +import Resources +import ComposableArchitecture +import AppComponents + +struct AppearanceSettingView: View { + private let store: StoreOf + @Shared(.setting) private var setting: Setting + + init(store: StoreOf) { + self.store = store + } + + var body: some View { + Form { + Section { + Picker( + .theme, + selection: Binding($setting.preferredColorScheme) + ) { + ForEach(PreferredColorScheme.allCases) { colorScheme in + Text(colorScheme.value) + .tag(colorScheme) + } + } + .pickerStyle(.menu) + + ColorPicker(.tintColor, selection: Binding($setting.accentColor)) + + Button(.appIcon) { + store.send(.delegate(.pushAppIcon)) + } + .foregroundStyle(.primary) + .withArrow() + } + Section(.list) { + Picker( + .appearanceDisplayMode, + selection: Binding($setting.listDisplayMode), + content: { + ForEach(ListDisplayMode.allCases) { listMode in + Text(listMode.value) + .tag(listMode) + } + } + ) + .pickerStyle(.menu) + + Toggle(isOn: Binding($setting.showsTagsInList)) { + Text(.showsTagsInList) + } + + Picker( + .maximumNumberOfTags, + selection: Binding($setting.listTagsNumberMaximum) + ) { + Text(.infite) + .tag(0) + + ForEach(Array(stride(from: 5, through: 20, by: 5)), id: \.self) { num in + Text(num, format: .number) + .tag(num) + } + } + .pickerStyle(.menu) + .disabled(!setting.showsTagsInList) + } + Section(.gallery) { + Toggle( + .displaysJapaneseTitle, + isOn: Binding($setting.displaysJapaneseTitle) + ) + } + } + .navigationTitle(.appearance) + .onChange(of: setting.preferredColorScheme) { _, newValue in + store.send(.preferredColorSchemeChanged(newValue)) + } + } +} + +// MARK: SelectAppIconView +struct AppIconView: View { + private let store: StoreOf + @Shared(.setting) private var setting: Setting + + init(store: StoreOf) { + self.store = store + } + + var body: some View { + Form { + Section { + ForEach(AppIconType.allCases) { icon in + AppIconRow( + iconName: icon.name, + filename: icon.filename, + isSelected: icon == setting.appIconType + ) + .contentShape(.rect) + .onTapGesture { $setting.withLock { $0.appIconType = icon } } + } + } + } + .navigationTitle(.appIcon) + .onChange(of: setting.appIconType) { _, newValue in + store.send(.appIconTypeChanged(newValue)) + } + } +} + +// MARK: AppIconRow +private struct AppIconRow: View { + private let iconName: LocalizedStringResource + private let filename: String + private let isSelected: Bool + + init(iconName: LocalizedStringResource, filename: String, isSelected: Bool) { + self.iconName = iconName + self.filename = filename + self.isSelected = isSelected + } + + var body: some View { + HStack(spacing: 20) { + UIImage(named: filename, in: .main, with: nil) + .map(Image.init)? + .resizable() + .scaledToFit() + .frame(width: 60, height: 60) + .clipShape(.rect(cornerRadius: 15)) + .padding(.vertical, 10) + + Text(iconName) + + Spacer() + + Image(systemSymbol: .checkmarkCircleFill) + .opacity(isSelected ? 1 : 0) + .foregroundStyle(.tint) + .imageScale(.large) + } + } +} + +struct AppearanceSettingView_Previews: PreviewProvider { + static var previews: some View { + NavigationStack { + AppearanceSettingView( + store: .init(initialState: .init(), reducer: AppearanceSettingReducer.init) + ) + } + } +} diff --git a/AppPackage/Sources/SettingFeature/Components/AboutView.swift b/AppPackage/Sources/SettingFeature/Components/AboutView.swift new file mode 100644 index 000000000..f2a04bafc --- /dev/null +++ b/AppPackage/Sources/SettingFeature/Components/AboutView.swift @@ -0,0 +1,239 @@ +import AppModels +import SwiftUI +import Resources +import AppTools +import AppComponents + +struct AboutView: View { + private var version: String { + [ + String(localized: .version), + AppUtil.version, "(\(AppUtil.build))" + ] + .joined(separator: " ") + } + + var body: some View { + Form { + Section { + ForEach(contacts) { contact in + LinkRow(urlString: contact.urlString, text: contact.text) + } + } + Section(.specialThanks) { + ForEach(specialThanks) { specialThank in + LinkRow(urlString: specialThank.urlString, text: specialThank.text) + } + } + Section(.codeLevelContributors) { + ForEach(codeLevelContributors) { codeLevelContributor in + LinkRow(urlString: codeLevelContributor.urlString, text: codeLevelContributor.text) + } + } + Section(.translationContributors) { + ForEach(translationContributors) { translationContributor in + LinkRow(urlString: translationContributor.urlString, text: translationContributor.text) + } + } + Section(.acknowledgements) { + ForEach(acknowledgements) { acknowledgement in + LinkRow(urlString: acknowledgement.urlString, text: acknowledgement.text) + } + } + } + .navigationTitle(.ehPanda) + .toolbar { + ToolbarItem(placement: .largeSubtitle) { + VStack(alignment: .leading) { + Text(.Constant.copyright) + Text(version) + } + .foregroundStyle(.gray) + .font(.caption2.bold()) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.vertical, 4) + } + } + } + + // MARK: Contacts + private let contacts: [Info] = {[ + .init( + urlString: String(localized: .Constant.contactWebsite), + text: .website + ), + .init( + urlString: String(localized: .Constant.contactGitHub), + text: .Constant.contactGitHubLink + ), + .init( + urlString: String(localized: .Constant.contactDiscord), + text: .Constant.contactDiscordLink + ), + .init( + urlString: String(localized: .Constant.contactTelegram), + text: .Constant.contactTelegramLink + ), + .init( + urlString: String(localized: .Constant.contactAltStoreLink), + text: .altStoreSource + ) + ]}() + + // MARK: Special thanks + private let specialThanks: [Info] = {[ + .init( + urlString: String(localized: .Constant.specialThanksTaylorlannisterLink), + text: .Constant.specialThanksTaylorlannister + ), + .init( + urlString: String(localized: .Constant.specialThanksLuminescentYqLink), + text: .Constant.specialThanksLuminescentYq + ), + .init( + urlString: String(localized: .Constant.specialThanksCaxerxLink), + text: .Constant.specialThanksCaxerx + ), + .init( + urlString: String(localized: .Constant.specialThanksHonjowLink), + text: .Constant.specialThanksHonjow + ) + ]}() + + // MARK: Code level contributors + private let codeLevelContributors: [Info] = {[ + .init( + urlString: String(localized: .Constant.codeLevelContributorVvbbnn00Link), + text: .Constant.codeLevelContributorVvbbnn00 + ), + .init( + urlString: String(localized: .Constant.codeLevelContributorKaed3MiLink), + text: .Constant.codeLevelContributorKaed3Mi + ), + .init( + urlString: String(localized: .Constant.codeLevelContributorAalberrtyLink), + text: .Constant.codeLevelContributorAalberrty + ), + .init( + urlString: String(localized: .Constant.codeLevelContributorJimmyPrimeLink), + text: .Constant.codeLevelContributorJimmyPrime + ), + .init( + urlString: String(localized: .Constant.codeLevelContributorXioxinLink), + text: .Constant.codeLevelContributorXioxin + ) + ]}() + + // MARK: Translation contributors + private let translationContributors: [Info] = {[ + .init( + urlString: String(localized: .Constant.translationContributorNebulosaCatLink), + text: .Constant.translationContributorNebulosaCat + ), + .init( + urlString: String(localized: .Constant.translationContributorPaulHaeusslerLink), + text: .Constant.translationContributorPaulHaeussler + ), + .init( + urlString: String(localized: .Constant.translationContributorCaxerxLink), + text: .Constant.translationContributorCaxerx + ), + .init( + urlString: String(localized: .Constant.translationContributorNeKoOuOLink), + text: .Constant.translationContributorNeKoOuO + ) + ]}() + + // MARK: Acknowledgements + private let acknowledgements: [Info] = {[ + .init( + urlString: String(localized: .Constant.acknowledgementKannaLink), + text: .Constant.acknowledgementKanna + ), + .init( + urlString: String(localized: .Constant.acknowledgementColorfulLink), + text: .Constant.acknowledgementColorful + ), + .init( + urlString: String(localized: .Constant.acknowledgementKingfisherLink), + text: .Constant.acknowledgementKingfisher + ), + .init( + urlString: String(localized: .Constant.acknowledgementSwiftUIPagerLink), + text: .Constant.acknowledgementSwiftUIPager + ), + .init( + urlString: String(localized: .Constant.acknowledgementWaterfallGridLink), + text: .Constant.acknowledgementWaterfallGrid + ), + .init( + urlString: String(localized: .Constant.acknowledgementSwiftyOpenCCLink), + text: .Constant.acknowledgementSwiftyOpenCC + ), + .init( + urlString: String(localized: .Constant.acknowledgementUiImageColorsLink), + text: .Constant.acknowledgementUiImageColors + ), + .init( + urlString: String(localized: .Constant.acknowledgementSfSafeSymbolsLink), + text: .Constant.acknowledgementSfSafeSymbols + ), + .init( + urlString: String(localized: .Constant.acknowledgementSystemNotificationLink), + text: .Constant.acknowledgementSystemNotification + ), + .init( + urlString: String(localized: .Constant.acknowledgementSwiftCommonMarkLink), + text: .Constant.acknowledgementSwiftCommonMark + ), + .init( + urlString: String(localized: .Constant.acknowledgementEhTagTranslationDatabaseLink), + text: .Constant.acknowledgementEhTagTranslationDatabase + ), + .init( + urlString: String(localized: .Constant.acknowledgementTcaLink), + text: .Constant.acknowledgementTca + ) + ]}() +} + +// MARK: LinkRow +private struct LinkRow: View { + private let urlString: String + private let text: LocalizedStringResource + + init(urlString: String, text: LocalizedStringResource) { + self.urlString = urlString + self.text = text + } + + var body: some View { + ZStack { + let text = Text(text).fontWeight(.medium) + if let url = URL(string: urlString) { + Link(destination: url) { + text.withArrow() + } + } else { + text + } + } + .foregroundColor(.primary) + } +} + +// MARK: Definition +private struct Info: Identifiable { + var id: String { urlString } + + let urlString: String + let text: LocalizedStringResource +} + +struct EhPandaView_Previews: PreviewProvider { + static var previews: some View { + NavigationStack { + AboutView() + } + } +} diff --git a/AppPackage/Sources/SettingFeature/Components/DownloadSettingView.swift b/AppPackage/Sources/SettingFeature/Components/DownloadSettingView.swift new file mode 100644 index 000000000..6abaf332e --- /dev/null +++ b/AppPackage/Sources/SettingFeature/Components/DownloadSettingView.swift @@ -0,0 +1,53 @@ +import SwiftUI +import AppModels +import Sharing +import Resources + +struct DownloadSettingView: View { + @Shared(.setting) private var setting: Setting + + var body: some View { + Form { + Section { + VStack(alignment: .leading) { + LabeledContent(.concurrentImageDownloads) { + Text(setting.downloadThreadLimit, format: .number) + .monospacedDigit() + } + Slider(value: downloadThreadLimitValue, in: 1...5, step: 1) + } + Toggle( + .retryFailedPagesAutomatically, + isOn: Binding($setting.downloadAutoRetryFailedPages) + ) + } + + Section { + Toggle( + .allowCellularDownloads, + isOn: Binding($setting.downloadAllowCellular) + ) + } header: { + Text(.network) + } footer: { + Text(.networkDescription) + } + } + .navigationTitle(.title) + } + + private var downloadThreadLimitValue: Binding { + .init( + get: { Double(setting.downloadThreadLimit) }, + set: { newValue in $setting.withLock { $0.downloadThreadLimit = Int(newValue.rounded()) } } + ) + } +} + +struct DownloadSettingView_Previews: PreviewProvider { + static var previews: some View { + NavigationStack { + DownloadSettingView() + } + } +} diff --git a/AppPackage/Sources/SettingFeature/Components/LaboratorySettingReducer.swift b/AppPackage/Sources/SettingFeature/Components/LaboratorySettingReducer.swift new file mode 100644 index 000000000..f84aef0c3 --- /dev/null +++ b/AppPackage/Sources/SettingFeature/Components/LaboratorySettingReducer.swift @@ -0,0 +1,35 @@ +import ComposableArchitecture +import HapticsClient +import DFClient + +@Reducer +public struct LaboratorySettingReducer: Sendable { + @ObservableState + public struct State: Equatable, Sendable { + public init() {} + } + + public enum Action: Equatable, Sendable { + // Sent by the view when the SNI-filtering toggle changes. The write itself lands directly in + // `@Shared(.setting)`, which dispatches no action, so the view bridges the change here and this + // reducer owns the side effect the write can't trigger. + case bypassesSNIFilteringChanged(Bool) + } + + @Dependency(\.hapticsClient) private var hapticsClient + @Dependency(\.dfClient) private var dfClient + + public init() {} + + public var body: some Reducer { + Reduce { _, action in + switch action { + case .bypassesSNIFilteringChanged(let value): + return .merge( + .run { _ in await hapticsClient.generateFeedback(.soft) }, + .run { _ in dfClient.setActive(value) } + ) + } + } + } +} diff --git a/EhPanda/View/Setting/Components/LaboratorySettingView.swift b/AppPackage/Sources/SettingFeature/Components/LaboratorySettingView.swift similarity index 65% rename from EhPanda/View/Setting/Components/LaboratorySettingView.swift rename to AppPackage/Sources/SettingFeature/Components/LaboratorySettingView.swift index 15a75877a..34641628c 100644 --- a/EhPanda/View/Setting/Components/LaboratorySettingView.swift +++ b/AppPackage/Sources/SettingFeature/Components/LaboratorySettingView.swift @@ -1,41 +1,45 @@ -// -// LaboratorySettingView.swift -// LabSettingView -// - import SwiftUI +import AppModels +import Sharing +import Resources import SFSafeSymbols +import ComposableArchitecture +import AppComponents struct LaboratorySettingView: View { - @Binding private var bypassesSNIFiltering: Bool + private let store: StoreOf + @Shared(.setting) private var setting: Setting - init(bypassesSNIFiltering: Binding) { - _bypassesSNIFiltering = bypassesSNIFiltering + init(store: StoreOf) { + self.store = store } var body: some View { ScrollView { VStack { LaboratoryCell( - isOn: $bypassesSNIFiltering, - title: L10n.Localizable.LaboratorySettingView.Title.bypassesSNIFiltering, + isOn: Binding($setting.bypassesSNIFiltering), + title: .bypassesSniFiltering, symbol: .theatermasksFill, tintColor: .purple ) } .padding() } - .navigationTitle(L10n.Localizable.LaboratorySettingView.Title.laboratory) + .navigationTitle(.laboratory) + .onChange(of: setting.bypassesSNIFiltering) { _, newValue in + store.send(.bypassesSNIFilteringChanged(newValue)) + } } } struct LaboratoryCell: View { @Binding private var isOn: Bool - private let title: String + private let title: LocalizedStringResource private let symbol: SFSymbol private let tintColor: Color init( - isOn: Binding, title: String, + isOn: Binding, title: LocalizedStringResource, symbol: SFSymbol, tintColor: Color ) { _isOn = isOn @@ -75,9 +79,9 @@ struct LaboratoryCell: View { struct LaboratorySettingView_Previews: PreviewProvider { static var previews: some View { - NavigationView { + NavigationStack { LaboratorySettingView( - bypassesSNIFiltering: .constant(false) + store: .init(initialState: .init(), reducer: LaboratorySettingReducer.init) ) } } diff --git a/EhPanda/View/Setting/Components/WebView.swift b/AppPackage/Sources/SettingFeature/Components/WebView.swift similarity index 87% rename from EhPanda/View/Setting/Components/WebView.swift rename to AppPackage/Sources/SettingFeature/Components/WebView.swift index 885152caa..246c6aaf8 100644 --- a/EhPanda/View/Setting/Components/WebView.swift +++ b/AppPackage/Sources/SettingFeature/Components/WebView.swift @@ -1,11 +1,11 @@ -// -// WebView.swift -// EhPanda -// - +import AppTools import WebKit +import AppModels +import OSLogExt import SwiftUI +private let logger = Logger(category: .init(describing: WebView.self)) + struct WebView: UIViewControllerRepresentable { private let url: URL private let loginDoneAction: (() -> Void)? @@ -26,8 +26,8 @@ struct WebView: UIViewControllerRepresentable { guard parent.url.absoluteString == Defaults.URL.webLogin.absoluteString, let webViewURL = webView.url, let queryItems = URLComponents(url: webViewURL, resolvingAgainstBaseURL: false)?.queryItems, queryItems.contains(where: { queryItem in - queryItem.name == Defaults.URL.Component.Key.code.rawValue - && queryItem.value == Defaults.URL.Component.Value.zeroOne.rawValue + queryItem.name == Defaults.URL.Component.Key.code.rawValue + && queryItem.value == Defaults.URL.Component.Value.zeroOne.rawValue }) else { return } @@ -41,7 +41,7 @@ struct WebView: UIViewControllerRepresentable { } func webView(_ webView: WKWebView, didFail navigation: WKNavigation!, withError error: Error) { - Logger.error(error) + logger.error("\(error, privacy: .public)") } } diff --git a/EhPanda/View/Setting/EhSetting/EhSettingReducer.swift b/AppPackage/Sources/SettingFeature/EhSetting/EhSettingReducer.swift similarity index 61% rename from EhPanda/View/Setting/EhSetting/EhSettingReducer.swift rename to AppPackage/Sources/SettingFeature/EhSetting/EhSettingReducer.swift index 6c80f66d3..ab3acfb6b 100644 --- a/EhPanda/View/Setting/EhSetting/EhSettingReducer.swift +++ b/AppPackage/Sources/SettingFeature/EhSetting/EhSettingReducer.swift @@ -1,31 +1,39 @@ -// -// EhSettingReducer.swift -// EhPanda -// - +import AppTools import Foundation +import AppModels +import Resources import ComposableArchitecture +import ApplicationClient +import HapticsClient +import NetworkingFeature +import CookieClient +import AppComponents @Reducer -struct EhSettingReducer { - @CasePathable - enum Route: Equatable { +public struct EhSettingReducer: Sendable { + @Reducer + public enum Destination { + @ReducerCaseIgnored case webView(URL) - case deleteProfile } - private enum CancelID: CaseIterable { + public enum Dialog: Equatable, Sendable { + case confirmDeleteProfile + } + + private enum CancelID { case fetchEhSetting, submitChanges, performAction } @ObservableState - struct State: Equatable { - var route: Route? - var editingProfileName = "" - var ehSetting: EhSetting? - var ehProfile: EhProfile? - var loadingState: LoadingState = .idle - var submittingState: LoadingState = .idle + public struct State: Equatable, Sendable { + @Presents public var destination: Destination.State? + @Presents public var confirmationDialog: ConfirmationDialogState? + public var editingProfileName = "" + public var ehSetting: EhSetting? + public var ehProfile: EhProfile? + public var loadingState: LoadingState = .idle + public var submittingState: LoadingState = .idle mutating func setEhSetting(_ ehSetting: EhSetting) { let ehProfile: EhProfile = ehSetting.ehProfiles @@ -36,13 +44,15 @@ struct EhSettingReducer { } } - enum Action: BindableAction, Equatable { + public enum Action: BindableAction, Equatable { case binding(BindingAction) - case setNavigation(Route?) + case destination(PresentationAction) + case presentWebView(URL) + case confirmationDialog(PresentationAction) + case deleteProfileButtonTapped case setKeyboardHidden case setDefaultProfile(Int) - case teardown case fetchEhSetting case fetchEhSettingDone(Result) case submitChanges @@ -51,10 +61,12 @@ struct EhSettingReducer { case performActionDone(Result) } - @Dependency(\.uiApplicationClient) private var uiApplicationClient + @Dependency(\.applicationClient) private var applicationClient @Dependency(\.hapticsClient) private var hapticsClient @Dependency(\.cookieClient) private var cookieClient + public init() {} + public var body: some Reducer { BindingReducer() @@ -63,12 +75,37 @@ struct EhSettingReducer { case .binding: return .none - case .setNavigation(let route): - state.route = route + case .destination: + return .none + + case .presentWebView(let url): + state.destination = .webView(url) + return .none + + case .deleteProfileButtonTapped: + state.confirmationDialog = ConfirmationDialogState(titleVisibility: .hidden) { + TextState(localized: .RLocalizable.delete) + } actions: { + ButtonState(role: .destructive, action: .confirmDeleteProfile) { + TextState(localized: .RLocalizable.delete) + } + ButtonState(role: .cancel) { + TextState(localized: .RLocalizable.cancel) + } + } message: { + TextState(localized: .RLocalizable.deleteDescription) + } + return .none + + case .confirmationDialog(.presented(.confirmDeleteProfile)): + guard let value = state.ehProfile?.value else { return .none } + return .send(.performAction(action: .delete, name: nil, set: value)) + + case .confirmationDialog: return .none case .setKeyboardHidden: - return .run(operation: { _ in await uiApplicationClient.hideKeyboard() }) + return .run(operation: { _ in await applicationClient.hideKeyboard() }) case .setDefaultProfile(let profileSet): return .run { _ in @@ -77,9 +114,6 @@ struct EhSettingReducer { ) } - case .teardown: - return .merge(CancelID.allCases.map(Effect.cancel(id:))) - case .fetchEhSetting: guard state.loadingState != .loading else { return .none } state.loadingState = .loading @@ -145,9 +179,14 @@ struct EhSettingReducer { } } .haptics( - unwrapping: \.route, + unwrapping: \.destination, case: \.webView, hapticsClient: hapticsClient ) + .ifLet(\.$destination, action: \.destination) + .ifLet(\.$confirmationDialog, action: \.confirmationDialog) } } + +extension EhSettingReducer.Destination.State: Equatable, Sendable {} +extension EhSettingReducer.Destination.Action: Equatable, Sendable {} diff --git a/AppPackage/Sources/SettingFeature/EhSetting/EhSettingView+Sections1.swift b/AppPackage/Sources/SettingFeature/EhSetting/EhSettingView+Sections1.swift new file mode 100644 index 000000000..c7b5efd40 --- /dev/null +++ b/AppPackage/Sources/SettingFeature/EhSetting/EhSettingView+Sections1.swift @@ -0,0 +1,311 @@ +import SwiftUI +import AppModels +import Resources +import ComposableArchitecture +import AppTools +import AppComponents + +extension EhSettingView { + +// MARK: EhProfileSection +struct EhProfileSection: View { + @Binding var ehSetting: EhSetting + @Binding var ehProfile: EhProfile + @Binding var editingProfileName: String + let deleteDialogAction: () -> Void + let deleteConfirmationDialog: + Binding, EhSettingReducer.Dialog>?> + let performEhProfileAction: (EhProfileAction?, String?, Int) -> Void + + @FocusState private var isFocused + + var body: some View { + Section { + Picker(.selectedProfile, selection: $ehProfile) { + ForEach(ehSetting.ehProfiles) { ehProfile in + Text(ehProfile.name) + .tag(ehProfile) + } + } + .pickerStyle(.menu) + + if !ehProfile.isDefault { + Button(.setAsDefault) { + performEhProfileAction(.default, nil, ehProfile.value) + } + + Button( + .deleteProfile, + role: .destructive, + action: deleteDialogAction + ) + .confirmationDialog(deleteConfirmationDialog) + } + } header: { + Text(.profileSettings) + .ehSettingRegularHeaderStyled() + } + .onChange(of: ehProfile) { _, newValue in + performEhProfileAction(nil, nil, newValue.value) + } + + Section { + SettingTextField( + text: $editingProfileName, title: .selectedProfile, + width: nil, alignment: .leading, background: .clear + ) + .focused($isFocused) + + Button(.rename) { + performEhProfileAction(.rename, editingProfileName, ehProfile.value) + } + .disabled(isFocused) + + if ehSetting.isCapableOfCreatingNewProfile { + Button(.createNew) { + performEhProfileAction(.create, editingProfileName, ehProfile.value) + } + .disabled(isFocused) + } + } + } +} + +// MARK: ImageLoadSettingsSection +struct ImageLoadSettingsSection: View { + @Binding var ehSetting: EhSetting + + var body: some View { + Section { + Picker( + .loadImagesThroughTheHathNetwork, + selection: $ehSetting.loadThroughHathSetting + ) { + ForEach(ehSetting.capableLoadThroughHathSettings) { setting in + Text(setting.value) + .tag(setting) + } + } + .pickerStyle(.menu) + } header: { + Text.ehSettingBoldHeader(.imageLoadSettings) + } footer: { + Text(ehSetting.loadThroughHathSetting.description) + } + + Section { + Picker(.browsingCountry, selection: $ehSetting.browsingCountry) { + ForEach(EhSetting.BrowsingCountry.allCases) { country in + Text(country.name) + .tag(country) + .foregroundColor(country == ehSetting.browsingCountry ? .accentColor : .primary) + } + } + } header: { + Text( + String(localized: .browsingCountryDescription( + ehSetting.localizedLiteralBrowsingCountry ?? ehSetting.literalBrowsingCountry + )) + .localizedKey + ) + .ehSettingRegularHeaderStyled() + } + } +} + +// MARK: ImageSizeSettingsSection +struct ImageSizeSettingsSection: View { + @Binding var ehSetting: EhSetting + + var body: some View { + Section { + Picker(.imageResolution, selection: $ehSetting.imageResolution) { + ForEach(ehSetting.capableImageResolutions) { setting in + Text(setting.value) + .tag(setting) + } + } + .pickerStyle(.menu) + } header: { + Text.ehSettingBoldHeader( + .imageSizeSettings, + description: .imageResolutionDescription + ) + } + + if let useOriginalImagesBinding = Binding($ehSetting.useOriginalImages) { + Section { + Toggle( + .useOriginalImages, + isOn: useOriginalImagesBinding + ) + } header: { + Text(.originalImages) + .ehSettingRegularHeaderStyled() + } + } + + Section { + Text(.imageSize) + + ValuePicker( + title: .horizontal, + value: $ehSetting.imageSizeWidth, range: 0...65535, unit: "px" + ) + + ValuePicker( + title: .vertical, + value: $ehSetting.imageSizeHeight, range: 0...65535, unit: "px" + ) + } header: { + Text(.imageSizeDescription) + .ehSettingRegularHeaderStyled() + } + } +} + +// MARK: GalleryNameDisplaySection +struct GalleryNameDisplaySection: View { + @Binding var ehSetting: EhSetting + + var body: some View { + Section { + Picker(.galleryName, selection: $ehSetting.galleryName) { + ForEach(EhSetting.GalleryName.allCases) { name in + Text(name.value) + .tag(name) + } + } + .pickerStyle(.menu) + } header: { + Text.ehSettingBoldHeader( + .galleryNameDisplay, + description: .galleryNameDescription + ) + } + } +} + +// MARK: ArchiverSettingsSection +struct ArchiverSettingsSection: View { + @Binding var ehSetting: EhSetting + + var body: some View { + Section { + Picker(.archiverBehavior, selection: $ehSetting.archiverBehavior) { + ForEach(EhSetting.ArchiverBehavior.allCases) { behavior in + Text(behavior.value) + .tag(behavior) + } + } + .pickerStyle(.menu) + } header: { + Text.ehSettingBoldHeader( + .archiverSettings, + description: .archiverBehaviorDescription + ) + } + } +} + +// MARK: FrontPageSettingsSection +struct FrontPageSettingsSection: View { + @Binding var ehSetting: EhSetting + + private var categoryBindings: [Binding] { + $ehSetting.disabledCategories.map({ $0 }) + } + + var body: some View { + Section { + CategoryView(bindings: categoryBindings) + } header: { + Text.ehSettingBoldHeader( + .frontPageSettings, + description: .galleryCategory + ) + } + + Section { + Picker(.displayMode, selection: $ehSetting.displayMode) { + ForEach(EhSetting.DisplayMode.allCases) { mode in + Text(mode.value) + .tag(mode) + } + } + .pickerStyle(.menu) + } header: { + Text(.displayModeDescription) + .ehSettingRegularHeaderStyled() + } + + Section { + Toggle( + .showSearchRangeIndicatorDescription, + isOn: $ehSetting.showSearchRangeIndicator + ) + } header: { + Text(.showSearchRangeIndicator) + .ehSettingRegularHeaderStyled() + } + } +} + +// MARK: Shared Helpers +struct ValuePicker: View { + private let title: LocalizedStringResource + @Binding var value: Float + private let range: ClosedRange + private let unit: String + + init(title: LocalizedStringResource, value: Binding, range: ClosedRange, unit: String = "") { + self.title = title + _value = value + self.range = range + self.unit = unit + } + + var body: some View { + LabeledContent(title) { + Text(String(Int(value)) + unit) + .foregroundStyle(.tint) + } + + Slider( + value: $value, + in: range, + label: EmptyView.init, + minimumValueLabel: { + Text(String(Int(range.lowerBound)) + unit) + .fontWeight(.medium) + .font(.callout) + }, + maximumValueLabel: { + Text(String(Int(range.upperBound)) + unit) + .fontWeight(.medium) + .font(.callout) + } + ) + } +} + +} + +extension Text { + static func ehSettingBoldHeader( + _ title: LocalizedStringResource, description: LocalizedStringResource? = nil + ) -> Self { + var result = AttributedString(String(localized: title)) + result.font = .body.weight(.bold) + if let description { + var descriptionString = AttributedString("\n\(String(localized: description))") + descriptionString.font = .subheadline.weight(.regular) + result.append(descriptionString) + } + return Text(result) + } + + func ehSettingRegularHeaderStyled() -> Self { + font(.subheadline.weight(.regular)) + } +} diff --git a/AppPackage/Sources/SettingFeature/EhSetting/EhSettingView+Sections2.swift b/AppPackage/Sources/SettingFeature/EhSetting/EhSettingView+Sections2.swift new file mode 100644 index 000000000..cf26a20fd --- /dev/null +++ b/AppPackage/Sources/SettingFeature/EhSetting/EhSettingView+Sections2.swift @@ -0,0 +1,185 @@ +import SwiftUI +import AppModels +import Resources +import AppTools +import AppComponents + +extension EhSettingView { + +// MARK: OptionalUIElementsSection +struct OptionalUIElementsSection: View { + @Binding var ehSetting: EhSetting + + var body: some View { + Section { + Toggle( + .enableGalleryThumbnailSelector, + isOn: $ehSetting.enableGalleryThumbnailSelector + ) + } header: { + Text.ehSettingBoldHeader( + .optionalUiElements, + description: .optionalUiElementsDescription + ) + } + } +} + +// MARK: FavoritesSection +struct FavoritesSection: View { + @Binding var ehSetting: EhSetting + @FocusState private var isFocused + + private var tuples: [(Int, Binding)] { + AppModels.Category.allFavoritesCases.enumerated().map { index, _ in + (index, $ehSetting.favoriteCategories[index]) + } + } + + var body: some View { + Section { + ForEach(tuples, id: \.0) { index, nameBinding in + HStack(spacing: 30) { + Circle() + .foregroundColor(Defaults.FavoriteColor.colors[index]) + .frame(width: 10) + + SettingTextField( + text: nameBinding, title: .favoriteCategories, + width: nil, alignment: .leading, background: .clear + ) + .focused($isFocused) + } + .padding(.leading) + } + } header: { + Text.ehSettingBoldHeader( + .favoritesSection, + description: .favoriteCategories + ) + } + + Section { + Picker( + .favoritesSortOrder, + selection: $ehSetting.favoritesSortOrder + ) { + ForEach(EhSetting.FavoritesSortOrder.allCases) { order in + Text(order.value) + .tag(order) + } + } + .pickerStyle(.menu) + } header: { + Text(.favoritesSortOrderDescription) + .ehSettingRegularHeaderStyled() + } + } +} + +// MARK: RatingsSection +struct RatingsSection: View { + @Binding var ehSetting: EhSetting + @FocusState var isFocused + + var body: some View { + Section { + LabeledContent(.ratingsColor) { + SettingTextField( + text: $ehSetting.ratingsColor, + title: .ratingsColor, + promptText: .ratingsColorPrompt, + width: 80 + ) + .focused($isFocused) + } + } header: { + Text.ehSettingBoldHeader( + .ratings, + description: .ratingsColorDescription + ) + } + } +} + +} + +// MARK: SearchResultCountSection +struct SearchResultCountSection: View { + @Binding var ehSetting: EhSetting + + var body: some View { + Section { + Picker(.resultCount, selection: $ehSetting.searchResultCount) { + ForEach(ehSetting.capableSearchResultCounts) { count in + Text(String(count.value)) + .tag(count) + } + } + .pickerStyle(.menu) + } header: { + Text.ehSettingBoldHeader( + .searchResultCount, + description: .resultCountDescription + ) + } + } +} + +// MARK: ThumbnailSettingsSection +struct ThumbnailSettingsSection: View { + @Binding var ehSetting: EhSetting + + var body: some View { + Section { + Picker( + .thumbnailLoadTiming, + selection: $ehSetting.thumbnailLoadTiming + ) { + ForEach(EhSetting.ThumbnailLoadTiming.allCases) { timing in + Text(timing.value) + .tag(timing) + } + } + .pickerStyle(.menu) + } header: { + Text.ehSettingBoldHeader( + .thumbnailSettings, + description: .thumbnailLoadTimingDescription + ) + } footer: { + Text(ehSetting.thumbnailLoadTiming.description) + } + + Section { + LabeledContent(.thumbnailSize) { + Picker(selection: $ehSetting.thumbnailConfigSize) { + ForEach(ehSetting.capableThumbnailConfigSizes) { size in + Text(size.value) + .tag(size) + } + } label: { + Text(ehSetting.thumbnailConfigSize.value) + } + .pickerStyle(.segmented) + .frame(width: 200) + } + + LabeledContent(.thumbnailRowCount) { + Picker(selection: $ehSetting.thumbnailConfigRows) { + ForEach(ehSetting.capableThumbnailConfigRowCounts) { row in + Text(row.value) + .tag(row) + } + } label: { + Text(ehSetting.capableThumbnailConfigRowCount.value) + } + .pickerStyle(.segmented) + .frame(width: 200) + } + } header: { + Text(.thumbnailConfiguration) + .ehSettingRegularHeaderStyled() + } + } +} diff --git a/AppPackage/Sources/SettingFeature/EhSetting/EhSettingView+Sections3.swift b/AppPackage/Sources/SettingFeature/EhSetting/EhSettingView+Sections3.swift new file mode 100644 index 000000000..8d690d0af --- /dev/null +++ b/AppPackage/Sources/SettingFeature/EhSetting/EhSettingView+Sections3.swift @@ -0,0 +1,355 @@ +import SwiftUI +import AppModels +import Resources +import AppTools + +extension EhSettingView { + +// MARK: CoverScalingSection +struct CoverScalingSection: View { + @Binding var ehSetting: EhSetting + + var body: some View { + Section { + ValuePicker( + title: .scaleFactor, + value: $ehSetting.coverScaleFactor, + range: 75...150, + unit: "%" + ) + } header: { + Text.ehSettingBoldHeader( + .coverScaling, + description: .coverScaleFactor + ) + } + } +} + +// MARK: TagFilteringThresholdSection +struct TagFilteringThresholdSection: View { + @Binding var ehSetting: EhSetting + + var body: some View { + Section { + ValuePicker( + title: .tagFilteringThreshold, + value: $ehSetting.tagFilteringThreshold, range: -9999...0 + ) + } header: { + Text.ehSettingBoldHeader( + .tagFilteringThreshold, + description: .tagFilteringThresholdDescription + ) + } + } +} + +// MARK: TagWatchingThresholdSection +struct TagWatchingThresholdSection: View { + @Binding var ehSetting: EhSetting + + var body: some View { + Section { + ValuePicker( + title: .tagWatchingThreshold, + value: $ehSetting.tagWatchingThreshold, range: 0...9999 + ) + } header: { + Text.ehSettingBoldHeader( + .tagWatchingThreshold, + description: .tagWatchingThresholdDescription + ) + } + } +} + +// MARK: FilteredRemovalCountSection +struct FilteredRemovalCountSection: View { + @Binding var ehSetting: EhSetting + + var body: some View { + Section { + Toggle( + .showFilteredRemovalCount, + isOn: $ehSetting.showFilteredRemovalCount + ) + } header: { + Text.ehSettingBoldHeader( + .filteredRemovalCount, + description: .filteredRemovalCountDescription + ) + } + } +} + +// MARK: ExcludedLanguagesSection +struct ExcludedLanguagesSection: View { + @Binding var ehSetting: EhSetting + + private let languages = Language.allExcludedCases.map(\.value) + private var languageBindings: [Binding] { + $ehSetting.excludedLanguages.map({ $0 }) + } + private func rowBindings(index: Int) -> [Binding] { + [-1, 0, 1].map { num in + let index = index * 3 + num + if index != -1 { + return languageBindings[index] + } else { + return .constant(false) + } + } + } + + var body: some View { + Section { + HStack { + // Blank corner above the language column; the hidden label names the column and + // supplies the row's line height (the category cells below are height-less Color.clear). + Text(.RLocalizable.language) + .hidden() + .frame(width: DeviceUtil.windowW * 0.25) + + ForEach(EhSetting.ExcludedLanguagesCategory.allCases) { category in + Color.clear + .overlay { + Text(category.value) + .lineLimit(1) + .font(.subheadline) + .fixedSize() + } + } + } + + ForEach(0..<(languageBindings.count / 3) + 1, id: \.self) { index in + ExcludeRow( + title: languages[index], + bindings: rowBindings(index: index), + isFirstRow: index == 0 + ) + } + } header: { + Text.ehSettingBoldHeader( + .excludedLanguages, + description: .excludedLanguagesDescription + ) + } + } +} + +struct ExcludeRow: View { + let title: LocalizedStringResource + let bindings: [Binding] + let isFirstRow: Bool + + var body: some View { + HStack { + Text(title) + .lineLimit(1) + .font(.subheadline) + .fixedSize() + .frame(maxWidth: .infinity, alignment: .leading) + .frame(width: DeviceUtil.windowW * 0.25) + + ForEach(0.. + @SharedReader(.setting) private var setting: Setting + private let blurRadius: Double + + // Should make it an Environment value. + private var galleryHost: GalleryHost { AppUtil.galleryHost } + + init(store: StoreOf, blurRadius: Double) { + self.store = store + self.blurRadius = blurRadius + } + + // MARK: EhSettingView + var body: some View { + ZStack { + // Workaround: Stay if-else approach + if store.loadingState == .loading || store.submittingState == .loading { + LoadingView() + .tint(nil) + } else if case .failed(let error) = store.loadingState { + ErrorView(error: error, action: { store.send(.fetchEhSetting) }) + .tint(nil) + } + // Using `Binding.init` will crash the app + else if let ehSetting = Binding(unwrapping: $store.ehSetting), + let ehProfile = Binding(unwrapping: $store.ehProfile) { + form(ehSetting: ehSetting, ehProfile: ehProfile) + .transition(.opacity.animation(.default)) + } + } + .onAppear { + if store.ehSetting == nil { + store.send(.fetchEhSetting) + } + } + .onDisappear { + if let profileSet = store.ehSetting?.ehpandaProfile?.value { + store.send(.setDefaultProfile(profileSet)) + } + } + .sheet(item: $store.destination.webView, id: \.absoluteString) { url in + WebView(url: url.wrappedValue) + .ignoresSafeArea(edges: .bottom) + .autoBlur(radius: blurRadius) + } + .toolbar(content: toolbar) + .navigationTitle(.hostSettings(galleryHost.rawValue)) + } + // MARK: Form + private func form(ehSetting: Binding, ehProfile: Binding) -> some View { + Form { + Group { + EhProfileSection( + ehSetting: ehSetting, + ehProfile: ehProfile, + editingProfileName: $store.editingProfileName, + deleteDialogAction: { store.send(.deleteProfileButtonTapped) }, + deleteConfirmationDialog: $store.scope( + state: \.confirmationDialog, action: \.confirmationDialog + ), + performEhProfileAction: { store.send(.performAction(action: $0, name: $1, set: $2)) } + ) + + ImageLoadSettingsSection(ehSetting: ehSetting) + ImageSizeSettingsSection(ehSetting: ehSetting) + GalleryNameDisplaySection(ehSetting: ehSetting) + ArchiverSettingsSection(ehSetting: ehSetting) + FrontPageSettingsSection(ehSetting: ehSetting) + OptionalUIElementsSection(ehSetting: ehSetting) + FavoritesSection(ehSetting: ehSetting) + SearchResultCountSection(ehSetting: ehSetting) + ThumbnailSettingsSection(ehSetting: ehSetting) + } + Group { + CoverScalingSection(ehSetting: ehSetting) + RatingsSection(ehSetting: ehSetting) + TagWatchingThresholdSection(ehSetting: ehSetting) + TagFilteringThresholdSection(ehSetting: ehSetting) + FilteredRemovalCountSection(ehSetting: ehSetting) + ExcludedLanguagesSection(ehSetting: ehSetting) + ExcludedUploadersSection(ehSetting: ehSetting) + ViewportOverrideSection(ehSetting: ehSetting) + GalleryCommentsSection(ehSetting: ehSetting) + GalleryTagsSection(ehSetting: ehSetting) + } + Group { + GalleryPageThumbnailLabelingSection(ehSetting: ehSetting) + MultiplePageViewerSection(ehSetting: ehSetting) + } + } + } + // MARK: Toolbar + private func toolbar() -> some ToolbarContent { + Group { + ToolbarItem(placement: .navigationBarTrailing) { + Button { + store.send(.presentWebView(Defaults.URL.uConfig)) + } label: { + Image(systemSymbol: .globe) + } + .disabled(setting.bypassesSNIFiltering) + } + + ToolbarItem(placement: .confirmationAction) { + Button { + store.send(.submitChanges) + } label: { + Image(systemSymbol: .icloudAndArrowUp) + } + .disabled(store.ehSetting == nil) + } + + ToolbarItem(placement: .keyboard) { + Button(.done) { + store.send(.setKeyboardHidden) + } + .frame(maxWidth: .infinity, alignment: .trailing) + } + } + } +} + +struct EhSettingView_Previews: PreviewProvider { + static var previews: some View { + NavigationStack { + EhSettingView( + store: .init( + initialState: .init(ehSetting: .empty, ehProfile: .empty, loadingState: .idle), + reducer: EhSettingReducer.init + ), + blurRadius: 0 + ) + } + } +} diff --git a/AppPackage/Sources/SettingFeature/GeneralSetting/GeneralSettingReducer.swift b/AppPackage/Sources/SettingFeature/GeneralSetting/GeneralSettingReducer.swift new file mode 100644 index 000000000..9bcddb835 --- /dev/null +++ b/AppPackage/Sources/SettingFeature/GeneralSetting/GeneralSettingReducer.swift @@ -0,0 +1,173 @@ +import LocalAuthentication +import AppModels +import Resources +import ComposableArchitecture +import AuthorizationClient +import ApplicationClient +import LibraryClient +import OSLogExt +import AppComponents + +private let logger = Logger(category: .init(describing: GeneralSettingReducer.self)) + +@Reducer +public struct GeneralSettingReducer: Sendable { + @Reducer + public enum Destination { + @ReducerCaseIgnored + case importTranslations + } + + public enum Dialog: Equatable, Sendable { + case confirmClearCache + case confirmRemoveCustomTranslations + } + + // Handled by SettingReducer, which owns the Setting navigation stack and the tag-translator + // subsystem. `enablesTagsExtensionChanged` lets the view report a `@Shared(.setting)` edit so the + // parent can rebuild the translator (the write itself dispatches no action). + public enum Delegate: Equatable, Sendable { + case pushAppActivityLogs + case enablesTagsExtensionChanged + } + + @ObservableState + public struct State: Equatable, Sendable { + // Drives the native `.fileImporter` presentation for importing custom tag translations. + @Presents public var destination: Destination.State? + // Two separate dialogs so each anchors to its own trigger button on iPad (the clear-cache + // and remove-translations buttons live in different sections). + @Presents public var clearCacheDialog: ConfirmationDialogState? + @Presents public var removeTranslationsDialog: ConfirmationDialogState? + + public var loadingState: LoadingState = .idle + public var diskImageCacheSize = "0 KB" + public var passcodeNotSet = false + + public init() {} + } + + public enum Action: BindableAction, Equatable { + case binding(BindingAction) + case destination(PresentationAction) + case clearCacheDialog(PresentationAction) + case removeTranslationsDialog(PresentationAction) + case delegate(Delegate) + case importCustomTranslationsButtonTapped + case onTranslationsFilePicked(URL) + case removeCustomTranslationsButtonTapped + case onRemoveCustomTranslations + + case clearImageCachesButtonTapped + case clearWebImageCache + case checkPasscodeSetting + case navigateToSystemSetting + case calculateWebImageDiskCache + case calculateWebImageDiskCacheDone(UInt?) + } + + @Dependency(\.authorizationClient) private var authorizationClient + @Dependency(\.applicationClient) private var applicationClient + @Dependency(\.libraryClient) private var libraryClient + + public init() {} + + public var body: some Reducer { + BindingReducer() + + Reduce { state, action in + switch action { + case .binding: + return .none + + case .destination: + return .none + + case .delegate: + return .none + + case .importCustomTranslationsButtonTapped: + state.destination = .importTranslations + return .none + + case .removeCustomTranslationsButtonTapped: + state.removeTranslationsDialog = ConfirmationDialogState(titleVisibility: .hidden) { + TextState(localized: .remove) + } actions: { + ButtonState(role: .destructive, action: .confirmRemoveCustomTranslations) { + TextState(localized: .remove) + } + ButtonState(role: .cancel) { + TextState(localized: .RLocalizable.cancel) + } + } message: { + TextState(localized: .removeCustomTranslationsConfirmation) + } + return .none + + case .clearImageCachesButtonTapped: + state.clearCacheDialog = ConfirmationDialogState(titleVisibility: .hidden) { + TextState(localized: .RLocalizable.clear) + } actions: { + ButtonState(role: .destructive, action: .confirmClearCache) { + TextState(localized: .RLocalizable.clear) + } + ButtonState(role: .cancel) { + TextState(localized: .RLocalizable.cancel) + } + } message: { + TextState(localized: .RLocalizable.clearDescription) + } + return .none + + case .removeTranslationsDialog(.presented(.confirmRemoveCustomTranslations)): + return .send(.onRemoveCustomTranslations) + + case .clearCacheDialog(.presented(.confirmClearCache)): + return .send(.clearWebImageCache) + + case .removeTranslationsDialog, .clearCacheDialog: + return .none + + case .onTranslationsFilePicked: + return .none + + case .onRemoveCustomTranslations: + return .none + + case .clearWebImageCache: + return .run { send in + await libraryClient.removeAllCachedImages() + logger.notice("Cleared image cache.") + await send(.calculateWebImageDiskCache) + } + + case .checkPasscodeSetting: + state.passcodeNotSet = authorizationClient.passcodeNotSet() + return .none + + case .navigateToSystemSetting: + return .run(operation: { _ in await applicationClient.openSettings() }) + + case .calculateWebImageDiskCache: + return .run { send in + let size = await libraryClient.calculateWebImageDiskCacheSize() + await send(.calculateWebImageDiskCacheDone(size)) + } + + case .calculateWebImageDiskCacheDone(let bytes): + guard let bytes = bytes else { return .none } + let formatter = ByteCountFormatter() + formatter.allowedUnits = .useAll + state.diskImageCacheSize = formatter.string(fromByteCount: .init(bytes)) + return .none + } + } + .ifLet(\.$destination, action: \.destination) + .ifLet(\.$clearCacheDialog, action: \.clearCacheDialog) + .ifLet(\.$removeTranslationsDialog, action: \.removeTranslationsDialog) + } +} + +extension GeneralSettingReducer.Destination.State: Equatable, Sendable {} +extension GeneralSettingReducer.Destination.Action: Equatable, Sendable {} diff --git a/AppPackage/Sources/SettingFeature/GeneralSetting/GeneralSettingView.swift b/AppPackage/Sources/SettingFeature/GeneralSetting/GeneralSettingView.swift new file mode 100644 index 000000000..acab88a46 --- /dev/null +++ b/AppPackage/Sources/SettingFeature/GeneralSetting/GeneralSettingView.swift @@ -0,0 +1,171 @@ +import SwiftUI +import AppModels +import Sharing +import Resources +import UniformTypeIdentifiers +import ComposableArchitecture +import AppComponents + +struct GeneralSettingView: View { + @Bindable private var store: StoreOf + @Shared(.setting) private var setting: Setting + // `tagTranslator` is the in-memory shared table, so its derived flags are read here directly rather + // than threaded from the parent; only the parent-owned fetch `loadingState` is passed in. + @SharedReader(.tagTranslator) private var tagTranslator: TagTranslator + private let tagTranslatorLoadingState: LoadingState + + init(store: StoreOf, tagTranslatorLoadingState: LoadingState) { + self.store = store + self.tagTranslatorLoadingState = tagTranslatorLoadingState + } + + private var tagTranslatorEmpty: Bool { tagTranslator.translations.isEmpty } + private var tagTranslatorHasCustomTranslations: Bool { tagTranslator.hasCustomTranslations } + + private var language: String { + Locale.current.language.languageCode.map(\.identifier).flatMap(Locale.current.localizedString(forLanguageCode:)) + ?? String(localized: .defaultLanguageDescription) + } + + var body: some View { + Form { + Section { + HStack { + Text(.RLocalizable.language) + Spacer() + Button(language) { + store.send(.navigateToSystemSetting) + } + .foregroundStyle(.tint) + } + Button(.appActivityLogs) { + store.send(.delegate(.pushAppActivityLogs)) + } + .foregroundColor(.primary).withArrow() + } + Section(.tags) { + HStack { + Text(.enablesTagsExtension) + .frame(maxWidth: .infinity, alignment: .leading) + + ZStack { + Image(systemSymbol: .exclamationmarkTriangleFill) + .foregroundStyle(.yellow) + .opacity( + setting.translatesTags && tagTranslatorEmpty + && tagTranslatorLoadingState != .loading ? 1 : 0 + ) + ProgressView() + .tint(nil) + .opacity(tagTranslatorLoadingState == .loading ? 1 : 0) + } + + Toggle(.enablesTagsExtension, isOn: Binding($setting.enablesTagsExtension)) + .labelsHidden() + .frame(width: 50) + .padding(.leading, 20) + } + if setting.enablesTagsExtension && !tagTranslatorEmpty { + Toggle(.translatesTags, isOn: Binding($setting.translatesTags)) + Toggle( + .showsTagsSearchSuggestion, + isOn: Binding($setting.showsTagsSearchSuggestion) + ) + Toggle(.showsImagesInTags, isOn: Binding($setting.showsImagesInTags)) + } + Button(.importCustomTranslations) { + store.send(.importCustomTranslationsButtonTapped) + } + .fileImporter( + isPresented: $store.destination.importTranslations, + allowedContentTypes: [.json] + ) { result in + if case .success(let url) = result { + store.send(.onTranslationsFilePicked(url)) + } + } + if tagTranslatorHasCustomTranslations { + Button( + .removeCustomTranslations, + role: .destructive, action: { store.send(.removeCustomTranslationsButtonTapped) } + ) + .confirmationDialog( + $store.scope(state: \.removeTranslationsDialog, action: \.removeTranslationsDialog) + ) + } + } + Section(.navigation) { + Toggle( + .redirectsLinksToTheSelectedHost, + isOn: Binding($setting.redirectsLinksToSelectedHost) + ) + Toggle( + .detectsLinksFromClipboard, + isOn: Binding($setting.detectsLinksFromClipboard) + ) + } + Section(.security) { + HStack { + Picker( + .autoLock, + selection: Binding($setting.autoLockPolicy) + ) { + ForEach(AutoLockPolicy.allCases) { policy in + Text(policy.value).tag(policy) + } + } + .pickerStyle(.menu) + if store.passcodeNotSet && setting.autoLockPolicy != .never { + Image(systemSymbol: .exclamationmarkTriangleFill).foregroundStyle(.yellow) + } + } + VStack(alignment: .leading) { + Text(.backgroundBlurRadius) + HStack { + Image(systemSymbol: .eye) + Slider(value: Binding($setting.backgroundBlurRadius), in: 0...100, step: 10) + Image(systemSymbol: .eyeSlash) + } + } + } + Section(.caches) { + Button { + store.send(.clearImageCachesButtonTapped) + } label: { + HStack { + Text(.clearImageCaches) + Spacer() + Text(store.diskImageCacheSize).foregroundStyle(.tint) + } + .foregroundColor(.primary) + } + .confirmationDialog( + $store.scope(state: \.clearCacheDialog, action: \.clearCacheDialog) + ) + } + } + .animation(.default, value: tagTranslatorHasCustomTranslations) + .animation(.default, value: tagTranslatorLoadingState) + .animation(.default, value: setting.enablesTagsExtension) + .animation(.default, value: tagTranslatorEmpty) + .onChange(of: setting.enablesTagsExtension) { _, _ in + store.send(.delegate(.enablesTagsExtensionChanged)) + } + .onAppear { + store.send(.checkPasscodeSetting) + store.send(.calculateWebImageDiskCache) + } + .navigationTitle(.general) + } +} + +struct GeneralSettingView_Previews: PreviewProvider { + static var previews: some View { + NavigationStack { + GeneralSettingView( + store: .init(initialState: .init(), reducer: GeneralSettingReducer.init), + tagTranslatorLoadingState: .idle + ) + } + } +} diff --git a/AppPackage/Sources/SettingFeature/Logger+.swift b/AppPackage/Sources/SettingFeature/Logger+.swift new file mode 100644 index 000000000..deff821cc --- /dev/null +++ b/AppPackage/Sources/SettingFeature/Logger+.swift @@ -0,0 +1,7 @@ +import OSLogExt + +extension Logger { + init(category: String) { + self.init(moduleName: "SettingFeature", category: category) + } +} diff --git a/EhPanda/View/Setting/Login/LoginReducer.swift b/AppPackage/Sources/SettingFeature/Login/LoginReducer.swift similarity index 50% rename from EhPanda/View/Setting/Login/LoginReducer.swift rename to AppPackage/Sources/SettingFeature/Login/LoginReducer.swift index 358cd9a26..e118049cb 100644 --- a/EhPanda/View/Setting/Login/LoginReducer.swift +++ b/AppPackage/Sources/SettingFeature/Login/LoginReducer.swift @@ -1,57 +1,63 @@ -// -// LoginReducer.swift -// EhPanda -// - import SwiftUI +import AppModels import ComposableArchitecture +import HapticsClient +import NetworkingFeature +import CookieClient +import OSLogExt + +private let logger = Logger(category: .init(describing: LoginReducer.self)) @Reducer -struct LoginReducer { +public struct LoginReducer: Sendable { private enum CancelID: Hashable { case login } - @CasePathable - enum Route: Equatable { + @Reducer + public enum Destination { + @ReducerCaseIgnored case webView(URL) } - enum FocusedField { + public enum FocusedField: Sendable { case username case password } @ObservableState - struct State: Equatable { - var route: Route? - var focusedField: FocusedField? - var username = "" - var password = "" - var loginState: LoadingState = .idle + public struct State: Equatable, Sendable { + @Presents public var destination: Destination.State? + public var focusedField: FocusedField? + public var username = "" + public var password = "" + public 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) + ? .primary.opacity(0.25) : .primary.opacity(0.75) } } - enum Action: BindableAction, Equatable { + public enum Action: BindableAction, Equatable { case binding(BindingAction) - case setNavigation(Route?) + case destination(PresentationAction) + case presentWebView(URL) - case teardown case login case loginDone(Result) } @Dependency(\.hapticsClient) private var hapticsClient @Dependency(\.cookieClient) private var cookieClient + @Dependency(\.dismiss) private var dismiss + + public init() {} - var body: some Reducer { + public var body: some Reducer { BindingReducer() Reduce { state, action in @@ -59,19 +65,19 @@ struct LoginReducer { case .binding: return .none - case .setNavigation(let route): - state.route = route + case .destination: return .none - case .teardown: - return .cancel(id: CancelID.login) + case .presentWebView(let url): + state.destination = .webView(url) + return .none case .login: guard !state.loginButtonDisabled || state.loginState == .loading else { return .none } state.focusedField = nil state.loginState = .loading return .merge( - .run(operation: { _ in hapticsClient.generateFeedback(.soft) }), + .run(operation: { _ in await hapticsClient.generateFeedback(.soft) }), .run { [state] send in let response = await LoginRequest(username: state.username, password: state.password).response() await send(.loginDone(response)) @@ -80,14 +86,22 @@ struct LoginReducer { ) case .loginDone(let result): - state.route = nil + state.destination = nil var effects = [Effect]() if cookieClient.didLogin { state.loginState = .idle - effects.append(.run(operation: { _ in hapticsClient.generateNotificationFeedback(.success) })) + effects.append(.run(operation: { _ in + logger.notice("Login succeeded.") + await hapticsClient.generateNotificationFeedback(.success) + })) + // Pop this login screen off the Setting stack now that we're signed in. + effects.append(.run { _ in await dismiss() }) } else { state.loginState = .failed(.unknown) - effects.append(.run(operation: { _ in hapticsClient.generateNotificationFeedback(.error) })) + effects.append(.run(operation: { _ in + logger.notice("Login failed.") + await hapticsClient.generateNotificationFeedback(.error) + })) } if case .success(let response) = result, let response = response { effects.append(.run(operation: { _ in cookieClient.setCredentials(response: response) })) @@ -96,9 +110,13 @@ struct LoginReducer { } } .haptics( - unwrapping: \.route, + unwrapping: \.destination, case: \.webView, hapticsClient: hapticsClient ) + .ifLet(\.$destination, action: \.destination) } } + +extension LoginReducer.Destination.State: Equatable, Sendable {} +extension LoginReducer.Destination.Action: Equatable, Sendable {} diff --git a/EhPanda/View/Setting/Login/LoginView.swift b/AppPackage/Sources/SettingFeature/Login/LoginView.swift similarity index 81% rename from EhPanda/View/Setting/Login/LoginView.swift rename to AppPackage/Sources/SettingFeature/Login/LoginView.swift index 7825edb96..6f19fb54c 100644 --- a/EhPanda/View/Setting/Login/LoginView.swift +++ b/AppPackage/Sources/SettingFeature/Login/LoginView.swift @@ -1,21 +1,20 @@ -// -// LoginView.swift -// EhPanda -// - +import AppTools import SwiftUI +import Sharing +import AppModels +import Resources import ComposableArchitecture +import AppComponents struct LoginView: View { @Bindable private var store: StoreOf - private let bypassesSNIFiltering: Bool + @SharedReader(.setting) private var setting: Setting private let blurRadius: Double @FocusState private var focusedField: LoginReducer.FocusedField? - init(store: StoreOf, bypassesSNIFiltering: Bool, blurRadius: Double) { + init(store: StoreOf, blurRadius: Double) { self.store = store - self.bypassesSNIFiltering = bypassesSNIFiltering self.blurRadius = blurRadius } @@ -35,13 +34,13 @@ struct LoginView: View { LoginTextField( focusedField: $focusedField, text: $store.username, - description: L10n.Localizable.LoginView.Title.username, + description: .username, isPassword: false ) LoginTextField( focusedField: $focusedField, text: $store.password, - description: L10n.Localizable.LoginView.Title.password, + description: .password, isPassword: true ) } @@ -69,8 +68,8 @@ struct LoginView: View { } } .synchronize($store.focusedField, $focusedField) - .sheet(item: $store.route.sending(\.setNavigation).webView, id: \.absoluteString) { route in - WebView(url: route.wrappedValue) { + .sheet(item: $store.destination.webView, id: \.absoluteString) { url in + WebView(url: url.wrappedValue) { store.send(.loginDone(.success(nil))) } .ignoresSafeArea(edges: .bottom) @@ -87,18 +86,18 @@ struct LoginView: View { } .animation(.default, value: store.loginState) .toolbar(content: toolbar) - .navigationTitle(L10n.Localizable.LoginView.Title.login) + .navigationTitle(.RLocalizable.login) .ignoresSafeArea() } // MARK: Toolbar private func toolbar() -> some ToolbarContent { ToolbarItem(placement: .navigationBarTrailing) { Button { - store.send(.setNavigation(.webView(Defaults.URL.webLogin))) + store.send(.presentWebView(Defaults.URL.webLogin)) } label: { Image(systemSymbol: .globe) } - .disabled(bypassesSNIFiltering) + .disabled(setting.bypassesSNIFiltering) } } } @@ -108,12 +107,12 @@ private struct LoginTextField: View { @Environment(\.colorScheme) private var colorScheme private let focusedField: FocusState.Binding @Binding private var text: String - private let description: String + private let description: LocalizedStringResource private let isPassword: Bool init( focusedField: FocusState.Binding, - text: Binding, description: String, isPassword: Bool + text: Binding, description: LocalizedStringResource, isPassword: Bool ) { self.focusedField = focusedField _text = text @@ -129,11 +128,12 @@ private struct LoginTextField: View { Group { if isPassword { - SecureField("", text: $text) + SecureField(description, text: $text) } else { - TextField("", text: $text) + TextField(description, text: $text) } } + .labelsHidden() .focused(focusedField.projectedValue, equals: isPassword ? .password : .username) .textContentType(isPassword ? .password : .username) .submitLabel(isPassword ? .done : .next) @@ -148,10 +148,9 @@ private struct LoginTextField: View { struct LoginView_Previews: PreviewProvider { static var previews: some View { - NavigationView { + NavigationStack { LoginView( store: .init(initialState: .init(), reducer: LoginReducer.init), - bypassesSNIFiltering: false, blurRadius: 0 ) } diff --git a/AppPackage/Sources/SettingFeature/Resources/Constant.xcstrings b/AppPackage/Sources/SettingFeature/Resources/Constant.xcstrings new file mode 100644 index 000000000..02cb1109c --- /dev/null +++ b/AppPackage/Sources/SettingFeature/Resources/Constant.xcstrings @@ -0,0 +1,2484 @@ +{ + "sourceLanguage": "en", + "strings": { + "acknowledgement.colorful": { + "extractionState": "manual", + "shouldTranslate": false, + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Colorful" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Colorful" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "Colorful" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "Colorful" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "Colorful" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "Colorful" + } + } + } + }, + "acknowledgement.colorful_link": { + "extractionState": "manual", + "shouldTranslate": false, + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "https://github.com/Co2333/Colorful" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "https://github.com/Co2333/Colorful" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "https://github.com/Co2333/Colorful" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "https://github.com/Co2333/Colorful" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "https://github.com/Co2333/Colorful" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "https://github.com/Co2333/Colorful" + } + } + } + }, + "acknowledgement.ehTagTranslationDatabase": { + "extractionState": "manual", + "shouldTranslate": false, + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "EhTagTranslation/Database" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "EhTagTranslation/Database" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "EhTagTranslation/Database" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "EhTagTranslation/Database" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "EhTagTranslation/Database" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "EhTagTranslation/Database" + } + } + } + }, + "acknowledgement.ehTagTranslationDatabase_link": { + "extractionState": "manual", + "shouldTranslate": false, + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "https://github.com/EhTagTranslation/Database" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "https://github.com/EhTagTranslation/Database" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "https://github.com/EhTagTranslation/Database" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "https://github.com/EhTagTranslation/Database" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "https://github.com/EhTagTranslation/Database" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "https://github.com/EhTagTranslation/Database" + } + } + } + }, + "acknowledgement.kanna": { + "extractionState": "manual", + "shouldTranslate": false, + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Kanna" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Kanna" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "Kanna" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "Kanna" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "Kanna" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "Kanna" + } + } + } + }, + "acknowledgement.kanna_link": { + "extractionState": "manual", + "shouldTranslate": false, + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "https://github.com/tid-kijyun/Kanna" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "https://github.com/tid-kijyun/Kanna" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "https://github.com/tid-kijyun/Kanna" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "https://github.com/tid-kijyun/Kanna" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "https://github.com/tid-kijyun/Kanna" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "https://github.com/tid-kijyun/Kanna" + } + } + } + }, + "acknowledgement.kingfisher": { + "extractionState": "manual", + "shouldTranslate": false, + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Kingfisher" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Kingfisher" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "Kingfisher" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "Kingfisher" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "Kingfisher" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "Kingfisher" + } + } + } + }, + "acknowledgement.kingfisher_link": { + "extractionState": "manual", + "shouldTranslate": false, + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "https://github.com/onevcat/Kingfisher" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "https://github.com/onevcat/Kingfisher" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "https://github.com/onevcat/Kingfisher" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "https://github.com/onevcat/Kingfisher" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "https://github.com/onevcat/Kingfisher" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "https://github.com/onevcat/Kingfisher" + } + } + } + }, + "acknowledgement.sfSafeSymbols": { + "extractionState": "manual", + "shouldTranslate": false, + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "SFSafeSymbols" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "SFSafeSymbols" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "SFSafeSymbols" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "SFSafeSymbols" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "SFSafeSymbols" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "SFSafeSymbols" + } + } + } + }, + "acknowledgement.sfSafeSymbols_link": { + "extractionState": "manual", + "shouldTranslate": false, + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "https://github.com/SFSafeSymbols/SFSafeSymbols" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "https://github.com/SFSafeSymbols/SFSafeSymbols" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "https://github.com/SFSafeSymbols/SFSafeSymbols" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "https://github.com/SFSafeSymbols/SFSafeSymbols" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "https://github.com/SFSafeSymbols/SFSafeSymbols" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "https://github.com/SFSafeSymbols/SFSafeSymbols" + } + } + } + }, + "acknowledgement.swiftCommonMark": { + "extractionState": "manual", + "shouldTranslate": false, + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "SwiftCommonMark" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "SwiftCommonMark" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "SwiftCommonMark" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "SwiftCommonMark" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "SwiftCommonMark" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "SwiftCommonMark" + } + } + } + }, + "acknowledgement.swiftCommonMark_link": { + "extractionState": "manual", + "shouldTranslate": false, + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "https://github.com/gonzalezreal/SwiftCommonMark" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "https://github.com/gonzalezreal/SwiftCommonMark" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "https://github.com/gonzalezreal/SwiftCommonMark" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "https://github.com/gonzalezreal/SwiftCommonMark" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "https://github.com/gonzalezreal/SwiftCommonMark" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "https://github.com/gonzalezreal/SwiftCommonMark" + } + } + } + }, + "acknowledgement.swiftUIPager": { + "extractionState": "manual", + "shouldTranslate": false, + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "SwiftUIPager" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "SwiftUIPager" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "SwiftUIPager" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "SwiftUIPager" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "SwiftUIPager" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "SwiftUIPager" + } + } + } + }, + "acknowledgement.swiftUIPager_link": { + "extractionState": "manual", + "shouldTranslate": false, + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "https://github.com/fermoya/SwiftUIPager" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "https://github.com/fermoya/SwiftUIPager" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "https://github.com/fermoya/SwiftUIPager" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "https://github.com/fermoya/SwiftUIPager" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "https://github.com/fermoya/SwiftUIPager" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "https://github.com/fermoya/SwiftUIPager" + } + } + } + }, + "acknowledgement.swiftyOpenCC": { + "extractionState": "manual", + "shouldTranslate": false, + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "SwiftyOpenCC" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "SwiftyOpenCC" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "SwiftyOpenCC" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "SwiftyOpenCC" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "SwiftyOpenCC" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "SwiftyOpenCC" + } + } + } + }, + "acknowledgement.swiftyOpenCC_link": { + "extractionState": "manual", + "shouldTranslate": false, + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "https://github.com/ddddxxx/SwiftyOpenCC" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "https://github.com/ddddxxx/SwiftyOpenCC" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "https://github.com/ddddxxx/SwiftyOpenCC" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "https://github.com/ddddxxx/SwiftyOpenCC" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "https://github.com/ddddxxx/SwiftyOpenCC" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "https://github.com/ddddxxx/SwiftyOpenCC" + } + } + } + }, + "acknowledgement.systemNotification": { + "extractionState": "manual", + "shouldTranslate": false, + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "SystemNotification" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "SystemNotification" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "SystemNotification" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "SystemNotification" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "SystemNotification" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "SystemNotification" + } + } + } + }, + "acknowledgement.systemNotification_link": { + "extractionState": "manual", + "shouldTranslate": false, + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "https://github.com/danielsaidi/SystemNotification" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "https://github.com/danielsaidi/SystemNotification" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "https://github.com/danielsaidi/SystemNotification" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "https://github.com/danielsaidi/SystemNotification" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "https://github.com/danielsaidi/SystemNotification" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "https://github.com/danielsaidi/SystemNotification" + } + } + } + }, + "acknowledgement.tca": { + "extractionState": "manual", + "shouldTranslate": false, + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "The Composable Architecture" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "The Composable Architecture" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "The Composable Architecture" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "The Composable Architecture" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "The Composable Architecture" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "The Composable Architecture" + } + } + } + }, + "acknowledgement.tca_link": { + "extractionState": "manual", + "shouldTranslate": false, + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "https://github.com/pointfreeco/swift-composable-architecture" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "https://github.com/pointfreeco/swift-composable-architecture" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "https://github.com/pointfreeco/swift-composable-architecture" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "https://github.com/pointfreeco/swift-composable-architecture" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "https://github.com/pointfreeco/swift-composable-architecture" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "https://github.com/pointfreeco/swift-composable-architecture" + } + } + } + }, + "acknowledgement.uiImageColors": { + "extractionState": "manual", + "shouldTranslate": false, + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "UIImageColors" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "UIImageColors" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "UIImageColors" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "UIImageColors" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "UIImageColors" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "UIImageColors" + } + } + } + }, + "acknowledgement.uiImageColors_link": { + "extractionState": "manual", + "shouldTranslate": false, + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "https://github.com/jathu/UIImageColors" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "https://github.com/jathu/UIImageColors" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "https://github.com/jathu/UIImageColors" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "https://github.com/jathu/UIImageColors" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "https://github.com/jathu/UIImageColors" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "https://github.com/jathu/UIImageColors" + } + } + } + }, + "acknowledgement.waterfallGrid": { + "extractionState": "manual", + "shouldTranslate": false, + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "WaterfallGrid" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "WaterfallGrid" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "WaterfallGrid" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "WaterfallGrid" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "WaterfallGrid" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "WaterfallGrid" + } + } + } + }, + "acknowledgement.waterfallGrid_link": { + "extractionState": "manual", + "shouldTranslate": false, + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "https://github.com/paololeonardi/WaterfallGrid" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "https://github.com/paololeonardi/WaterfallGrid" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "https://github.com/paololeonardi/WaterfallGrid" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "https://github.com/paololeonardi/WaterfallGrid" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "https://github.com/paololeonardi/WaterfallGrid" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "https://github.com/paololeonardi/WaterfallGrid" + } + } + } + }, + "code_level_contributor.Jimmy-Prime": { + "extractionState": "manual", + "shouldTranslate": false, + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Jimmy Prime" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Jimmy Prime" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "Jimmy Prime" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "Jimmy Prime" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "Jimmy Prime" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "Jimmy Prime" + } + } + } + }, + "code_level_contributor.Jimmy-Prime_link": { + "extractionState": "manual", + "shouldTranslate": false, + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "https://github.com/Jimmy-Prime" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "https://github.com/Jimmy-Prime" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "https://github.com/Jimmy-Prime" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "https://github.com/Jimmy-Prime" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "https://github.com/Jimmy-Prime" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "https://github.com/Jimmy-Prime" + } + } + } + }, + "code_level_contributor.Kaed3mi": { + "extractionState": "manual", + "shouldTranslate": false, + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Kaed3mi" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Kaed3mi" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "Kaed3mi" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "Kaed3mi" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "Kaed3mi" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "Kaed3mi" + } + } + } + }, + "code_level_contributor.Kaed3mi_link": { + "extractionState": "manual", + "shouldTranslate": false, + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "https://github.com/Kaed3mi" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "https://github.com/Kaed3mi" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "https://github.com/Kaed3mi" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "https://github.com/Kaed3mi" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "https://github.com/Kaed3mi" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "https://github.com/Kaed3mi" + } + } + } + }, + "code_level_contributor.aalberrty": { + "extractionState": "manual", + "shouldTranslate": false, + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Zack Asahina" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Zack Asahina" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "Zack Asahina" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "Zack Asahina" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "Zack Asahina" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "Zack Asahina" + } + } + } + }, + "code_level_contributor.aalberrty_link": { + "extractionState": "manual", + "shouldTranslate": false, + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "https://github.com/aalberrty" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "https://github.com/aalberrty" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "https://github.com/aalberrty" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "https://github.com/aalberrty" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "https://github.com/aalberrty" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "https://github.com/aalberrty" + } + } + } + }, + "code_level_contributor.vvbbnn00": { + "extractionState": "manual", + "shouldTranslate": false, + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "vvbbnn00" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "vvbbnn00" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "vvbbnn00" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "vvbbnn00" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "vvbbnn00" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "vvbbnn00" + } + } + } + }, + "code_level_contributor.vvbbnn00_link": { + "extractionState": "manual", + "shouldTranslate": false, + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "https://github.com/vvbbnn00" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "https://github.com/vvbbnn00" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "https://github.com/vvbbnn00" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "https://github.com/vvbbnn00" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "https://github.com/vvbbnn00" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "https://github.com/vvbbnn00" + } + } + } + }, + "code_level_contributor.xioxin": { + "extractionState": "manual", + "shouldTranslate": false, + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "xioxin" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "xioxin" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "xioxin" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "xioxin" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "xioxin" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "xioxin" + } + } + } + }, + "code_level_contributor.xioxin_link": { + "extractionState": "manual", + "shouldTranslate": false, + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "https://github.com/xioxin" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "https://github.com/xioxin" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "https://github.com/xioxin" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "https://github.com/xioxin" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "https://github.com/xioxin" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "https://github.com/xioxin" + } + } + } + }, + "contact.altStore_link": { + "extractionState": "manual", + "shouldTranslate": false, + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "altstore://source?url=https://github.com/EhPanda-Team/EhPanda/raw/main/AltStore.json" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "altstore://source?url=https://github.com/EhPanda-Team/EhPanda/raw/main/AltStore.json" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "altstore://source?url=https://github.com/EhPanda-Team/EhPanda/raw/main/AltStore.json" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "altstore://source?url=https://github.com/EhPanda-Team/EhPanda/raw/main/AltStore.json" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "altstore://source?url=https://github.com/EhPanda-Team/EhPanda/raw/main/AltStore.json" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "altstore://source?url=https://github.com/EhPanda-Team/EhPanda/raw/main/AltStore.json" + } + } + } + }, + "contact.discord": { + "extractionState": "manual", + "shouldTranslate": false, + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "https://discord.gg/BSBE9FCBTq" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "https://discord.gg/BSBE9FCBTq" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "https://discord.gg/BSBE9FCBTq" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "https://discord.gg/BSBE9FCBTq" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "https://discord.gg/BSBE9FCBTq" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "https://discord.gg/BSBE9FCBTq" + } + } + } + }, + "contact.discord_link": { + "extractionState": "manual", + "shouldTranslate": false, + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Discord" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Discord" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "Discord" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "Discord" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "Discord" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "Discord" + } + } + } + }, + "contact.gitHub": { + "extractionState": "manual", + "shouldTranslate": false, + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "https://github.com/EhPanda-Team/EhPanda" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "https://github.com/EhPanda-Team/EhPanda" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "https://github.com/EhPanda-Team/EhPanda" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "https://github.com/EhPanda-Team/EhPanda" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "https://github.com/EhPanda-Team/EhPanda" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "https://github.com/EhPanda-Team/EhPanda" + } + } + } + }, + "contact.gitHub_link": { + "extractionState": "manual", + "shouldTranslate": false, + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "GitHub" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "GitHub" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "GitHub" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "GitHub" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "GitHub" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "GitHub" + } + } + } + }, + "contact.telegram": { + "extractionState": "manual", + "shouldTranslate": false, + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "https://t.me/ehpanda" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "https://t.me/ehpanda" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "https://t.me/ehpanda" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "https://t.me/ehpanda" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "https://t.me/ehpanda" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "https://t.me/ehpanda" + } + } + } + }, + "contact.telegram_link": { + "extractionState": "manual", + "shouldTranslate": false, + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Telegram" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Telegram" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "Telegram" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "Telegram" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "Telegram" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "Telegram" + } + } + } + }, + "contact.website": { + "extractionState": "manual", + "shouldTranslate": false, + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "https://ehpanda.app" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "https://ehpanda.app" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "https://ehpanda.app" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "https://ehpanda.app" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "https://ehpanda.app" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "https://ehpanda.app" + } + } + } + }, + "copyright": { + "extractionState": "manual", + "shouldTranslate": false, + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Copyright © 2026 EhPanda Team" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Copyright © 2026 EhPanda Team" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "Copyright © 2026 EhPanda Team" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "Copyright © 2026 EhPanda Team" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "Copyright © 2026 EhPanda Team" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "Copyright © 2026 EhPanda Team" + } + } + } + }, + "special_thanks.caxerx": { + "extractionState": "manual", + "shouldTranslate": false, + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "caxerx" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "caxerx" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "caxerx" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "caxerx" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "caxerx" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "caxerx" + } + } + } + }, + "special_thanks.caxerx_link": { + "extractionState": "manual", + "shouldTranslate": false, + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "https://github.com/caxerx" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "https://github.com/caxerx" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "https://github.com/caxerx" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "https://github.com/caxerx" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "https://github.com/caxerx" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "https://github.com/caxerx" + } + } + } + }, + "special_thanks.honjow": { + "extractionState": "manual", + "shouldTranslate": false, + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "honjow" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "honjow" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "honjow" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "honjow" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "honjow" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "honjow" + } + } + } + }, + "special_thanks.honjow_link": { + "extractionState": "manual", + "shouldTranslate": false, + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "https://github.com/honjow" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "https://github.com/honjow" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "https://github.com/honjow" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "https://github.com/honjow" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "https://github.com/honjow" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "https://github.com/honjow" + } + } + } + }, + "special_thanks.luminescent_yq": { + "extractionState": "manual", + "shouldTranslate": false, + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Luminescent_yq" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Luminescent_yq" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "Luminescent_yq" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "Luminescent_yq" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "Luminescent_yq" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "Luminescent_yq" + } + } + } + }, + "special_thanks.luminescent_yq_link": { + "extractionState": "manual", + "shouldTranslate": false, + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "" + } + } + } + }, + "special_thanks.taylorlannister": { + "extractionState": "manual", + "shouldTranslate": false, + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "taylorlannister" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "taylorlannister" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "taylorlannister" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "taylorlannister" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "taylorlannister" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "taylorlannister" + } + } + } + }, + "special_thanks.taylorlannister_link": { + "extractionState": "manual", + "shouldTranslate": false, + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "https://github.com/taylorlannister" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "https://github.com/taylorlannister" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "https://github.com/taylorlannister" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "https://github.com/taylorlannister" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "https://github.com/taylorlannister" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "https://github.com/taylorlannister" + } + } + } + }, + "translation_contributor.NeKoOuO": { + "extractionState": "manual", + "shouldTranslate": false, + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "ɴᴇᴋᴏ" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "ɴᴇᴋᴏ" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ɴᴇᴋᴏ" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "ɴᴇᴋᴏ" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "ɴᴇᴋᴏ" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "ɴᴇᴋᴏ" + } + } + } + }, + "translation_contributor.NeKoOuO_link": { + "extractionState": "manual", + "shouldTranslate": false, + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "https://github.com/NeKoOuO" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "https://github.com/NeKoOuO" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "https://github.com/NeKoOuO" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "https://github.com/NeKoOuO" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "https://github.com/NeKoOuO" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "https://github.com/NeKoOuO" + } + } + } + }, + "translation_contributor.caxerx": { + "extractionState": "manual", + "shouldTranslate": false, + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "caxerx" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "caxerx" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "caxerx" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "caxerx" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "caxerx" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "caxerx" + } + } + } + }, + "translation_contributor.caxerx_link": { + "extractionState": "manual", + "shouldTranslate": false, + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "https://github.com/caxerx" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "https://github.com/caxerx" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "https://github.com/caxerx" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "https://github.com/caxerx" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "https://github.com/caxerx" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "https://github.com/caxerx" + } + } + } + }, + "translation_contributor.nebulosa-cat": { + "extractionState": "manual", + "shouldTranslate": false, + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "雲豹 ΦωΦ" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "雲豹 ΦωΦ" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "雲豹 ΦωΦ" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "雲豹 ΦωΦ" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "雲豹 ΦωΦ" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "雲豹 ΦωΦ" + } + } + } + }, + "translation_contributor.nebulosa-cat_link": { + "extractionState": "manual", + "shouldTranslate": false, + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "https://github.com/Nebulosa-Cat" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "https://github.com/Nebulosa-Cat" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "https://github.com/Nebulosa-Cat" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "https://github.com/Nebulosa-Cat" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "https://github.com/Nebulosa-Cat" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "https://github.com/Nebulosa-Cat" + } + } + } + }, + "translation_contributor.paulHaeussler": { + "extractionState": "manual", + "shouldTranslate": false, + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "PaulHaeussler" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "PaulHaeussler" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "PaulHaeussler" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "PaulHaeussler" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "PaulHaeussler" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "PaulHaeussler" + } + } + } + }, + "translation_contributor.paulHaeussler_link": { + "extractionState": "manual", + "shouldTranslate": false, + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "https://github.com/PaulHaeussler" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "https://github.com/PaulHaeussler" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "https://github.com/PaulHaeussler" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "https://github.com/PaulHaeussler" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "https://github.com/PaulHaeussler" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "https://github.com/PaulHaeussler" + } + } + } + } + }, + "version": "1.0" +} \ No newline at end of file diff --git a/AppPackage/Sources/SettingFeature/Resources/Localizable.xcstrings b/AppPackage/Sources/SettingFeature/Resources/Localizable.xcstrings new file mode 100644 index 000000000..9d3feac54 --- /dev/null +++ b/AppPackage/Sources/SettingFeature/Resources/Localizable.xcstrings @@ -0,0 +1,6514 @@ +{ + "sourceLanguage": "en", + "strings": { + "account": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Account" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Konto" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "アカウント" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "계정" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "账户" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "帳號設定" + } + } + } + }, + "account_configuration": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Account configuration" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Kontoeinstellungen" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "アカウント設定" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "계정 설정" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "账户设置" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "帳號設定" + } + } + } + }, + "acknowledgements": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Acknowledgements" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Danksagungen" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "謝辞" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "도움을 주신 분들" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "致谢" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "致謝" + } + } + } + }, + "allow_cellular_downloads": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Allow cellular downloads" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Downloads über Mobilfunk erlauben" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "モバイル通信でのダウンロードを許可" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "셀룰러 다운로드 허용" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "允许蜂窝网络下载" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "允許行動網路下載" + } + } + } + }, + "altStore_source": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "AltStore source" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "AltStore Quelle" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "AltStore ソース" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "AltStore 소스" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "AltStore 源" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "AltStore source" + } + } + } + }, + "app_activity_logs": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "App activity logs" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "App-Aktivitätsprotokolle" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "アプリアクティビティログ" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "앱 활동 로그" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "应用活动日志" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "應用程式活動日誌" + } + } + } + }, + "app_activity_logs_view.current": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Current" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Aktuell" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "現在" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "현재" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "当前" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "目前" + } + } + } + }, + "app_activity_logs_view.more_logs": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "More logs" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Weitere Protokolle" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "他のログ" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "더 많은 로그" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "更多日志" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "更多日誌" + } + } + } + }, + "app_activity_logs_view.no_logs": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "No logs found" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Keine Protokolle gefunden" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ログが見つかりません" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "로그가 없습니다" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "未找到日志" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "找不到日誌" + } + } + } + }, + "app_activity_logs_view.open_in_files": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Open in Files" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "In „Dateien“ öffnen" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ファイルで開く" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "파일 앱에서 열기" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "在“文件”中打开" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "在「檔案」中開啟" + } + } + } + }, + "app_activity_logs_view.run": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Run %#@count@" + }, + "substitutions": { + "count": { + "argNum": 1, + "formatSpecifier": "lld", + "variations": { + "plural": { + "other": { + "stringUnit": { + "state": "translated", + "value": "%arg" + } + } + } + } + } + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Ausführung %#@count@" + }, + "substitutions": { + "count": { + "argNum": 1, + "formatSpecifier": "lld", + "variations": { + "plural": { + "other": { + "stringUnit": { + "state": "translated", + "value": "%arg" + } + } + } + } + } + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "起動 %#@count@" + }, + "substitutions": { + "count": { + "argNum": 1, + "formatSpecifier": "lld", + "variations": { + "plural": { + "other": { + "stringUnit": { + "state": "translated", + "value": "%arg" + } + } + } + } + } + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "실행 %#@count@" + }, + "substitutions": { + "count": { + "argNum": 1, + "formatSpecifier": "lld", + "variations": { + "plural": { + "other": { + "stringUnit": { + "state": "translated", + "value": "%arg" + } + } + } + } + } + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "运行 %#@count@" + }, + "substitutions": { + "count": { + "argNum": 1, + "formatSpecifier": "lld", + "variations": { + "plural": { + "other": { + "stringUnit": { + "state": "translated", + "value": "%arg" + } + } + } + } + } + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "運行 %#@count@" + }, + "substitutions": { + "count": { + "argNum": 1, + "formatSpecifier": "lld", + "variations": { + "plural": { + "other": { + "stringUnit": { + "state": "translated", + "value": "%arg" + } + } + } + } + } + } + } + } + }, + "app_activity_logs_view.runs": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Runs" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Ausführungen" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "起動" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "실행" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "运行" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "運行" + } + } + } + }, + "app_activity_logs_view.title": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "App activity logs" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "App-Aktivitätsprotokolle" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "アプリアクティビティログ" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "앱 활동 로그" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "应用活动日志" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "應用程式活動日誌" + } + } + } + }, + "app_icon": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "App icon" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "App-Symbol" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "アプリアイコン" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "앱 아이콘" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "应用图标" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "App 圖案" + } + } + } + }, + "appearance": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Appearance" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Oberfläche" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "外観" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "외관" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "外观" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "外觀設定" + } + } + } + }, + "appearance_display_mode": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Display mode" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Anzeigemodus" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "表示モード" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "표시방식" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "显示样式" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "顯示模式" + } + } + } + }, + "archiver_behavior": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Archiver behavior" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Archiver-Verhalten" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "アーカイバー動作" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "아카이버 동작 방법 설정" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "归档下载方式" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "存檔邏輯設定" + } + } + } + }, + "archiver_behavior_description": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "The default behavior for the Archiver is to confirm the cost and selection for original or resampled archive, then present a link that can be clicked or copied elsewhere. You can change this behavior here." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Standardmäßig fragt der Archiver Kosten und Auswahl (Original oder neu berechnet) ab und zeigt dann einen Link an, den du anklicken oder woanders kopieren kannst. Dieses Verhalten kannst du hier ändern." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "アーカイバーのデフォルト動作はオリジナルとリサンプルのアーカイブのコストと選択を確認してからリンクを提供し、それからそのリンクをクリックしたりどこかにペーストしたりすることも可能です。そのデフォルト動作はここで変更できます。" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "아카이버의 기본 동작은 원본 또는 저화질 갤러리 저장에 대한 비용과 선택을 확인한 다음 다른 곳에서 클릭하거나 복사할 수 있는 링크를 표시하는 것입니다. 여기서 이 동작을 변경할 수 있습니다." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "默认归档下载方式为手动选择(原画质或压缩画质),然后手动复制或点击下载链接。你可以修改归档下载方式。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "存檔的預設邏輯是確認原始圖片或重新採樣後進行存檔的成本差異與選擇,然後顯示一個可以在其他地方點擊、複製的連結,你可以在此處更改他的運作方式。" + } + } + } + }, + "archiver_settings": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Archiver Settings" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Archiver" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "アーカイバー設定" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "아카이버" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "归档设置" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "存檔設定" + } + } + } + }, + "auto_lock": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Auto-Lock" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Automatische Sperre" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "自動ロック" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "앱 자동 잠금" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "自动锁定" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "自動鎖定" + } + } + } + }, + "background_blur_radius": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Background blur radius" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Hintergrund-Unschärfe" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "バッググラウンドぼかし度" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "백그라운드 흐림 정도" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "后台模糊效果" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "後台背景模糊" + } + } + } + }, + "browsing_country": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Browsing country" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Browsing-Land" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "閲覧国" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "브라우징하는 나라" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "浏览国家" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "所在國家" + } + } + } + }, + "browsing_country_description": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "You appear to be browsing the site from **%@** or use a VPN or proxy in this country, which means the site will try to load images from H@H clients in this general geographic region. If this is incorrect, or if you want to use a different region for any reason (like if you are using a split tunneling VPN), you can select a different country below." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Es sieht so aus, als würdest du die Seite aus **%@** aufrufen oder ein VPN bzw. einen Proxy in diesem Land verwenden. Die Seite versucht daher, Bilder von H@H-Clients in dieser Region zu laden. Falls das nicht stimmt oder du aus irgendeinem Grund eine andere Region verwenden möchtest (etwa mit einem Split-Tunneling-VPN), kannst du unten ein anderes Land auswählen." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "**%@** から本サイトを閲覧している、またはその国の VPN・プロキシを使用しているようです。本サイトはその地域の H@H クライアントから画像を読み込もうとしますが、もし自動検知の結果が誤っている、または特別な事情でほかの地域のクライアントを希望する場合(例えばスプリットトンネル VPN を使用している)は下に手動選択できます。" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "**%@**에서 사이트를 탐색하거나 이 나라에서 VPN이나 프록시를 사용하려고 하는 것 같네요. 이런 경우엔 사이트에서 이 지역의 H@H 클라이언트의 이미지를 로드하려고 시도할 거에요. 만약에 이 나라가 잘못되었거나 분할 터널링 VPN을 사용하는 경우와 같이 어떤 이유로든 다른 지역을 사용하려는 경우라면, 아래에서 다른 나라를 선택할 수 있어요." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "你似乎正在 **%@** 浏览此网页,或是使用了一个来自这个国家的 VPN 或代理,这意味着网站将尝试通过在此区域的 H@H 客户端加载图片。如果该结果不正确,或你想通过其它地区的 H@H 客户端加载图片(例如你正在使用分割隧道 VPN),你可以在下方选择另一个国家。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "你似乎是從 **%@** 瀏覽這個網站或在使用當地的 VPN,這意味著網站將嘗試從這個地理區域的 H@H 用戶端載入圖片。如果這不正確或你出於任何原因想要使用不同的區域(例如你正在透過 VPN 連線),您可以在下面選擇不同的國家/地區。" + } + } + } + }, + "bypasses_SNI_filtering": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Bypasses SNI Filtering" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "SNI Filter umgehen" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "SNI フィルタリング回避" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "SNI 차단 우회" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "域前置绕过 SNI 阻断" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "繞過 SNI 過濾" + } + } + } + }, + "caches": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Caches" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Cache" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "キャッシュ" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "캐시" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "缓存" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "快取" + } + } + } + }, + "clear_image_caches": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Clear image caches" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Zwischengespeicherte Bilder (Cache) löschen" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "画像キャッシュを削除" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "이미지 캐시 지우기" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "清空图片缓存" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "清理圖片快取" + } + } + } + }, + "code_level_contributors": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Code-level contributors" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Mitwirkende am Code" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "コードレベル貢献者" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "코드 기여자" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "代码级贡献者" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "程式碼貢獻者" + } + } + } + }, + "comments_sort_order": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Comments sort order" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Sortierung der Kommentare" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "コメントの並び替え" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "댓글 순서" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "评论排序方式" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "留言排序方式" + } + } + } + }, + "comments_votes_show_timing": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Comment votes show timing" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Anzeige der Kommentarbewertungen" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "コメントスコア表示タイミング" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "평가의 시간을 보이기" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "显示评论分数时机" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "留言投票數顯示時機" + } + } + } + }, + "concurrent_image_downloads": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Concurrent image downloads" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Gleichzeitige Bilddownloads" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "同時画像ダウンロード数" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "동시 이미지 다운로드 수" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "并发图片下载" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "並行圖片下載" + } + } + } + }, + "copy_cookies": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Copy cookies" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Cookies kopieren" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "クッキーをコピー" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "쿠키 복사하기" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "复制 Cookies" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "複製 Cookies" + } + } + } + }, + "cover_scale_factor": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "The cover size in gallery list views can be scaled to between 75% and 150% when using the Thumbnail or Extended display modes." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Die Covergröße in Galerielisten kann in den Anzeigemodi „Vorschaubilder“ und „Erweitert“ auf 75% bis 150% skaliert werden." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "サムネイル・拡張表示モードでのカバーを 75% ~ 150% にスケールすることができます。" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "썸네일 또는 확장 표시방식에서는 갤러리 목록의 표지 크기를 75%에서 150% 사이로 조절할 수 있어요." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "缩略图和扩展模式下的画廊列表封面可以缩放为 75% 到 150% 之间的值。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "在縮圖與放大檢視這兩種檢視模式下,封面的重新採樣比率介於 75% 至 150%." + } + } + } + }, + "cover_scaling": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Cover Scaling" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Cover-Skalierung" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "カバースケーリング" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "표지 크기 조절" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "封面缩放" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "封面縮放" + } + } + } + }, + "create_new": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Create new" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Neu erstellen" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "新規作成" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "추가" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "创建新档案" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "新增設定檔" + } + } + } + }, + "default_language_description": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "N/A" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Unbekannt" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "無効" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "알 수 없음" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "无效" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "N/A" + } + } + } + }, + "delete_profile": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Delete profile" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Profil löschen" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "プロファイルを削除" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "프로필 삭제" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "删除档案" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "刪除設定檔" + } + } + } + }, + "detects_links_from_clipboard": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Detects links from the clipboard" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Übernimmt automatisch Links aus der Zwischenablage" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "クリップボードからリンクを探知" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "클립보드의 링크 인식하기" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "从剪切板检测链接" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "偵測剪貼簿中的連結" + } + } + } + }, + "display_mode": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Display mode" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Anzeigemodus" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "表示モード" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "표시방식" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "显示样式" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "顯示模式" + } + } + } + }, + "display_mode_description": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Which display mode would you like to use on the front and search pages?" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Welchen Anzeigemodus möchtest du auf der Startseite und den Suchseiten verwenden?" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "フロント・検索ページで使う表示モードはどれにしますか?" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "프론트와 검색 페이지에서 사용할 디스플레이 모드를 선택하세요." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "你希望在扉页和搜索页显示哪种样式?" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "你想在首頁和搜尋結果中使用哪一種顯示方式?" + } + } + } + }, + "display_style": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Display style" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Darstellungsstil" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "表示仕様" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "보여주기 스타일" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "显示样式" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "顯示方式" + } + } + } + }, + "displays_japanese_title": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Displays Japanese title" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Japanischen Titel anzeigen" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "日本語タイトルを表示" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "일본어 제목 보여주기" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "显示日文标题" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "以日文顯示標籤" + } + } + } + }, + "done": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Done" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Fertig" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "完了" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "완료" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "完成" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "完成" + } + } + } + }, + "ehPanda": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "EhPanda" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "EhPanda" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "EhPanda" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "EhPanda" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "EhPanda" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "EhPanda" + } + } + } + }, + "enable_gallery_thumbnail_selector": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Enable thumbnail selector on gallery screen" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Vorschaubild-Auswahl auf der Galerieseite aktivieren" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ギャラリーのサムネイルセレクタ" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "갤러리 화면에서 썸네일 선택기 사용" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "在画廊页面启用缩图选择器" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "在畫廊頁面啟用縮圖選擇器" + } + } + } + }, + "enables_tags_extension": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Enables tags extension" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Tag-Erweiterung aktivieren" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "タグの拡張機能を有効" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "태그 확장 기능 사용하기" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "启用标签扩展" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "啟用自訂標籤擴充功能" + } + } + } + }, + "excluded_languages": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Excluded Languages" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Ausgeschlossene Sprachen" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "排除された言語" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "제외된 언어" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "屏蔽的语言" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "排除語言" + } + } + } + }, + "excluded_languages_description": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "If you wish to hide galleries in certain languages from the gallery list and searches, select them from the list below. Note that matching galleries will never appear regardless of your search query." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Wenn du Galerien in bestimmten Sprachen aus der Galerieliste und den Suchergebnissen ausblenden möchtest, wähle sie unten aus. Passende Galerien erscheinen dann unabhängig von deiner Suchanfrage nie." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "特定の言語のギャラリーをリストと検索結果から隠したい場合、下に選択してください。注意:どんな検索クエリーを使ってもこれらの言語のギャラリーは表示されません。" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "갤러리 목록에서 특정 언어로 된 갤러리를 숨기고 검색하려면 아래 목록에서 해당 갤러리를 선택해주세요. 검색어에 관계없이 일치하는 갤러리는 나타나지 않아요." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "如果你希望以从列表或搜索结果中隐藏特定语言的画廊,请从下面的列表中选择。注意:无论搜索条件为何,这些画廊都不会出现。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "如果你希望從畫廊列表和搜尋中隱藏掉某些語言的畫廊,請從下面的清單中選取它們。請注意,無論你的搜尋查詢如何,相符於篩除規則的畫廊都不會出現。" + } + } + } + }, + "excluded_uploaders": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Excluded Uploaders" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Ausgeschlossene Uploader" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "排除された投稿者" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "제외된 업로드" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "屏蔽的上传者" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "排除的上傳者" + } + } + } + }, + "excluded_uploaders_count": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "You are currently using **%#@used@ / %#@limit@** exclusion slots." + }, + "substitutions": { + "used": { + "argNum": 1, + "formatSpecifier": "lld", + "variations": { + "plural": { + "other": { + "stringUnit": { + "state": "translated", + "value": "%arg" + } + } + } + } + }, + "limit": { + "argNum": 2, + "formatSpecifier": "lld", + "variations": { + "plural": { + "other": { + "stringUnit": { + "state": "translated", + "value": "%arg" + } + } + } + } + } + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Du belegst derzeit **%#@used@ / %#@limit@** Ausschlussplätze." + }, + "substitutions": { + "used": { + "argNum": 1, + "formatSpecifier": "lld", + "variations": { + "plural": { + "other": { + "stringUnit": { + "state": "translated", + "value": "%arg" + } + } + } + } + }, + "limit": { + "argNum": 2, + "formatSpecifier": "lld", + "variations": { + "plural": { + "other": { + "stringUnit": { + "state": "translated", + "value": "%arg" + } + } + } + } + } + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "現時点で **%#@used@ / %#@limit@** の排除スロットが使用済みです。" + }, + "substitutions": { + "used": { + "argNum": 1, + "formatSpecifier": "lld", + "variations": { + "plural": { + "other": { + "stringUnit": { + "state": "translated", + "value": "%arg" + } + } + } + } + }, + "limit": { + "argNum": 2, + "formatSpecifier": "lld", + "variations": { + "plural": { + "other": { + "stringUnit": { + "state": "translated", + "value": "%arg" + } + } + } + } + } + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "**%#@used@ / %#@limit@** 개의 슬롯을 사용하고 있어요." + }, + "substitutions": { + "used": { + "argNum": 1, + "formatSpecifier": "lld", + "variations": { + "plural": { + "other": { + "stringUnit": { + "state": "translated", + "value": "%arg" + } + } + } + } + }, + "limit": { + "argNum": 2, + "formatSpecifier": "lld", + "variations": { + "plural": { + "other": { + "stringUnit": { + "state": "translated", + "value": "%arg" + } + } + } + } + } + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "已使用 **%#@used@ / %#@limit@** 个屏蔽槽位。" + }, + "substitutions": { + "used": { + "argNum": 1, + "formatSpecifier": "lld", + "variations": { + "plural": { + "other": { + "stringUnit": { + "state": "translated", + "value": "%arg" + } + } + } + } + }, + "limit": { + "argNum": 2, + "formatSpecifier": "lld", + "variations": { + "plural": { + "other": { + "stringUnit": { + "state": "translated", + "value": "%arg" + } + } + } + } + } + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "你正在使用 **%#@used@ / %#@limit@** 排除欄位" + }, + "substitutions": { + "used": { + "argNum": 1, + "formatSpecifier": "lld", + "variations": { + "plural": { + "other": { + "stringUnit": { + "state": "translated", + "value": "%arg" + } + } + } + } + }, + "limit": { + "argNum": 2, + "formatSpecifier": "lld", + "variations": { + "plural": { + "other": { + "stringUnit": { + "state": "translated", + "value": "%arg" + } + } + } + } + } + } + } + } + }, + "excluded_uploaders_description": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "If you wish to hide galleries from certain uploaders from the gallery list and searches, add them below. Put one username per line. Note that galleries from these uploaders will never appear regardless of your search query." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Wenn du Galerien bestimmter Uploader aus der Galerieliste und den Suchergebnissen ausblenden möchtest, füge sie unten hinzu. Ein Benutzername pro Zeile. Galerien dieser Uploader erscheinen dann unabhängig von deiner Suchanfrage nie." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "特定の投稿者のギャラリーをリストと検索結果から隠したい場合、下に名前を記入してください。一行に一つのユーザー名で。注意:どんな検索クエリーを使ってもこの投稿者たちのギャラリーは表示されません。" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "갤러리 목록 및 검색에서 특정 업로더의 갤러리를 숨기려면 아래에 해당 갤러리를 추가해주세요. 한 줄에 하나의 사용자 이름을 입력해주세요. 이러한 업로더의 갤러리는 검색 쿼리에 관계없이 나타나지 않아요." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "如果你希望在画廊中和搜索中隐藏某个上传者的话,请把他们的用户名填写在下方,每行一个。注意:无论搜索条件为何,这些上传者都不会出现。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "如果你希望從畫廊列表和搜尋結果中隱藏某些上傳者的畫廊,請將它們新增到下方,每行輸入一個使用者名稱。請注意,無論你的搜尋結果如何,這些上傳者的畫廊都不會出現。" + } + } + } + }, + "favorite_categories": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Here you can choose and rename your favorite categories." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Hier kannst du deine Favoriten-Kategorien auswählen und umbenennen." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ここではお気に入りカテゴリー名の変更ができます。" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "여기서 좋아하는 장르들을 선택하고 이름을 바꿀 수 있어요." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "在这里你可以重命名你的收藏夹。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "在這裡你可以選擇並重新命名收藏匣" + } + } + } + }, + "favorites_section": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Favorites" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Favoriten" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "お気に入り" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "즐겨찾기" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "收藏" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "收藏匣" + } + } + } + }, + "favorites_sort_order": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Favorites sort order" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Sortierung der Favoriten" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "お気に入りの並び替え" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "관심 순서를 배열" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "收藏排序方式" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "收藏匣排序" + } + } + } + }, + "favorites_sort_order_description": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "You can also select your default sort order for galleries on your favorites page. Note that favorites added prior to the March 2016 revamp did not store a timestamp, and will use the gallery posted time regardless of this setting." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Du kannst auch die Standardsortierung für Galerien auf deiner Favoritenseite festlegen. Favoriten, die vor der Überarbeitung im März 2016 hinzugefügt wurden, haben keinen Zeitstempel und werden unabhängig von dieser Einstellung nach dem Veröffentlichungszeitpunkt der Galerie sortiert." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "お気に入りページのデフォルト並び替え順序も変更可能です。注意:平成28年3月の改修前にお気に入りに追加した項目はタイムスタンプが含まれていないため、この設定を無視して代わりにギャラリーの投稿時間を使います。" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "당신의 관심 페이지의 기본 정렬 방식을 선택할 수 있어요. 2016년 3월 개정 전에 추가된 즐겨찾기는 타임스탬프가 저장되지 않아 이 설정에 관계없이 갤러리가 게시된 시간으로 정렬되어요." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "你也可以选择收藏夹中默认排序。注意:2016 年 3 月改版之前加入收藏夹的画廊并未保存收藏时间,会以画廊发布时间代替。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "你還可以在收藏匣頁面上為畫廊選擇預設排列順序。 請注意,在 2016 年 3 月網站改版之前新增的畫廊並不儲存時間印記,並且無論使用何種設定,都將使用畫廊發佈時間作為排序參考。" + } + } + } + }, + "filtered_removal_count": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Show Filtered Removal Count" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Anzahl gefilterter Galerien" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "フィルター除去数" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "필터로 제거된 수" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "筛选器移除数" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "顯示過濾結果計數器" + } + } + } + }, + "filtered_removal_count_description": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Show the \"Your default filters removed XX galleries from this page\" readout?" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Soll die Meldung „Deine Standardfilter haben XX Galerien von dieser Seite entfernt“ angezeigt werden?" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "「既定フィルターにより本ページから XX 個のギャラリーが除去されました」を表示しますか?" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "\"기본 필터가 이 페이지에서 갤러리 XX개를 제거했어요\" 문구를 표시할까요?" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "要显示“你的默认筛选器从本页移除了 XX 个画廊”提示吗?" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "顯示 \"Your default filters removed XX galleries from this page\" ?" + } + } + } + }, + "front_page_settings": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Front Page Settings" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Startseite" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "フロントページ設定" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "프론트 페이지 설정" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "扉页设置" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "首頁設定" + } + } + } + }, + "gallery": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Gallery" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Galerie" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ギャラリー" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "갤러리" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "画廊" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "畫廊" + } + } + } + }, + "gallery_category": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "What categories would you like to show by default on the front page and in searches?" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Welche Kategorien sollen standardmäßig auf der Startseite und in Suchergebnissen angezeigt werden?" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "フロント・検索ページでどれらのカテゴリーのギャラリーを表示しますか?" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "프론트와 검색 페이지에서 어떤 카테고리가 보여지도록 할까요?" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "你希望在扉页和搜索页看到哪些类别?" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "預設情況下你希望在首頁和搜尋結果中顯示哪些類別的結果?" + } + } + } + }, + "gallery_comments": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Gallery Comments" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Galerie-Kommentare" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ギャラリーコメント" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "갤러리 댓글" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "画廊评论" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "畫廊留言" + } + } + } + }, + "gallery_name": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Gallery name" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Galeriename" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ギャラリー名" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "갤러리 이름" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "画廊名称" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "畫廊名稱" + } + } + } + }, + "gallery_name_description": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Many galleries have both an English/Romanized title and a title in Japanese script. Which gallery name would you like as default?" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Viele Galerien haben sowohl einen englischen bzw. romanisierten Titel als auch einen Titel in japanischer Schrift. Welchen Namen möchtest du standardmäßig sehen?" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "英語・ローマ字と日本語両方のタイトルを持つギャラリーはたくさんあります。どちらをデフォルトにしますか?" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "영어 제목과 일본어 제목 중 기본값으로 보일 언어를 선택해주세요." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "很多画廊都同时拥有英文或者日文标题,你想默认显示哪一个?" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "許多畫廊同時提供了英文/預設標題與日文標題,你想優先顯示哪種畫廊名稱?" + } + } + } + }, + "gallery_name_display": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Gallery Name Display" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Anzeige des Galerienamens" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ギャラリー名表示" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "갤러리 이름 보이기" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "画廊名称显示" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "畫廊顯示名稱" + } + } + } + }, + "gallery_page_thumbnail_labeling": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Gallery Page Thumbnail Labeling" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Beschriftung der Vorschaubilder" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ギャラリーサムネイルのラベル" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "갤러리 페이지 썸네일 라벨" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "画廊页面缩略图标签" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "畫廊頁面縮圖標籤" + } + } + } + }, + "gallery_tags": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Gallery Tags" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Galerie-Tags" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ギャラリータグ" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "갤러리 태그" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "画廊标签" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "畫廊標籤" + } + } + } + }, + "general": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "General" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Allgemein" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "一般" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "일반" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "一般" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "一般設定" + } + } + } + }, + "horizontal": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Horizontal" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Horizontal" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "幅" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "가로" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "宽度" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "水平尺寸(寬)" + } + } + } + }, + "host_settings": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "%@ settings" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "%@-Einstellungen" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "%@ 設定" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "%@ 설정" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "%@ 设置" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "%@ 設定" + } + } + } + }, + "image_load_settings": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Image Load Settings" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Laden von Bildern" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "画像読み込み設定" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "이미지 로드 설정" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "图片加载设置" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "圖片來源設定" + } + } + } + }, + "image_resolution": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Image resolution" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Bildauflösung" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "画像解像度" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "이미지 해상도" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "图像分辨率" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "圖片解析度" + } + } + } + }, + "image_resolution_description": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Normally, images are resampled to 1280 pixels of horizontal resolution for online viewing. You can alternatively select one of the following resample resolutions. To avoid murdering the staging servers, resolutions above 1280x are temporarily restricted to donators, people with any hath perk, and people with a UID below 3,000,000." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Normalerweise werden Bilder für die Online-Ansicht auf eine horizontale Auflösung von 1280 Pixeln neu berechnet. Alternativ kannst du eine der folgenden Auflösungen wählen. Um die Server nicht zu überlasten, sind Auflösungen über 1280x vorübergehend Spendern, Nutzern mit einem Hath-Perk und Nutzern mit einer UID unter 3.000.000 vorbehalten." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "一般的に、オンライン閲覧の画像は 1280x までにリサンプリングされます。下のいずれかのリサンプリング解像度に変更できます。サーバー負荷軽減のため、1280x 以上の解像度は現時点で寄付者、Hath Perks 利用者または UID が 3,000,000 以下の者に限定されます。" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "일반적으로 이미지는 온라인 뷰어를 위해 1280 픽셀의 수평 해상도로 작아져요. 아래의 압축된 해상도 중 하나를 선택할 수 있어요. 서버 과부하를 막기 위해, 1280 이상의 해상도는 도네이션을 한 사람, hath perk를 가진 사람, 그리고 UID가 300만 이하인 사람들로 일시적으로 제한되어요." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "通常情况,图像将重采样到 1280 像素宽度以用于在线浏览,你也可以选择以下重新采样分辨率。但是为了避免负载过高,高于 1280 像素将只供给于赞助者、特殊贡献者,以及 UID 小于 3,000,000 的用户。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "一般情況下圖片會被縮放成 1280 px 水平解析度以供線上瀏覽,你也可以選擇下列解析度之一。為了避免破壞伺服器正常運作,高於 1280x 的解析度暫時僅提供下列使用者使用: 贊助者、具有任何 Hath Perk 的使用者以及 UID 低於 3,000,000 的使用者" + } + } + } + }, + "image_size": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Image size" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Bildgröße" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "画像サイズ" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "이미지 사이즈" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "图像尺寸" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "圖片尺寸" + } + } + } + }, + "image_size_description": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "While the site will automatically scale down images to fit your screen width, you can also manually restrict the maximum display size of an image. Like the automatic scaling, this does not resample the image, as the resizing is done browser-side. (0 = no limit)" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Die Seite verkleinert Bilder automatisch passend zu deiner Bildschirmbreite, du kannst die maximale Anzeigegröße aber auch manuell begrenzen. Wie bei der automatischen Skalierung wird das Bild dabei nicht neu berechnet, da die Größenänderung im Browser erfolgt. (0 = keine Begrenzung)" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "サイト側は画像を自動的にスクリーンに適したサイズにスケールしますが、手動的にその画像の表示サイズ最大値を指定することも可能です。ブラウザが処理を実行するため、画像のリサンプリングは行われません。(ゼロは無制限を意味します)" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "사이트가 사용자의 화면 너비에 맞게 이미지를 자동으로 축소시키지만, 수동으로 크기를 정할 수도 있어요. 크기 조정은 브라우저 측에서 수행되므로 이미지가 다시 샘플링되지 않아요. (0 = no limit)" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "虽然图片会自动根据窗口缩小,你也可以手动设置最大大小,图片并没有重新采样。(0 为不限制)" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "雖然網站會自動縮小圖片以適應瀏覽裝置的螢幕寬度,但您也可以手動限製圖片的最大顯示尺寸。 像自動縮放一樣,這不會重新採樣圖像,因為調整大小是在瀏覽器完成的 (0 = 沒有限制)" + } + } + } + }, + "image_size_settings": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Image Size Settings" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Bildgrößen-Einstellungen" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "画像サイズ設定" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "이미지 사이즈 설정" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "图像尺寸设置" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "圖片尺寸設定" + } + } + } + }, + "import_custom_translations": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Import custom translations" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Benutzerdefinierte Übersetzungen importieren" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "カスタム翻訳を取り込む" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "사용자 지정 번역 가져오기" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "导入自定义翻译" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "匯入自訂標籤翻譯" + } + } + } + }, + "infite": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Infite" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Unbegrenzt" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "無制限" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "제한 없음" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "无限" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "無限" + } + } + } + }, + "laboratory": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Laboratory" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Experimentelles" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ラボ" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "실험실" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "实验室" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "實驗性功能" + } + } + } + }, + "list": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "List" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Liste" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "リスト" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "리스트" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "列表" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "列表" + } + } + } + }, + "load_images_through_the_hath_network": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Load images through the Hath network" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Bilder über das Hath-Netzwerk laden" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "Hath ネットワーク経由で画像を読み込む" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "Hath 네트워크를 통하여 이미지 로드" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "通过 Hath 网络加载图像" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "透過 Hath Network 載入圖片" + } + } + } + }, + "logout": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Logout" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Ausloggen" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ログアウト" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "로그아웃" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "退出登录" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "登出" + } + } + } + }, + "logout_description": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Are you sure to logout?" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Bist du sicher das du dich ausloggen möchtest?" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "本当にログアウトしてもよろしいですか?" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "로그아웃 하시겠어요?" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "确定要退出登录吗?" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "確定要登出嗎?" + } + } + } + }, + "maximum_number_of_tags": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Maximum number of tags" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Maximale Anzahl an Tags" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "タグ数上限" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "태그 갯수" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "标签数量上限" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "標籤最大顯示數量" + } + } + } + }, + "multi_page_viewer": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Multi-Page Viewer" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Multi-Page-Viewer" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "マルチページビューア" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "멀티 페이지 뷰어" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "多页查看器" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "多頁瀏覽" + } + } + } + }, + "navigation": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Navigation" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Navigation" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ナビゲーション" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "내비게이션" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "导航" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "導覽" + } + } + } + }, + "network": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Network" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Netzwerk" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ネットワーク" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "네트워크" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "网络" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "網路" + } + } + } + }, + "network_description": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Only one gallery downloads at a time. This setting controls how many gallery pages can download in parallel, can allow or block cellular downloads, and stores files in the app's Downloads folder." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Es wird immer nur eine Galerie gleichzeitig heruntergeladen. Mit dieser Einstellung steuerst du, wie viele Galerieseiten parallel geladen werden, ob Mobilfunk erlaubt ist und dass Dateien im Downloads-Ordner der App gespeichert werden." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "一度にダウンロードされるギャラリーは 1 件だけです。この設定では、1 つのギャラリー内で同時にダウンロードするページ数、モバイル通信の許可または禁止、そしてファイルをアプリの Downloads フォルダに保存する動作を管理します。" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "한 번에 하나의 갤러리만 다운로드됩니다. 이 설정으로 한 갤러리 안에서 동시에 다운로드할 페이지 수, 셀룰러 다운로드 허용 여부, 그리고 파일을 앱의 Downloads 폴더에 저장하는 방식을 제어합니다." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "每次只会下载一个画廊。这个设置用于控制单个画廊内页面的并行下载数量、是否允许蜂窝网络下载,以及文件在应用 Downloads 文件夹中的存储方式。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "每次只會下載一個畫廊。此設定用於控制單個畫廊內頁面的並行下載數量、是否允許行動網路下載,以及檔案在應用程式 Downloads 資料夾中的儲存方式。" + } + } + } + }, + "optional_UI_elements": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Optional UI Elements" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Optionale UI-Elemente" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "UI の表示制御" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "선택적 UI 요소" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "可选的 UI 组件" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "可選用的 UI 元件" + } + } + } + }, + "optional_UI_elements_description": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Some historic UI elements are now disabled by default. You can enable those here." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Einige ältere UI-Elemente sind inzwischen standardmäßig deaktiviert. Hier kannst du sie wieder aktivieren." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "一部の従来の UI はデフォルトで無効になっています。ここで有効にすることができます。" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "일부 예전 UI 요소는 이제 기본적으로 꺼져 있어요. 여기서 다시 켤 수 있어요." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "一些旧版 UI 组件现已默认禁用。您可以在此启用这些组件。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "一些舊版 UI 元件現已預設停用。您可以在此啟用這些元件。" + } + } + } + }, + "original_images": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Use original images instead of the resampled versions? Resampled images will still be used if you select a horizontal resolution different than \"Auto\" above and the image in question is wider, or if the original image is larger than 10 MiB (or 4 MiB for galleries older than one year)." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Sollen Originalbilder statt der neu berechneten Versionen verwendet werden? Neu berechnete Bilder werden weiterhin verwendet, wenn du oben eine andere horizontale Auflösung als „Automatisch“ wählst und das betreffende Bild breiter ist, oder wenn das Originalbild größer als 10 MiB ist (bzw. 4 MiB bei Galerien, die älter als ein Jahr sind)." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "オリジナル画像を使いますか?リサンプリングされた画像は、上記の解像度で「自動」以外を選択し、該当する画像の方が幅が広い場合、またはオリジナル画像が 10 MiB(一年以上前のギャラリーの場合は 4 MiB)より大きい場合に使用されます。" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "다시 샘플링된 버전 대신 원본 이미지를 사용할까요? 위에서 가로 해상도를 \"자동\" 이외로 선택했고 해당 이미지가 그보다 넓은 경우나, 원본 이미지가 10 MiB(1년 넘은 갤러리는 4 MiB)보다 큰 경우에는 다시 샘플링된 이미지가 계속 사용되어요." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "是否使用原始图像而非重新采样的版本?如果您在上方选择的水平分辨率不是“自动”,并且所查看的图像更宽,或者原始图像大于 10 MiB(对于超过一年的图库,则为 4 MiB),那么仍将使用重新采样的图像。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "要使用原始圖片而非重新取樣的版本嗎? 若您在上方選擇「自動」以外的水準解析度且圖片較寬,或原始圖片大於 10 MiB(一年以上的圖庫則為 4 MiB),系統仍會使用重新取樣的圖片。" + } + } + } + }, + "password": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Password" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Passwort" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "パスワード" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "비밀번호" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "密码" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "Password" + } + } + } + }, + "profile_settings": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Profile Settings" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Profile" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "プロファイル設定" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "프로필 설정" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "档案设置" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "設定檔設定" + } + } + } + }, + "ratings": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Ratings" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Bewertungen" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "評価" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "별점" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "评分" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "評分" + } + } + } + }, + "ratings_color": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Ratings color" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Farbe der Bewertungen" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "評価の色" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "별점 색깔" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "评分颜色" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "評分顏色" + } + } + } + }, + "ratings_color_description": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "By default, galleries that you have rated will appear with red stars for ratings of 2 stars and below, green for ratings between 2.5 and 4 stars, and blue for ratings of 4.5 or 5 stars. You can customize this by entering your desired color combination below. Each letter represents one star. The default RRGGB means R(ed) for the first and second star, G(reen) for the third and fourth, and B(lue) for the fifth. You can also use (Y)ellow for the normal stars. Any five-letter R/G/B/Y combo works." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Standardmäßig erscheinen von dir bewertete Galerien mit roten Sternen bei Bewertungen bis 2 Sternen, mit grünen zwischen 2,5 und 4 Sternen und mit blauen bei 4,5 oder 5 Sternen. Du kannst das anpassen, indem du unten deine gewünschte Farbkombination eingibst. Jeder Buchstabe steht für einen Stern. Das Standard-RRGGB bedeutet R(ot) für den ersten und zweiten Stern, G(rün) für den dritten und vierten und B(lau) für den fünften. Mit Y bekommst du gelbe Sterne. Jede fünfstellige Kombination aus R/G/B/Y funktioniert." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "デフォルトでは、評価済みのギャラリーは 2 以下の評価に赤い星を使う、2.5 ~ 4 には緑、4.5 以上には青。下に色の組み合わせを入れることでこのルールをカスタマイズできます。一つの星の色は一つの文字で指定します。デフォルトの「RRGGB」は「一番目と二番目の星は赤(Red)、三番と四番は緑(Green)、五番は青(Blue)」を意味します。黄色(Yellow)も使用可能です。R・G・B・Yで組み合わせた五文字はどれも機能します。" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "기본적으로 등급을 매긴 갤러리는 별 2개 이하의 등급에 대해 빨간색, 2.5~4개의 등급에 대해 녹색, 4.5~5개의 등급에 대해 파란색 별로 표시되어요. 아래에 원하는 색상 조합을 입력하여 사용자 정의할 수 있어요. 각 문자는 별 하나를 표현해요. 기본 RRGGB는 첫 번째와 두 번째 별의 경우 R(ed), 세 번째와 네 번째 별의 경우 G(reen), 다섯 번째 별의 경우 B(lue)를 의미해요. 일반 별에 (Y)ellow를 사용할 수도 있어요. 모든 5글자의 R/G/B/Y 콤보가 작동해요." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "默认设置下,你评为 2 星及以下的画廊显示为红星,2.5 ~ 4 星显示为绿星,4.5 ~ 5 星显示为蓝星。你可以将其设定为其它颜色组合。每一个字幕代表一颗星, 默认的 RRGGB 表示第一第二颗星显示为红色 R(ed),第三第四颗星显示是绿色 G(reen),第五颗星显示为蓝色 B(lue)。你也可以使用黄色 (Y)ellow,R/G/B/Y 任何五个组合都是有效的。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "預設情況下,你評分的畫廊將 2 星及以下的評分顯示為紅色星,2.5 ~ 4 顆星的評分為綠色,4.5 ~ 5 顆星的評分為藍色。 透過在下面輸入顏色組合你可以自訂想顯示的顏色。 每個字母各代表一顆星(1~5),預設的 RRGGB 表示第一顆和第二顆星的 R(ed),第三顆和第四顆的 G(reen),第五顆的 B(lue)。 你也可以將 (Y)ellow 用於普通星星。 任何五個字母的 R/G/B/Y 組合都有效" + } + } + } + }, + "ratings_color_prompt": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "RRGGB" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "RRGGB" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "RRGGB" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "RRGGB" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "RRGGB" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "RRGGB" + } + } + } + }, + "redirects_links_to_the_selected_host": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Redirects links to the selected host" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Links zum ausgewählten Host umleiten" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "リンクを選択されたホストへリダイレクト" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "선택한 서버로 이동하기" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "重定向链接到选定的站点" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "將連結重新導向至選擇的網站" + } + } + } + }, + "remove": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Remove" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Entfernen" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "削除" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "삭제" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "移除" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "移除" + } + } + } + }, + "remove_custom_translations": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Remove custom translations" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Benutzerdefinierte Übersetzungen entfernen" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "カスタム翻訳を削除" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "사용자 지정 번역 삭제" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "移除自定义翻译" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "刪除自訂標籤翻譯" + } + } + } + }, + "remove_custom_translations_confirmation": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Are you sure to remove your custom translations?" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Möchtest du deine benutzerdefinierten Übersetzungen wirklich entfernen?" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "本当にカスタム翻訳を削除してもよろしいですか?" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "사용자 지정 번역을 삭제하시겠어요?" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "确定要移除自定义翻译吗?" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "是否確定要刪除所有自訂翻譯?" + } + } + } + }, + "rename": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Rename" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Umbenennen" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "名前を変更" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "이름 변경" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "重命名" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "重新命名" + } + } + } + }, + "result_count": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Result count" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Anzahl Ergebnisse" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "結果数" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "결과 수" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "结果数" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "數量上限" + } + } + } + }, + "result_count_description": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "How many results would you like per page for the index/search page and torrent search pages?\n(Hath Perk: Paging Enlargement Required)" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Wie viele Ergebnisse pro Seite möchtest du auf Index-, Such- und Torrent-Suchseiten sehen?\n(Hath-Perk „Paging Enlargement“ erforderlich)" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "インデックス・トレントの検索ページで、各ページにどれくらいの結果数がお望みですか?\n(「Hath Perk:ページング拡張」が必要)" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "인덱스 / 검색 / 토렌트 검색 페이지에 대해 페이지당 몇 개의 결과를 원하시나요?\n(Hath Perk: 페이징 확장 필요)" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "搜索页面每页显示多少条数据?\n(需要“Hath Perk:页面扩大”)" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "你希望每頁顯示幾個搜尋結果?\n(該功能需要有 Hath Perk: Paging Enlargement)" + } + } + } + }, + "retry_failed_pages_automatically": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Retry failed pages automatically" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Fehlgeschlagene Seiten automatisch erneut versuchen" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "失敗したページを自動で再試行" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "실패한 페이지 자동 재시도" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "自动重试失败页面" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "自動重試失敗頁面" + } + } + } + }, + "scale_factor": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Scale factor" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Skalierungsfaktor" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "スケール係数" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "크기 비율" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "缩放比例" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "縮放比例" + } + } + } + }, + "search_result_count": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Search Result Count" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Anzahl der Suchergebnisse" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "検索結果数" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "검색 결과 수" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "搜索结果数" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "搜尋結果數量上限" + } + } + } + }, + "security": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Security" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Sicherheit" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "セキュリティ" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "개인 정보 보호" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "安全" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "安全" + } + } + } + }, + "selected_profile": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Selected profile" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Ausgewähltes Profil" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "選択されたプロファイル" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "선택한 프로필" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "当前选定档案" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "選擇設定檔" + } + } + } + }, + "set_as_default": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Set as default" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Als Standard festlegen" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "デフォルトに設定" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "기본으로 설정" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "设为默认" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "設為預設" + } + } + } + }, + "setting_state_route.about": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "About" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Über diese App" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "アプリについて" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "정보" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "关于" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "關於" + } + } + } + }, + "setting_state_route.account": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Account" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Konto" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "アカウント" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "계정" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "账户" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "帳號" + } + } + } + }, + "setting_state_route.appearance": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Appearance" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Oberfläche" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "外観" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "외관" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "外观" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "外觀" + } + } + } + }, + "setting_state_route.download": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Download" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Download" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ダウンロード" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "다운로드" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "下载" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "下載" + } + } + } + }, + "setting_state_route.general": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "General" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Allgemein" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "一般" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "일반" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "一般" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "一般" + } + } + } + }, + "setting_state_route.laboratory": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Laboratory" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Experimentelles" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ラボ" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "실험실" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "实验室" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "實驗性功能" + } + } + } + }, + "setting_state_route.reading": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Reading" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Am Lesen" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "閲覧" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "읽기" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "阅读" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "閱讀" + } + } + } + }, + "show_filtered_removal_count": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Show filtered removal count" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Anzahl gefilterter Galerien anzeigen" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "フィルター除去数を表示" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "필터로 제거된 수 표시" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "显示筛选器移除数" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "是否顯示過濾結果計數器" + } + } + } + }, + "show_label_below_gallery_thumbnails": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Show label below gallery thumbnails" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Beschriftung unter Galerie-Vorschaubildern anzeigen" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ギャラリーサムネイルの下にラベルを表示" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "갤러리 썸네일 아래에 라벨 표시" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "在画廊缩略图下方显示标签" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "在畫廊縮圖下方顯示標籤" + } + } + } + }, + "show_search_range_indicator": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Search Range Indicator" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Suchbereichsanzeige" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "検索範囲インジケーター" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "검색 범위 표시기" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "搜索范围指示器" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "搜尋範圍指示器" + } + } + } + }, + "show_search_range_indicator_description": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Show search range indicator" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Suchbereichsanzeige einblenden" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "検索範囲インジケーターを表示" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "검색 범위 표시기 표시" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "显示搜索范围指示器" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "顯示搜尋範圍指示器" + } + } + } + }, + "show_thumbnail_pane": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Show thumbnail pane" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Vorschaubild-Leiste anzeigen" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "サムネイルパネルを表示" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "썸네일 창 표시" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "显示缩略图侧栏" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "顯示縮圖窗格" + } + } + } + }, + "shows_images_in_tags": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Shows images in tags" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Bilder in Tags anzeigen" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "タグの画像を表示" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "태그에 이미지 보여주기" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "显示标签中的图像" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "在標籤顯示圖片" + } + } + } + }, + "shows_new_dawn_greeting": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Shows new dawn greeting" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Neuer-Tag-Meldung anzeigen" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "夜明けの挨拶を表示" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "새벽 인사 구독하기" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "显示黎明问候" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "顯示黎明問候" + } + } + } + }, + "shows_tags_in_list": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Shows tags in list" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Tags als Liste anzeigen" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "リストでタグを表示" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "리스트에서 태그 보여주기" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "在列表中显示标签" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "在列表中顯示標籤" + } + } + } + }, + "shows_tags_search_suggestion": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Shows tags search suggestion" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Tag-Vorschläge bei der Suche anzeigen" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "タグの検索提案を表示" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "태그 검색 제안 보여주기" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "显示标签搜索建议" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "搜尋時顯示標籤建議" + } + } + } + }, + "special_thanks": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Special thanks" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Besonderer Dank" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "特別な感謝" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "특별히 감사드리는 분들" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "特别致谢" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "特別銘謝" + } + } + } + }, + "tag_filtering_threshold": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Tag Filtering Threshold" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Schwellenwert für Tag-Filterung" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "タグフィルタリングしきい値" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "태그 필터링 임계값" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "标签筛选阈值" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "過濾標籤閾值" + } + } + } + }, + "tag_filtering_threshold_description": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "You can soft filter tags by adding them to My Tags with a negative weight. If a gallery has tags that add up to weight below this value, it is filtered from view. This threshold can be set between 0 and -9999." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Du kannst Tags weich filtern, indem du sie mit negativem Gewicht zu „Meine Tags“ hinzufügst. Ergeben die Tags einer Galerie zusammen ein Gewicht unter diesem Wert, wird sie ausgeblendet. Der Schwellenwert kann zwischen 0 und -9999 liegen." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "負の重み付きでマイタグに追加することでタグをソフトフィルタリングすることができます。もしあるギャラリーが持つタグの重み総和がこのしきい値より低ければ、そのギャラリーはフィルタリングされます。このしきい値はゼロから -9999 まで設定できます。" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "마이너스 가중치로 My Tags에 추가하여 태그를 소프트 필터할 수 있어요. 갤러리에 이 값 이하의 가중치를 추가하는 태그가 있으면 보기에서 필터링되어요. 이 임계값은 0과 -9999 사이에서 설정할 수 있어요." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "你可以通过将标签加入“我的标签”并设置一个负权重来软过滤它们。如果一个作品所有的标签权重之和低于设定值,此作品将从视图中被过滤。这个值可以设定为 0 ~ -9999。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "你可以透過將標籤新增到具有負數權重的“我的標籤”清單中來過濾標籤。 如果畫廊的標籤加起來的權重低於此值,則會被從列表中過濾掉,此閾值可以設定在 0 ~ -9999 之間" + } + } + } + }, + "tag_watching_threshold": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Tag Watching Threshold" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Schwellenwert für Tag-Beobachtung" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "タグ購読しきい値" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "태그 보여주기 임계값" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "标签订阅阈值" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "關注標籤閾值" + } + } + } + }, + "tag_watching_threshold_description": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Recently uploaded galleries will be included on the watched screen if it has at least one watched tag with positive weight, and the sum of weights on its watched tags add up to this value or higher. This threshold can be set between 0 and 9999." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Kürzlich hochgeladene Galerien erscheinen unter „Meine Tags“, wenn sie mindestens einen beobachteten Tag mit positivem Gewicht haben und die Gewichte ihrer beobachteten Tags zusammen diesen Wert erreichen oder überschreiten. Der Schwellenwert kann zwischen 0 und 9999 liegen." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "もしあるギャラリーは最近投稿されたもので、少なくても一つの正の重みの購読タグを持っていて、購読タグの重み総和がこのしきい値と同じまたはより高ければ、そのギャラリーは購読画面で表示されます。このしきい値はゼロから 9999 まで設定できます。" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "최근에 업로드된 갤러리는 최소 1개의 Watched 태그가 있고 Watched 태그의 가중치의 합이 이 값 이상이 될 경우 Watched 화면에 포함되어요. 이 임계값은 0과 9999 사이에서 설정할 수 있어요." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "你可以通过将标签加入“我的标签”并设置一个正权重来关注它们。如果一个最近上传的作品所有标签的权重之和高于设定值,则它将会被包含在“关注”里。这个值可以设定为 0 ~ 9999。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "如果最近上傳的畫廊中至少有一個具有正權重的你正在關注/追蹤的標籤,並且這些標籤所具有的權重高於此設定中的數值,那這個畫廊將會出現在「追蹤標籤」頁面中,此閾值可以設定在 0 ~ -9999 之間" + } + } + } + }, + "tags": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Tags" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Tags" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "タグ" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "태그" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "标签" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "標籤" + } + } + } + }, + "tags_management": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Manage tags subscription" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Meine Tags bearbeiten" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "タグの購読を管理" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "태그 구독 관리" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "管理标签订阅" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "管理訂閱標籤" + } + } + } + }, + "tags_sort_order": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Tags sort order" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Sortierung der Tags" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "タグの並び替え" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "태그 순서를 배열" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "标签排序方式" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "標籤顯示順序" + } + } + } + }, + "theme": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Theme" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Erscheinungsbild" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "テーマ" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "테마" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "主题" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "主題" + } + } + } + }, + "thumbnail_configuration": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "You can set a default thumbnail configuration for all galleries you visit." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Du kannst eine Standardkonfiguration der Vorschaubilder für alle Galerien festlegen, die du besuchst." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "すべてのギャラリーに適応するデフォルトのサムネイル構成を設定できます。" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "모든 방문한 갤러리에 대하여 기본 썸네일을 설정할 수 있어요." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "你可以设定一个对所有画廊生效的默认缩略图配置。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "你可以為存取的所有畫廊設定預設的縮圖配置。" + } + } + } + }, + "thumbnail_load_timing": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Thumbnail load timing" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Ladezeitpunkt der Vorschaubilder" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "サムネイル読み込みタイミング" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "썸네일 로드 시간" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "缩略图加载时机" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "縮圖載入時機設定" + } + } + } + }, + "thumbnail_load_timing_description": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "How would you like the mouse-over thumbnails on the front page to load when using List Mode?" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Wann sollen die Mouseover-Vorschaubilder auf der Startseite im Listenmodus geladen werden?" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "リストでは、どんなタイミングでホームページのマウスオーバーサムネイルを読み込みますか?" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "목록 모드를 사용할 때 앞 페이지의 마우스 오버 미리 보기를 어떻게 로드할까요?" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "你希望列表中的鼠标悬停缩略图何时加载?" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "使用列表模式時,你希望如何載入以滑鼠位置顯示的縮圖?" + } + } + } + }, + "thumbnail_row_count": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Rows" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Zeilen" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "行数" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "줄" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "行数" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "行數" + } + } + } + }, + "thumbnail_settings": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Thumbnail Settings" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Vorschaubilder" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "サムネイル設定" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "썸네일 설정" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "缩略图设置" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "縮圖設定" + } + } + } + }, + "thumbnail_size": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Size" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Größe" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "サイズ" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "사이즈" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "尺寸" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "尺寸" + } + } + } + }, + "tint_color": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Tint color" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Farbe" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "テーマの色" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "액센트 색상" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "主题色" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "強調色" + } + } + } + }, + "title": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Download" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Download" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ダウンロード" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "다운로드" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "下载" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "下載" + } + } + } + }, + "translates_tags": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Translates tags" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Tags übersetzen" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "タグを訳す" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "태그 번역하기" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "翻译标签" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "標籤翻譯" + } + } + } + }, + "translation_contributors": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Translation contributors" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Mitwirkende an der Übersetzung" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "翻訳貢献者" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "번역 기여자" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "翻译贡献者" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "翻譯貢獻者" + } + } + } + }, + "use_multi_page_viewer": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Use Multi-Page Viewer" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Multi-Page-Viewer verwenden" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "マルチページビューアを使う" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "다중 페이지 뷰어 적용" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "使用多页查看器" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "使用多頁瀏覽" + } + } + } + }, + "use_original_images": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Use original images" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Originalbilder verwenden" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "オリジナル画像を使う" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "원본 뷰어 적용" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "显示原图" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "使用原始圖片(原解析度)" + } + } + } + }, + "username": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Username" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Benutzername" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ユーザー名" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "이름" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "用户名" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "Username" + } + } + } + }, + "version": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Version" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Version" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "バージョン" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "버전" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "版本" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "版本" + } + } + } + }, + "vertical": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Vertical" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Vertikal" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "高さ" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "세로" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "高度" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "垂直尺寸(長)" + } + } + } + }, + "viewport_override": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Viewport Override" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Viewport-Überschreibung" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "表示領域オーバーライド" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "뷰포트 조정" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "覆写可视区域" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "視窗覆蓋" + } + } + } + }, + "virtual_width": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Virtual width" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Virtuelle Breite" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "仮想幅" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "가상 너비" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "虚拟宽度" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "虛擬寬度" + } + } + } + }, + "virtual_width_description": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Allows you to override the virtual width of the site for mobile devices. This is normally determined automatically by your device based on its DPI. Sensible values at 100% thumbnail scale are between 640 and 1400." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Hiermit kannst du die virtuelle Breite der Seite auf Mobilgeräten überschreiben. Normalerweise bestimmt dein Gerät sie automatisch anhand der DPI. Sinnvolle Werte bei 100% Vorschaubild-Skalierung liegen zwischen 640 und 1400." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "モバイルデバイスの仮想幅をオーバーライドすることができます。一般的にはデバイスの DPI に基づいて自動的に決定されます。例えばサムネイルスケール係数が 100% の場合、640 ~ 1400 の幅が合理的です。" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "모바일 장치의 사이트 가상 너비를 설정할 수 있어요. 일반적으로 DPI에 따라 장치에 의해 자동으로 결정되어요. 100% 썸네일 스케일의 추천 값은 640에서 1400 사이에요." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "允许你覆写移动设备的可视区域,默认是根据 DPI 自动计算的,100% 缩略图比例下的合理值在 640 到 1400 之间。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "允許你覆蓋行動裝置網站的虛擬寬度。 這通常由你的裝置根據其 DPI 自動確定。 100% 縮圖比例的合理值介於 640 和 1400 之間。" + } + } + } + }, + "website": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Website" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Website" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ウェブサイト" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "웹사이트" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "网站" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "官方網站" + } + } + } + } + }, + "version": "1.0" +} \ No newline at end of file diff --git a/AppPackage/Sources/SettingFeature/SettingPath.swift b/AppPackage/Sources/SettingFeature/SettingPath.swift new file mode 100644 index 000000000..38e488681 --- /dev/null +++ b/AppPackage/Sources/SettingFeature/SettingPath.swift @@ -0,0 +1,54 @@ +import ComposableArchitecture +import ReadingSettingFeature + +// The single flat navigation stack for the Setting tab, owned by `SettingReducer`. Every drill-down +// screen is a path element; child screens never push directly — they emit `delegate` actions that +// `SettingReducer` observes and appends to `path`. Each screen's view reads and writes `setting` +// through its own `@Shared(.setting)`/`@SharedReader(.setting)`. Screens whose edits trigger a side +// effect own a dedicated reducer for it; the remaining state-free screens (download, about) share +// `StaticSettingScreenReducer`. +@Reducer +public enum SettingPath { + case account(AccountSettingReducer) + case general(GeneralSettingReducer) + case appearance(AppearanceSettingReducer) + case login(LoginReducer) + case ehSetting(EhSettingReducer) + case appActivityLogs(AppActivityLogsReducer) + case download(StaticSettingScreenReducer) + case reading(ReadingSettingReducer) + case laboratory(LaboratorySettingReducer) + case about(StaticSettingScreenReducer) + case appIcon(AppIconReducer) +} + +extension SettingPath.State: Equatable, Sendable {} + +extension StackState where Element == SettingPath.State { + // Skip appending a screen identical to the current top, so a rapid double-activation of a Setting + // row — or a child re-emitting the same `delegate` — can't stack the same screen twice. Setting + // path states are cleanly `Equatable` (no volatile per-init fields), so a plain value comparison + // suffices here; only the adjacent element is checked, mirroring the gallery stacks' guard. + mutating func appendGuardingDuplicate(_ element: SettingPath.State) { + guard last != element else { return } + append(element) + } +} + +// A placeholder reducer for Setting screens that hold no state and run no logic (their views are +// driven entirely by bindings into `SettingReducer.State.setting`). Shared across every such leaf. +@Reducer +public struct StaticSettingScreenReducer: Sendable { + @ObservableState + public struct State: Equatable, Sendable { + public init() {} + } + + public enum Action: Equatable, Sendable {} + + public init() {} + + public var body: some Reducer { + EmptyReducer() + } +} diff --git a/AppPackage/Sources/SettingFeature/SettingReducer+Body.swift b/AppPackage/Sources/SettingFeature/SettingReducer+Body.swift new file mode 100644 index 000000000..66cdfcb42 --- /dev/null +++ b/AppPackage/Sources/SettingFeature/SettingReducer+Body.swift @@ -0,0 +1,238 @@ +import AppTools +import Foundation +import AppModels +import OSLogExt +import ComposableArchitecture +import NetworkingFeature + +private let logger = Logger(category: .init(describing: SettingReducer.self)) + +extension SettingReducer { + @ReducerBuilder + var reducerBody: some Reducer { + // No `BindingReducer`: every Setting screen writes `setting` through its own `@Shared`, so the + // parent never sees a `.binding` action. Per-edit side effects live in each screen's reducer, + // and cross-field invariants live on the `Setting` model, so every write path stays consistent. + Reduce { state, action in + switch action { + case .settingRowTapped(let screen): + state.path.appendGuardingDuplicate(screen.pathElement) + return .none + + case .pushLogin: + state.path.appendGuardingDuplicate(.login(.init())) + return .none + + // Account emits a delegate to push its children onto the shared stack. + case .path(.element(id: _, action: .account(.delegate(.pushLogin)))): + state.path.appendGuardingDuplicate(.login(.init())) + return .none + + case .path(.element(id: _, action: .account(.delegate(.pushEhSetting)))): + state.path.appendGuardingDuplicate(.ehSetting(.init())) + return .none + + case .path(.element(id: _, action: .general(.delegate(.pushAppActivityLogs)))): + state.path.appendGuardingDuplicate(.appActivityLogs(.init())) + return .none + + // The General screen edits `enablesTagsExtension` via `@Shared(.setting)`; rebuild the tag + // translator when it's turned on. The model's `didSet` clears the sub-toggles on disable, so + // only the enable case does work here. `.rebuildTagTranslator` sequences the remote fetch + // after the offline cache rebuild. + case .path(.element(id: _, action: .general(.delegate(.enablesTagsExtensionChanged)))): + return state.setting.enablesTagsExtension ? .send(.rebuildTagTranslator) : .none + + case .path(.element(id: _, action: .appearance(.delegate(.pushAppIcon)))): + state.path.appendGuardingDuplicate(.appIcon(.init())) + return .none + + case .syncAppIconType: + return .run { send in + await send(.syncAppIconTypeDone(await applicationClient.alternateIconName())) + } + + case .syncAppIconTypeDone(let iconName): + if let iconName { + state.$setting.withLock { $0.appIconType = .matching(alternateIconName: iconName) } + } + return .none + + case .syncUserInterfaceStyle: + let style = state.setting.preferredColorScheme.userInterfaceStyle + return .run(operation: { _ in await applicationClient.setUserInterfaceStyle(style) }) + + case .loadUserSettings: + // `setting`/`user`/`tagTranslator` are all @Shared (auto-loaded). + return handleLoadUserSettings(&state) + + case .loadUserSettingsDone: + state.hasLoadedInitialSetting = true + return .none + + case .createDefaultEhProfile: + return .run { _ in + _ = await EhProfileRequest(action: .create, name: "EhPanda").response() + } + + case .fetchIgneous: + guard cookieClient.didLogin else { return .none } + return .run { send in + let response = await IgneousRequest().response() + await send(.fetchIgneousDone(response)) + } + + case .fetchIgneousDone(let result): + if case .success(let response) = result { + return .run { send in + logger.notice("Igneous token refreshed.") + cookieClient.setCredentials(response: response) + await send(.igneousRefreshed) + } + } + return .merge( + .run { _ in logger.notice("Igneous refresh failed.") }, + .send(.igneousRefreshed) + ) + + case .fetchUserInfo: + guard cookieClient.didLogin else { return .none } + let uid = cookieClient + .getCookie(Defaults.URL.host, Defaults.Cookie.ipbMemberId).rawValue + if !uid.isEmpty { + return .run { send in + let response = await UserInfoRequest(uid: uid).response() + await send(.fetchUserInfoDone(response)) + } + } + return .none + + case .fetchUserInfoDone(let result): + if case .success(let user) = result { + state.updateUser(user) + } + return .none + + case .fetchGreeting: + return handleFetchGreeting(&state) + + case .fetchGreetingDone(let result): + switch result { + case .success(let greeting): + state.setGreeting(greeting) + case .failure(let error): + if case .parseFailed = error { + var greeting = Greeting() + greeting.updateTime = Date() + state.setGreeting(greeting) + } + } + return .none + + case .fetchTagTranslator: + return handleFetchTagTranslator(&state) + + case .fetchTagTranslatorDone(let result): + state.tagTranslatorLoadingState = .idle + switch result { + case .success(let tagTranslator): + state.$tagTranslator.withLock { $0 = tagTranslator } + state.$tagTranslatorInfo.withLock { + $0 = TagTranslatorInfo( + language: tagTranslator.language, + updatedDate: tagTranslator.updatedDate, + hasCustomTranslations: tagTranslator.hasCustomTranslations + ) + } + case .failure(let error): + state.tagTranslatorLoadingState = .failed(error) + } + return .none + + // Offline rebuild of the in-memory table from the cached raw JSON described by the + // persisted metadata (custom import → Application Support, remote → Caches), THEN the + // remote update check. Sequenced in one effect so the (slower) network fetch can't land a + // fresh table and metadata only to be overwritten by a rebuild that captured stale info. + case .rebuildTagTranslator: + let info = state.tagTranslatorInfo + return .run { send in + if let tagTranslator = fileClient.loadCachedTagTranslator(info) { + await send(.tagTranslatorRebuilt(tagTranslator)) + } + await send(.fetchTagTranslator) + } + + case .tagTranslatorRebuilt(let tagTranslator): + state.$tagTranslator.withLock { $0 = tagTranslator } + return .none + + case .fetchEhProfileIndex: + guard cookieClient.didLogin else { return .none } + return .run { send in + let response = await VerifyEhProfileRequest().response() + await send(.fetchEhProfileIndexDone(response)) + } + + case .fetchEhProfileIndexDone(let result): + return handleFetchEhProfileIndexDone(result) + + case .fetchFavoriteCategories: + guard cookieClient.didLogin else { return .none } + return .run { send in + let response = await FavoriteCategoriesRequest().response() + await send(.fetchFavoriteCategoriesDone(response)) + } + + case .fetchFavoriteCategoriesDone(let result): + if case .success(let categories) = result { + state.$user.withLock { $0.favoriteCategories = categories } + } + return .none + + // Login is a top-level stack element; it self-dismisses on success while Setting runs + // the post-login setup. + case .path(.element(id: _, action: .login(.loginDone))): + return .merge( + .run(operation: { _ in cookieClient.removeYay() }), + .run(operation: { _ in cookieClient.syncExCookies() }), + .run(operation: { _ in cookieClient.fulfillAnotherHostField() }), + .run { _ in await WatchedTagsSetting.shared.clearOnlineTagSets() }, + .send(.fetchIgneous), + .send(.fetchUserInfo), + .send(.fetchFavoriteCategories), + .send(.fetchEhProfileIndex) + ) + + case .path(.element(id: _, action: .account(.onLogoutConfirmButtonTapped))): + state.$user.withLock { $0 = User() } + return .merge( + .run(operation: { _ in cookieClient.clearAll() }), + .run { _ in await WatchedTagsSetting.shared.clearOnlineTagSets() }, + .run(operation: { _ in await libraryClient.removeAllCachedImages() }), + .run { _ in logger.notice("Logged out.") } + ) + + case .path(.element(id: _, action: .general(.onTranslationsFilePicked(let url)))): + return .run { send in + let result = await fileClient.importTagTranslator(url) + await send(.fetchTagTranslatorDone(result)) + } + + case .path(.element(id: _, action: .general(.onRemoveCustomTranslations))): + // Drop the custom table from memory, metadata, and disk; the launch/remote flow refills + // it. FileClient owns the path, so the file lifecycle stays behind one module. + state.$tagTranslator.withLock { $0 = TagTranslator() } + state.$tagTranslatorInfo.withLock { $0.hasCustomTranslations = false } + return .run { _ in fileClient.removeCustomTranslations() } + + case .igneousRefreshed: + return .none + + case .path: + return .none + } + } + .forEach(\.path, action: \.path) + } + +} diff --git a/AppPackage/Sources/SettingFeature/SettingReducer+Helpers.swift b/AppPackage/Sources/SettingFeature/SettingReducer+Helpers.swift new file mode 100644 index 000000000..f78bfd135 --- /dev/null +++ b/AppPackage/Sources/SettingFeature/SettingReducer+Helpers.swift @@ -0,0 +1,143 @@ +import AppTools +import Foundation +import AppModels +import Sharing +import OSLogExt +import ComposableArchitecture +import NetworkingFeature + +private let logger = Logger(category: .init(describing: SettingReducer.self)) + +extension SettingReducer { + func handleLoadUserSettings(_ state: inout State) -> Effect { + // `setting` and `user` are both `@Shared` and auto-load from persisted storage — there is no + // working copy to prime here. `tagTranslator` is in-memory and rebuilt from its cache below. + var effects: [Effect] = [ + .send(.syncAppIconType), + .send(.loadUserSettingsDone), + .send(.syncUserInterfaceStyle), + .run { [bypassesSNIFiltering = state.setting.bypassesSNIFiltering] _ in + dfClient.setActive(bypassesSNIFiltering) + } + ] + if let value: String = userDefaultsClient.getValue(.galleryHost), + let galleryHost = GalleryHost(rawValue: value) { + state.$setting.withLock { $0.galleryHost = galleryHost } + } + if cookieClient.shouldFetchIgneous { + effects.append(.send(.fetchIgneous)) + } + if cookieClient.didLogin { + effects.append(contentsOf: [ + .send(.fetchUserInfo), + .send(.fetchGreeting), + .send(.fetchFavoriteCategories), + .send(.fetchEhProfileIndex) + ]) + } + if state.setting.enablesTagsExtension { + // Rebuild the table from cache first (offline, immediate); `.rebuildTagTranslator` then + // sequences the remote update check so a slow fetch can't be clobbered by a stale rebuild. + effects.append(.send(.rebuildTagTranslator)) + } + return .merge(effects) + } + + func handleFetchGreeting(_ state: inout State) -> Effect { + func verifyDate(with updateTime: Date?) -> Bool { + guard let updateTime = updateTime else { return true } + + let currentTime = Date() + let formatter = DateFormatter() + formatter.locale = Locale.current + formatter.timeZone = TimeZone(secondsFromGMT: 0) + formatter.dateFormat = Defaults.DateFormat.greeting + + let currentTimeString = formatter.string(from: currentTime) + if let currentDay = formatter.date(from: currentTimeString) { + return currentTime > currentDay && updateTime < currentDay + } + + return false + } + + guard cookieClient.didLogin, + state.setting.showsNewDawnGreeting + else { return .none } + let requestEffect = Effect.run { send in + let response = await GreetingRequest().response() + await send(Action.fetchGreetingDone(response)) + } + if let greeting = state.greeting { + if verifyDate(with: greeting.updateTime) { + return requestEffect + } + } else { + return requestEffect + } + return .none + } + + func handleFetchTagTranslator(_ state: inout State) -> Effect { + guard state.tagTranslatorLoadingState != .loading, + !state.tagTranslatorInfo.hasCustomTranslations, + let language = TranslatableLanguage.current + else { return .none } + state.tagTranslatorLoadingState = .loading + + // A language switch resets the in-memory table and its persisted metadata; the request then + // downloads the new language's data from scratch. + if state.tagTranslatorInfo.language != language { + state.$tagTranslator.withLock { $0 = TagTranslator(language: language) } + state.$tagTranslatorInfo.withLock { $0 = TagTranslatorInfo(language: language) } + } + let updatedDate = state.tagTranslatorInfo.updatedDate + return .run { send in + // Download the raw JSON, then let `FileClient` decode/convert/cache it into a translator. + switch await TagTranslatorRequest(language: language, updatedDate: updatedDate).response() { + case .success(let payload): + if let tagTranslator = fileClient.cacheAndBuildRemoteTagTranslator( + payload.data, language, payload.updatedDate + ) { + await send(.fetchTagTranslatorDone(.success(tagTranslator))) + } else { + await send(.fetchTagTranslatorDone(.failure(.parseFailed))) + } + case .failure(let error): + await send(.fetchTagTranslatorDone(.failure(error))) + } + } + } + + func handleFetchEhProfileIndexDone( + _ result: Result + ) -> Effect { + var effects = [Effect]() + + if case .success(let response) = result { + if let profileValue = response.profileValue { + let hostURL = Defaults.URL.host + let profileValueString = String(profileValue) + let selectedProfileKey = Defaults.Cookie.selectedProfile + + let cookieValue = cookieClient.getCookie(hostURL, selectedProfileKey) + if cookieValue.rawValue != profileValueString { + effects.append( + .run { _ in + cookieClient.setOrEditCookie( + for: hostURL, key: selectedProfileKey, value: profileValueString + ) + } + ) + } + } else if response.isProfileNotFound { + effects.append(.send(.createDefaultEhProfile)) + } else { + effects.append(.run { _ in + logger.error("Found profile but failed in parsing value.") + }) + } + } + return effects.isEmpty ? .none : .merge(effects) + } +} diff --git a/AppPackage/Sources/SettingFeature/SettingReducer.swift b/AppPackage/Sources/SettingFeature/SettingReducer.swift new file mode 100644 index 000000000..f7684fdab --- /dev/null +++ b/AppPackage/Sources/SettingFeature/SettingReducer.swift @@ -0,0 +1,129 @@ +import Foundation +import AppModels +import Sharing +import ComposableArchitecture +import UserDefaultsClient +import ApplicationClient +import HapticsClient +import LibraryClient +import DFClient +import FileClient +import CookieClient + +@Reducer +public struct SettingReducer: Sendable { + // The top-level Setting screens listed in the root menu. Each maps 1:1 to a `SettingPath` + // element that `settingRowTapped` appends when its row is tapped. + public enum RootScreen: Int, Equatable, Hashable, Identifiable, CaseIterable, Sendable { + public var id: Int { rawValue } + + case account + case general + case appearance + case download + case reading + case laboratory + case about + + var pathElement: SettingPath.State { + switch self { + case .account: + return .account(.init()) + case .general: + return .general(.init()) + case .appearance: + return .appearance(.init()) + case .download: + return .download(.init()) + case .reading: + return .reading(.init()) + case .laboratory: + return .laboratory(.init()) + case .about: + return .about(.init()) + } + } + } + + @ObservableState + public struct State: Equatable, Sendable { + // `setting` is stored directly in `@Shared(.setting)`. The parent only reads it for launch + // reconciliation and its non-binding syncs (e.g. `syncAppIconTypeDone`); each Setting screen + // reads and writes it through its own `@Shared`/`@SharedReader`, so there is no working copy and + // no `.binding` cascade here. `user` is likewise shared. `tagTranslator` is derived, + // re-downloadable data: it lives in memory only and is rebuilt at launch from the cached raw + // JSON; only its thin `tagTranslatorInfo` metadata persists (see `AppSharedKeys`). + @Shared(.setting) public var setting: Setting + @Shared(.tagTranslator) public var tagTranslator: TagTranslator + @Shared(.tagTranslatorInfo) public var tagTranslatorInfo: TagTranslatorInfo + @Shared(.user) public var user: User + @Shared(.greeting) public var greeting: Greeting? + + public var hasLoadedInitialSetting = false + + public var path = StackState() + public var tagTranslatorLoadingState: LoadingState = .idle + + public init() {} + + mutating func setGreeting(_ newGreeting: Greeting) { + $greeting.withLock { $0.mergeNewer(newGreeting) } + } + + mutating func updateUser(_ user: User) { + $user.withLock { current in + if let displayName = user.displayName { + current.displayName = displayName + } + if let avatarURL = user.avatarURL { + current.avatarURL = avatarURL + } + if let galleryPoints = user.galleryPoints, + let credits = user.credits { + current.galleryPoints = galleryPoints + current.credits = credits + } + } + } + } + + public enum Action { + case path(StackActionOf) + case settingRowTapped(RootScreen) + case pushLogin + + case syncAppIconType + case syncAppIconTypeDone(String?) + case syncUserInterfaceStyle + + case loadUserSettings + case loadUserSettingsDone + case createDefaultEhProfile + case fetchIgneous + case fetchIgneousDone(Result) + case fetchUserInfo + case fetchUserInfoDone(Result) + case fetchGreeting + case fetchGreetingDone(Result) + case fetchTagTranslator + case fetchTagTranslatorDone(Result) + case rebuildTagTranslator + case tagTranslatorRebuilt(TagTranslator) + case fetchEhProfileIndex + case fetchEhProfileIndexDone(Result) + case fetchFavoriteCategories + case fetchFavoriteCategoriesDone(Result<[Int: String], AppError>) + case igneousRefreshed + } + + @Dependency(\.applicationClient) var applicationClient + @Dependency(\.userDefaultsClient) var userDefaultsClient + @Dependency(\.libraryClient) var libraryClient + @Dependency(\.cookieClient) var cookieClient + @Dependency(\.fileClient) var fileClient + @Dependency(\.dfClient) var dfClient + + public init() {} + + public var body: some Reducer { reducerBody } +} diff --git a/AppPackage/Sources/SettingFeature/SettingView.swift b/AppPackage/Sources/SettingFeature/SettingView.swift new file mode 100644 index 000000000..951a5824e --- /dev/null +++ b/AppPackage/Sources/SettingFeature/SettingView.swift @@ -0,0 +1,166 @@ +import SwiftUI +import Resources +import SFSafeSymbols +import ComposableArchitecture +import AppComponents +import ReadingSettingFeature + +public struct SettingView: View { + @Bindable private var store: StoreOf + private let blurRadius: Double + + public init(store: StoreOf, blurRadius: Double) { + self.store = store + self.blurRadius = blurRadius + } + + // MARK: SettingView + public var body: some View { + NavigationStack(path: $store.scope(state: \.path, action: \.path)) { + ScrollView { + VStack(spacing: 0) { + ForEach(SettingReducer.RootScreen.allCases) { screen in + SettingRow(rowType: screen) { + store.send(.settingRowTapped($0)) + } + } + } + .padding(.vertical, 40).padding(.horizontal) + } + .navigationTitle(.RLocalizable.setting) + } destination: { pathStore in + destination(pathStore) + .tint(store.setting.accentColor) + } + } + + // MARK: Destinations + @ViewBuilder + private func destination(_ pathStore: StoreOf) -> some View { + switch pathStore.case { + case .account(let accountStore): + AccountSettingView(store: accountStore, blurRadius: blurRadius) + + case .general(let generalStore): + GeneralSettingView( + store: generalStore, + tagTranslatorLoadingState: store.tagTranslatorLoadingState + ) + + case .appearance(let appearanceStore): + AppearanceSettingView(store: appearanceStore) + + case .login(let loginStore): + LoginView(store: loginStore, blurRadius: blurRadius) + + case .ehSetting(let ehSettingStore): + EhSettingView(store: ehSettingStore, blurRadius: blurRadius) + + case .appActivityLogs(let logsStore): + AppActivityLogsView(store: logsStore) + + case .download: + DownloadSettingView() + + case .reading(let readingStore): + ReadingSettingView(store: readingStore) + + case .laboratory(let laboratoryStore): + LaboratorySettingView(store: laboratoryStore) + + case .about: + AboutView() + + case .appIcon(let appIconStore): + AppIconView(store: appIconStore) + } + } +} + +// MARK: SettingRow +private struct SettingRow: View { + @Environment(\.colorScheme) private var colorScheme + @State private var isPressing = false + + private let rowType: SettingReducer.RootScreen + private let tapAction: (SettingReducer.RootScreen) -> Void + + private var color: Color { + colorScheme == .light ? Color(.darkGray) : Color(.lightGray) + } + private var backgroundColor: Color { + isPressing ? color.opacity(0.1) : .clear + } + + init(rowType: SettingReducer.RootScreen, tapAction: @escaping (SettingReducer.RootScreen) -> Void) { + self.rowType = rowType + self.tapAction = tapAction + } + + var body: some View { + HStack { + Image(systemSymbol: rowType.symbol) + .font(.largeTitle).foregroundColor(color) + .padding(.trailing, 20).frame(width: 45, height: 45) + Text(rowType.value).fontWeight(.medium) + .font(.title3).foregroundColor(color) + Spacer() + } + .contentShape(.rect).padding(.vertical, 10) + .padding(.horizontal, 20).background(backgroundColor) + .cornerRadius(10).onTapGesture { tapAction(rowType) } + .onLongPressGesture( + minimumDuration: .infinity, maximumDistance: 50, + pressing: { isPressing = $0 }, perform: {} + ) + } +} + +// MARK: Definition +extension SettingReducer.RootScreen { + var value: LocalizedStringResource { + switch self { + case .account: + return .settingStateRouteAccount + case .general: + return .settingStateRouteGeneral + case .appearance: + return .settingStateRouteAppearance + case .download: + return .settingStateRouteDownload + case .reading: + return .settingStateRouteReading + case .laboratory: + return .settingStateRouteLaboratory + case .about: + return .settingStateRouteAbout + } + } + var symbol: SFSymbol { + switch self { + case .account: + return .personFill + case .general: + return .switch2 + case .appearance: + return .circleRighthalfFilled + case .download: + return .squareAndArrowDownOnSquare + case .reading: + return .newspaper + case .laboratory: + return .testtube2 + case .about: + return .infoCircle + } + } +} + +struct SettingView_Previews: PreviewProvider { + static var previews: some View { + SettingView( + store: .init(initialState: .init(), reducer: SettingReducer.init), + blurRadius: 0 + ) + } +} diff --git a/EhPanda/View/Support/Components/WaveForm.swift b/AppPackage/Sources/SettingFeature/WaveForm.swift similarity index 89% rename from EhPanda/View/Support/Components/WaveForm.swift rename to AppPackage/Sources/SettingFeature/WaveForm.swift index a61676ae5..e1a6c7a63 100644 --- a/EhPanda/View/Support/Components/WaveForm.swift +++ b/AppPackage/Sources/SettingFeature/WaveForm.swift @@ -1,24 +1,17 @@ -// -// WaveForm.swift -// WaveForm -// -// Copied from Kavsoft -// - import SwiftUI -struct WaveForm: View { +public struct WaveForm: View { private let color: Color private let amplify: CGFloat private let isReversed: Bool - init(color: Color, amplify: CGFloat, isReversed: Bool) { + public init(color: Color, amplify: CGFloat, isReversed: Bool) { self.color = color self.amplify = amplify self.isReversed = isReversed } - var body: some View { + public var body: some View { TimelineView(.animation) { timeLine in Canvas { context, size in let timeNow = timeLine.date.timeIntervalSinceReferenceDate diff --git a/AppPackage/Sources/SystemNotificationExt/.swiftlint.yml b/AppPackage/Sources/SystemNotificationExt/.swiftlint.yml new file mode 100644 index 000000000..1242ffcaa --- /dev/null +++ b/AppPackage/Sources/SystemNotificationExt/.swiftlint.yml @@ -0,0 +1 @@ +parent_config: ../../../.swiftlint.yml diff --git a/AppPackage/Sources/SystemNotificationExt/ToastMessageView.swift b/AppPackage/Sources/SystemNotificationExt/ToastMessageView.swift new file mode 100644 index 000000000..57845970f --- /dev/null +++ b/AppPackage/Sources/SystemNotificationExt/ToastMessageView.swift @@ -0,0 +1,97 @@ +// +// The Liquid Glass capsule shown by `View.toast(_:)`. The layout adapts SystemNotificationMessage +// (MIT, https://github.com/danielsaidi/SystemNotification): a leading symbol, a one-line bold title +// over an optional one-line subtitle, and a hidden trailing symbol that mirrors the leading one so +// the text stays optically centered. The capsule is pure Liquid Glass with nothing behind it, +// layering glass over a Material would render it opaque. +// + +import SwiftUI +import SFSafeSymbols +import AppComponents +import ComposableArchitecture + +/// The rendered content of a toast, mapped from ``AppAlertState`` by ``AppAlertState/toastContent``. +struct ToastContent: Equatable { + enum Icon: Equatable { + case loading, success, error + } + + var icon: Icon + var title: String + var subtitle: String? + var autoHide: Bool +} + +struct ToastMessageView: View { + let content: ToastContent + + var body: some View { + HStack(spacing: 16) { + icon + text + icon.hidden() + } + .padding(.horizontal, 20) + .padding(.vertical, 12) + .glassEffect(.regular, in: .capsule) + .accessibilityElement(children: .combine) + } + + @ViewBuilder + private var icon: some View { + switch content.icon { + case .loading: + ProgressView() + case .success: + Image(systemSymbol: .checkmarkCircle) + .font(.title3) + .foregroundStyle(.green) + case .error: + Image(systemSymbol: .exclamationmarkTriangle) + .font(.title3) + .foregroundStyle(.red) + } + } + + private var text: some View { + VStack(spacing: 2) { + Text(content.title) + .font(.footnote.bold()) + .foregroundStyle(.primary) + if let subtitle = content.subtitle { + Text(subtitle) + .font(.footnote) + .foregroundStyle(.secondary) + } + } + .lineLimit(1) + } +} + +extension AppAlertState where Action == Never { + /// Maps the unified presentation state onto renderable toast content. The `.alert` style never + /// reaches a `toast` binding, so it degrades to a plain loading spinner defensively. + var toastContent: ToastContent { + let icon: ToastContent.Icon + let autoHide: Bool + switch style { + case .alert: + icon = .loading + autoHide = false + case let .toast(toastIcon, shouldAutoHide): + autoHide = shouldAutoHide + switch toastIcon { + case .loading: icon = .loading + case .success: icon = .success + case .error: icon = .error + } + } + return .init( + icon: icon, + title: String(state: title), + subtitle: message.map { String(state: $0) }, + autoHide: autoHide + ) + } +} diff --git a/AppPackage/Sources/SystemNotificationExt/View+Toast.swift b/AppPackage/Sources/SystemNotificationExt/View+Toast.swift new file mode 100644 index 000000000..32e45d320 --- /dev/null +++ b/AppPackage/Sources/SystemNotificationExt/View+Toast.swift @@ -0,0 +1,84 @@ +// +// Presents a bottom-anchored Liquid Glass toast, driven by `AppAlertState` presentation state. +// The presentation model is adapted from Daniel Saidi's SystemNotification (MIT-licensed): +// https://github.com/danielsaidi/SystemNotification, reduced to a single bottom edge and rebuilt +// on TCA presentation state and SwiftUI's Liquid Glass (`glassEffect`) instead of a Material chrome. +// + +import SwiftUI +import AppComponents +import ComposableArchitecture + +extension View { + /// Overlays a bottom-anchored Liquid Glass toast driven by presentation state, mirroring + /// ``SwiftUICore/View/appAlert(_:)``. A non-`nil` store presents the toast; auto-hiding toasts + /// dismiss themselves after a short delay, and a downward swipe dismisses an auto-hiding toast + /// early. Both paths clear the presentation binding, sending `.dismiss` through the store. + /// + /// Drive it with a presented store scope, exactly like `appAlert`: + /// + /// ```swift + /// .toast($store.scope(state: \.toast, action: \.toast)) + /// ``` + @MainActor + public func toast( + _ item: Binding, Never>?> + ) -> some View { + modifier(ToastViewModifier(item: item)) + } +} + +private struct ToastViewModifier: ViewModifier { + @Binding var item: Store, Never>? + + func body(content: Content) -> some View { + content.overlay(alignment: .bottom) { + ZStack { + if let store = item { + let toast = store.toastContent + // The dismiss timer keys off the state's own UUID. Not `store.id`: TCA declares + // `Store: Identifiable`, so that is the Store object's identity, which shadows + // the state's UUID and only coincidentally tracks replacement. + let id = store.state.id + // SwiftUI keeps this conditional child alive through its removal transition, so + // the last content stays visible while the toast slides back off-screen — no + // manual hold. + ToastMessageView(content: toast) + .padding(.horizontal) + .padding(.bottom) + .gesture(dismissGesture(autoHide: toast.autoHide)) + .task(id: id) { await autoDismiss(toast, presentedID: id) } + .transition(.move(edge: .bottom).combined(with: .opacity)) + } + } + // Scoped inside the overlay: the host view can mutate in the same transaction that + // presents or clears the toast, and must not inherit this animation. + .animation(.bouncy, value: item != nil) + } + } + + // Only auto-hiding toasts (success / error) can be flicked away; a loading toast stays until + // its reducer clears the state, so a downward drag on it is ignored. As in the ported design, + // the drag must also be predominantly vertical — a sideways flick is not a dismissal. + private func dismissGesture(autoHide: Bool) -> some Gesture { + DragGesture(minimumDistance: 20) + .onEnded { value in + let translation = value.translation + guard autoHide, + abs(translation.height) > abs(translation.width), + translation.height > 0 + else { return } + item = nil + } + } + + private func autoDismiss(_ toast: ToastContent, presentedID: UUID) async { + guard toast.autoHide else { return } + try? await Task.sleep(for: .seconds(3)) + // The task is cancelled when the toast is replaced or dismissed, but a continuation already + // enqueued when the replacement lands can still run before SwiftUI restarts the task. Only + // a completed timer whose state is still presented may clear it. + guard !Task.isCancelled, item?.state.id == presentedID else { return } + item = nil + } +} diff --git a/AppPackage/Sources/TagTranslationFeature/.swiftlint.yml b/AppPackage/Sources/TagTranslationFeature/.swiftlint.yml new file mode 100644 index 000000000..1242ffcaa --- /dev/null +++ b/AppPackage/Sources/TagTranslationFeature/.swiftlint.yml @@ -0,0 +1 @@ +parent_config: ../../../.swiftlint.yml diff --git a/EhPanda/Models/Tags/TagSuggestion.swift b/AppPackage/Sources/TagTranslationFeature/TagSuggestion+Display.swift similarity index 81% rename from EhPanda/Models/Tags/TagSuggestion.swift rename to AppPackage/Sources/TagTranslationFeature/TagSuggestion+Display.swift index 37cb849f5..bced014ef 100644 --- a/EhPanda/Models/Tags/TagSuggestion.swift +++ b/AppPackage/Sources/TagTranslationFeature/TagSuggestion+Display.swift @@ -1,20 +1,9 @@ -// -// TagSuggestion.swift -// EhPanda -// +import AppTools +import AppModels +import Foundation -import SwiftUI - -struct TagSuggestion: Equatable, Hashable, Identifiable { - let id: UUID = .init() - let tag: TagTranslation - let weight: Float - let keyRange: Range? - let valueRange: Range? - let originalKeyword: String - let matchesNamespace: Bool - - var displayKey: String { +extension TagSuggestion { + public var displayKey: String { var namespace = tag.namespace.rawValue let leftSideString = leftSideString(of: keyRange, string: tag.key) var middleString = middleString(of: keyRange, string: tag.key) @@ -23,7 +12,7 @@ struct TagSuggestion: Equatable, Hashable, Identifiable { namespace = matchesNamespace ? namespace.linkStyled : namespace return [namespace, ":", leftSideString, middleString, rightSideString].joined() } - var displayValue: String { + public var displayValue: String { let text = tag.displayValue let leftSideString = leftSideString(of: valueRange, string: text) var middleString = middleString(of: valueRange, string: text) diff --git a/AppPackage/Sources/TagTranslationFeature/TagTranslation+Markdown.swift b/AppPackage/Sources/TagTranslationFeature/TagTranslation+Markdown.swift new file mode 100644 index 000000000..a33e7ee1a --- /dev/null +++ b/AppPackage/Sources/TagTranslationFeature/TagTranslation+Markdown.swift @@ -0,0 +1,34 @@ +import AppModels +import CommonMarkExt +import Foundation + +extension TagTranslation { + public var displayValue: String { + valuePlainText ?? value + } + + public var valuePlainText: String? { + MarkdownUtil.parseTexts(markdown: value).first + } + public var valueImageURL: URL? { + MarkdownUtil.parseImages(markdown: value).first + } + public var descriptionPlainText: String? { + if let description = description { + return MarkdownUtil.parseTexts(markdown: description.replacingOccurrences(of: "`", with: " ")).joined() + } + return nil + } + public var descriptionImageURLs: [URL] { + if let description = description { + return MarkdownUtil.parseImages(markdown: description) + } + return .init() + } + public var links: [URL] { + if let linksString = linksString { + return MarkdownUtil.parseLinks(markdown: linksString) + } + return .init() + } +} diff --git a/AppPackage/Sources/TagTranslationFeature/TagTranslator+Lookup.swift b/AppPackage/Sources/TagTranslationFeature/TagTranslator+Lookup.swift new file mode 100644 index 000000000..dc6c8a7fb --- /dev/null +++ b/AppPackage/Sources/TagTranslationFeature/TagTranslator+Lookup.swift @@ -0,0 +1,22 @@ +import AppTools +import AppModels +import Foundation + +extension TagTranslator { + public func lookup(word: String, returnOriginal: Bool) -> (String, TagTranslation?) { + guard !returnOriginal else { return (word, nil) } + let (lhs, rhs) = word.stringsBesideColon + + var key = rhs + if let lhs = lhs { + key = lhs + rhs + } + guard let translation = translations[key] else { return (word, nil) } + + var result = translation.displayValue + if let lhs = lhs { + result = [lhs, ":", result].joined() + } + return (result, translation) + } +} diff --git a/AppPackage/Sources/TestingSupport/.swiftlint.yml b/AppPackage/Sources/TestingSupport/.swiftlint.yml new file mode 100644 index 000000000..1242ffcaa --- /dev/null +++ b/AppPackage/Sources/TestingSupport/.swiftlint.yml @@ -0,0 +1 @@ +parent_config: ../../../.swiftlint.yml diff --git a/EhPandaTests/Models/HTMLFilename.swift b/AppPackage/Sources/TestingSupport/HTMLFilename.swift similarity index 92% rename from EhPandaTests/Models/HTMLFilename.swift rename to AppPackage/Sources/TestingSupport/HTMLFilename.swift index 243eee1bb..76c198406 100644 --- a/EhPandaTests/Models/HTMLFilename.swift +++ b/AppPackage/Sources/TestingSupport/HTMLFilename.swift @@ -1,9 +1,4 @@ -// -// HTMLFilename.swift -// EhPandaTests -// - -enum HTMLFilename: String { +public enum HTMLFilename: String { // List // FrontPage case frontPageMinimalList = "FrontPageMinimalList" @@ -43,6 +38,8 @@ enum HTMLFilename: String { // Other case ipBanned = "IPBanned" + case bandwidthExceeded = "BandwidthExceeded" + case exLoginRequired = "ExLoginRequired" case ehSetting = "EhSetting" case galleryDetailWithGreeting = "GalleryDetailWithGreeting" } diff --git a/EhPandaTests/Resources/Parser/Gallery/GalleryDetail.html b/AppPackage/Sources/TestingSupport/Resources/Parser/Gallery/GalleryDetail.html similarity index 100% rename from EhPandaTests/Resources/Parser/Gallery/GalleryDetail.html rename to AppPackage/Sources/TestingSupport/Resources/Parser/Gallery/GalleryDetail.html diff --git a/EhPandaTests/Resources/Parser/Gallery/GalleryMPVKeys.html b/AppPackage/Sources/TestingSupport/Resources/Parser/Gallery/GalleryMPVKeys.html similarity index 100% rename from EhPandaTests/Resources/Parser/Gallery/GalleryMPVKeys.html rename to AppPackage/Sources/TestingSupport/Resources/Parser/Gallery/GalleryMPVKeys.html diff --git a/EhPandaTests/Resources/Parser/Gallery/GalleryNormalImageURL.html b/AppPackage/Sources/TestingSupport/Resources/Parser/Gallery/GalleryNormalImageURL.html similarity index 100% rename from EhPandaTests/Resources/Parser/Gallery/GalleryNormalImageURL.html rename to AppPackage/Sources/TestingSupport/Resources/Parser/Gallery/GalleryNormalImageURL.html diff --git a/EhPandaTests/Resources/Parser/List/FavoritesCompactList.html b/AppPackage/Sources/TestingSupport/Resources/Parser/List/FavoritesCompactList.html similarity index 100% rename from EhPandaTests/Resources/Parser/List/FavoritesCompactList.html rename to AppPackage/Sources/TestingSupport/Resources/Parser/List/FavoritesCompactList.html diff --git a/EhPandaTests/Resources/Parser/List/FavoritesExtendedList.html b/AppPackage/Sources/TestingSupport/Resources/Parser/List/FavoritesExtendedList.html similarity index 100% rename from EhPandaTests/Resources/Parser/List/FavoritesExtendedList.html rename to AppPackage/Sources/TestingSupport/Resources/Parser/List/FavoritesExtendedList.html diff --git a/EhPandaTests/Resources/Parser/List/FavoritesMinimalList.html b/AppPackage/Sources/TestingSupport/Resources/Parser/List/FavoritesMinimalList.html similarity index 100% rename from EhPandaTests/Resources/Parser/List/FavoritesMinimalList.html rename to AppPackage/Sources/TestingSupport/Resources/Parser/List/FavoritesMinimalList.html diff --git a/EhPandaTests/Resources/Parser/List/FavoritesMinimalPlusList.html b/AppPackage/Sources/TestingSupport/Resources/Parser/List/FavoritesMinimalPlusList.html similarity index 100% rename from EhPandaTests/Resources/Parser/List/FavoritesMinimalPlusList.html rename to AppPackage/Sources/TestingSupport/Resources/Parser/List/FavoritesMinimalPlusList.html diff --git a/EhPandaTests/Resources/Parser/List/FavoritesThumbnailList.html b/AppPackage/Sources/TestingSupport/Resources/Parser/List/FavoritesThumbnailList.html similarity index 100% rename from EhPandaTests/Resources/Parser/List/FavoritesThumbnailList.html rename to AppPackage/Sources/TestingSupport/Resources/Parser/List/FavoritesThumbnailList.html diff --git a/EhPandaTests/Resources/Parser/List/FrontPageCompactList.html b/AppPackage/Sources/TestingSupport/Resources/Parser/List/FrontPageCompactList.html similarity index 100% rename from EhPandaTests/Resources/Parser/List/FrontPageCompactList.html rename to AppPackage/Sources/TestingSupport/Resources/Parser/List/FrontPageCompactList.html diff --git a/EhPandaTests/Resources/Parser/List/FrontPageExtendedList.html b/AppPackage/Sources/TestingSupport/Resources/Parser/List/FrontPageExtendedList.html similarity index 100% rename from EhPandaTests/Resources/Parser/List/FrontPageExtendedList.html rename to AppPackage/Sources/TestingSupport/Resources/Parser/List/FrontPageExtendedList.html diff --git a/EhPandaTests/Resources/Parser/List/FrontPageMinimalList.html b/AppPackage/Sources/TestingSupport/Resources/Parser/List/FrontPageMinimalList.html similarity index 100% rename from EhPandaTests/Resources/Parser/List/FrontPageMinimalList.html rename to AppPackage/Sources/TestingSupport/Resources/Parser/List/FrontPageMinimalList.html diff --git a/EhPandaTests/Resources/Parser/List/FrontPageMinimalPlusList.html b/AppPackage/Sources/TestingSupport/Resources/Parser/List/FrontPageMinimalPlusList.html similarity index 100% rename from EhPandaTests/Resources/Parser/List/FrontPageMinimalPlusList.html rename to AppPackage/Sources/TestingSupport/Resources/Parser/List/FrontPageMinimalPlusList.html diff --git a/EhPandaTests/Resources/Parser/List/FrontPageThumbnailList.html b/AppPackage/Sources/TestingSupport/Resources/Parser/List/FrontPageThumbnailList.html similarity index 100% rename from EhPandaTests/Resources/Parser/List/FrontPageThumbnailList.html rename to AppPackage/Sources/TestingSupport/Resources/Parser/List/FrontPageThumbnailList.html diff --git a/EhPandaTests/Resources/Parser/List/PopularCompactList.html b/AppPackage/Sources/TestingSupport/Resources/Parser/List/PopularCompactList.html similarity index 100% rename from EhPandaTests/Resources/Parser/List/PopularCompactList.html rename to AppPackage/Sources/TestingSupport/Resources/Parser/List/PopularCompactList.html diff --git a/EhPandaTests/Resources/Parser/List/PopularExtendedList.html b/AppPackage/Sources/TestingSupport/Resources/Parser/List/PopularExtendedList.html similarity index 100% rename from EhPandaTests/Resources/Parser/List/PopularExtendedList.html rename to AppPackage/Sources/TestingSupport/Resources/Parser/List/PopularExtendedList.html diff --git a/EhPandaTests/Resources/Parser/List/PopularMinimalList.html b/AppPackage/Sources/TestingSupport/Resources/Parser/List/PopularMinimalList.html similarity index 100% rename from EhPandaTests/Resources/Parser/List/PopularMinimalList.html rename to AppPackage/Sources/TestingSupport/Resources/Parser/List/PopularMinimalList.html diff --git a/EhPandaTests/Resources/Parser/List/PopularMinimalPlusList.html b/AppPackage/Sources/TestingSupport/Resources/Parser/List/PopularMinimalPlusList.html similarity index 100% rename from EhPandaTests/Resources/Parser/List/PopularMinimalPlusList.html rename to AppPackage/Sources/TestingSupport/Resources/Parser/List/PopularMinimalPlusList.html diff --git a/EhPandaTests/Resources/Parser/List/PopularThumbnailList.html b/AppPackage/Sources/TestingSupport/Resources/Parser/List/PopularThumbnailList.html similarity index 100% rename from EhPandaTests/Resources/Parser/List/PopularThumbnailList.html rename to AppPackage/Sources/TestingSupport/Resources/Parser/List/PopularThumbnailList.html diff --git a/EhPandaTests/Resources/Parser/List/ToplistsCompactList.html b/AppPackage/Sources/TestingSupport/Resources/Parser/List/ToplistsCompactList.html similarity index 100% rename from EhPandaTests/Resources/Parser/List/ToplistsCompactList.html rename to AppPackage/Sources/TestingSupport/Resources/Parser/List/ToplistsCompactList.html diff --git a/EhPandaTests/Resources/Parser/List/WatchedCompactList.html b/AppPackage/Sources/TestingSupport/Resources/Parser/List/WatchedCompactList.html similarity index 100% rename from EhPandaTests/Resources/Parser/List/WatchedCompactList.html rename to AppPackage/Sources/TestingSupport/Resources/Parser/List/WatchedCompactList.html diff --git a/EhPandaTests/Resources/Parser/List/WatchedExtendedList.html b/AppPackage/Sources/TestingSupport/Resources/Parser/List/WatchedExtendedList.html similarity index 100% rename from EhPandaTests/Resources/Parser/List/WatchedExtendedList.html rename to AppPackage/Sources/TestingSupport/Resources/Parser/List/WatchedExtendedList.html diff --git a/EhPandaTests/Resources/Parser/List/WatchedMinimalList.html b/AppPackage/Sources/TestingSupport/Resources/Parser/List/WatchedMinimalList.html similarity index 100% rename from EhPandaTests/Resources/Parser/List/WatchedMinimalList.html rename to AppPackage/Sources/TestingSupport/Resources/Parser/List/WatchedMinimalList.html diff --git a/EhPandaTests/Resources/Parser/List/WatchedMinimalPlusList.html b/AppPackage/Sources/TestingSupport/Resources/Parser/List/WatchedMinimalPlusList.html similarity index 100% rename from EhPandaTests/Resources/Parser/List/WatchedMinimalPlusList.html rename to AppPackage/Sources/TestingSupport/Resources/Parser/List/WatchedMinimalPlusList.html diff --git a/EhPandaTests/Resources/Parser/List/WatchedThumbnailList.html b/AppPackage/Sources/TestingSupport/Resources/Parser/List/WatchedThumbnailList.html similarity index 100% rename from EhPandaTests/Resources/Parser/List/WatchedThumbnailList.html rename to AppPackage/Sources/TestingSupport/Resources/Parser/List/WatchedThumbnailList.html diff --git a/AppPackage/Sources/TestingSupport/Resources/Parser/Other/BandwidthExceeded.html b/AppPackage/Sources/TestingSupport/Resources/Parser/Other/BandwidthExceeded.html new file mode 100644 index 000000000..28d269f0b Binary files /dev/null and b/AppPackage/Sources/TestingSupport/Resources/Parser/Other/BandwidthExceeded.html differ diff --git a/EhPandaTests/Resources/Parser/Other/EhSetting.html b/AppPackage/Sources/TestingSupport/Resources/Parser/Other/EhSetting.html similarity index 99% rename from EhPandaTests/Resources/Parser/Other/EhSetting.html rename to AppPackage/Sources/TestingSupport/Resources/Parser/Other/EhSetting.html index 72f8aeba0..f31e26b30 100644 --- a/EhPandaTests/Resources/Parser/Other/EhSetting.html +++ b/AppPackage/Sources/TestingSupport/Resources/Parser/Other/EhSetting.html @@ -113,13 +113,13 @@

Image Load Settings

Image Size Settings

-

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

+

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

-
+
-
-
+
+
diff --git a/AppPackage/Sources/TestingSupport/Resources/Parser/Other/ExLoginRequired.html b/AppPackage/Sources/TestingSupport/Resources/Parser/Other/ExLoginRequired.html new file mode 100644 index 000000000..e69de29bb diff --git a/EhPandaTests/Resources/Parser/Other/GalleryDetailWithGreeting.html b/AppPackage/Sources/TestingSupport/Resources/Parser/Other/GalleryDetailWithGreeting.html similarity index 100% rename from EhPandaTests/Resources/Parser/Other/GalleryDetailWithGreeting.html rename to AppPackage/Sources/TestingSupport/Resources/Parser/Other/GalleryDetailWithGreeting.html diff --git a/EhPandaTests/Resources/Parser/Other/IPBanned.html b/AppPackage/Sources/TestingSupport/Resources/Parser/Other/IPBanned.html similarity index 100% rename from EhPandaTests/Resources/Parser/Other/IPBanned.html rename to AppPackage/Sources/TestingSupport/Resources/Parser/Other/IPBanned.html diff --git a/AppPackage/Sources/TestingSupport/Resources/Parser/Other/Kokomade.jpg b/AppPackage/Sources/TestingSupport/Resources/Parser/Other/Kokomade.jpg new file mode 100644 index 000000000..df25f454b Binary files /dev/null and b/AppPackage/Sources/TestingSupport/Resources/Parser/Other/Kokomade.jpg differ diff --git a/EhPandaTests/Models/TestError.swift b/AppPackage/Sources/TestingSupport/TestError.swift similarity index 90% rename from EhPandaTests/Models/TestError.swift rename to AppPackage/Sources/TestingSupport/TestError.swift index 71f33c6cb..e42d2bd25 100644 --- a/EhPandaTests/Models/TestError.swift +++ b/AppPackage/Sources/TestingSupport/TestError.swift @@ -1,8 +1,3 @@ -// -// TestError.swift -// EhPandaTests -// - enum TestError: Error { case htmlDocumentNotFound(HTMLFilename) case parsingFailed(String) diff --git a/AppPackage/Sources/TestingSupport/TestFixtures.swift b/AppPackage/Sources/TestingSupport/TestFixtures.swift new file mode 100644 index 000000000..98b61cba9 --- /dev/null +++ b/AppPackage/Sources/TestingSupport/TestFixtures.swift @@ -0,0 +1,12 @@ +import Foundation + +/// Accessor for the test fixtures bundled with `TestingSupport`. +/// +/// Test targets that depend on `TestingSupport` cannot reach these resources through their own +/// `Bundle.module`, which resolves to the (resource-less) test bundle. Routing through this type +/// resolves `Bundle.module` inside `TestingSupport`, where the fixtures actually live. +public enum TestFixtures { + public static func url(forResource name: String, withExtension ext: String) -> URL? { + Bundle.module.url(forResource: name, withExtension: ext) + } +} diff --git a/AppPackage/Sources/TestingSupport/TestHelper.swift b/AppPackage/Sources/TestingSupport/TestHelper.swift new file mode 100644 index 000000000..588ec507f --- /dev/null +++ b/AppPackage/Sources/TestingSupport/TestHelper.swift @@ -0,0 +1,15 @@ +import Kanna +import Foundation + +public protocol TestHelper {} + +extension TestHelper { + public func htmlDocument(filename: HTMLFilename) throws -> HTMLDocument { + guard let url = Bundle.module + .url(forResource: filename.rawValue, withExtension: "html") + else { + throw TestError.htmlDocumentNotFound(filename) + } + return try Kanna.HTML(url: url, encoding: .utf8) + } +} diff --git a/AppPackage/Sources/URLClient/.swiftlint.yml b/AppPackage/Sources/URLClient/.swiftlint.yml new file mode 100644 index 000000000..1242ffcaa --- /dev/null +++ b/AppPackage/Sources/URLClient/.swiftlint.yml @@ -0,0 +1 @@ +parent_config: ../../../.swiftlint.yml diff --git a/EhPanda/App/Tools/Clients/URLClient.swift b/AppPackage/Sources/URLClient/URLClient.swift similarity index 53% rename from EhPanda/App/Tools/Clients/URLClient.swift rename to AppPackage/Sources/URLClient/URLClient.swift index 5a9136a2d..ebf82bf7f 100644 --- a/EhPanda/App/Tools/Clients/URLClient.swift +++ b/AppPackage/Sources/URLClient/URLClient.swift @@ -1,29 +1,44 @@ -// -// URLClient.swift -// EhPanda -// - import SwiftUI +import AppModels import Dependencies +import AppTools + +public struct URLAnalysisResult: Sendable { + public let isGalleryImageURL: Bool + public let pageIndex: Int? + public let commentID: String? +} + +public struct URLClient: Sendable { + public let checkIfHandleable: @Sendable (URL) -> Bool + public let checkIfMPVURL: @Sendable (URL?) -> Bool + public let parseGalleryID: @Sendable (URL) -> String -struct URLClient { - let checkIfHandleable: (URL) -> Bool - let checkIfMPVURL: (URL?) -> Bool - let parseGalleryID: (URL) -> String + public init( + checkIfHandleable: @escaping @Sendable (URL) -> Bool, + checkIfMPVURL: @escaping @Sendable (URL?) -> Bool, + parseGalleryID: @escaping @Sendable (URL) -> String + ) { + self.checkIfHandleable = checkIfHandleable + self.checkIfMPVURL = checkIfMPVURL + self.parseGalleryID = parseGalleryID + } } extension URLClient { - static let live: Self = .init( + public static func isMPVURL(_ url: URL?) -> Bool { + guard let url else { return false } + return url.pathComponents.count >= 2 && url.pathComponents[1] == "mpv" + } + + public static let live: Self = .init( checkIfHandleable: { url in (url.absoluteString.contains(Defaults.URL.ehentai.absoluteString) - || url.absoluteString.contains(Defaults.URL.exhentai.absoluteString)) + || url.absoluteString.contains(Defaults.URL.exhentai.absoluteString)) && url.pathComponents.count >= 4 && ["g", "s"].contains(url.pathComponents[1]) && !url.pathComponents[2].isEmpty && !url.pathComponents[3].isEmpty }, - checkIfMPVURL: { - guard let url = $0 else { return false } - return url.pathComponents.count >= 1 && url.pathComponents[1] == "mpv" - }, + checkIfMPVURL: Self.isMPVURL, parseGalleryID: { url in var gid = url.pathComponents[2] let token = url.pathComponents[3] @@ -34,15 +49,15 @@ extension URLClient { } ) - func resolveAppSchemeURL(_ url: URL) -> URL? { + public func resolveAppSchemeURL(_ url: URL) -> URL? { guard url.scheme == "ehpanda", let newURL = url.replaceScheme(to: "https") else { return url } return newURL } - func analyzeURL(_ url: URL) -> (Bool, Int?, String?) { + public func analyzeURL(_ url: URL) -> URLAnalysisResult { guard checkIfHandleable(url) else { - return (false, nil, nil) + return URLAnalysisResult(isGalleryImageURL: false, pageIndex: nil, commentID: nil) } var isGalleryImageURL = false var commentID: String? @@ -62,19 +77,19 @@ extension URLClient { } } - return (isGalleryImageURL, pageIndex, commentID) + return URLAnalysisResult(isGalleryImageURL: isGalleryImageURL, pageIndex: pageIndex, commentID: commentID) } } // MARK: API -enum URLClientKey: DependencyKey { - static let liveValue = URLClient.live - static let previewValue = URLClient.noop - static let testValue = URLClient.unimplemented +public enum URLClientKey: DependencyKey { + public static let liveValue = URLClient.live + public static let previewValue = URLClient.noop + public static let testValue = URLClient.unimplemented } extension DependencyValues { - var urlClient: URLClient { + public var urlClient: URLClient { get { self[URLClientKey.self] } set { self[URLClientKey.self] = newValue } } @@ -82,15 +97,15 @@ extension DependencyValues { // MARK: Test extension URLClient { - static let noop: Self = .init( + public static let noop: Self = .init( checkIfHandleable: { _ in false }, checkIfMPVURL: { _ in false }, parseGalleryID: { _ in .init() } ) - static func placeholder() -> Result { fatalError() } + public static func placeholder() -> Result { fatalError() } - static let unimplemented: Self = .init( + public static let unimplemented: Self = .init( checkIfHandleable: IssueReporting.unimplemented(placeholder: placeholder()), checkIfMPVURL: IssueReporting.unimplemented(placeholder: placeholder()), parseGalleryID: IssueReporting.unimplemented(placeholder: placeholder()) diff --git a/AppPackage/Sources/UserDefaultsClient/.swiftlint.yml b/AppPackage/Sources/UserDefaultsClient/.swiftlint.yml new file mode 100644 index 000000000..1242ffcaa --- /dev/null +++ b/AppPackage/Sources/UserDefaultsClient/.swiftlint.yml @@ -0,0 +1 @@ +parent_config: ../../../.swiftlint.yml diff --git a/AppPackage/Sources/UserDefaultsClient/UserDefaultsClient.swift b/AppPackage/Sources/UserDefaultsClient/UserDefaultsClient.swift new file mode 100644 index 000000000..ac15a8062 --- /dev/null +++ b/AppPackage/Sources/UserDefaultsClient/UserDefaultsClient.swift @@ -0,0 +1,46 @@ +import Foundation +import ComposableArchitecture +import AppTools + +public struct UserDefaultsClient: Sendable { + public let setValue: @Sendable (Any, AppUserDefaults) -> Void +} + +extension UserDefaultsClient { + public static let live: Self = .init( + setValue: { value, key in + UserDefaults.standard.set(value, forKey: key.rawValue) + } + ) + + public func getValue(_ key: AppUserDefaults) -> T? { + UserDefaultsUtil.value(forKey: key) + } +} + +// MARK: API +public enum UserDefaultsClientKey: DependencyKey { + public static let liveValue = UserDefaultsClient.live + public static let previewValue = UserDefaultsClient.noop + public static let testValue = UserDefaultsClient.unimplemented +} + +extension DependencyValues { + public var userDefaultsClient: UserDefaultsClient { + get { self[UserDefaultsClientKey.self] } + set { self[UserDefaultsClientKey.self] = newValue } + } +} + +// MARK: Test +extension UserDefaultsClient { + public static let noop: Self = .init( + setValue: { _, _ in } + ) + + public static func placeholder() -> Result { fatalError() } + + public static let unimplemented: Self = .init( + setValue: IssueReporting.unimplemented(placeholder: placeholder()) + ) +} diff --git a/AppPackage/Tests/AppModelsTests/.swiftlint.yml b/AppPackage/Tests/AppModelsTests/.swiftlint.yml new file mode 100644 index 000000000..1242ffcaa --- /dev/null +++ b/AppPackage/Tests/AppModelsTests/.swiftlint.yml @@ -0,0 +1 @@ +parent_config: ../../../.swiftlint.yml diff --git a/AppPackage/Tests/AppModelsTests/AppIconTypeMatchingTests.swift b/AppPackage/Tests/AppModelsTests/AppIconTypeMatchingTests.swift new file mode 100644 index 000000000..81464ce51 --- /dev/null +++ b/AppPackage/Tests/AppModelsTests/AppIconTypeMatchingTests.swift @@ -0,0 +1,20 @@ +import Testing +import AppModels + +// `AppIconType.matching(alternateIconName:)` is the shared mapping used by both the Setting tab's launch +// reconciliation and the App Icon screen's post-edit sync. This pins that every known icon round-trips +// through its filename and that anything unrecognized falls back to `.default`. +@Suite +struct AppIconTypeMatchingTests { + @Test + func knownAlternateIconNamesRoundTrip() { + for iconType in AppIconType.allCases { + #expect(AppIconType.matching(alternateIconName: iconType.filename) == iconType) + } + } + + @Test + func unrecognizedNameFallsBackToDefault() { + #expect(AppIconType.matching(alternateIconName: "SomethingElse") == .default) + } +} diff --git a/AppPackage/Tests/AppModelsTests/GalleryHistoryOperationsTests.swift b/AppPackage/Tests/AppModelsTests/GalleryHistoryOperationsTests.swift new file mode 100644 index 000000000..a228691fd --- /dev/null +++ b/AppPackage/Tests/AppModelsTests/GalleryHistoryOperationsTests.swift @@ -0,0 +1,81 @@ +import Foundation +import Testing +import AppModels + +// REV-13: the shared history mutators are the single home for the persisted-list invariants, so they +// must reject unresolvable records at the source — a non-numeric gid, or a brand-new entry with no +// token — rather than relying on every call site to pre-validate. +@Suite +struct GalleryHistoryOperationsTests { + private let date = Date(timeIntervalSince1970: 1_000) + + @Test + func recordGalleryOpenRejectsNonNumericGID() { + var history = [GalleryHistoryEntry]() + history.recordGalleryOpen(gid: "not-a-number", token: "tok", date: date) + #expect(history.isEmpty) + } + + @Test + func recordGalleryOpenRejectsNewTokenlessEntry() { + var history = [GalleryHistoryEntry]() + history.recordGalleryOpen(gid: "42", token: "", date: date) + #expect(history.isEmpty) + } + + @Test + func recordGalleryOpenInsertsResolvableEntry() { + var history = [GalleryHistoryEntry]() + history.recordGalleryOpen(gid: "42", token: "tok", date: date) + #expect(history.count == 1) + #expect(history.first?.gid == "42") + #expect(history.first?.token == "tok") + } + + @Test + func recordGalleryOpenBackfillsMissingTokenAndMovesToFront() { + var history = [ + GalleryHistoryEntry(gid: "1", token: "t1", lastOpenDate: date), + GalleryHistoryEntry(gid: "42", token: "", lastOpenDate: date, readingProgress: 5) + ] + let later = date.addingTimeInterval(60) + history.recordGalleryOpen(gid: "42", token: "tok", date: later) + #expect(history.count == 2) + #expect(history.first?.gid == "42") + #expect(history.first?.token == "tok") // backfilled + #expect(history.first?.readingProgress == 5) // preserved + #expect(history.first?.lastOpenDate == later) + } + + @Test + func updateReadingProgressRejectsNonNumericGID() { + var history = [GalleryHistoryEntry]() + history.updateReadingProgress(gid: "junk", token: "tok", progress: 3, date: date) + #expect(history.isEmpty) + } + + @Test + func updateReadingProgressRejectsNewTokenlessEntry() { + var history = [GalleryHistoryEntry]() + history.updateReadingProgress(gid: "42", token: "", progress: 3, date: date) + #expect(history.isEmpty) + } + + @Test + func updateReadingProgressInsertsResolvableEntryWithToken() { + var history = [GalleryHistoryEntry]() + history.updateReadingProgress(gid: "42", token: "tok", progress: 3, date: date) + #expect(history.count == 1) + #expect(history.first?.token == "tok") + #expect(history.first?.readingProgress == 3) + } + + @Test + func updateReadingProgressInPlaceKeepsStoredToken() { + var history = [GalleryHistoryEntry(gid: "42", token: "stored", lastOpenDate: date)] + history.updateReadingProgress(gid: "42", token: "ignored", progress: 7, date: date) + #expect(history.count == 1) + #expect(history.first?.token == "stored") // in-place update keeps stored token + #expect(history.first?.readingProgress == 7) + } +} diff --git a/AppPackage/Tests/AppModelsTests/GreetingMergeTests.swift b/AppPackage/Tests/AppModelsTests/GreetingMergeTests.swift new file mode 100644 index 000000000..3f758c9aa --- /dev/null +++ b/AppPackage/Tests/AppModelsTests/GreetingMergeTests.swift @@ -0,0 +1,41 @@ +import Foundation +import Testing +import AppModels + +// REV-21 / #3: greetings have two writers (the Setting daily fetch and the Detail-page parse), so the +// "keep the newer" merge lives on `Optional.mergeNewer`. A stale detail-page greeting must +// not clobber a fresher one. The greeting itself is a session-only in-memory shared slot (`.greeting`), +// so its non-persistence is enforced by the key's strategy rather than a Codable round-trip. +@Suite +struct GreetingMergeTests { + private let older = Greeting(gainedCredits: 1, updateTime: Date(timeIntervalSince1970: 100)) + private let newer = Greeting(gainedCredits: 2, updateTime: Date(timeIntervalSince1970: 200)) + + @Test + func mergeNewerAdoptsWhenNoneHeld() { + var greeting: Greeting? + greeting.mergeNewer(newer) + #expect(greeting?.gainedCredits == 2) + } + + @Test + func mergeNewerAdoptsAStrictlyNewerGreeting() { + var greeting: Greeting? = older + greeting.mergeNewer(newer) + #expect(greeting?.gainedCredits == 2) + } + + @Test + func mergeNewerKeepsTheHeldGreetingWhenIncomingIsOlder() { + var greeting: Greeting? = newer + greeting.mergeNewer(older) + #expect(greeting?.gainedCredits == 2) + } + + @Test + func mergeNewerIgnoresADatelessGreeting() { + var greeting: Greeting? = newer + greeting.mergeNewer(Greeting(gainedCredits: 9, updateTime: nil)) + #expect(greeting?.gainedCredits == 2) + } +} diff --git a/AppPackage/Tests/AppModelsTests/SchemaMigrationTests.swift b/AppPackage/Tests/AppModelsTests/SchemaMigrationTests.swift new file mode 100644 index 000000000..e320dc236 --- /dev/null +++ b/AppPackage/Tests/AppModelsTests/SchemaMigrationTests.swift @@ -0,0 +1,315 @@ +import Foundation +import Testing +import AppModels + +// MARK: - MOCK migratable models — REMOVE when real v2 models land +// +// No real model has a breaking change yet, so these mocks exercise the migration engine +// (`SchemaMigrator` walking a `VersionedSchema` history). Each conforms to `MigratableModel`, declares +// an ordered `schemas` list (its v1 base schema plus a v2 that carries the map), and provides +// `init(currentFrom:)` for its current shape. A schema's `migrate` is a raw-JSON transform; the engine +// reads a stored blob's version and runs every schema newer than it, in order, up to `currentSchemaVersion`. +// +// Shapes covered, one per model: RENAME, ADD, REMOVE, TYPE, DERIVE, MERGE. `ProgressiveMock` covers a +// multi-step chain (v1 → v2 → v3). +// +// FUTURE AGENT: when a model gains a REAL v2 (it adopts `MigratableModel` and appends a real +// `VersionedSchema` to its `schemas`), DELETE that model's mock + its tests here and replace them with +// tests over the real migration. Delete this whole file once every model has real migration coverage. + +// MOCK — remove with real Setting v2. RENAME: v1 `galleryHost` → v2 `host`. +private struct SettingMock: MigratableModel { + var host: GalleryHost + static var schemas: [any VersionedSchema.Type] { [SchemaV1.self, SchemaV2.self] } + enum SchemaV1: VersionedSchema { + static let version = 1 + static func migrate(_ object: inout [String: JSONValue]) throws {} + } + enum SchemaV2: VersionedSchema { + static let version = 2 + static func migrate(_ object: inout [String: JSONValue]) throws { + object["host"] = object["galleryHost"] + object["galleryHost"] = nil + } + } + init(from decoder: Decoder) throws { self = try SchemaMigrator.migrate(Self.self, from: decoder) } + init(currentFrom decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + host = try container.decode(GalleryHost.self, forKey: .host) + } +} + +// MOCK — remove with real User v2. ADD: v2 introduces a required `region`, defaulted when migrating. +private struct UserMock: MigratableModel { + var displayName: String? + var region: String + static var schemas: [any VersionedSchema.Type] { [SchemaV1.self, SchemaV2.self] } + enum SchemaV1: VersionedSchema { + static let version = 1 + static func migrate(_ object: inout [String: JSONValue]) throws {} + } + enum SchemaV2: VersionedSchema { + static let version = 2 + static func migrate(_ object: inout [String: JSONValue]) throws { + object["region"] = .string("") + } + } + init(from decoder: Decoder) throws { self = try SchemaMigrator.migrate(Self.self, from: decoder) } + init(currentFrom decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + displayName = try container.decodeIfPresent(String.self, forKey: .displayName) + region = try container.decode(String.self, forKey: .region) + } +} + +// MOCK — remove with real Filter v2. REMOVE: v2 drops `minRating`; a v1 blob still carrying it decodes. +private struct FilterMock: MigratableModel { + var doujinshi: Bool + static var schemas: [any VersionedSchema.Type] { [SchemaV1.self, SchemaV2.self] } + enum SchemaV1: VersionedSchema { + static let version = 1 + static func migrate(_ object: inout [String: JSONValue]) throws {} + } + enum SchemaV2: VersionedSchema { + static let version = 2 + static func migrate(_ object: inout [String: JSONValue]) throws { + object["minRating"] = nil + } + } + init(from decoder: Decoder) throws { self = try SchemaMigrator.migrate(Self.self, from: decoder) } + init(currentFrom decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + doujinshi = try container.decode(Bool.self, forKey: .doujinshi) + } +} + +// MOCK — remove with real TagTranslatorInfo v2. TYPE: v1 `hasCustomTranslations: Bool` → v2 Int. +private struct TagTranslatorInfoMock: MigratableModel { + var customTranslations: Int + static var schemas: [any VersionedSchema.Type] { [SchemaV1.self, SchemaV2.self] } + enum SchemaV1: VersionedSchema { + static let version = 1 + static func migrate(_ object: inout [String: JSONValue]) throws {} + } + enum SchemaV2: VersionedSchema { + static let version = 2 + static func migrate(_ object: inout [String: JSONValue]) throws { + let flag = object["hasCustomTranslations"]?.boolValue ?? false + object["customTranslations"] = .int(flag ? 1 : 0) + object["hasCustomTranslations"] = nil + } + } + init(from decoder: Decoder) throws { self = try SchemaMigrator.migrate(Self.self, from: decoder) } + init(currentFrom decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + customTranslations = try container.decode(Int.self, forKey: .customTranslations) + } +} + +// MOCK — remove with real GalleryHistoryEntry v2. DERIVE: v2 `started` computed from v1 `readingProgress`. +private struct GalleryHistoryEntryMock: MigratableModel { + var started: Bool + static var schemas: [any VersionedSchema.Type] { [SchemaV1.self, SchemaV2.self] } + enum SchemaV1: VersionedSchema { + static let version = 1 + static func migrate(_ object: inout [String: JSONValue]) throws {} + } + enum SchemaV2: VersionedSchema { + static let version = 2 + static func migrate(_ object: inout [String: JSONValue]) throws { + let progress = object["readingProgress"]?.intValue ?? 0 + object["started"] = .bool(progress > 0) + } + } + init(from decoder: Decoder) throws { self = try SchemaMigrator.migrate(Self.self, from: decoder) } + init(currentFrom decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + started = try container.decode(Bool.self, forKey: .started) + } +} + +// MOCK — remove with real QuickSearchWord v2. MERGE: v1 `name` + `content` → v2 `combined`. +private struct QuickSearchWordMock: MigratableModel { + var combined: String + static var schemas: [any VersionedSchema.Type] { [SchemaV1.self, SchemaV2.self] } + enum SchemaV1: VersionedSchema { + static let version = 1 + static func migrate(_ object: inout [String: JSONValue]) throws {} + } + enum SchemaV2: VersionedSchema { + static let version = 2 + static func migrate(_ object: inout [String: JSONValue]) throws { + let name = object["name"]?.stringValue ?? "" + let content = object["content"]?.stringValue ?? "" + object["combined"] = .string("\(name): \(content)") + } + } + init(from decoder: Decoder) throws { self = try SchemaMigrator.migrate(Self.self, from: decoder) } + init(currentFrom decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + combined = try container.decode(String.self, forKey: .combined) + } +} + +// MOCK — progressive chain across three versions. v1 field `a` → v2 `b` (doubled) → v3 `value` (plus one). +private struct ProgressiveMock: MigratableModel { + var value: Int + static var schemas: [any VersionedSchema.Type] { [SchemaV1.self, SchemaV2.self, SchemaV3.self] } + enum SchemaV1: VersionedSchema { + static let version = 1 + static func migrate(_ object: inout [String: JSONValue]) throws {} + } + enum SchemaV2: VersionedSchema { + static let version = 2 + static func migrate(_ object: inout [String: JSONValue]) throws { + let old = object["a"]?.intValue ?? 0 + object["b"] = .int(old * 2) + object["a"] = nil + } + } + enum SchemaV3: VersionedSchema { + static let version = 3 + static func migrate(_ object: inout [String: JSONValue]) throws { + let old = object["b"]?.intValue ?? 0 + object["value"] = .int(old + 1) + object["b"] = nil + } + } + init(from decoder: Decoder) throws { self = try SchemaMigrator.migrate(Self.self, from: decoder) } + init(currentFrom decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + value = try container.decode(Int.self, forKey: .value) + } +} + +// MARK: - Migration tests +@Suite +struct SchemaMigrationTests { + private func decode(_ type: T.Type, _ json: String) throws -> T { + try JSONDecoder().decode(T.self, from: Data(json.utf8)) + } + + // MARK: Progressive chaining (the headline) + @Test + func aV1BlobChainsThroughEveryStep() throws { + // a=5 → v1→v2 doubles to b=10 → v2→v3 adds one to value=11. Both steps run, in order. + let migrated = try decode(ProgressiveMock.self, #"{"schemaVersion": 1, "a": 5}"#) + #expect(migrated.value == 11) + } + @Test + func aMidVersionBlobRunsOnlyTheRemainingSteps() throws { + // Starting at v2 (b=10) runs only v2→v3: value = 11. + let migrated = try decode(ProgressiveMock.self, #"{"schemaVersion": 2, "b": 10}"#) + #expect(migrated.value == 11) + } + @Test + func aCurrentVersionBlobDecodesWithNoSteps() throws { + let decoded = try decode(ProgressiveMock.self, #"{"schemaVersion": 3, "value": 11}"#) + #expect(decoded.value == 11) + } + @Test + func aNewerVersionBlobIsRejected() { + // currentSchemaVersion is 3 (the schemas head); a v4 blob is a downgrade and throws. + #expect(throws: (any Error).self) { + try decode(ProgressiveMock.self, #"{"schemaVersion": 4, "value": 11}"#) + } + } + + // MARK: Per-shape maps, fed a REAL v1 blob + @Test + func settingRenamesAField() throws { + let v1Blob = try JSONEncoder().encode(Setting(galleryHost: .exhentai)) + let migrated = try JSONDecoder().decode(SettingMock.self, from: v1Blob) + #expect(migrated.host == .exhentai) + } + @Test + func settingMockDecodesNatively() throws { + let decoded = try decode(SettingMock.self, #"{"schemaVersion": 2, "host": "ExHentai"}"#) + #expect(decoded.host == .exhentai) + } + + @Test + func userAddsARequiredField() throws { + let v1Blob = try JSONEncoder().encode(User(displayName: "alice")) + let migrated = try JSONDecoder().decode(UserMock.self, from: v1Blob) + #expect(migrated.displayName == "alice") // existing field preserved + #expect(migrated.region == "") // new field defaulted by the map + } + @Test + func userMockDecodesNatively() throws { + let decoded = try decode(UserMock.self, #"{"schemaVersion": 2, "displayName": "bob", "region": "eu"}"#) + #expect(decoded.region == "eu") + } + + @Test + func filterRemovesAField() throws { + // The v1 blob still carries `minRating`; the map drops it and the current shape decodes. + let v1Blob = try JSONEncoder().encode(Filter(doujinshi: true, minRating: 5)) + let migrated = try JSONDecoder().decode(FilterMock.self, from: v1Blob) + #expect(migrated.doujinshi) + } + @Test + func filterMockDecodesNatively() throws { + let decoded = try decode(FilterMock.self, #"{"schemaVersion": 2, "doujinshi": true}"#) + #expect(decoded.doujinshi) + } + + @Test + func tagTranslatorInfoChangesAFieldType() throws { + let v1Blob = try JSONEncoder().encode(TagTranslatorInfo(hasCustomTranslations: true)) + let migrated = try JSONDecoder().decode(TagTranslatorInfoMock.self, from: v1Blob) + #expect(migrated.customTranslations == 1) // Bool true → Int 1 + } + @Test + func tagTranslatorInfoMockDecodesNatively() throws { + let decoded = try decode(TagTranslatorInfoMock.self, #"{"schemaVersion": 2, "customTranslations": 5}"#) + #expect(decoded.customTranslations == 5) + } + + @Test + func galleryHistoryEntryDerivesAField() throws { + let started = GalleryHistoryEntry( + gid: "1", token: "a", lastOpenDate: Date(timeIntervalSince1970: 1), readingProgress: 7 + ) + let unstarted = GalleryHistoryEntry( + gid: "2", token: "b", lastOpenDate: Date(timeIntervalSince1970: 1), readingProgress: 0 + ) + let migratedStarted = try JSONDecoder().decode( + GalleryHistoryEntryMock.self, from: JSONEncoder().encode(started) + ) + let migratedUnstarted = try JSONDecoder().decode( + GalleryHistoryEntryMock.self, from: JSONEncoder().encode(unstarted) + ) + #expect(migratedStarted.started) + #expect(!migratedUnstarted.started) + } + @Test + func galleryHistoryEntryMockDecodesNatively() throws { + let decoded = try decode(GalleryHistoryEntryMock.self, #"{"schemaVersion": 2, "started": true}"#) + #expect(decoded.started) + } + + @Test + func quickSearchWordMergesFields() throws { + let v1Blob = try JSONEncoder().encode(QuickSearchWord(name: "n", content: "c")) + let migrated = try JSONDecoder().decode(QuickSearchWordMock.self, from: v1Blob) + #expect(migrated.combined == "n: c") + } + @Test + func quickSearchWordMockDecodesNatively() throws { + let decoded = try decode(QuickSearchWordMock.self, #"{"schemaVersion": 2, "combined": "x"}"#) + #expect(decoded.combined == "x") + } + + // MARK: Invariant — well-formed schema history + @Test + func everyModelDeclaresWellFormedSchemas() { + #expect(Setting.hasWellFormedSchemas) + #expect(User.hasWellFormedSchemas) + #expect(Filter.hasWellFormedSchemas) + #expect(TagTranslatorInfo.hasWellFormedSchemas) + #expect(GalleryHistoryEntry.hasWellFormedSchemas) + #expect(QuickSearchWord.hasWellFormedSchemas) + #expect(ProgressiveMock.hasWellFormedSchemas) // versions 1, 2, 3 — contiguous from 1 + } +} diff --git a/AppPackage/Tests/AppModelsTests/SettingAutoLockClampTests.swift b/AppPackage/Tests/AppModelsTests/SettingAutoLockClampTests.swift new file mode 100644 index 000000000..2738d2364 --- /dev/null +++ b/AppPackage/Tests/AppModelsTests/SettingAutoLockClampTests.swift @@ -0,0 +1,35 @@ +import Testing +import AppModels + +// The auto-lock policy and background-blur radius are coupled on the `Setting` model: auto-lock relies +// on a non-zero blur to obscure content, so enabling auto-lock while blur is 0 restores a default blur, +// and dropping blur to 0 disables auto-lock. Kept on the model (not a reducer `BindingReducer`) so every +// write path holds the invariant; this pins that coupling. +@Suite +struct SettingAutoLockClampTests { + @Test + func enablingAutoLockWithZeroBlurRestoresBlur() { + var setting = Setting() + setting.backgroundBlurRadius = 0 + setting.autoLockPolicy = .instantly + #expect(setting.backgroundBlurRadius == 10) + #expect(setting.autoLockPolicy == .instantly) + } + + @Test + func zeroingBlurWhileAutoLockOnDisablesAutoLock() { + var setting = Setting() + setting.autoLockPolicy = .min1 + setting.backgroundBlurRadius = 0 + #expect(setting.autoLockPolicy == .never) + #expect(setting.backgroundBlurRadius == 0) + } + + @Test + func zeroingBlurWhileAutoLockNeverIsLeftUntouched() { + var setting = Setting() + setting.backgroundBlurRadius = 0 + #expect(setting.backgroundBlurRadius == 0) + #expect(setting.autoLockPolicy == .never) + } +} diff --git a/AppPackage/Tests/AppModelsTests/SettingScaleClampTests.swift b/AppPackage/Tests/AppModelsTests/SettingScaleClampTests.swift new file mode 100644 index 000000000..a1af480d2 --- /dev/null +++ b/AppPackage/Tests/AppModelsTests/SettingScaleClampTests.swift @@ -0,0 +1,34 @@ +import Testing +import AppModels + +// V2-B / #9: `maximumScaleFactor` and `doubleTapScaleFactor` are mutually clamped in the `Setting` +// model itself, so `doubleTapScaleFactor <= maximumScaleFactor` holds on every write path. The reader +// sheet writes these through a direct `@Shared(.setting)` binding that never passes through a reducer, +// so the invariant can no longer live in a `BindingReducer`; this pins it at the model level. +@Suite +struct SettingScaleClampTests { + @Test + func loweringMaximumClampsDoubleTapDown() { + var setting = Setting() + setting.doubleTapScaleFactor = 3 + setting.maximumScaleFactor = 2 + #expect(setting.doubleTapScaleFactor == 2) + #expect(setting.maximumScaleFactor == 2) + } + + @Test + func raisingDoubleTapPushesMaximumUp() { + var setting = Setting() + setting.doubleTapScaleFactor = 5 + #expect(setting.maximumScaleFactor == 5) + #expect(setting.doubleTapScaleFactor == 5) + } + + @Test + func consistentValuesAreLeftUntouched() { + var setting = Setting() + setting.maximumScaleFactor = 4 + #expect(setting.maximumScaleFactor == 4) + #expect(setting.doubleTapScaleFactor == 2) + } +} diff --git a/AppPackage/Tests/AppModelsTests/StrictDecodingTests.swift b/AppPackage/Tests/AppModelsTests/StrictDecodingTests.swift new file mode 100644 index 000000000..17a5d9699 --- /dev/null +++ b/AppPackage/Tests/AppModelsTests/StrictDecodingTests.swift @@ -0,0 +1,178 @@ +import Foundation +import Testing +import AppModels + +// #2: persisted models decode *strictly* — the blanket-tolerant decoder is gone. A corrupt or +// shape-incompatible blob fails to decode so Sharing resets the key to its default (a clean, coherent +// value) instead of surfacing a partially-filled Franken-value. Identity-bearing array elements +// (`GalleryHistoryEntry`, `QuickSearchWord`) validate their identity and reject an unknown +// `schemaVersion`; whole-struct models (`Filter`, `Setting`, …) keep synthesized strict Codable and +// reject an unknown `schemaVersion` through a self-validating `SchemaVersion` field. +@Suite +struct StrictDecodingTests { + private func decode(_ type: T.Type, _ json: String) throws -> T { + try JSONDecoder().decode(T.self, from: Data(json.utf8)) + } + + @Test + func wellFormedValuesRoundTrip() throws { + var original = Filter(minRating: 5, pageLowerBound: "10") + original.doujinshi = true + let decoded = try JSONDecoder().decode(Filter.self, from: JSONEncoder().encode(original)) + #expect(decoded == original) + } + + @Test + func aWholeStructModelRejectsAPartialBlob() { + // Missing required keys → synthesized decode throws → Sharing falls back to the key default. + #expect(throws: (any Error).self) { + try decode(Filter.self, "{}") + } + } + + @Test + func aWholeStructModelRejectsAWrongTypedValue() { + // Previously "not-an-int" silently defaulted `minRating` to 2; now the whole decode fails. + #expect(throws: (any Error).self) { + try decode(Filter.self, #"{"minRating": "not-an-int"}"#) + } + } + + @Test + func galleryHistoryEntryRoundTrips() throws { + let entry = GalleryHistoryEntry( + gid: "123", token: "abc", lastOpenDate: Date(timeIntervalSince1970: 1), readingProgress: 5 + ) + let decoded = try JSONDecoder().decode( + GalleryHistoryEntry.self, from: JSONEncoder().encode(entry) + ) + #expect(decoded == entry) + } + + @Test + func galleryHistoryEntryRejectsABlankIdentity() { + // A blank gid/token is unresolvable; strict decode throws rather than yielding a `""`-id entry + // that would collide with every other corrupt entry in an `Identifiable` list. + let json = #"{"schemaVersion": 1, "gid": "", "token": "", "lastOpenDate": 0, "readingProgress": 0}"# + #expect(throws: (any Error).self) { + try decode(GalleryHistoryEntry.self, json) + } + } + + @Test + func galleryHistoryEntryRejectsAMissingField() { + // `lastOpenDate` absent → strict decode throws (previously it silently defaulted to distantPast). + let json = #"{"schemaVersion": 1, "gid": "1", "token": "a", "readingProgress": 0}"# + #expect(throws: (any Error).self) { + try decode(GalleryHistoryEntry.self, json) + } + } + + @Test + func galleryHistoryEntryRejectsAnUnknownSchemaVersion() { + let json = #"{"schemaVersion": 2, "gid": "1", "token": "a", "lastOpenDate": 0, "readingProgress": 0}"# + #expect(throws: (any Error).self) { + try decode(GalleryHistoryEntry.self, json) + } + } + + @Test + func aSingleBadElementFailsTheWholeHistoryArray() { + // One malformed element fails the array decode; Sharing then resets `galleryHistory` to []. + let json = """ + [ + {"schemaVersion": 1, "gid": "1", "token": "a", "lastOpenDate": 0, "readingProgress": 0}, + {"schemaVersion": 1, "gid": "", "token": "", "lastOpenDate": 0, "readingProgress": 0} + ] + """ + #expect(throws: (any Error).self) { + try decode([GalleryHistoryEntry].self, json) + } + } + + @Test + func quickSearchWordPreservesItsPersistedIdentity() throws { + let id = UUID() + let json = #"{"schemaVersion": 1, "id": "\#(id.uuidString)", "name": "n", "content": "c"}"# + let word = try decode(QuickSearchWord.self, json) + #expect(word.id == id) // decoded, never a fresh random UUID + #expect(word.name == "n") + } + + @Test + func quickSearchWordRejectsAMissingIdentity() { + // Previously a missing `id` fabricated a fresh UUID() on every decode; now it throws. + #expect(throws: (any Error).self) { + try decode(QuickSearchWord.self, #"{"schemaVersion": 1, "name": "n", "content": "c"}"#) + } + } + + @Test + func quickSearchWordRejectsAnUnknownSchemaVersion() { + // The version now validates through the shared `SchemaVersion` field, like the whole-struct + // models; a newer value throws, failing the (array) decode even when the identity is valid. + let json = """ + {"schemaVersion": 2, "id": "00000000-0000-0000-0000-000000000000", "name": "n", "content": "c"} + """ + #expect(throws: (any Error).self) { + try decode(QuickSearchWord.self, json) + } + } + + // MARK: schemaVersion gating + + @Test + func schemaVersionAcceptsTheCurrentVersion() throws { + let version = try decode(SchemaVersion.self, "1") + #expect(version.value == 1) + } + + @Test + func schemaVersionRejectsANewerVersion() { + // A blob written by a newer build (a downgrade) is rejected rather than half-read. + #expect(throws: (any Error).self) { + try decode(SchemaVersion.self, "2") + } + } + + @Test + func schemaVersionRejectsACorruptVersion() { + // 0 / negative is outside the 1...current range. + #expect(throws: (any Error).self) { + try decode(SchemaVersion.self, "0") + } + } + + @Test + func aWholeStructModelRejectsANewerSchemaVersion() throws { + // An otherwise-valid blob whose schemaVersion is newer than this build supports fails the whole + // decode, so Sharing falls back to the key default instead of dropping the new fields. + let data = try JSONEncoder().encode(Filter()) + let json = try JSONSerialization.jsonObject(with: data) + var object = try #require(json as? [String: Any]) + object["schemaVersion"] = 2 + let tampered = try JSONSerialization.data(withJSONObject: object) + #expect(throws: (any Error).self) { + try JSONDecoder().decode(Filter.self, from: tampered) + } + } + + @Test + func schemaVersionEncodesAsABareInteger() throws { + // The persisted shape must stay `"schemaVersion": 1` (a bare int, not a wrapper object) so + // blobs written before this type existed keep decoding. + let data = try JSONEncoder().encode(Filter()) + let json = try JSONSerialization.jsonObject(with: data) + let object = try #require(json as? [String: Any]) + #expect(object["schemaVersion"] as? Int == 1) + } + + @Test + func aWholeStructModelStillToleratesAnAbsentOptionalField() throws { + // Optional-field tolerance survives the switch to a `SchemaVersion` field: a blob carrying only + // the version still decodes, with the absent optionals left nil. + let user = try decode(User.self, #"{"schemaVersion": 1}"#) + #expect(user.displayName == nil) + #expect(user.favoriteCategories == nil) + } +} diff --git a/AppPackage/Tests/DetailFeatureTests/.swiftlint.yml b/AppPackage/Tests/DetailFeatureTests/.swiftlint.yml new file mode 100644 index 000000000..1242ffcaa --- /dev/null +++ b/AppPackage/Tests/DetailFeatureTests/.swiftlint.yml @@ -0,0 +1 @@ +parent_config: ../../../.swiftlint.yml diff --git a/AppPackage/Tests/DetailFeatureTests/CommentsReducerTests.swift b/AppPackage/Tests/DetailFeatureTests/CommentsReducerTests.swift new file mode 100644 index 000000000..041930e93 --- /dev/null +++ b/AppPackage/Tests/DetailFeatureTests/CommentsReducerTests.swift @@ -0,0 +1,46 @@ +import Testing +import Foundation +import AppModels +import HapticsClient +@testable import DetailFeature +import ComposableArchitecture + +@Suite +struct CommentsReducerTests { + // Regression: editing a comment then opening a new one used to leak the edited text, because the + // compose state was reset only on dismiss (which a swipe-down never triggers). The reset now + // happens on present, so a fresh compose always starts empty regardless of how the sheet closed. + @MainActor + @Test + func presentingPostCommentResetsStaleComposeState() async { + let store = TestStore( + initialState: CommentsReducer.State(galleryURL: .mock), + reducer: CommentsReducer.init + ) { + $0.hapticsClient = .noop + } + + // Editing carries the prefill through the present action. + await store.send(.presentPostComment(commentID: "42", content: "existing text")) { + $0.commentContent = "existing text" + $0.destination = .postComment("42") + } + + // Dismissing (Cancel or swipe-down) intentionally leaves the compose state untouched. + await store.send(.destination(.dismiss)) { + $0.destination = nil + } + + // Mimic the focus the editor's onAppear would have set. + await store.send(.setPostCommentFocused(true)) { + $0.postCommentFocused = true + } + + // Opening a new comment clears the stale text and focus on present. + await store.send(.presentPostComment(commentID: "")) { + $0.commentContent = "" + $0.postCommentFocused = false + $0.destination = .postComment("") + } + } +} diff --git a/AppPackage/Tests/DetailFeatureTests/DetailReadingSeedTests.swift b/AppPackage/Tests/DetailFeatureTests/DetailReadingSeedTests.swift new file mode 100644 index 000000000..04bf3365d --- /dev/null +++ b/AppPackage/Tests/DetailFeatureTests/DetailReadingSeedTests.swift @@ -0,0 +1,62 @@ +import Foundation +import Testing +import AppModels +import ComposableArchitecture +@testable import DetailFeature +@testable import ReadingFeature + +// REV-1/REV-4/REV-10: pushing the reader from Detail must seed the reader's `gallery` (so remote +// reading isn't blank and history upserts carry the real gid/token), plus the threaded `previewConfig` +// (page math) and `language` (Live Text). A regression here silently bricks the primary read flow. +@Suite +@MainActor +struct DetailReadingSeedTests { + private func makeGallery() -> Gallery { + Gallery( + gid: "42", token: "abc123", title: "Seed", rating: 4.5, tags: [], + category: .doujinshi, pageCount: 30, postedDate: .init(timeIntervalSince1970: 0), + coverURL: nil, galleryURL: URL(string: "https://example.com/g/42/abc123/") + ) + } + + private func makeSeededState() -> DetailReducer.State { + var state = DetailReducer.State(gallery: makeGallery()) + state.galleryDetail = .preview + state.previewConfig = .large(rows: 3) + return state + } + + @Test + func openReadingRemoteSeedsGalleryPreviewConfigAndLanguage() async { + let store = TestStore(initialState: makeSeededState(), reducer: DetailReducer.init) + store.exhaustivity = .off + + await store.send(.openReadingDone(.failure(.notFound))) + + guard case let .reading(readingState)? = store.state.destination else { + Issue.record("expected a reading destination") + return + } + #expect(readingState.gallery.gid == "42") + #expect(readingState.gallery.token == "abc123") + #expect(readingState.gallery.galleryURL != nil) + #expect(readingState.previewConfig == .large(rows: 3)) + #expect(readingState.language == GalleryDetail.preview.language) + } + + @Test + func presentReadingSeedsGalleryPreviewConfigAndLanguage() async { + let store = TestStore(initialState: makeSeededState(), reducer: DetailReducer.init) + store.exhaustivity = .off + + await store.send(.presentReading) + + guard case let .reading(readingState)? = store.state.destination else { + Issue.record("expected a reading destination") + return + } + #expect(readingState.gallery.gid == "42") + #expect(readingState.previewConfig == .large(rows: 3)) + #expect(readingState.language == GalleryDetail.preview.language) + } +} diff --git a/AppPackage/Tests/DetailFeatureTests/GalleryNavigationTests.swift b/AppPackage/Tests/DetailFeatureTests/GalleryNavigationTests.swift new file mode 100644 index 000000000..4cd847cd6 --- /dev/null +++ b/AppPackage/Tests/DetailFeatureTests/GalleryNavigationTests.swift @@ -0,0 +1,36 @@ +import Testing +import AppModels +@testable import DetailFeature +import ComposableArchitecture + +@Suite +struct GalleryNavigationTests { + // A minimal gallery whose `id` ("1") drives the detail route key that dedup compares. + private let galleryOne = Gallery( + gid: "1", token: "", title: "", rating: 0, tags: [], + category: .doujinshi, uploader: "", pageCount: 1, + postedDate: .distantPast, coverURL: nil, galleryURL: nil + ) + + // appendGuardingDuplicate skips only an adjacent identical element, so a rapid double-activation + // pushes one screen while a legitimate same-gid re-push through a deeper screen still appends. + @Test + func appendGuardingDuplicateSkipsOnlyAdjacentDuplicates() { + var path = StackState() + + path.appendGuardingDuplicate(.detail(.init(gallery: galleryOne))) + #expect(path.count == 1) + + // A second identical push (double-tap) is skipped. + path.appendGuardingDuplicate(.detail(.init(gallery: galleryOne))) + #expect(path.count == 1) + + // A different screen is appended. + path.appendGuardingDuplicate(.comments(.init(galleryURL: .mock))) + #expect(path.count == 2) + + // The same detail after a non-adjacent screen is appended (only the top is compared). + path.appendGuardingDuplicate(.detail(.init(gallery: galleryOne))) + #expect(path.count == 3) + } +} diff --git a/AppPackage/Tests/DownloadsFeatureTests/.swiftlint.yml b/AppPackage/Tests/DownloadsFeatureTests/.swiftlint.yml new file mode 100644 index 000000000..1242ffcaa --- /dev/null +++ b/AppPackage/Tests/DownloadsFeatureTests/.swiftlint.yml @@ -0,0 +1 @@ +parent_config: ../../../.swiftlint.yml diff --git a/AppPackage/Tests/DownloadsFeatureTests/AppReadingFlushTests.swift b/AppPackage/Tests/DownloadsFeatureTests/AppReadingFlushTests.swift new file mode 100644 index 000000000..a03ddd321 --- /dev/null +++ b/AppPackage/Tests/DownloadsFeatureTests/AppReadingFlushTests.swift @@ -0,0 +1,104 @@ +import Foundation +import ComposableArchitecture +import Testing +import Sharing +import AppModels +import DownloadClient +import CookieClient +import AppLaunchAutomationClient +import ReadingFeature +import DownloadsFeature +@testable import AppFeature + +// Scene-phase observation lives in AppReducer, not the reader view. On `.background`, AppReducer +// routes `.flushReadingProgress` to whichever reading session is on top of a navigation host (located +// from navigation state), so a force-quit from the background still persists the reader's last +// debounced page. When no reader is presented the flush is a no-op. +@Suite(.serialized) +struct AppReadingFlushTests: DownloadFeatureTestCase { + private let now = Date(timeIntervalSince1970: 1_000) + + private func persistedProgress(_ defaults: UserDefaults, gid: String) -> Int { + withDependencies { + $0.defaultAppStorage = defaults + } operation: { + @Shared(.galleryHistory) var history + return history.readingProgress(gid: gid) + } + } + + @MainActor + @Test + func backgroundFlushesTheActiveReaderProgressIntoHistory() async throws { + let suiteName = "app-reading-flush-\(UUID().uuidString)" + let defaults = try #require(UserDefaults(suiteName: suiteName)) + defer { defaults.removePersistentDomain(forName: suiteName) } + let gallery = sampleGallery() + + var reader = ReadingReducer.State(gallery: gallery, contentSource: .remote) + reader.pendingReadingProgress = 7 + + var initialState = AppReducer.State() + initialState.settingState.hasLoadedInitialSetting = true + initialState.downloadsState.destination = .reading(reader) + + let store = TestStore( + initialState: initialState, + reducer: AppReducer.init, + withDependencies: { + $0.defaultAppStorage = defaults + $0.date = .constant(now) + $0.appLaunchAutomationClient = .none + $0.cookieClient = .noop + $0.downloadClient = .noop + } + ) + store.exhaustivity = .off + + await store.send(.onScenePhaseChange(.background)) + await store.finish() + + #expect(persistedProgress(defaults, gid: gallery.id) == 7) + } + + @MainActor + @Test + func backgroundWithoutAReaderLeavesHistoryUntouched() async throws { + let suiteName = "app-reading-flush-\(UUID().uuidString)" + let defaults = try #require(UserDefaults(suiteName: suiteName)) + defer { defaults.removePersistentDomain(forName: suiteName) } + let gallery = sampleGallery() + + // Pre-seed a saved progress of 3; with no reader presented the flush must not touch it. + withDependencies { + $0.defaultAppStorage = defaults + } operation: { + @Shared(.galleryHistory) var history + $history.withLock { + $0.recordGalleryOpen(gid: gallery.id, token: gallery.token, date: now) + $0.updateReadingProgress(gid: gallery.id, token: gallery.token, progress: 3, date: now) + } + } + + var initialState = AppReducer.State() + initialState.settingState.hasLoadedInitialSetting = true + + let store = TestStore( + initialState: initialState, + reducer: AppReducer.init, + withDependencies: { + $0.defaultAppStorage = defaults + $0.date = .constant(now) + $0.appLaunchAutomationClient = .none + $0.cookieClient = .noop + $0.downloadClient = .noop + } + ) + store.exhaustivity = .off + + await store.send(.onScenePhaseChange(.background)) + await store.finish() + + #expect(persistedProgress(defaults, gid: gallery.id) == 3) + } +} diff --git a/AppPackage/Tests/DownloadsFeatureTests/DataCacheTests.swift b/AppPackage/Tests/DownloadsFeatureTests/DataCacheTests.swift new file mode 100644 index 000000000..d2239371e --- /dev/null +++ b/AppPackage/Tests/DownloadsFeatureTests/DataCacheTests.swift @@ -0,0 +1,128 @@ +import Foundation +import Testing +import AppTools +@testable import AppFeature + +@Suite(.serialized) +struct DataCacheTests { + @Test + func testStoreAndReadDataFromDiskWithHashedFilename() async throws { + let rootURL = makeRootURL() + defer { try? FileManager.default.removeItem(at: rootURL) } + let cache = DataCache( + configuration: .init(rootURL: rootURL, memoryCostLimit: 1, maxDiskAge: 60) + ) + let key = "https://example.com/reader/1.webp" + let data = Data([0x01, 0x02, 0x03]) + + try await cache.store(data, forKey: key) + await cache.removeAllMemory() + + #expect(await cache.data(forKey: key) == data) + let files = try FileManager.default.contentsOfDirectory(atPath: rootURL.path) + #expect(files.count == 1) + #expect(files.first != key) + let resourceValues = try rootURL.resourceValues(forKeys: [.isExcludedFromBackupKey]) + #expect(resourceValues.isExcludedFromBackup == true) + } + + @Test + func testStoreReadAndRemoveDataForOrderedKeys() async throws { + let rootURL = makeRootURL() + defer { try? FileManager.default.removeItem(at: rootURL) } + let cache = DataCache(configuration: .init(rootURL: rootURL)) + let data = Data([0x0A, 0x0B]) + + try await cache.store(data, forKeys: ["stable", "absolute", "stable"]) + + #expect(await cache.data(forKeys: ["missing", "absolute"]) == data) + let files = try FileManager.default.contentsOfDirectory(atPath: rootURL.path) + #expect(files.count == 2) + + try await cache.removeData(forKeys: ["stable", "absolute", "stable"]) + + #expect(await cache.data(forKeys: ["stable", "absolute"]) == nil) + #expect(try await cache.totalSize() == 0) + } + + @Test + func testExpiredDataIsRemovedOnRead() async throws { + let rootURL = makeRootURL() + defer { try? FileManager.default.removeItem(at: rootURL) } + let cache = DataCache( + configuration: .init(rootURL: rootURL, maxDiskAge: 0.01) + ) + + try await cache.store(Data([0x01]), forKey: "expired") + try await Task.sleep(for: .milliseconds(20)) + await cache.removeAllMemory() + + #expect(await cache.data(forKey: "expired") == nil) + #expect(try await cache.totalSize() == 0) + } + + @Test + func testDiskSweepEvictsOldestEntriesToHalfLimit() async throws { + let rootURL = makeRootURL() + defer { try? FileManager.default.removeItem(at: rootURL) } + let cache = DataCache( + configuration: .init( + rootURL: rootURL, + maxDiskAge: 60, + diskSizeLimit: 10 + ) + ) + + try await cache.store(Data(repeating: 0x01, count: 4), forKey: "old") + try await Task.sleep(for: .milliseconds(10)) + try await cache.store(Data(repeating: 0x02, count: 4), forKey: "middle") + try await Task.sleep(for: .milliseconds(10)) + try await cache.store(Data(repeating: 0x03, count: 4), forKey: "new") + + #expect(await cache.data(forKey: "old") == nil) + #expect(await cache.data(forKey: "middle") == nil) + #expect(await cache.data(forKey: "new") == Data(repeating: 0x03, count: 4)) + #expect(try await cache.totalSize() <= 5) + } + + @Test + func testRemoveAllClearsMemoryAndDisk() async throws { + let rootURL = makeRootURL() + defer { try? FileManager.default.removeItem(at: rootURL) } + let cache = DataCache( + configuration: .init(rootURL: rootURL) + ) + + try await cache.store(Data([0x01]), forKey: "page") + try await cache.removeAll() + + #expect(await cache.data(forKey: "page") == nil) + #expect(try await cache.totalSize() == 0) + } + + @Test + func testUnreadableDiskEntryIsTreatedAsMissAndRemoved() async throws { + let rootURL = makeRootURL() + defer { try? FileManager.default.removeItem(at: rootURL) } + let cache = DataCache(configuration: .init(rootURL: rootURL)) + + try await cache.store(Data([0x01, 0x02]), forKey: "page") + await cache.removeAllMemory() + + // Make the on-disk entry unreadable by replacing the cached file with a + // directory, which `Data(contentsOf:)` cannot read — the same failure mode + // as a corrupt or mid-read-purged file. + let files = try FileManager.default.contentsOfDirectory(atPath: rootURL.path) + let entryURL = rootURL.appendingPathComponent(try #require(files.first)) + try FileManager.default.removeItem(at: entryURL) + try FileManager.default.createDirectory(at: entryURL, withIntermediateDirectories: false) + + #expect(await cache.data(forKey: "page") == nil) + #expect(try await cache.totalSize() == 0) + } + + private func makeRootURL() -> URL { + FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + } +} diff --git a/AppPackage/Tests/DownloadsFeatureTests/DetailReducerDownloadTests.swift b/AppPackage/Tests/DownloadsFeatureTests/DetailReducerDownloadTests.swift new file mode 100644 index 000000000..925315c91 --- /dev/null +++ b/AppPackage/Tests/DownloadsFeatureTests/DetailReducerDownloadTests.swift @@ -0,0 +1,215 @@ +import AppTools +import Foundation +import AppModels +import ComposableArchitecture +import Testing +import HapticsClient +import DownloadClient +import CookieClient +import AppLaunchAutomationClient +@testable import DetailFeature +@testable import AppFeature + +@Suite(.serialized) +@MainActor +struct DetailReducerDownloadTests: DownloadFeatureTestCase { + @MainActor + @Test + func testDetailReducerStartDownloadEnqueuesGalleryPayload() async throws { + let capturedPayload = UncheckedBox(nil) + let gallery = sampleGallery() + let detail = sampleGalleryDetail(gid: gallery.gid, title: gallery.title) + let queuedDownload = sampleDownload(gid: gallery.gid, title: gallery.title, status: .queued) + let previewURL = try #require(URL(string: "https://example.com/1.jpg")) + let store = makeDownloadTestStore( + gallery: gallery, detail: detail, + downloadValue: queuedDownload, + configure: { state in + state.galleryPreviewURLs = [1: previewURL] + state.previewConfig = .large(rows: 2) + }, + enqueue: { payload in + capturedPayload.value = payload + } + ) + store.exhaustivity = .off + + await store.send(.startDownload("Folder")) + await store.skipReceivedActions(strict: false) + + #expect(capturedPayload.value?.gallery.gid == gallery.gid) + #expect(capturedPayload.value?.galleryDetail == detail) + #expect(capturedPayload.value?.previewConfig == .large(rows: 2)) + #expect(capturedPayload.value?.mode == .initial) + #expect(store.state.downloadBadge == queuedDownload.badge) + } + + @MainActor + @Test + func testDetailReducerStartDownloadUnlocksActionsAfterQueueing() async throws { + let gallery = sampleGallery() + let detail = sampleGalleryDetail(gid: gallery.gid, title: gallery.title) + let queuedDownload = sampleDownload(gid: gallery.gid, title: gallery.title, status: .queued) + let previewURL = try #require(URL(string: "https://example.com/1.jpg")) + let store = makeDownloadTestStore( + gallery: gallery, detail: detail, + downloadValue: queuedDownload, + configure: { state in state.galleryPreviewURLs = [1: previewURL] }, + enqueue: { _ in } + ) + store.exhaustivity = .off + + await store.send(.startDownload("Folder")) { + $0.isPreparingDownload = true + $0.didRunLaunchAutomation = true + } + await store.receive(\.startDownloadDone) { + $0.isPreparingDownload = false + $0.downloadBadge = DownloadBadge( + status: .queued, + progress: .init(completedPageCount: 0, pageCount: detail.pageCount) + ) + $0.hasLoadedDownloadBadge = true + } + await store.receive(\.fetchDownloadBadge) + await store.receive(\.fetchDownloadBadgeDone, queuedDownload) { + $0.downloadBadge = queuedDownload.badge + $0.hasLoadedDownloadBadge = true + } + } + + @MainActor + @Test + func testDetailReducerFetchDownloadFoldersPopulatesStateAndRefetchesAfterChanges() async { + let gallery = sampleGallery() + let detail = sampleGalleryDetail(gid: gallery.gid, title: gallery.title) + let store = makeDownloadTestStore( + gallery: gallery, detail: detail, + downloadValue: nil, + folders: { ["Library"] }, + configure: { $0.destination = .folderManager(.init()) }, + enqueue: { _ in } + ) + store.exhaustivity = .off + + await store.send(.fetchDownloadFolders) + await store.receive(\.fetchDownloadFoldersDone, ["Library"]) { + $0.downloadFolders = ["Library"] + } + + await store.send(.destination(.presented(.folderManager(.createFolderDone(.success(())))))) + await store.receive(\.fetchDownloadFolders) + await store.skipReceivedActions(strict: false) + } + + @MainActor + @Test + func testDetailReducerCreateDefaultFolderCreatesNamedFolderAndRefetches() async { + let capturedFolderName = UncheckedBox(nil) + let gallery = sampleGallery() + let detail = sampleGalleryDetail(gid: gallery.gid, title: gallery.title) + let store = makeDownloadTestStore( + gallery: gallery, detail: detail, + downloadValue: nil, + folders: { [Defaults.FilePath.defaultDownloadFolder] }, + createFolder: { name in capturedFolderName.value = name }, + enqueue: { _ in } + ) + store.exhaustivity = .off + + await store.send(.createDefaultFolder) + await store.receive(\.createDefaultFolderDone) + await store.receive(\.fetchDownloadFolders) + await store.receive(\.fetchDownloadFoldersDone, [Defaults.FilePath.defaultDownloadFolder]) { + $0.downloadFolders = [Defaults.FilePath.defaultDownloadFolder] + } + + #expect(capturedFolderName.value == Defaults.FilePath.defaultDownloadFolder) + } + + @MainActor + @Test + func testDetailReducerLaunchAutomationWaitsForResolvedDownloadBadge() async throws { + let capturedPayload = UncheckedBox(nil) + let gallery = sampleGallery() + let detail = sampleGalleryDetail(gid: gallery.gid, title: gallery.title) + let previewURL = try #require(URL(string: "https://example.com/1.jpg")) + + let store = makeDownloadTestStore( + gallery: gallery, detail: detail, + downloadValue: nil, + automationGID: gallery.gid, + configure: { state in + state.gid = "" + state.galleryPreviewURLs = [1: previewURL] + }, + enqueue: { payload in + capturedPayload.value = payload + } + ) + store.exhaustivity = .off + + await store.send(.runLaunchAutomationIfNeeded) + #expect(capturedPayload.value == nil) + #expect(store.state.didRunLaunchAutomation == false) + + await store.send(.fetchDownloadBadgeDone(.none)) { + $0.hasLoadedDownloadBadge = true + } + await store.send(.runLaunchAutomationIfNeeded) { + $0.didRunLaunchAutomation = true + } + await store.receive(\.startDownload) + await store.skipReceivedActions(strict: false) + + #expect(capturedPayload.value?.gallery.gid == gallery.gid) + #expect(capturedPayload.value?.folderName == Defaults.FilePath.automationDownloadFolder) + } +} + +// MARK: - Store Factory Helpers + +private extension DetailReducerDownloadTests { + func makeDownloadTestStore( + gallery: Gallery, detail: GalleryDetail, + downloadValue: DownloadedGallery?, + automationGID: String? = nil, + folders: @escaping @Sendable () -> [String] = { [] }, + createFolder: @escaping @Sendable (String) async throws -> Void = { _ in }, + configure: (inout DetailReducer.State) -> Void = { _ in }, + enqueue: @escaping @Sendable (DownloadRequestPayload) async throws -> Void + ) -> TestStoreOf { + var initialState = DetailReducer.State(gallery: gallery) + initialState.galleryDetail = detail + configure(&initialState) + return TestStore( + initialState: initialState, + reducer: DetailReducer.init, + withDependencies: { + $0.downloadClient = DownloadClient() + $0.downloadClient.observeDownloads = { + AsyncStream { continuation in continuation.finish() } + } + $0.downloadClient.fetchDownloads = { [] } + $0.downloadClient.fetchDownload = { _ in downloadValue } + $0.downloadClient.refreshDownloads = {} + $0.downloadClient.enqueue = enqueue + $0.downloadClient.togglePause = { _ in } + $0.downloadClient.retry = { _, _ in } + $0.downloadClient.delete = { _ in } + $0.downloadClient.loadManifest = { _ in throw AppError.notFound } + $0.downloadClient.loadLocalPageURLs = { _ in [:] } + $0.downloadClient.fetchVersionMetadata = { _, _ in nil } + $0.downloadClient.fetchFolders = { folders() } + $0.downloadClient.createFolder = createFolder + $0.hapticsClient = .noop + $0.cookieClient = .noop + if let automationGID { + $0.appLaunchAutomationClient = appLaunchAutomationClient( + autoDownloadGID: automationGID + ) + } + } + ) + } +} diff --git a/AppPackage/Tests/DownloadsFeatureTests/DetailReducerMetadataTests.swift b/AppPackage/Tests/DownloadsFeatureTests/DetailReducerMetadataTests.swift new file mode 100644 index 000000000..1a7e43258 --- /dev/null +++ b/AppPackage/Tests/DownloadsFeatureTests/DetailReducerMetadataTests.swift @@ -0,0 +1,196 @@ +import Foundation +import AppModels +import ComposableArchitecture +import Testing +import HapticsClient +import NetworkingFeature +import DownloadClient +import CookieClient +@testable import DetailFeature +@testable import AppFeature + +@Suite(.serialized) +@MainActor +struct DetailReducerMetadataTests: DownloadFeatureTestCase { + @MainActor + @Test + func testDetailReducerDoesNotRequestVersionMetadataForUndownloadedGallery() async throws { + let updateCheckCount = UncheckedBox(0) + let gallery = sampleGallery() + let detail = sampleGalleryDetail(gid: gallery.gid, title: gallery.title) + let galleryState = GalleryState(gid: gallery.gid) + + let store = makeMetadataTestStore( + gid: gallery.gid, gallery: gallery, + downloadValue: nil, updateCheckCount: updateCheckCount + ) + store.exhaustivity = .off + + await store.send( + .fetchGalleryDetailDone( + .success(GalleryDetailResponse( + galleryDetail: detail, galleryState: galleryState, apiKey: "", greeting: nil + )) + ) + ) + await store.skipReceivedActions(strict: false) + + #expect(updateCheckCount.value == 0) + #expect(store.state.galleryVersionMetadata == nil) + #expect(store.state.shouldCheckForRemoteUpdates == false) + _ = galleryState + } + + @MainActor + @Test + func testDetailReducerRequestsVersionMetadataWhenBadgeArrivesAfterDetail() async throws { + let updateCheckCount = UncheckedBox(0) + let gallery = sampleGallery() + let detail = sampleGalleryDetail(gid: gallery.gid, title: gallery.title) + let galleryState = try sampleGalleryState(gid: gallery.gid) + + let completedDownload = sampleDownload( + gid: gallery.gid, title: gallery.title, status: .completed + ) + let store = makeDownloadedMetadataTestStore( + gid: gallery.gid, gallery: gallery, + downloadValue: nil, updateCheckCount: updateCheckCount + ) + store.exhaustivity = .off + + await store.send(.fetchGalleryDetailDone(.success(GalleryDetailResponse( + galleryDetail: detail, galleryState: galleryState, apiKey: "", greeting: nil + )))) + await store.skipReceivedActions(strict: false) + #expect(updateCheckCount.value == 0) + + await store.send(.fetchDownloadBadgeDone(completedDownload)) + await drainDetailMetadataEffects( + store, + condition: { + updateCheckCount.value == 1 && store.state.galleryVersionMetadata != nil + } + ) + + #expect(updateCheckCount.value == 1) + #expect(store.state.shouldCheckForRemoteUpdates) + #expect(store.state.didRequestVersionMetadata) + #expect(store.state.galleryVersionMetadata != nil) + } + + @MainActor + @Test + func testDetailReducerRequestsVersionMetadataWhenBadgeArrivesBeforeDetail() async throws { + let updateCheckCount = UncheckedBox(0) + let gallery = sampleGallery() + let detail = sampleGalleryDetail(gid: gallery.gid, title: gallery.title) + let galleryState = try sampleGalleryState(gid: gallery.gid) + + let completedDownload = sampleDownload( + gid: gallery.gid, title: gallery.title, status: .completed + ) + let store = makeDownloadedMetadataTestStore( + gid: gallery.gid, gallery: gallery, + downloadValue: completedDownload, updateCheckCount: updateCheckCount + ) + store.exhaustivity = .off + + await store.send(.fetchDownloadBadgeDone(completedDownload)) + await store.skipReceivedActions(strict: false) + #expect(updateCheckCount.value == 0) + + await store.send(.fetchGalleryDetailDone(.success(GalleryDetailResponse( + galleryDetail: detail, galleryState: galleryState, apiKey: "", greeting: nil + )))) + await drainDetailMetadataEffects( + store, + condition: { + updateCheckCount.value == 1 && store.state.galleryVersionMetadata != nil + } + ) + + #expect(updateCheckCount.value == 1) + #expect(store.state.shouldCheckForRemoteUpdates) + #expect(store.state.didRequestVersionMetadata) + #expect(store.state.galleryVersionMetadata != nil) + } +} + +// MARK: - Store Factory Helpers + +private extension DetailReducerMetadataTests { + func makeMetadataTestStore( + gid: String, gallery: Gallery, + downloadValue: DownloadedGallery?, updateCheckCount: UncheckedBox + ) -> TestStoreOf { + var initialState = DetailReducer.State(gallery: gallery) + initialState.gid = gid + return TestStore( + initialState: initialState, + reducer: DetailReducer.init, + withDependencies: { + $0.downloadClient = DownloadClient() + $0.downloadClient.observeDownloads = { + AsyncStream { continuation in continuation.finish() } + } + $0.downloadClient.fetchDownloads = { [] } + $0.downloadClient.fetchDownload = { _ in downloadValue } + $0.downloadClient.refreshDownloads = {} + $0.downloadClient.fetchVersionMetadata = { _, _ in + sampleVersionMetadata(gid: gallery.gid, token: gallery.token) + } + $0.downloadClient.updateRemoteVersion = { _, _ in + updateCheckCount.value += 1 + return .none + } + $0.downloadClient.enqueue = { _ in } + $0.downloadClient.togglePause = { _ in } + $0.downloadClient.retry = { _, _ in } + $0.downloadClient.delete = { _ in } + $0.downloadClient.loadManifest = { _ in throw AppError.notFound } + $0.downloadClient.loadLocalPageURLs = { _ in [:] } + $0.hapticsClient = .noop + $0.cookieClient = .noop + } + ) + } + + func makeDownloadedMetadataTestStore( + gid: String, gallery: Gallery, + downloadValue: DownloadedGallery?, updateCheckCount: UncheckedBox + ) -> TestStoreOf { + let updatedDownload = sampleDownload( + gid: gallery.gid, title: gallery.title, status: .completed + ) + var initialState = DetailReducer.State(gallery: gallery) + initialState.gid = gid + return TestStore( + initialState: initialState, + reducer: DetailReducer.init, + withDependencies: { + $0.downloadClient = DownloadClient() + $0.downloadClient.observeDownloads = { + AsyncStream { continuation in continuation.finish() } + } + $0.downloadClient.fetchDownloads = { [] } + $0.downloadClient.fetchDownload = { _ in downloadValue } + $0.downloadClient.refreshDownloads = {} + $0.downloadClient.fetchVersionMetadata = { _, _ in + sampleVersionMetadata(gid: gallery.gid, token: gallery.token) + } + $0.downloadClient.updateRemoteVersion = { _, _ in + updateCheckCount.value += 1 + return updatedDownload + } + $0.downloadClient.enqueue = { _ in } + $0.downloadClient.togglePause = { _ in } + $0.downloadClient.retry = { _, _ in } + $0.downloadClient.delete = { _ in } + $0.downloadClient.loadManifest = { _ in throw AppError.notFound } + $0.downloadClient.loadLocalPageURLs = { _ in [:] } + $0.hapticsClient = .noop + $0.cookieClient = .noop + } + ) + } +} diff --git a/AppPackage/Tests/DownloadsFeatureTests/DetailReducerMetadataUpdateTests.swift b/AppPackage/Tests/DownloadsFeatureTests/DetailReducerMetadataUpdateTests.swift new file mode 100644 index 000000000..8373db13d --- /dev/null +++ b/AppPackage/Tests/DownloadsFeatureTests/DetailReducerMetadataUpdateTests.swift @@ -0,0 +1,177 @@ +import Foundation +import AppModels +import ComposableArchitecture +import Testing +import HapticsClient +import DownloadClient +import CookieClient +@testable import DetailFeature +@testable import AppFeature + +@Suite(.serialized) +@MainActor +struct DetailReducerMetadataUpdateTests: DownloadFeatureTestCase { + @MainActor + @Test + func testDetailReducerObserveDownloadDoneAlsoTriggersMetadataCheckWithoutDuplicateRequests() async throws { + let updateCheckCount = UncheckedBox(0) + let gallery = sampleGallery() + let detail = sampleGalleryDetail(gid: gallery.gid, title: gallery.title) + let completedDownload = sampleDownload( + gid: gallery.gid, title: gallery.title, status: .completed + ) + + let store = makeUpdateTestStore( + gid: gallery.gid, gallery: gallery, detail: detail, + updatedDownload: completedDownload, + updateCheckCount: updateCheckCount + ) + store.exhaustivity = .off + + await store.send(.observeDownloadDone(completedDownload)) + await drainDetailMetadataEffects(store, condition: { updateCheckCount.value == 1 }) + #expect(updateCheckCount.value == 1) + + await store.send(.observeDownloadDone(completedDownload)) + await store.skipReceivedActions(strict: false) + #expect(updateCheckCount.value == 1) + } + + @MainActor + @Test + func testDetailReducerRemoteUpdateFlagDoesNotStayStickyWhenBadgeReturnsToNone() async throws { + let updateCheckCount = UncheckedBox(0) + let gallery = sampleGallery() + let detail = sampleGalleryDetail(gid: gallery.gid, title: gallery.title) + let completedDownload = sampleDownload( + gid: gallery.gid, title: gallery.title, status: .completed + ) + + let store = makeUpdateTestStore( + gid: gallery.gid, gallery: gallery, detail: detail, + updatedDownload: completedDownload, + updateCheckCount: updateCheckCount + ) + store.exhaustivity = .off + + await store.send(.fetchDownloadBadgeDone(completedDownload)) + await drainDetailMetadataEffects( + store, + condition: { + updateCheckCount.value == 1 && store.state.galleryVersionMetadata != nil + } + ) + #expect(updateCheckCount.value == 1) + #expect(store.state.shouldCheckForRemoteUpdates) + #expect(store.state.didRequestVersionMetadata) + + await store.send(.fetchDownloadBadgeDone(.none)) { + $0.downloadBadge = .none + $0.hasLoadedDownloadBadge = true + $0.shouldCheckForRemoteUpdates = false + $0.didRequestVersionMetadata = false + $0.galleryVersionMetadata = nil + } + await store.skipReceivedActions(strict: false) + + #expect(store.state.shouldCheckForRemoteUpdates == false) + #expect(store.state.didRequestVersionMetadata == false) + #expect(store.state.galleryVersionMetadata == nil) + } + + @MainActor + @Test + func testDetailReducerDeleteDownloadResetsMetadataState() async { + let download = sampleDownload(gid: "7733", title: "Reset Context", status: .completed) + var initialState = DetailReducer.State(gallery: download.gallery) + initialState.galleryVersionMetadata = sampleVersionMetadata( + gid: download.gid, token: download.token + ) + initialState.didRequestVersionMetadata = true + initialState.shouldCheckForRemoteUpdates = true + + let store = TestStore( + initialState: initialState, + reducer: DetailReducer.init, + withDependencies: { + $0.downloadClient = makeDeleteTestClient(download: download) + $0.hapticsClient = .noop + $0.cookieClient = .noop + } + ) + store.exhaustivity = .off + + await store.send(.deleteDownloadDone(.success(()))) { + $0.galleryVersionMetadata = nil + $0.didRequestVersionMetadata = false + $0.shouldCheckForRemoteUpdates = false + } + await store.skipReceivedActions(strict: false) + + #expect(store.state.shouldCheckForRemoteUpdates == false) + #expect(store.state.didRequestVersionMetadata == false) + #expect(store.state.galleryVersionMetadata == nil) + } + +} + +// MARK: - Store Factory Helpers + +private extension DetailReducerMetadataUpdateTests { + func makeUpdateTestStore( + gid: String, gallery: Gallery, detail: GalleryDetail, + updatedDownload: DownloadedGallery, + updateCheckCount: UncheckedBox + ) -> TestStoreOf { + var initialState = DetailReducer.State(gallery: gallery) + initialState.gid = gid + initialState.galleryDetail = detail + return TestStore( + initialState: initialState, + reducer: DetailReducer.init, + withDependencies: { + $0.downloadClient = DownloadClient() + $0.downloadClient.observeDownloads = { + AsyncStream { continuation in continuation.finish() } + } + $0.downloadClient.fetchDownloads = { [] } + $0.downloadClient.fetchDownload = { _ in updatedDownload } + $0.downloadClient.refreshDownloads = {} + $0.downloadClient.fetchVersionMetadata = { _, _ in + sampleVersionMetadata(gid: gallery.gid, token: gallery.token) + } + $0.downloadClient.updateRemoteVersion = { _, _ in + updateCheckCount.value += 1 + return updatedDownload + } + $0.downloadClient.enqueue = { _ in } + $0.downloadClient.togglePause = { _ in } + $0.downloadClient.retry = { _, _ in } + $0.downloadClient.delete = { _ in } + $0.downloadClient.loadManifest = { _ in throw AppError.notFound } + $0.downloadClient.loadLocalPageURLs = { _ in [:] } + $0.hapticsClient = .noop + $0.cookieClient = .noop + } + ) + } + + func makeDeleteTestClient(download: DownloadedGallery) -> DownloadClient { + var client = DownloadClient() + client.observeDownloads = { + AsyncStream { continuation in continuation.finish() } + } + client.fetchDownloads = { [download] } + // After the delete the download no longer exists, so the follow-up badge re-fetch finds + // nothing and `shouldCheckForRemoteUpdates` stays reset. + client.fetchDownload = { _ in nil } + client.refreshDownloads = {} + client.enqueue = { _ in } + client.togglePause = { _ in } + client.retry = { _, _ in } + client.delete = { _ in } + client.loadManifest = { _ in throw AppError.notFound } + client.loadLocalPageURLs = { _ in [:] } + return client + } +} diff --git a/AppPackage/Tests/DownloadsFeatureTests/DetailReducerObserveTests.swift b/AppPackage/Tests/DownloadsFeatureTests/DetailReducerObserveTests.swift new file mode 100644 index 000000000..c5a3caf06 --- /dev/null +++ b/AppPackage/Tests/DownloadsFeatureTests/DetailReducerObserveTests.swift @@ -0,0 +1,196 @@ +import Foundation +import AppModels +import ComposableArchitecture +import Testing +import HapticsClient +import DownloadClient +import CookieClient +@testable import DetailFeature +@testable import AppFeature + +@Suite(.serialized) +@MainActor +struct DetailReducerObserveTests: DownloadFeatureTestCase { + @MainActor + @Test + func testDetailReducerObservesDownloadBadgeTransitions() async { + let gallery = sampleGallery() + let detail = sampleGalleryDetail(gid: gallery.gid, title: gallery.title) + let continuationBox = UncheckedBox.Continuation?>(nil) + let stream = AsyncStream<[DownloadedGallery]> { continuation in + continuationBox.value = continuation + } + let store = makeObserveTestStore(gallery: gallery, detail: detail, stream: stream) + store.exhaustivity = .off + + await store.send(.onAppear(gallery.gid)) { + $0.gid = gallery.gid + $0.hasLoadedDownloadBadge = false + $0.didRunLaunchAutomation = false + } + await store.skipReceivedActions(strict: false) + + continuationBox.value?.yield([ + sampleDownload(gid: gallery.gid, title: gallery.title, status: .queued) + ]) + await store.receive(\.observeDownloadDone) { + $0.downloadBadge = DownloadBadge( + status: .queued, + progress: .init(completedPageCount: 0, pageCount: 12) + ) + $0.hasLoadedDownloadBadge = true + } + + continuationBox.value?.yield([ + sampleDownload( + gid: gallery.gid, title: gallery.title, status: .downloading, + pageCount: 26, completedPageCount: 7 + ) + ]) + await store.receive(\.observeDownloadDone) { + $0.downloadBadge = DownloadBadge( + status: .active, + progress: .init(completedPageCount: 7, pageCount: 26) + ) + $0.hasLoadedDownloadBadge = true + } + + continuationBox.value?.yield([ + sampleDownload( + gid: gallery.gid, title: gallery.title, status: .completed, + pageCount: 26, completedPageCount: 26 + ) + ]) + await store.receive(\.observeDownloadDone) { + $0.downloadBadge = DownloadBadge( + status: .completed, + progress: .init(completedPageCount: 26, pageCount: 26) + ) + $0.hasLoadedDownloadBadge = true + } + + continuationBox.value?.finish() + } + + @MainActor + @Test + func testDetailReducerOpenReadingUsesLocalManifestWhenAvailable() async throws { + let download = sampleDownload(gid: "888", title: "Offline Archive", status: .completed, pageCount: 2) + let manifest = try sampleManifest(gid: download.gid, title: download.title) + var initialState = DetailReducer.State(gallery: download.gallery) + initialState.galleryDetail = sampleGalleryDetail(gid: download.gid, title: download.title) + + let store = TestStore( + initialState: initialState, + reducer: DetailReducer.init, + withDependencies: { + $0.downloadClient = makeLocalManifestClient(download: download, manifest: manifest) + $0.hapticsClient = .noop + $0.cookieClient = .noop + } + ) + store.exhaustivity = .off + + await store.send(.openReading) + await store.skipReceivedActions(strict: false) + + #expect(store.state.destination?.reading?.contentSource == .local(download, manifest)) + } + + @MainActor + @Test + func testDetailReducerOpenReadingFallsBackToRemoteWhenManifestUnavailable() async { + let gallery = sampleGallery() + let detail = sampleGalleryDetail(gid: gallery.gid, title: gallery.title) + var initialState = DetailReducer.State(gallery: gallery) + initialState.galleryDetail = detail + + let store = TestStore( + initialState: initialState, + reducer: DetailReducer.init, + withDependencies: { + $0.downloadClient = makeNoManifestClient() + $0.hapticsClient = .noop + $0.cookieClient = .noop + } + ) + store.exhaustivity = .off + + await store.send(.openReading) + await store.skipReceivedActions(strict: false) + + #expect(store.state.destination?.reading?.contentSource == .remote) + } + +} + +// MARK: - Store Factory Helpers + +private extension DetailReducerObserveTests { + func makeObserveTestStore( + gallery: Gallery, detail: GalleryDetail, + stream: AsyncStream<[DownloadedGallery]> + ) -> TestStoreOf { + var initialState = DetailReducer.State(gallery: gallery) + initialState.galleryDetail = detail + return TestStore( + initialState: initialState, + reducer: DetailReducer.init, + withDependencies: { + $0.downloadClient = DownloadClient() + $0.downloadClient.observeDownloads = { stream } + $0.downloadClient.fetchDownloads = { [] } + $0.downloadClient.fetchDownload = { _ in nil } + $0.downloadClient.refreshDownloads = {} + $0.downloadClient.enqueue = { _ in } + $0.downloadClient.togglePause = { _ in } + $0.downloadClient.retry = { _, _ in } + $0.downloadClient.delete = { _ in } + $0.downloadClient.loadManifest = { _ in throw AppError.notFound } + $0.downloadClient.loadLocalPageURLs = { _ in [:] } + $0.downloadClient.fetchVersionMetadata = { _, _ in nil } + $0.downloadClient.fetchFolders = { [] } + $0.hapticsClient = .noop + $0.cookieClient = .noop + $0.date = .constant(.init(timeIntervalSince1970: 0)) + } + ) + } + + func makeLocalManifestClient( + download: DownloadedGallery, manifest: DownloadManifest + ) -> DownloadClient { + var client = DownloadClient() + client.observeDownloads = { + AsyncStream { continuation in continuation.finish() } + } + client.fetchDownloads = { [download] } + client.fetchDownload = { gid in gid == download.gid ? download : nil } + client.refreshDownloads = {} + client.enqueue = { _ in } + client.togglePause = { _ in } + client.retry = { _, _ in } + client.delete = { _ in } + client.loadManifest = { gid in + guard gid == download.gid else { throw AppError.notFound } + return (download, manifest) + } + return client + } + + func makeNoManifestClient() -> DownloadClient { + var client = DownloadClient() + client.observeDownloads = { + AsyncStream { continuation in continuation.finish() } + } + client.fetchDownloads = { [] } + client.fetchDownload = { _ in nil } + client.refreshDownloads = {} + client.enqueue = { _ in } + client.togglePause = { _ in } + client.retry = { _, _ in } + client.delete = { _ in } + client.loadManifest = { _ in throw AppError.notFound } + return client + } +} diff --git a/AppPackage/Tests/DownloadsFeatureTests/DetailReducerPauseAndGuardTests.swift b/AppPackage/Tests/DownloadsFeatureTests/DetailReducerPauseAndGuardTests.swift new file mode 100644 index 000000000..46e27ef73 --- /dev/null +++ b/AppPackage/Tests/DownloadsFeatureTests/DetailReducerPauseAndGuardTests.swift @@ -0,0 +1,171 @@ +import Foundation +import AppModels +import ComposableArchitecture +import Testing +import HapticsClient +import DownloadClient +import CookieClient +import AppLaunchAutomationClient +@testable import DetailFeature +@testable import AppFeature + +@Suite(.serialized) +@MainActor +struct DetailReducerPauseAndGuardTests: DownloadFeatureTestCase { + @MainActor + @Test + func testDetailReducerLaunchAutomationDoesNotRedownloadWhenBadgeIsResolved() async { + let gallery = sampleGallery() + let detail = sampleGalleryDetail(gid: gallery.gid, title: gallery.title) + let completedDownload = sampleDownload( + gid: gallery.gid, title: gallery.title, status: .completed + ) + var initialState = DetailReducer.State(gallery: gallery) + initialState.galleryDetail = detail + + let store = TestStore( + initialState: initialState, + reducer: DetailReducer.init, + withDependencies: { + $0.appLaunchAutomationClient = appLaunchAutomationClient( + autoDownloadGID: gallery.gid + ) + $0.downloadClient = .noop + $0.hapticsClient = .noop + $0.cookieClient = .noop + } + ) + store.exhaustivity = .off + + await store.send(.fetchDownloadBadgeDone(completedDownload)) { + $0.downloadBadge = completedDownload.badge + $0.hasLoadedDownloadBadge = true + } + await store.send(.runLaunchAutomationIfNeeded) { + $0.didRunLaunchAutomation = true + } + } + + @MainActor + @Test + func testDetailReducerIgnoresStartDownloadWhilePreparing() async { + let gallery = sampleGallery() + let detail = sampleGalleryDetail(gid: gallery.gid, title: gallery.title) + let enqueueCount = UncheckedBox(0) + + var initialState = DetailReducer.State(gallery: gallery) + initialState.galleryDetail = detail + initialState.isPreparingDownload = true + + let store = TestStore( + initialState: initialState, + reducer: DetailReducer.init, + withDependencies: { + $0.downloadClient = DownloadClient() + $0.downloadClient.observeDownloads = { + AsyncStream { continuation in + continuation.finish() + } + } + $0.downloadClient.fetchDownloads = { [] } + $0.downloadClient.fetchDownload = { _ in nil } + $0.downloadClient.refreshDownloads = {} + $0.downloadClient.enqueue = { _ in + enqueueCount.value += 1 + } + $0.downloadClient.togglePause = { _ in } + $0.downloadClient.retry = { _, _ in } + $0.downloadClient.delete = { _ in } + $0.downloadClient.loadManifest = { _ in throw AppError.notFound } + $0.hapticsClient = .noop + $0.cookieClient = .noop + } + ) + + await store.send(.startDownload("Folder")) + + #expect(enqueueCount.value == 0) + #expect(store.state.isPreparingDownload) + #expect(store.state.downloadBadge == .none) + } + + @MainActor + @Test + func testDetailReducerTogglesPauseForActiveDownload() async { + let gallery = sampleGallery() + let detail = sampleGalleryDetail(gid: gallery.gid, title: gallery.title) + let pausedDownload = sampleDownload( + gid: gallery.gid, title: gallery.title, status: .paused, + pageCount: 26, completedPageCount: 7 + ) + let togglePauseCount = UncheckedBox(0) + + var initialState = DetailReducer.State(gallery: gallery) + initialState.galleryDetail = detail + initialState.downloadBadge = DownloadBadge( + status: .active, + progress: .init(completedPageCount: 7, pageCount: 26) + ) + + let store = makeTogglePauseStore( + initialState: initialState, + pausedDownload: pausedDownload, + togglePauseCount: togglePauseCount + ) + + await store.send(.toggleDownloadPause) { $0.isPreparingDownload = true } + await store.receive(\.toggleDownloadPauseDone) { + $0.isPreparingDownload = false + $0.downloadBadge = DownloadBadge( + status: .inactive, + progress: .init(completedPageCount: 7, pageCount: 26) + ) + $0.hasLoadedDownloadBadge = true + } + await store.receive(\.fetchDownloadBadge) + await store.receive(\.fetchDownloadBadgeDone, pausedDownload) { + $0.downloadBadge = pausedDownload.badge + $0.hasLoadedDownloadBadge = true + } + + #expect(togglePauseCount.value == 1) + #expect(store.state.downloadBadge == pausedDownload.badge) + #expect(store.state.isPreparingDownload == false) + } + +} + +// MARK: - Store Factory Helpers + +private extension DetailReducerPauseAndGuardTests { + func makeTogglePauseStore( + initialState: DetailReducer.State, + pausedDownload: DownloadedGallery, + togglePauseCount: UncheckedBox + ) -> TestStoreOf { + let store = TestStore( + initialState: initialState, + reducer: DetailReducer.init, + withDependencies: { + $0.downloadClient = DownloadClient() + $0.downloadClient.observeDownloads = { AsyncStream { continuation in continuation.finish() } } + $0.downloadClient.fetchDownloads = { [] } + $0.downloadClient.fetchDownload = { _ in pausedDownload } + $0.downloadClient.refreshDownloads = {} + $0.downloadClient.enqueue = { _ in } + $0.downloadClient.togglePause = { _ in + togglePauseCount.value += 1 + } + $0.downloadClient.retry = { _, _ in } + $0.downloadClient.delete = { _ in } + $0.downloadClient.loadManifest = { _ in throw AppError.notFound } + $0.downloadClient.loadLocalPageURLs = { _ in [:] } + $0.downloadClient.fetchVersionMetadata = { _, _ in nil } + $0.hapticsClient = .noop + $0.cookieClient = .noop + } + ) + store.exhaustivity = .off + return store + } +} diff --git a/AppPackage/Tests/DownloadsFeatureTests/DownloadAutomationTests.swift b/AppPackage/Tests/DownloadsFeatureTests/DownloadAutomationTests.swift new file mode 100644 index 000000000..c2e3a95eb --- /dev/null +++ b/AppPackage/Tests/DownloadsFeatureTests/DownloadAutomationTests.swift @@ -0,0 +1,349 @@ +import AppTools +import Foundation +import AppModels +import SwiftUI +import ComposableArchitecture +import Testing +import URLClient +import UserDefaultsClient +import ApplicationClient +import HapticsClient +import LibraryClient +import DFClient +import DownloadClient +import FileClient +import CookieClient +import AppLaunchAutomationClient +import DeviceClient +import AppDelegateClient +@testable import AppFeature + +@Suite(.serialized) +struct DownloadAutomationTests: DownloadFeatureTestCase { + @MainActor + @Test + func testAppForegroundReturnReconcilesDownloads() async { + let reconcileCount = UncheckedBox(0) + var initialState = AppReducer.State() + initialState.settingState.hasLoadedInitialSetting = true + + let store = TestStore( + initialState: initialState, + reducer: AppReducer.init, + withDependencies: { + $0.appLaunchAutomationClient = .none + $0.cookieClient = .noop + $0.logsClient = .noop + $0.continuousClock = TestClock() + $0.date = .constant(.init(timeIntervalSince1970: 0)) + $0.downloadClient = DownloadClient() + $0.downloadClient.observeDownloads = { .init { $0.finish() } } + $0.downloadClient.fetchDownloads = { [] } + $0.downloadClient.fetchDownload = { _ in nil } + $0.downloadClient.reconcileDownloads = { + reconcileCount.value += 1 + } + $0.downloadClient.hasPendingWork = { false } + $0.downloadClient.refreshDownloads = {} + $0.downloadClient.enqueue = { _ in } + $0.downloadClient.togglePause = { _ in } + $0.downloadClient.retry = { _, _ in } + $0.downloadClient.delete = { _ in } + $0.downloadClient.loadManifest = { _ in throw AppError.notFound } + } + ) + store.exhaustivity = .off + + // iOS always interposes .inactive on both edges of a background cycle: + // .active -> .inactive -> .background -> .inactive -> .active. The + // reconcile must survive the trailing .inactive and fire exactly once. + await store.send(.onScenePhaseChange(.inactive)) { + $0.scenePhase = .inactive + } + await store.send(.onScenePhaseChange(.background)) { + $0.scenePhase = .background + $0.hasEnteredBackground = true + } + await store.send(.onScenePhaseChange(.inactive)) { + $0.scenePhase = .inactive + } + await store.send(.onScenePhaseChange(.active)) { + $0.scenePhase = .active + $0.hasEnteredBackground = false + } + #expect(reconcileCount.value == 1) + + // Foreground return starts the always-on activity-logs pump; background + // again to cancel it so no effect is left in flight at finish(). + await store.send(.onScenePhaseChange(.background)) + await store.finish() + } + + @Test + func testAppLaunchAutomationResolveParsesGalleryURLAndCookies() { + let automation = AppLaunchAutomation.resolve(environment: [ + "EHPANDA_AUTOMATION_TAB": "downloads", + "EHPANDA_AUTOMATION_AUTO_DOWNLOAD_GID": "1394965", + "EHPANDA_AUTOMATION_DOWNLOAD_FOLDER": " UI Tests ", + "EHPANDA_AUTOMATION_GALLERY_URL": "https://e-hentai.org/g/1394965/56c35114b6/", + "EHPANDA_AUTOMATION_IPB_MEMBER_ID": "4172984", + "EHPANDA_AUTOMATION_IPB_PASS_HASH": "pass-hash", + "EHPANDA_AUTOMATION_IGNEOUS": "igneous-value" + ]) + + #expect(automation?.initialTab == .downloads) + #expect(automation?.autoDownloadGID == "1394965") + #expect(automation?.downloadFolderName == "UI Tests") + #expect( + automation?.galleryURL == URL(string: "https://e-hentai.org/g/1394965/56c35114b6/") + ) + #expect(automation?.loginCookies?.memberID == "4172984") + #expect(automation?.loginCookies?.passHash == "pass-hash") + #expect(automation?.loginCookies?.igneous == "igneous-value") + } + + @Test + func testImportAutomationCookiesClearsStaleIgneousAndUsesSessionCookies() { + let cookieClient = CookieClient.testing() + cookieClient.clearAll() + defer { cookieClient.clearAll() } + + cookieClient.setOrEditCookie( + for: Defaults.URL.exhentai, + key: Defaults.Cookie.igneous, + value: "stale-igneous" + ) + + cookieClient.importAutomationCookies( + memberID: "4172984", + passHash: "pass-hash", + igneous: nil + ) + + let exCookies = cookieClient.cookies(for: Defaults.URL.exhentai) + let memberCookie = exCookies.first { $0.name == Defaults.Cookie.ipbMemberId } + let passHashCookie = exCookies.first { $0.name == Defaults.Cookie.ipbPassHash } + let igneousCookie = exCookies.first { $0.name == Defaults.Cookie.igneous } + + #expect(memberCookie?.value == "4172984") + #expect(passHashCookie?.value == "pass-hash") + #expect(memberCookie?.isSessionOnly == true) + #expect(passHashCookie?.isSessionOnly == true) + #expect(igneousCookie == nil) + #expect(cookieClient.didLogin) + #expect(cookieClient.shouldFetchIgneous) + } + + @Test + func testSyncExCookiesPreservesSiblingHostLoginCookies() { + let cookieClient = CookieClient.testing() + cookieClient.clearAll() + defer { cookieClient.clearAll() } + + cookieClient.setOrEditCookie( + for: Defaults.URL.exhentai, key: Defaults.Cookie.ipbMemberId, value: "4172984" + ) + cookieClient.setOrEditCookie( + for: Defaults.URL.exhentai, key: Defaults.Cookie.ipbPassHash, value: "pass-hash" + ) + + // Syncing exhentai.org's cookies onto the sibling host s.exhentai.org must not disturb the + // exhentai.org cookies (regression: a suffix domain match used to delete them). + cookieClient.syncExCookies() + + let exCookies = cookieClient.cookies(for: Defaults.URL.exhentai) + #expect(exCookies.first { $0.name == Defaults.Cookie.ipbMemberId }?.value == "4172984") + #expect(exCookies.first { $0.name == Defaults.Cookie.ipbPassHash }?.value == "pass-hash") + } + + @MainActor + @Test + func testRunLaunchAutomationFallsBackToInitialTabWhenGalleryURLIsUnhandleable() async { + let automation = AppLaunchAutomation( + initialTab: .downloads, + autoDownloadGID: nil, + downloadFolderName: nil, + loginCookies: nil, + galleryURL: URL(string: "https://example.com/not-a-gallery") + ) + + let store = TestStore( + initialState: AppReducer.State(), + reducer: AppReducer.init, + withDependencies: { + $0.appLaunchAutomationClient = appLaunchAutomationClient(automation) + $0.cookieClient = .noop + $0.deviceClient = .noop + $0.hapticsClient = .noop + $0.urlClient = .init( + checkIfHandleable: { _ in false }, + checkIfMPVURL: { _ in false }, + parseGalleryID: { _ in .init() } + ) + } + ) + + await store.send(.runLaunchAutomation) { + $0.didRunLaunchAutomation = true + } + await store.receive(\.tabBar.setTabBarItemType, .downloads) { + $0.tabBarState.tabBarItemType = .downloads + } + } + + @MainActor + @Test + func testLaunchFinishImportsAutomationCookiesBeforeLoadingSettings() async { + let cookieClient = CookieClient.testing() + let automation = AppLaunchAutomation( + initialTab: nil, + autoDownloadGID: nil, + downloadFolderName: nil, + loginCookies: .init( + memberID: "4172984", + passHash: "pass-hash", + igneous: nil + ), + galleryURL: nil + ) + + let store = TestStore( + initialState: AppReducer.State(), + reducer: AppReducer.init, + withDependencies: { + $0.appLaunchAutomationClient = appLaunchAutomationClient(automation) + $0.cookieClient = cookieClient + $0.deviceClient = .noop + $0.hapticsClient = .noop + $0.applicationClient = .noop + $0.userDefaultsClient = .noop + $0.appDelegateClient = .noop + $0.libraryClient = .noop + $0.fileClient = .noop + $0.dfClient = .noop + $0.urlClient = .noop + $0.logsClient = .noop + $0.continuousClock = TestClock() + $0.date = .constant(.init(timeIntervalSince1970: 0)) + } + ) + store.exhaustivity = .off + + // The launch effect imports the automation cookies and only then sends + // loadUserSettings, so receiving it proves the import already ran. Assert + // on `didLogin` (backed by the imported e-hentai auth cookies) rather than + // the transient `shouldFetchIgneous`, which loadUserSettings itself consumes + // by kicking off an igneous fetch. Left in flight (with the log pump) at + // deinit: the login cascade's network effects, which the store cancels. + await store.send(.appDelegate(.onLaunchFinish)) + await store.receive(\.setting.loadUserSettings) + #expect(cookieClient.didLogin) + } + + @MainActor + @Test + func testLoadUserSettingsDefersExLaunchAutomationUntilIgneousArrives() async throws { + let cookieClient = CookieClient.testing( + memberID: "4172984", + passHash: "pass-hash", + igneous: nil + ) + let automation = AppLaunchAutomation( + initialTab: nil, + autoDownloadGID: nil, + downloadFolderName: nil, + loginCookies: nil, + galleryURL: URL(string: "https://exhentai.org/g/1394965/56c35114b6/") + ) + + let store = TestStore( + initialState: AppReducer.State(), + reducer: AppReducer.init, + withDependencies: { + $0.appLaunchAutomationClient = appLaunchAutomationClient(automation) + $0.cookieClient = cookieClient + $0.deviceClient = .noop + $0.hapticsClient = .noop + $0.applicationClient = .noop + $0.userDefaultsClient = .noop + $0.appDelegateClient = .noop + $0.libraryClient = .noop + $0.fileClient = .noop + $0.dfClient = .noop + $0.urlClient = .init( + checkIfHandleable: { _ in false }, + checkIfMPVURL: { _ in false }, + parseGalleryID: { _ in .init() } + ) + } + ) + store.exhaustivity = .off + + await store.send(.setting(.loadUserSettingsDone)) + #expect(store.state.didRunLaunchAutomation == false) + #expect(store.state.isAwaitingIgneousForLaunchAutomation) + + let response = try #require(HTTPURLResponse( + url: Defaults.URL.exhentai, + statusCode: 200, + httpVersion: nil, + headerFields: [ + "Set-Cookie": "\(Defaults.Cookie.igneous)=test-igneous" + ] + )) + await store.send(.setting(.fetchIgneousDone(.success(response)))) + await store.receive(\.runLaunchAutomation) { + $0.didRunLaunchAutomation = true + $0.isAwaitingIgneousForLaunchAutomation = false + } + } + + @MainActor + @Test + func testLoadUserSettingsKeepsExLaunchAutomationDeferredWhenIgneousFetchFails() async { + let cookieClient = CookieClient.testing( + memberID: "4172984", + passHash: "pass-hash", + igneous: nil + ) + let automation = AppLaunchAutomation( + initialTab: nil, + autoDownloadGID: nil, + downloadFolderName: nil, + loginCookies: nil, + galleryURL: URL(string: "https://exhentai.org/g/1394965/56c35114b6/") + ) + + let store = TestStore( + initialState: AppReducer.State(), + reducer: AppReducer.init, + withDependencies: { + $0.appLaunchAutomationClient = appLaunchAutomationClient(automation) + $0.cookieClient = cookieClient + $0.deviceClient = .noop + $0.hapticsClient = .noop + $0.applicationClient = .noop + $0.userDefaultsClient = .noop + $0.appDelegateClient = .noop + $0.libraryClient = .noop + $0.fileClient = .noop + $0.dfClient = .noop + $0.urlClient = .init( + checkIfHandleable: { _ in false }, + checkIfMPVURL: { _ in false }, + parseGalleryID: { _ in .init() } + ) + } + ) + store.exhaustivity = .off + + await store.send(.setting(.loadUserSettingsDone)) + #expect(store.state.didRunLaunchAutomation == false) + #expect(store.state.isAwaitingIgneousForLaunchAutomation) + + await store.send(.setting(.fetchIgneousDone(.failure(.networkingFailed)))) + await store.receive(\.setting.igneousRefreshed) + #expect(store.state.didRunLaunchAutomation == false) + #expect(store.state.isAwaitingIgneousForLaunchAutomation) + } +} diff --git a/AppPackage/Tests/DownloadsFeatureTests/DownloadBackgroundAssertionTests.swift b/AppPackage/Tests/DownloadsFeatureTests/DownloadBackgroundAssertionTests.swift new file mode 100644 index 000000000..a52d9a0e2 --- /dev/null +++ b/AppPackage/Tests/DownloadsFeatureTests/DownloadBackgroundAssertionTests.swift @@ -0,0 +1,222 @@ +import Foundation +import Synchronization +import UIKit +import Testing +import DownloadClient +@testable import AppFeature + +@Suite +struct DownloadBackgroundAssertionTests: DownloadFeatureTestCase { + @Test + func testBeginsAssertionWhenWorkScheduled() async throws { + let gid = "200001" + let context = try await makeBlockingCoordinator(gid: gid, title: "Queued") + defer { try? FileManager.default.removeItem(at: context.rootURL) } + + await context.manager.scheduleNextIfNeeded() + + #expect(context.spy.beginCount == 1) + #expect(context.spy.endCount == 0) + #expect(await context.manager.testingHasBackgroundAssertion()) + + _ = await context.manager.pause(gid: gid) + } + + @Test + func testEndsAssertionWhenQueueDrainsViaPause() async throws { + let gid = "200002" + let context = try await makeBlockingCoordinator(gid: gid, title: "Queued") + defer { try? FileManager.default.removeItem(at: context.rootURL) } + + await context.manager.scheduleNextIfNeeded() + #expect(context.spy.beginCount == 1) + + guard case .success = await context.manager.pause(gid: gid) else { + Issue.record("Pause should succeed for the active test download.") + return + } + + // Regression: the queue draining to empty must release the assertion, even + // though `pause` nulls `activeTask` directly instead of via the finish path. + #expect(context.spy.beginCount == 1) + #expect(context.spy.endCount == 1) + #expect(!(await context.manager.testingHasBackgroundAssertion())) + } + + @Test + func testRepeatedSchedulingBeginsAssertionOnce() async throws { + let gid = "200003" + let context = try await makeBlockingCoordinator(gid: gid, title: "Queued") + defer { try? FileManager.default.removeItem(at: context.rootURL) } + + await context.manager.scheduleNextIfNeeded() + await context.manager.scheduleNextIfNeeded() + + #expect(context.spy.beginCount == 1) + #expect(context.spy.endCount == 0) + + _ = await context.manager.pause(gid: gid) + } + + @Test + func testExpirationHandlerReleasesAssertion() async throws { + let gid = "200004" + let context = try await makeBlockingCoordinator(gid: gid, title: "Queued") + defer { try? FileManager.default.removeItem(at: context.rootURL) } + + await context.manager.scheduleNextIfNeeded() + #expect(await context.manager.testingHasBackgroundAssertion()) + + context.spy.fireExpiration() + + // The expiration handler releases the assertion on a detached task that nils the + // token *before* awaiting the MainActor `end` hop. Wait on `endCount` (the later + // of the two) rather than the token so the assertion below can't observe the + // in-between window where the token is already nil but `end` hasn't run yet. + try await waitUntil { + context.spy.endCount == 1 + } + #expect(context.spy.beginCount == 1) + #expect(!(await context.manager.testingHasBackgroundAssertion())) + + _ = await context.manager.pause(gid: gid) + } + + @Test + func testDeleteOfLastActiveDownloadReleasesAssertion() async throws { + let gid = "200005" + let context = try await makeBlockingCoordinator(gid: gid, title: "Queued") + defer { try? FileManager.default.removeItem(at: context.rootURL) } + + await context.manager.scheduleNextIfNeeded() + #expect(context.spy.beginCount == 1) + + guard case .success = await context.manager.delete(gid: gid) else { + Issue.record("Delete should succeed for the active test download.") + return + } + + #expect(context.spy.endCount == 1) + #expect(!(await context.manager.testingHasBackgroundAssertion())) + } + + @Test + func testDeleteFolderOfLastActiveDownloadReleasesAssertion() async throws { + let gid = "200006" + let context = try await makeBlockingCoordinator(gid: gid, title: "Queued") + defer { try? FileManager.default.removeItem(at: context.rootURL) } + + await context.manager.scheduleNextIfNeeded() + #expect(context.spy.beginCount == 1) + + guard case .success = await context.manager.deleteFolder(name: "Folder") else { + Issue.record("Delete folder should succeed for the active test download.") + return + } + + #expect(context.spy.endCount == 1) + #expect(!(await context.manager.testingHasBackgroundAssertion())) + } +} + +// MARK: - Helpers + +private extension DownloadBackgroundAssertionTests { + struct BlockingCoordinatorContext { + let manager: DownloadCoordinator + let storage: DownloadStore + let spy: BackgroundTaskClientSpy + let rootURL: URL + } + + /// Builds a coordinator whose single queued download blocks forever once scheduled, + /// so `activeTask` stays installed and the assertion lifecycle can be observed. + func makeBlockingCoordinator( + gid: String, + title: String + ) async throws -> BlockingCoordinatorContext { + let rootURL = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + let storage = DownloadStore(rootURL: rootURL, fileManager: .default) + let spy = BackgroundTaskClientSpy() + let taskRunner = DownloadTaskRunner( + runScheduledDownload: { _, _ in + while !Task.isCancelled { + try? await Task.sleep(for: .milliseconds(10)) + } + return .skippedOperation + } + ) + let manager = DownloadCoordinator( + storage: storage, + urlSession: .shared, + backgroundTaskClient: spy.client, + taskRunner: taskRunner + ) + + try storage.ensureRootDirectory() + let folderURL = storage.folderURL(relativePath: "Folder/[\(gid)_token] \(title)") + try FileManager.default.createDirectory( + at: folderURL, + withIntermediateDirectories: true + ) + try storage.writeManifest( + sampleManifest(gid: gid, title: title), + folderURL: folderURL + ) + await manager.reloadDownloadIndex() + await manager.testingSetQueuedGalleryIDs([gid]) + return BlockingCoordinatorContext( + manager: manager, + storage: storage, + spy: spy, + rootURL: rootURL + ) + } + + func waitUntil( + timeout: Duration = .seconds(1), + _ condition: @Sendable () async -> Bool + ) async throws { + let clock = ContinuousClock() + let deadline = clock.now.advanced(by: timeout) + while await !condition(), clock.now < deadline { + try? await Task.sleep(for: .milliseconds(10)) + } + try #require(await condition(), "Timed out waiting for condition.") + } +} + +// MARK: - Spy + +final class BackgroundTaskClientSpy: Sendable { + private struct State { + var beginCount = 0 + var endCount = 0 + var expirationHandler: (@Sendable () -> Void)? + } + private let state = Mutex(State()) + + var beginCount: Int { state.withLock { $0.beginCount } } + var endCount: Int { state.withLock { $0.endCount } } + + func fireExpiration() { + let handler = state.withLock { $0.expirationHandler } + handler?() + } + + var client: BackgroundTaskClient { + BackgroundTaskClient( + begin: { handler in + self.state.withLock { + $0.beginCount += 1 + $0.expirationHandler = handler + } + return UIBackgroundTaskIdentifier(rawValue: 1) + }, + end: { _ in + self.state.withLock { $0.endCount += 1 } + } + ) + } +} diff --git a/AppPackage/Tests/DownloadsFeatureTests/DownloadBackgroundCompletionTests.swift b/AppPackage/Tests/DownloadsFeatureTests/DownloadBackgroundCompletionTests.swift new file mode 100644 index 000000000..d38f26a88 --- /dev/null +++ b/AppPackage/Tests/DownloadsFeatureTests/DownloadBackgroundCompletionTests.swift @@ -0,0 +1,311 @@ +import Foundation +import Testing +import DownloadClient +@testable import AppFeature + +@Suite(.serialized) +struct DownloadBackgroundCompletionTests: DownloadFeatureTestCase { + @Test + func testOrphanedBackgroundCompletionAttachesPageAndClearsTaskRecord() async throws { + let gid = String(Int(Date().timeIntervalSince1970 * 1000) + 901) + let rootURL = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + defer { try? FileManager.default.removeItem(at: rootURL) } + + let storage = DownloadStore(rootURL: rootURL, fileManager: .default) + let taskStore = DownloadBackgroundTaskStore(fileURL: storage.backgroundTaskRegistryURL()) + let manager = DownloadCoordinator( + storage: storage, + urlSession: .shared, + backgroundTaskStore: taskStore + ) + let folderURL = try writeDownloadFolder( + storage: storage, + gid: gid + ) + await manager.reloadDownloadIndex() + + let taskIdentifier = 77 + let stagedURL = try writeStagedBackgroundFile(storage: storage) + await taskStore.record( + taskIdentifier: taskIdentifier, + gid: gid, + pageIndex: 1 + ) + let responseURL = try #require(URL(string: "https://ehgt.org/ab/cd/0001-\(gid).jpg")) + let response = try #require(HTTPURLResponse( + url: responseURL, + statusCode: 200, + httpVersion: nil, + headerFields: ["Content-Type": "image/jpeg"] + )) + + await manager.handleBackgroundPageDownloadCompleted( + taskIdentifier: taskIdentifier, + fileURL: stagedURL, + response: response + ) + + let pageRelativePath = storage.makePageRelativePath( + gid: gid, + token: "token", + index: 1, + fileExtension: "jpg" + ) + let pageURL = folderURL.appendingPathComponent(pageRelativePath) + let manifest = try storage.readManifest(folderURL: folderURL) + + #expect(await taskStore.record(taskIdentifier: taskIdentifier) == nil) + #expect(FileManager.default.fileExists(atPath: pageURL.path)) + #expect(FileManager.default.fileExists(atPath: stagedURL.path) == false) + #expect(manifest.pages[1]?.hasPrefix("sha256:") == true) + #expect(try await manager.loadLocalPageURLs(gid: gid).get()[1] == pageURL) + } + + @Test + func testReceiverReplaysCompletionBufferedBeforeCoordinatorIsSet() async throws { + let gid = String(Int(Date().timeIntervalSince1970 * 1000) + 908) + let rootURL = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + defer { try? FileManager.default.removeItem(at: rootURL) } + + let storage = DownloadStore(rootURL: rootURL, fileManager: .default) + let taskStore = DownloadBackgroundTaskStore(fileURL: storage.backgroundTaskRegistryURL()) + let manager = DownloadCoordinator( + storage: storage, + urlSession: .shared, + backgroundTaskStore: taskStore + ) + let folderURL = try writeDownloadFolder(storage: storage, gid: gid) + await manager.reloadDownloadIndex() + + let taskIdentifier = 88 + let stagedURL = try writeStagedBackgroundFile(storage: storage) + await taskStore.record(taskIdentifier: taskIdentifier, gid: gid, pageIndex: 1) + let responseURL = try #require(URL(string: "https://ehgt.org/ab/cd/0001-\(gid).jpg")) + let response = try #require(HTTPURLResponse( + url: responseURL, + statusCode: 200, + httpVersion: nil, + headerFields: ["Content-Type": "image/jpeg"] + )) + + // A completion replayed by iOS in the window before the coordinator is installed + // must be buffered and drained on setCoordinator, not dropped against a nil + // coordinator (which would strand the page and let resumeQueue re-download it). + let receiver = BackgroundPageCompletionReceiver() + await receiver.handleCompletion( + taskIdentifier: taskIdentifier, + fileURL: stagedURL, + response: response + ) + await receiver.setCoordinator(manager) + + let pageRelativePath = storage.makePageRelativePath( + gid: gid, + token: "token", + index: 1, + fileExtension: "jpg" + ) + let pageURL = folderURL.appendingPathComponent(pageRelativePath) + + #expect(await taskStore.record(taskIdentifier: taskIdentifier) == nil) + #expect(FileManager.default.fileExists(atPath: pageURL.path)) + #expect(FileManager.default.fileExists(atPath: stagedURL.path) == false) + } + + @Test + func testOrphanedBackgroundFailureRecordsPageFailureAndClearsTaskRecord() async throws { + let gid = String(Int(Date().timeIntervalSince1970 * 1000) + 904) + let rootURL = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + defer { try? FileManager.default.removeItem(at: rootURL) } + + let storage = DownloadStore(rootURL: rootURL, fileManager: .default) + let taskStore = DownloadBackgroundTaskStore(fileURL: storage.backgroundTaskRegistryURL()) + let manager = DownloadCoordinator( + storage: storage, + urlSession: .shared, + backgroundTaskStore: taskStore + ) + _ = try writeDownloadFolder(storage: storage, gid: gid) + await manager.reloadDownloadIndex() + + let taskIdentifier = 91 + await taskStore.record( + taskIdentifier: taskIdentifier, + gid: gid, + pageIndex: 1 + ) + + await manager.handleBackgroundPageDownloadFailed( + taskIdentifier: taskIdentifier, + error: .networkingFailed + ) + + #expect(await taskStore.record(taskIdentifier: taskIdentifier) == nil) + let inspection = try await manager.loadInspection(gid: gid).get() + #expect(inspection.failedPageIndices.contains(1)) + } + + @Test + func testOrphanedBackgroundFatalFailureSettlesQueueAndSurfacesError() async throws { + let gid = String(Int(Date().timeIntervalSince1970 * 1000) + 907) + let rootURL = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + defer { try? FileManager.default.removeItem(at: rootURL) } + + let storage = DownloadStore(rootURL: rootURL, fileManager: .default) + let taskStore = DownloadBackgroundTaskStore(fileURL: storage.backgroundTaskRegistryURL()) + let queueStore = DownloadQueueStore(fileURL: storage.queueURL()) + let manager = DownloadCoordinator( + storage: storage, + urlSession: .shared, + backgroundTaskStore: taskStore, + queueStore: queueStore + ) + _ = try writeDownloadFolder(storage: storage, gid: gid) + await manager.reloadDownloadIndex() + await queueStore.enqueue(gid) + + let taskIdentifier = 92 + await taskStore.record(taskIdentifier: taskIdentifier, gid: gid, pageIndex: 1) + + // A fatal account error on an orphaned background page must settle the whole + // download like the foreground path, not just record a page failure. + await manager.handleBackgroundPageDownloadFailed( + taskIdentifier: taskIdentifier, + error: .authenticationRequired + ) + + let failed = try #require(await manager.fetchDownload(gid: gid)) + #expect(queueStore.gids == []) + #expect(failed.displayStatus == .error) + #expect(failed.lastError?.code == .authenticationRequired) + } + + @Test + func testOrphanedBackgroundCancellationClearsTaskRecordWithoutPageFailure() async throws { + let gid = String(Int(Date().timeIntervalSince1970 * 1000) + 905) + let rootURL = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + defer { try? FileManager.default.removeItem(at: rootURL) } + + let storage = DownloadStore(rootURL: rootURL, fileManager: .default) + let taskStore = DownloadBackgroundTaskStore(fileURL: storage.backgroundTaskRegistryURL()) + let manager = DownloadCoordinator( + storage: storage, + urlSession: .shared, + backgroundTaskStore: taskStore + ) + _ = try writeDownloadFolder(storage: storage, gid: gid) + await manager.reloadDownloadIndex() + + let taskIdentifier = 92 + await taskStore.record( + taskIdentifier: taskIdentifier, + gid: gid, + pageIndex: 1 + ) + + await manager.handleBackgroundPageDownloadFailed( + taskIdentifier: taskIdentifier, + error: nil + ) + + #expect(await taskStore.record(taskIdentifier: taskIdentifier) == nil) + let inspection = try await manager.loadInspection(gid: gid).get() + #expect(inspection.failedPageIndices.isEmpty) + } + + @Test + func testDeleteClearsPersistedBackgroundTaskRecords() async throws { + let gid = String(Int(Date().timeIntervalSince1970 * 1000) + 902) + let rootURL = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + defer { try? FileManager.default.removeItem(at: rootURL) } + + let storage = DownloadStore(rootURL: rootURL, fileManager: .default) + let taskStore = DownloadBackgroundTaskStore(fileURL: storage.backgroundTaskRegistryURL()) + let manager = DownloadCoordinator( + storage: storage, + urlSession: .shared, + backgroundTaskStore: taskStore + ) + _ = try writeDownloadFolder(storage: storage, gid: gid) + await manager.reloadDownloadIndex() + + await taskStore.record(taskIdentifier: 77, gid: gid, pageIndex: 1) + await taskStore.record(taskIdentifier: 78, gid: "other", pageIndex: 1) + + let result = await manager.delete(gid: gid) + guard case .success = result else { + Issue.record("Expected delete to succeed, got \(result)") + return + } + + #expect(await taskStore.records(for: gid).isEmpty) + #expect(await taskStore.record(taskIdentifier: 78) == .init(gid: "other", pageIndex: 1)) + } + + @Test + func testDeleteFolderClearsPersistedBackgroundTaskRecords() async throws { + let gid = String(Int(Date().timeIntervalSince1970 * 1000) + 903) + let rootURL = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + defer { try? FileManager.default.removeItem(at: rootURL) } + + let storage = DownloadStore(rootURL: rootURL, fileManager: .default) + let taskStore = DownloadBackgroundTaskStore(fileURL: storage.backgroundTaskRegistryURL()) + let manager = DownloadCoordinator( + storage: storage, + urlSession: .shared, + backgroundTaskStore: taskStore + ) + _ = try writeDownloadFolder(storage: storage, gid: gid, folderName: "Doomed") + await manager.reconcileDownloads() + + await taskStore.record(taskIdentifier: 81, gid: gid, pageIndex: 1) + await taskStore.record(taskIdentifier: 82, gid: "other", pageIndex: 1) + + let result = await manager.deleteFolder(name: "Doomed") + guard case .success = result else { + Issue.record("Expected folder delete to succeed, got \(result)") + return + } + + #expect(await taskStore.records(for: gid).isEmpty) + #expect(await taskStore.record(taskIdentifier: 82) == .init(gid: "other", pageIndex: 1)) + } + + private func writeDownloadFolder( + storage: DownloadStore, + gid: String, + folderName: String = "Folder" + ) throws -> URL { + try storage.ensureRootDirectory() + let folderURL = storage.folderURL(relativePath: "\(folderName)/[\(gid)_token] Background") + try FileManager.default.createDirectory( + at: folderURL, + withIntermediateDirectories: true + ) + try storage.writeManifest( + sampleManifest(gid: gid, title: "Background", pageCount: 2), + folderURL: folderURL + ) + return folderURL + } + + private func writeStagedBackgroundFile( + storage: DownloadStore + ) throws -> URL { + let holdingDirectory = storage.backgroundTransferHoldingDirectoryURL() + try FileManager.default.createDirectory( + at: holdingDirectory, + withIntermediateDirectories: true + ) + let fileURL = holdingDirectory.appendingPathComponent(UUID().uuidString) + try Data([0x01, 0x02, 0x03]).write(to: fileURL, options: .atomic) + return fileURL + } +} diff --git a/AppPackage/Tests/DownloadsFeatureTests/DownloadBackgroundProcessingTests.swift b/AppPackage/Tests/DownloadsFeatureTests/DownloadBackgroundProcessingTests.swift new file mode 100644 index 000000000..55cc8abe5 --- /dev/null +++ b/AppPackage/Tests/DownloadsFeatureTests/DownloadBackgroundProcessingTests.swift @@ -0,0 +1,192 @@ +import Foundation +import ComposableArchitecture +import Testing +import DownloadClient +import BackgroundProcessingClient +import CookieClient +import AppLaunchAutomationClient +@testable import AppFeature + +@Suite(.serialized) +struct DownloadBackgroundProcessingTests: DownloadFeatureTestCase { + @Test + func testHasPendingWorkReflectsQueueState() async throws { + let gid = "210001" + let rootURL = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + defer { try? FileManager.default.removeItem(at: rootURL) } + let storage = DownloadStore(rootURL: rootURL, fileManager: .default) + let manager = DownloadCoordinator(storage: storage, urlSession: .shared) + + await manager.reloadDownloadIndex() + #expect(!(await manager.hasPendingWork())) + + try writeQueuedManifest(storage: storage, gid: gid, title: "Queued") + await manager.reloadDownloadIndex() + await manager.testingSetQueuedGalleryIDs([gid]) + #expect(await manager.hasPendingWork()) + } + + @Test + func testRunQueueUntilIdleDrainsAllQueuedItems() async throws { + let sessionID = UUID().uuidString + let gids = ["210011", "210012", "210013"] + let rootURL = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + defer { try? FileManager.default.removeItem(at: rootURL) } + + let (storage, manager) = makeStubbedDownloadCoordinator( + rootURL: rootURL, + sessionID: sessionID + ) + // Every request fails, so each scheduled download settles to .error and leaves + // the queue — letting the drain converge without a live network. + SharedSessionStubURLProtocol.setHandler(for: sessionID) { _ in + throw URLError(.notConnectedToInternet) + } + defer { SharedSessionStubURLProtocol.removeHandler(for: sessionID) } + + for gid in gids { + try writeQueuedManifest(storage: storage, gid: gid, title: "Queued \(gid)") + } + await manager.reloadDownloadIndex() + await manager.testingSetQueuedGalleryIDs(gids) + #expect(await manager.hasPendingWork()) + + await manager.runQueueUntilIdle() + + #expect(!(await manager.hasPendingWork())) + for gid in gids { + #expect(await manager.fetchDownload(gid: gid)?.displayStatus == .error) + } + } + + @Test + func testRunQueueUntilIdleReturnsPromptlyOnCancellation() async throws { + let gid = "210021" + let rootURL = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + defer { try? FileManager.default.removeItem(at: rootURL) } + let storage = DownloadStore(rootURL: rootURL, fileManager: .default) + let taskRunner = DownloadTaskRunner( + runScheduledDownload: { _, _ in + while !Task.isCancelled { + try? await Task.sleep(for: .milliseconds(10)) + } + return .skippedOperation + } + ) + let manager = DownloadCoordinator( + storage: storage, + urlSession: .shared, + taskRunner: taskRunner + ) + + try writeQueuedManifest(storage: storage, gid: gid, title: "Blocking") + await manager.reloadDownloadIndex() + await manager.testingSetQueuedGalleryIDs([gid]) + + let drainTask = Task { await manager.runQueueUntilIdle() } + try await waitUntil { await manager.testingHasActiveTask() } + drainTask.cancel() + + // Without the cancellation handler the drain would block on the never-finishing + // transfer; cancelling it must cancel the active task and return. + _ = try await waitForTaskValue( + drainTask, + timeout: .seconds(2), + description: "runQueueUntilIdle cancellation" + ) + } + + @MainActor + @Test + func testBackgroundSchedulesProcessingWhenWorkPending() async { + let scheduleCount = UncheckedBox(0) + let store = makeBackgroundStore(hasPendingWork: true, scheduleCount: scheduleCount) + + await store.send(.onScenePhaseChange(.background)) { + $0.scenePhase = .background + $0.hasEnteredBackground = true + } + await store.finish() + + #expect(scheduleCount.value == 1) + } + + @MainActor + @Test + func testBackgroundSkipsSchedulingWhenIdle() async { + let scheduleCount = UncheckedBox(0) + let store = makeBackgroundStore(hasPendingWork: false, scheduleCount: scheduleCount) + + await store.send(.onScenePhaseChange(.background)) { + $0.scenePhase = .background + $0.hasEnteredBackground = true + } + await store.finish() + + #expect(scheduleCount.value == 0) + } +} + +// MARK: - Helpers + +private extension DownloadBackgroundProcessingTests { + func writeQueuedManifest( + storage: DownloadStore, + gid: String, + title: String + ) throws { + try storage.ensureRootDirectory() + let folderURL = storage.folderURL(relativePath: "Folder/[\(gid)_token] \(title)") + try FileManager.default.createDirectory( + at: folderURL, + withIntermediateDirectories: true + ) + try storage.writeManifest( + sampleManifest(gid: gid, title: title), + folderURL: folderURL + ) + } + + func waitUntil( + timeout: Duration = .seconds(1), + _ condition: @Sendable () async -> Bool + ) async throws { + let clock = ContinuousClock() + let deadline = clock.now.advanced(by: timeout) + while await !condition(), clock.now < deadline { + try? await Task.sleep(for: .milliseconds(10)) + } + try #require(await condition(), "Timed out waiting for condition.") + } + + @MainActor + func makeBackgroundStore( + hasPendingWork: Bool, + scheduleCount: UncheckedBox + ) -> TestStoreOf { + var initialState = AppReducer.State() + initialState.settingState.hasLoadedInitialSetting = true + let store = TestStore( + initialState: initialState, + reducer: AppReducer.init, + withDependencies: { + $0.appLaunchAutomationClient = .none + $0.cookieClient = .noop + $0.downloadClient = DownloadClient() + $0.downloadClient.hasPendingWork = { hasPendingWork } + $0.backgroundProcessingClient = BackgroundProcessingClient( + register: { _ in }, + schedule: { + scheduleCount.value += 1 + }, + cancel: {} + ) + } + ) + store.exhaustivity = .off + return store + } +} diff --git a/AppPackage/Tests/DownloadsFeatureTests/DownloadBackgroundTaskStoreTests.swift b/AppPackage/Tests/DownloadsFeatureTests/DownloadBackgroundTaskStoreTests.swift new file mode 100644 index 000000000..af9a9ab66 --- /dev/null +++ b/AppPackage/Tests/DownloadsFeatureTests/DownloadBackgroundTaskStoreTests.swift @@ -0,0 +1,65 @@ +import Foundation +import Testing +import DownloadClient +@testable import AppFeature + +struct DownloadBackgroundTaskStoreTests { + @Test + func testRecordsPersistByTaskIdentifier() async { + let fixture = makeStore() + defer { try? FileManager.default.removeItem(at: fixture.rootURL) } + + await fixture.store.record(taskIdentifier: 41, gid: "123", pageIndex: 7) + + let reloadedStore = DownloadBackgroundTaskStore(fileURL: fixture.fileURL) + #expect(await reloadedStore.record(taskIdentifier: 41) == .init(gid: "123", pageIndex: 7)) + #expect(await reloadedStore.records(for: "123") == [41: .init(gid: "123", pageIndex: 7)]) + } + + @Test + func testRemoveSingleRecord() async { + let fixture = makeStore() + defer { try? FileManager.default.removeItem(at: fixture.rootURL) } + + await fixture.store.record(taskIdentifier: 41, gid: "123", pageIndex: 7) + await fixture.store.record(taskIdentifier: 42, gid: "456", pageIndex: 8) + + let removed = await fixture.store.remove(taskIdentifier: 41) + + #expect(removed == .init(gid: "123", pageIndex: 7)) + #expect(await fixture.store.record(taskIdentifier: 41) == nil) + #expect(await fixture.store.record(taskIdentifier: 42) == .init(gid: "456", pageIndex: 8)) + } + + @Test + func testRemoveAllForGallery() async { + let fixture = makeStore() + defer { try? FileManager.default.removeItem(at: fixture.rootURL) } + + await fixture.store.record(taskIdentifier: 41, gid: "123", pageIndex: 7) + await fixture.store.record(taskIdentifier: 42, gid: "123", pageIndex: 8) + await fixture.store.record(taskIdentifier: 43, gid: "456", pageIndex: 9) + + await fixture.store.removeAll(for: "123") + + #expect(await fixture.store.records(for: "123").isEmpty) + #expect(await fixture.store.record(taskIdentifier: 43) == .init(gid: "456", pageIndex: 9)) + } + + private struct StoreFixture { + let store: DownloadBackgroundTaskStore + let rootURL: URL + let fileURL: URL + } + + private func makeStore() -> StoreFixture { + let rootURL = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + let fileURL = rootURL.appendingPathComponent(".background-tasks.json") + return .init( + store: DownloadBackgroundTaskStore(fileURL: fileURL), + rootURL: rootURL, + fileURL: fileURL + ) + } +} diff --git a/AppPackage/Tests/DownloadsFeatureTests/DownloadBadgeSortTests.swift b/AppPackage/Tests/DownloadsFeatureTests/DownloadBadgeSortTests.swift new file mode 100644 index 000000000..686d7bb9d --- /dev/null +++ b/AppPackage/Tests/DownloadsFeatureTests/DownloadBadgeSortTests.swift @@ -0,0 +1,158 @@ +import SwiftUI +import AppModels +import Foundation +import SFSafeSymbols +import ComposableArchitecture +import Testing +import AppTools +@testable import AppFeature + +struct DownloadBadgeSortTests: DownloadFeatureTestCase { + @Test + func testBadgeSeparatesDisplayStatusFromProgress() { + let activeDownload = sampleDownload( + gid: "479", + title: "Active Archive", + status: .downloading, + pageCount: 26, + completedPageCount: 7 + ) + let badge = activeDownload.badge + + #expect(badge.status == activeDownload.displayStatus) + #expect(badge.progress == DownloadProgress(completedPageCount: 7, pageCount: 26)) + #expect(badge.symbol == .playFill) + #expect(badge.color == .green) + + let completedBadge = sampleDownload( + gid: "481", + title: "Done Archive", + status: .completed, + pageCount: 26 + ).badge + + #expect(completedBadge.symbol == .checkmarkCircleFill) + #expect(completedBadge.color == .gray) + #expect(completedBadge.progress.fraction == 1) + } + + @Test + func testPartialDownloadBadgeUsesNeedsAttentionCopy() { + let partialDownload = sampleDownload( + gid: "480", + title: "Incomplete Archive", + status: .partial, + pageCount: 12, + completedPageCount: 5 + ) + #expect(partialDownload.badge.symbol == .exclamationmarkTriangleFill) + #expect(partialDownload.badge.color == .yellow) + #expect( + partialDownload.badge.progress + == DownloadProgress(completedPageCount: 5, pageCount: 12) + ) + } + + @Test + func testQueuedRepairKeepsQueuedStatusWhileMissingFilesStaysError() { + let queuedRepair = sampleDownload( + gid: "606", + title: "Repair Archive", + status: .queued, + completedPageCount: 3 + ) + let missingFilesWithoutQueuedWork = sampleDownload( + gid: "607", + title: "Actually Missing", + status: .missingFiles, + pageCount: 4, + completedPageCount: 0 + ) + + #expect(queuedRepair.displayStatus == .queued) + #expect(missingFilesWithoutQueuedWork.displayStatus == .error) + #expect(missingFilesWithoutQueuedWork.lastError?.code == .fileOperationFailed) + } + + @Test + func testQueuedRedownloadKeepsQueuedSortPriority() { + let completedDownload = sampleDownload( + gid: "707", + title: "Completed Archive", + status: .completed, + lastDownloadedDate: .distantFuture + ) + + let queuedRedownload = sampleDownload( + gid: "808", + title: "Queued Archive", + status: .queued, + completedPageCount: 12, + lastDownloadedDate: .distantPast + ) + + let sortedDownloads = [completedDownload, queuedRedownload].sorted { lhs, rhs in + if lhs.displayStatus != rhs.displayStatus { + return lhs.displayStatus.sortPriority < rhs.displayStatus.sortPriority + } + return (lhs.lastDownloadedDate ?? .distantPast) > (rhs.lastDownloadedDate ?? .distantPast) + } + + #expect(queuedRedownload.displayStatus == .queued) + #expect(completedDownload.displayStatus == .completed) + #expect(sortedDownloads.map(\.gid) == [queuedRedownload.gid, completedDownload.gid]) + } + + @Test + func testInProgressDownloadUsesFinalCoverURL() throws { + let gid = "811" + let folderURL = FileUtil.downloadsDirectoryURL + .appendingPathComponent("[\(gid)_token] Local Cover Archive", isDirectory: true) + let coverURL = folderURL.appendingPathComponent("\(gid)_token_cover.jpg") + let download = sampleDownload( + gid: gid, + title: "Local Cover Archive", + status: .downloading, + completedPageCount: 3, + folderURL: folderURL, + localCoverURL: coverURL + ) + + try? FileManager.default.removeItem(at: folderURL) + defer { try? FileManager.default.removeItem(at: folderURL) } + + try FileManager.default.createDirectory(at: folderURL, withIntermediateDirectories: true) + try Data([0xFF, 0xD8, 0xFF]).write(to: coverURL, options: .atomic) + + #expect(download.coverURL == coverURL) + } + + @Test + func testActiveDownloadDoesNotNormalizeWhileTaskIsStillRunning() { + let activeDownload = sampleDownload( + gid: "810", + title: "Running Archive", + status: .downloading, + completedPageCount: 3 + ) + + #expect( + activeDownload.needsInterruptedDownloadNormalization( + activeGalleryID: activeDownload.gid, + hasActiveTask: true + ) == false + ) + #expect( + activeDownload.needsInterruptedDownloadNormalization( + activeGalleryID: nil, + hasActiveTask: false + ) + ) + #expect( + activeDownload.needsInterruptedDownloadNormalization( + activeGalleryID: "another-gid", + hasActiveTask: true + ) + ) + } +} diff --git a/AppPackage/Tests/DownloadsFeatureTests/DownloadCoordinatorCachedURLTests.swift b/AppPackage/Tests/DownloadsFeatureTests/DownloadCoordinatorCachedURLTests.swift new file mode 100644 index 000000000..aed55daaa --- /dev/null +++ b/AppPackage/Tests/DownloadsFeatureTests/DownloadCoordinatorCachedURLTests.swift @@ -0,0 +1,72 @@ +import Foundation +import AppModels +import Testing +import DownloadClient +@testable import AppFeature + +@Suite(.serialized) +struct DownloadCoordinatorCachedURLTests { + @Test + func testIndexedDownloadUsesCachedLocalURLsUntilExplicitReload() async throws { + let rootURL = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + defer { try? FileManager.default.removeItem(at: rootURL) } + + let storage = DownloadStore(rootURL: rootURL, fileManager: .default) + let manager = DownloadCoordinator(storage: storage, urlSession: .shared) + let folderRelativePath = "Folder/[900_token] Cached" + let folderURL = storage.folderURL(relativePath: folderRelativePath) + let page1URL = folderURL.appendingPathComponent("900_token_1.jpg") + let page2URL = folderURL.appendingPathComponent("900_token_2.jpg") + let coverURL = folderURL.appendingPathComponent("900_token_cover.jpg") + + try storage.ensureRootDirectory() + try FileManager.default.createDirectory( + at: folderURL, + withIntermediateDirectories: true + ) + try storage.writeManifest(manifest(), folderURL: folderURL) + try Data([0x01]).write(to: page1URL, options: .atomic) + await manager.reloadDownloadIndex() + + let initialDownload = try #require(await manager.fetchDownload(gid: "900")) + #expect(initialDownload.localCoverURL == nil) + #expect(initialDownload.localPageURLs == [1: page1URL]) + + try Data([0x02]).write(to: page2URL, options: .atomic) + try Data([0x03]).write(to: coverURL, options: .atomic) + + let cachedDownload = try #require(await manager.fetchDownload(gid: "900")) + let cachedPageURLs = try await manager.loadLocalPageURLs(gid: "900").get() + + #expect(cachedDownload.localCoverURL == nil) + #expect(cachedDownload.localPageURLs == [1: page1URL]) + #expect(cachedPageURLs == [1: page1URL]) + + await manager.reloadDownloadIndex() + + let reloadedDownload = try #require(await manager.fetchDownload(gid: "900")) + #expect(reloadedDownload.localCoverURL == coverURL) + #expect(reloadedDownload.localPageURLs == [1: page1URL, 2: page2URL]) + } +} + +private extension DownloadCoordinatorCachedURLTests { + func manifest() -> DownloadManifest { + DownloadManifest( + gid: "900", + host: .ehentai, + token: "token", + title: "Cached", + jpnTitle: nil, + category: .doujinshi, + language: .japanese, + remoteCoverURL: URL(string: "https://example.com/cover.jpg"), + uploader: "Uploader", + tags: [], + postedDate: Date(timeIntervalSince1970: 1_000), + rating: 4, + pages: [1: "", 2: ""] + ) + } +} diff --git a/AppPackage/Tests/DownloadsFeatureTests/DownloadCoordinatorCaptureTests.swift b/AppPackage/Tests/DownloadsFeatureTests/DownloadCoordinatorCaptureTests.swift new file mode 100644 index 000000000..bf144496c --- /dev/null +++ b/AppPackage/Tests/DownloadsFeatureTests/DownloadCoordinatorCaptureTests.swift @@ -0,0 +1,158 @@ +import UIKit +import AppModels +import Foundation +import Testing +import AppTools +import DownloadClient +@testable import AppFeature + +@Suite(.serialized) +struct DownloadCoordinatorCaptureTests: DownloadFeatureTestCase { + @Test + func testDownloadCoordinatorCaptureCachedPageRestoresFinalPage() async throws { + let gid = String(Int(Date().timeIntervalSince1970 * 1000) + 27) + let rootURL = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + defer { try? FileManager.default.removeItem(at: rootURL) } + + let storage = DownloadStore(rootURL: rootURL, fileManager: .default) + let manager = DownloadCoordinator( + storage: storage, + urlSession: .shared + ) + + let completedFolderURL = rootURL.appendingPathComponent("Folder/\(gid) - Capture", isDirectory: true) + try FileManager.default.createDirectory( + at: completedFolderURL, + withIntermediateDirectories: true + ) + try storage.writeManifest( + sampleManifest( + gid: gid, + title: "Capture", + pageCount: 2 + ), + folderURL: completedFolderURL + ) + await manager.reloadDownloadIndex() + + let imageURL = try #require(URL(string: "https://ehgt.org/ab/cd/0001-\(gid).jpg")) + let image = UIGraphicsImageRenderer(size: .init(width: 1, height: 1)).image { context in + UIColor.systemBlue.setFill() + context.fill(.init(x: 0, y: 0, width: 1, height: 1)) + } + let imageData = try #require(image.jpegData(compressionQuality: 1)) + let cacheKeys = imageURL.imageCacheKeys + try await DataCache.shared.store(imageData, forKeys: cacheKeys) + + await manager.captureCachedPage( + gid: gid, + index: 1, + imageURL: imageURL + ) + + let pageURLs = try await manager.loadLocalPageURLs(gid: gid).get() + #expect( + pageURLs[1] == completedFolderURL.appendingPathComponent( + storage.makePageRelativePath( + gid: gid, + token: "token", + index: 1, + fileExtension: "jpg" + ) + ) + ) + try? await DataCache.shared.removeData(forKeys: cacheKeys) + } + + @MainActor + @Test + func testDownloadCoordinatorCaptureCachedPageRepairsCompletedDownloadWithLatestRemoteImage() async throws { + let gid = String(Int(Date().timeIntervalSince1970 * 1000) + 28) + let rootURL = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + defer { try? FileManager.default.removeItem(at: rootURL) } + + let storage = DownloadStore(rootURL: rootURL, fileManager: .default) + let manager = DownloadCoordinator(storage: storage, urlSession: .shared) + let completedFolderURL = try setupCaptureMissingFilesFolder( + rootURL: rootURL, gid: gid + ) + await manager.reloadDownloadIndex() + let (imageURL, cacheKeys) = try await setupCaptureCachedImage(gid: gid) + + await manager.captureCachedPage(gid: gid, index: 1, imageURL: imageURL) + + let stored = await manager.fetchDownload(gid: gid) + let pageURLs = try await manager.loadLocalPageURLs(gid: gid).get() + + #expect(stored?.displayStatus == .completed) + #expect(stored?.completedPageCount == 2) + #expect(stored?.lastError == nil) + let pageRelativePath = storage.makePageRelativePath( + gid: gid, + token: "token", + index: 1, + fileExtension: "jpg" + ) + #expect(pageURLs[1] == completedFolderURL.appendingPathComponent(pageRelativePath)) + try? await DataCache.shared.removeData(forKeys: cacheKeys) + } + +} + +// MARK: - Setup Helpers + +private extension DownloadCoordinatorCaptureTests { + func setupCaptureMissingFilesFolder(rootURL: URL, gid: String) throws -> URL { + let completedFolderURL = rootURL.appendingPathComponent("Folder/\(gid) - Pause Race", isDirectory: true) + try FileManager.default.createDirectory( + at: completedFolderURL, + withIntermediateDirectories: true + ) + try Data([0x00]).write( + to: completedFolderURL.appendingPathComponent("123_token_cover.jpg"), options: .atomic + ) + let page2RelativePath = "\(gid)_token_2.jpg" + let page2URL = completedFolderURL.appendingPathComponent(page2RelativePath) + try Data([0x02]).write( + to: page2URL, options: .atomic + ) + let manifest = DownloadManifest( + gid: gid, + host: .ehentai, + token: "token", + title: "Pause Race", + jpnTitle: nil, + category: .doujinshi, + language: .japanese, + remoteCoverURL: URL(string: "https://example.com/cover.jpg"), + uploader: "Uploader", + tags: [], + postedDate: .now, + rating: 4, + pages: [ + 1: "sha256:missing", + 2: try DownloadStore().fileHash(at: page2URL) + ] + ) + try JSONEncoder().encode(manifest).write( + to: completedFolderURL.appendingPathComponent(Defaults.FilePath.downloadManifest), + options: .atomic + ) + return completedFolderURL + } + + @MainActor + func setupCaptureCachedImage(gid: String) async throws -> (URL, [String]) { + let imageURL = try #require(URL(string: "https://ehgt.org/ab/cd/0001-\(gid).jpg")) + let image = UIGraphicsImageRenderer(size: .init(width: 1, height: 1)).image { context in + UIColor.systemOrange.setFill() + context.fill(.init(x: 0, y: 0, width: 1, height: 1)) + } + let imageData = try #require(image.jpegData(compressionQuality: 1)) + let cacheKeys = imageURL.imageCacheKeys + try await DataCache.shared.store(imageData, forKeys: cacheKeys) + return (imageURL, cacheKeys) + } +} diff --git a/AppPackage/Tests/DownloadsFeatureTests/DownloadCoordinatorRepairSeedTests.swift b/AppPackage/Tests/DownloadsFeatureTests/DownloadCoordinatorRepairSeedTests.swift new file mode 100644 index 000000000..4a02f5b51 --- /dev/null +++ b/AppPackage/Tests/DownloadsFeatureTests/DownloadCoordinatorRepairSeedTests.swift @@ -0,0 +1,230 @@ +import AppTools +import Foundation +import AppModels +import Testing +import DownloadClient +@testable import AppFeature + +@Suite(.serialized) +struct DownloadCoordinatorRepairSeedTests: DownloadFeatureTestCase { + @Test + func testRepairSeedReusesCompletedFilesWhenPageCountMatches() async throws { + let gid = "repair-seed-\(UUID().uuidString)" + let rootURL = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + defer { try? FileManager.default.removeItem(at: rootURL) } + + let storage = DownloadStore(rootURL: rootURL, fileManager: .default) + let manager = DownloadCoordinator(storage: storage, urlSession: .shared) + try storage.ensureRootDirectory() + + let sourceFolderURL = storage.folderURL(relativePath: "Folder/[\(gid)_token] Existing") + let existingDownload = sampleDownload( + gid: gid, title: "Mixed Version", status: .missingFiles, + pageCount: 2, completedPageCount: 2, + folderURL: sourceFolderURL + ) + try setupRepairSeedFiles( + storage: storage, + sourceFolderURL: sourceFolderURL, + gid: gid + ) + + let payload = makeRepairSeedPayload(gid: gid) + let folderRelativePath = await manager.folderRelativePath( + for: payload, + parentFolderName: existingDownload.folderName + ) + let folderURL = storage.folderURL(relativePath: folderRelativePath) + try? FileManager.default.removeItem(at: folderURL) + let workingSeed = try await manager.prepareWorkingSeed( + payload: payload, + existingDownload: existingDownload, + folderURL: folderURL + ) + + let pageOneRelativePath = storage.makePageRelativePath( + gid: gid, token: "token", index: 1, fileExtension: "jpg" + ) + let pageTwoRelativePath = storage.makePageRelativePath( + gid: gid, token: "token", index: 2, fileExtension: "jpg" + ) + let coverRelativePath = storage.makeCoverRelativePath( + gid: gid, token: "token", fileExtension: "jpg" + ) + let manifest = workingSeed.manifest + #expect(manifest.gid == gid) + #expect(workingSeed.existingPages == [ + 1: pageOneRelativePath, + 2: pageTwoRelativePath + ]) + #expect(workingSeed.coverRelativePath == coverRelativePath) + #expect( + FileManager.default.fileExists( + atPath: workingSeed.folderURL.appendingPathComponent(pageOneRelativePath).path + ) + ) + #expect( + FileManager.default.fileExists( + atPath: workingSeed.folderURL.appendingPathComponent(pageTwoRelativePath).path + ) + ) + } + + @Test + func testDownloadCoordinatorLoadLocalPageURLsRemovesZeroBytePage() async throws { + let gid = String(Int(Date().timeIntervalSince1970 * 1000) + 13) + let rootURL = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + defer { try? FileManager.default.removeItem(at: rootURL) } + + let storage = DownloadStore(rootURL: rootURL, fileManager: .default) + let manager = DownloadCoordinator(storage: storage, urlSession: .shared) + + let (emptyPageURL, goodPageURL) = try setupZeroBytePageFiles( + rootURL: rootURL, gid: gid, storage: storage + ) + await manager.reloadDownloadIndex() + + let pageURLs = try await manager.loadLocalPageURLs(gid: gid).get() + + #expect(pageURLs[1] == nil) + #expect(pageURLs[2] == goodPageURL) + #expect(FileManager.default.fileExists(atPath: emptyPageURL.path) == false) + } + + @Test + func testRescanLocalPageURLsDropsExternallyDeletedPage() async throws { + let gid = String(Int(Date().timeIntervalSince1970 * 1000) + 71) + let rootURL = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + defer { try? FileManager.default.removeItem(at: rootURL) } + + let storage = DownloadStore(rootURL: rootURL, fileManager: .default) + let manager = DownloadCoordinator(storage: storage, urlSession: .shared) + + let folderURL = rootURL.appendingPathComponent( + "Folder/\(gid) - Rescan", isDirectory: true + ) + try FileManager.default.createDirectory( + at: folderURL, withIntermediateDirectories: true + ) + try JSONEncoder().encode(sampleManifest(gid: gid, title: "Rescan")).write( + to: folderURL.appendingPathComponent(Defaults.FilePath.downloadManifest), + options: .atomic + ) + let pageOneURL = folderURL.appendingPathComponent( + storage.makePageRelativePath(gid: gid, token: "token", index: 1, fileExtension: "jpg") + ) + let pageTwoURL = folderURL.appendingPathComponent( + storage.makePageRelativePath(gid: gid, token: "token", index: 2, fileExtension: "jpg") + ) + try Data([0x01]).write(to: pageOneURL, options: .atomic) + try Data([0x02]).write(to: pageTwoURL, options: .atomic) + await manager.reloadDownloadIndex() + + #expect(await manager.rescanLocalPageURLs(gid: gid) == [1: pageOneURL, 2: pageTwoURL]) + + try FileManager.default.removeItem(at: pageOneURL) + + #expect(await manager.rescanLocalPageURLs(gid: gid) == [2: pageTwoURL]) + } + +} + +// MARK: - Repair Seed Helpers + +private extension DownloadCoordinatorRepairSeedTests { + func setupRepairSeedFiles( + storage: DownloadStore, + sourceFolderURL: URL, + gid: String + ) throws { + try FileManager.default.createDirectory( + at: sourceFolderURL, + withIntermediateDirectories: true + ) + let oldManifest = try sampleManifest( + gid: gid, title: "Mixed Version", + pageCount: 2 + ) + try JSONEncoder().encode(oldManifest).write( + to: sourceFolderURL.appendingPathComponent(Defaults.FilePath.downloadManifest), + options: .atomic + ) + try Data([0x00]).write( + to: sourceFolderURL.appendingPathComponent( + storage.makeCoverRelativePath(gid: gid, token: "token", fileExtension: "jpg") + ), + options: .atomic + ) + try Data([0x01]).write( + to: sourceFolderURL.appendingPathComponent( + storage.makePageRelativePath(gid: gid, token: "token", index: 1, fileExtension: "jpg") + ), + options: .atomic + ) + try Data([0x02]).write( + to: sourceFolderURL.appendingPathComponent( + storage.makePageRelativePath(gid: gid, token: "token", index: 2, fileExtension: "jpg") + ), + options: .atomic + ) + } + + func makeRepairSeedPayload(gid: String) -> DownloadRequestPayload { + DownloadRequestPayload( + gallery: Gallery( + gid: gid, token: "token", title: "Mixed Version", + rating: 4, tags: [], category: .doujinshi, + uploader: "Uploader", pageCount: 2, postedDate: .now, + coverURL: URL(string: "https://example.com/cover.jpg"), + galleryURL: URL(string: "https://e-hentai.org/g/\(gid)/token") + ), + galleryDetail: GalleryDetail( + gid: gid, title: "Mixed Version", jpnTitle: nil, + isFavorited: false, visibility: .yes, + rating: 4, userRating: 0, ratingCount: 1, + category: .doujinshi, language: .japanese, + uploader: "Uploader", postedDate: .now, + coverURL: URL(string: "https://example.com/cover.jpg"), + favoritedCount: 0, pageCount: 2, + sizeCount: 1, sizeType: "MB", torrentCount: 0 + ), + previewURLs: [:], previewConfig: .normal(rows: 4), + host: .ehentai, folderName: "Folder", mode: .repair + ) + } + + func setupZeroBytePageFiles( + rootURL: URL, gid: String, storage: DownloadStore + ) throws -> (URL, URL) { + let completedFolderURL = rootURL.appendingPathComponent( + "Folder/\(gid) - Pause Race", isDirectory: true + ) + try FileManager.default.createDirectory( + at: completedFolderURL, + withIntermediateDirectories: true + ) + let manifest = try sampleManifest(gid: gid, title: "Pause Race") + try JSONEncoder().encode(manifest).write( + to: completedFolderURL.appendingPathComponent(Defaults.FilePath.downloadManifest), + options: .atomic + ) + try Data([0x00]).write( + to: completedFolderURL.appendingPathComponent( + storage.makeCoverRelativePath(gid: gid, token: "token", fileExtension: "jpg") + ), + options: .atomic + ) + let emptyPageURL = completedFolderURL.appendingPathComponent( + storage.makePageRelativePath(gid: gid, token: "token", index: 1, fileExtension: "jpg") + ) + try Data().write(to: emptyPageURL, options: .atomic) + let goodPageURL = completedFolderURL.appendingPathComponent( + storage.makePageRelativePath(gid: gid, token: "token", index: 2, fileExtension: "jpg") + ) + try Data([0x02]).write(to: goodPageURL, options: .atomic) + return (emptyPageURL, goodPageURL) + } +} diff --git a/AppPackage/Tests/DownloadsFeatureTests/DownloadCoordinatorStorageTests.swift b/AppPackage/Tests/DownloadsFeatureTests/DownloadCoordinatorStorageTests.swift new file mode 100644 index 000000000..cfc511900 --- /dev/null +++ b/AppPackage/Tests/DownloadsFeatureTests/DownloadCoordinatorStorageTests.swift @@ -0,0 +1,996 @@ +import AppTools +import Kingfisher +import AppModels +import Resources +import UIKit +import Foundation +import Testing +import DownloadClient +@testable import AppFeature + +@Suite(.serialized) +struct DownloadCoordinatorStorageTests: DownloadFeatureTestCase { + @Test + func testDownloadCoordinatorReloadDownloadIndexScansManifestFolders() async throws { + let rootURL = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + defer { try? FileManager.default.removeItem(at: rootURL) } + + let storage = DownloadStore(rootURL: rootURL, fileManager: .default) + let manager = DownloadCoordinator( + storage: storage, + urlSession: .shared + ) + + try storage.ensureRootDirectory() + try writeIndexedManifest( + storage: storage, + relativePath: "Folder/[100_token] Complete", + manifest: indexedManifest( + gid: "100", + title: "Complete", + pageHashes: ["sha256:1", "sha256:2"], + modificationDate: Date(timeIntervalSince1970: 100) + ) + ) + try writeIndexedManifest( + storage: storage, + relativePath: "Folder/[200_token] Queued", + manifest: indexedManifest( + gid: "200", + title: "Queued", + pageHashes: ["sha256:1", ""], + modificationDate: Date(timeIntervalSince1970: 200) + ) + ) + try FileManager.default.createDirectory( + at: rootURL.appendingPathComponent("No Manifest", isDirectory: true), + withIntermediateDirectories: true + ) + await manager.testingSetQueuedGalleryIDs(["200"]) + + let downloads = await manager.reloadDownloadIndex() + + #expect(downloads.map(\.gid) == ["200", "100"]) + let queuedDownload = try #require(downloads.first { $0.gid == "200" }) + let completedDownload = try #require(downloads.first { $0.gid == "100" }) + #expect(queuedDownload.displayStatus == .queued) + #expect(queuedDownload.completedPageCount == 1) + #expect(completedDownload.displayStatus == .completed) + #expect(completedDownload.completedPageCount == 2) + #expect((await manager.indexedDownload(gid: "100")) == completedDownload) + #expect(await manager.indexedDownload(gid: "missing") == nil) + } + + @Test + func testDownloadCoordinatorReloadDownloadIndexKeepsNewestDuplicateFolder() async throws { + let rootURL = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + defer { try? FileManager.default.removeItem(at: rootURL) } + + let storage = DownloadStore(rootURL: rootURL, fileManager: .default) + let manager = DownloadCoordinator( + storage: storage, + urlSession: .shared + ) + + let olderDate = Date(timeIntervalSince1970: 100) + let newerDate = Date(timeIntervalSince1970: 200) + try storage.ensureRootDirectory() + try writeIndexedManifest( + storage: storage, + relativePath: "Folder/[500_token] Old", + manifest: indexedManifest( + gid: "500", + title: "Old", + pageHashes: ["sha256:old"], + modificationDate: olderDate + ) + ) + try setFolderModificationDate( + olderDate, + storage: storage, + relativePath: "Folder/[500_token] Old" + ) + try writeIndexedManifest( + storage: storage, + relativePath: "Folder/[500_token] New", + manifest: indexedManifest( + gid: "500", + title: "New", + pageHashes: ["sha256:new"], + modificationDate: newerDate + ) + ) + try setFolderModificationDate( + newerDate, + storage: storage, + relativePath: "Folder/[500_token] New" + ) + + let downloads = await manager.reloadDownloadIndex() + + #expect(downloads.map(\.gid) == ["500"]) + let download = try #require(downloads.first) + #expect(download.title == "New") + #expect(download.folderURL == storage.folderURL(relativePath: "Folder/[500_token] New")) + #expect(download.lastDownloadedDate == newerDate) + #expect((await manager.indexedDownload(gid: "500")) == download) + } + + @Test + func testDownloadCoordinatorFetchesDownloadsFromManifestIndex() async throws { + let rootURL = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + defer { try? FileManager.default.removeItem(at: rootURL) } + + let storage = DownloadStore(rootURL: rootURL, fileManager: .default) + let manager = DownloadCoordinator( + storage: storage, + urlSession: .shared + ) + + try storage.ensureRootDirectory() + try writeIndexedManifest( + storage: storage, + relativePath: "Folder/[600_token] Disk", + manifest: indexedManifest( + gid: "600", + title: "Disk", + pageHashes: [""] + ) + ) + await manager.testingSetQueuedGalleryIDs(["600"]) + await manager.reloadDownloadIndex() + + let downloads = await manager.fetchDownloads() + let indexedDownload = try #require(await manager.fetchDownload(gid: "600")) + + #expect(downloads.map(\.gid) == ["600"]) + #expect(indexedDownload.title == "Disk") + #expect(indexedDownload.displayStatus == .queued) + #expect(indexedDownload.displayStatus == .queued) + #expect(await manager.fetchDownload(gid: "601") == nil) + #expect(indexedDownload.badge.status == .queued) + } + + @Test + func testDownloadCoordinatorWarmIndexMissDoesNotRescanDisk() async throws { + let rootURL = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + defer { try? FileManager.default.removeItem(at: rootURL) } + + let storage = DownloadStore(rootURL: rootURL, fileManager: .default) + let manager = DownloadCoordinator(storage: storage, urlSession: .shared) + + try storage.ensureRootDirectory() + try writeIndexedManifest( + storage: storage, + relativePath: "Folder/[610_token] Warm", + manifest: indexedManifest(gid: "610", title: "Warm", pageHashes: ["sha256:known"]) + ) + await manager.reloadDownloadIndex() + try writeIndexedManifest( + storage: storage, + relativePath: "Folder/[611_token] Later", + manifest: indexedManifest(gid: "611", title: "Later", pageHashes: ["sha256:new"]) + ) + + #expect(await manager.fetchDownload(gid: "611") == nil) + } + + @Test + func testDownloadCoordinatorObserverInitialSnapshotUsesManifestIndex() async throws { + let rootURL = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + defer { try? FileManager.default.removeItem(at: rootURL) } + + let storage = DownloadStore(rootURL: rootURL, fileManager: .default) + let manager = DownloadCoordinator( + storage: storage, + urlSession: .shared + ) + + try storage.ensureRootDirectory() + try writeIndexedManifest( + storage: storage, + relativePath: "Folder/[700_token] Observed", + manifest: indexedManifest( + gid: "700", + title: "Observed", + pageHashes: ["sha256:1"] + ) + ) + await manager.reloadDownloadIndex() + + let stream = await manager.observeDownloads() + let initialSnapshotTask = Task<[DownloadedGallery]?, Never> { + var iterator = stream.makeAsyncIterator() + return await iterator.next() + } + + let snapshot = try await waitForTaskValue( + initialSnapshotTask, + timeout: .seconds(1), + description: "initial download observer snapshot" + ) + let downloads = try #require(snapshot) + let download = try #require(downloads.first) + + #expect(downloads.map(\.gid) == ["700"]) + #expect(download.title == "Observed") + #expect(download.displayStatus == .completed) + } + + @Test + func testDownloadCoordinatorIndexAppliesSessionOnlyFlags() async throws { + let rootURL = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + defer { try? FileManager.default.removeItem(at: rootURL) } + + let storage = DownloadStore(rootURL: rootURL, fileManager: .default) + let manager = DownloadCoordinator( + storage: storage, + urlSession: .shared + ) + + try storage.ensureRootDirectory() + try writeIndexedManifest( + storage: storage, + relativePath: "Folder/[300_token] Updated", + manifest: indexedManifest( + gid: "300", + title: "Updated", + pageHashes: ["sha256:1"] + ) + ) + try writeIndexedManifest( + storage: storage, + relativePath: "Folder/[400_token] Failed", + manifest: indexedManifest( + gid: "400", + title: "Failed", + pageHashes: [""] + ) + ) + await manager.testingSetUpdatedGalleryIDs(["300"]) + await manager.testingSetDownloadError( + .init(code: .networkingFailed, message: "Network Error"), + gid: "400" + ) + + let downloads = await manager.reloadDownloadIndex() + + let updatedDownload = try #require(downloads.first { $0.gid == "300" }) + let failedDownload = try #require(downloads.first { $0.gid == "400" }) + #expect(updatedDownload.displayStatus == .updateAvailable) + #expect(failedDownload.displayStatus == .error) + #expect(failedDownload.lastError?.code == .networkingFailed) + } + + @Test + func testDownloadCoordinatorReconcileClearsIndexedCancellationError() async throws { + let rootURL = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + defer { try? FileManager.default.removeItem(at: rootURL) } + + let storage = DownloadStore(rootURL: rootURL, fileManager: .default) + let manager = DownloadCoordinator( + storage: storage, + urlSession: .shared + ) + let cancellationFailure = DownloadFailure( + code: .fileOperationFailed, + message: "The operation was cancelled." + ) + + try storage.ensureRootDirectory() + try writeIndexedManifest( + storage: storage, + relativePath: "Folder/[410_token] Cancelled", + manifest: indexedManifest( + gid: "410", + title: "Cancelled", + pageHashes: [""] + ) + ) + await manager.testingSetDownloadError( + cancellationFailure, + gid: "410" + ) + + await manager.reconcileDownloads() + + let download = try #require(await manager.fetchDownload(gid: "410")) + #expect(download.displayStatus == .inactive) + #expect(download.displayStatus == .inactive) + #expect(download.lastError == nil) + } + + @Test + func testDownloadCoordinatorReconcileClearsIndexedInterruptedActiveFlag() async throws { + let rootURL = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + defer { try? FileManager.default.removeItem(at: rootURL) } + + let storage = DownloadStore(rootURL: rootURL, fileManager: .default) + let manager = DownloadCoordinator( + storage: storage, + urlSession: .shared + ) + + try storage.ensureRootDirectory() + try writeIndexedManifest( + storage: storage, + relativePath: "Folder/[420_token] Interrupted", + manifest: indexedManifest( + gid: "420", + title: "Interrupted", + pageHashes: [""] + ) + ) + await manager.testingSetActiveGalleryID("420") + + await manager.reconcileDownloads() + + let download = try #require(await manager.fetchDownload(gid: "420")) + #expect(download.displayStatus == .inactive) + #expect(download.displayStatus == .inactive) + #expect(await manager.testingActiveGalleryID() == nil) + } + + @Test + func testDownloadCoordinatorSanitizeClearsIndexedError() async throws { + let rootURL = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + defer { try? FileManager.default.removeItem(at: rootURL) } + + let storage = DownloadStore(rootURL: rootURL, fileManager: .default) + let manager = DownloadCoordinator( + storage: storage, + urlSession: .shared + ) + let failure = DownloadFailure( + code: .fileOperationFailed, + message: "Page 1 is missing." + ) + + try storage.ensureRootDirectory() + try writeIndexedManifest( + storage: storage, + relativePath: "Folder/[430_token] Sanitize", + manifest: indexedManifest( + gid: "430", + title: "Sanitize", + pageHashes: [""] + ) + ) + await manager.reloadDownloadIndex() + await manager.testingSetDownloadError(failure, gid: "430") + + let sanitizedDownload = await manager.sanitizeLocalFilesIfNeeded( + gid: "430", + clearingLastError: true + ) + + #expect(sanitizedDownload?.displayStatus == .inactive) + #expect(sanitizedDownload?.displayStatus == .inactive) + #expect(sanitizedDownload?.lastError == nil) + } + + @Test + func testDownloadCoordinatorValidateIndexedMissingFileUsesSessionError() async throws { + let rootURL = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + defer { try? FileManager.default.removeItem(at: rootURL) } + + let storage = DownloadStore(rootURL: rootURL, fileManager: .default) + let manager = DownloadCoordinator( + storage: storage, + urlSession: .shared + ) + + try storage.ensureRootDirectory() + try writeIndexedManifest( + storage: storage, + relativePath: "Folder/[440_token] Missing", + manifest: indexedManifest( + gid: "440", + title: "Missing", + pageHashes: ["sha256:missing"] + ) + ) + await manager.reloadDownloadIndex() + + let validation = await manager.validateImageData(gid: "440") + + #expect(validation == .missingFiles(.RLocalizable.downloadStorePageMissing(page: 1))) + let download = try #require(await manager.fetchDownload(gid: "440")) + #expect(download.displayStatus == .error) + #expect(download.displayStatus == .error) + #expect(download.lastError?.code == .fileOperationFailed) + } + + @Test + func testDownloadCoordinatorRetryIndexedDownloadUsesQueueIntent() async throws { + let rootURL = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + defer { try? FileManager.default.removeItem(at: rootURL) } + + let storage = DownloadStore(rootURL: rootURL, fileManager: .default) + let queueStore = DownloadQueueStore(fileURL: storage.queueURL()) + let manager = DownloadCoordinator( + storage: storage, + urlSession: .shared, + queueStore: queueStore + ) + + try storage.ensureRootDirectory() + try writeIndexedManifest( + storage: storage, + relativePath: "Folder/[450_token] Retry", + manifest: indexedManifest( + gid: "450", + title: "Retry", + pageHashes: ["sha256:done"] + ) + ) + await manager.reloadDownloadIndex() + let blockingTask = Task { + do { + try await Task.sleep(for: .seconds(60)) + } catch {} + } + defer { blockingTask.cancel() } + await manager.testingInstallActiveTask(gid: "busy", task: blockingTask) + + let result = await manager.retry(gid: "450", mode: .redownload) + + guard case .success = result else { + Issue.record("Retry should succeed, got \(result).") + return + } + let download = try #require(await manager.fetchDownload(gid: "450")) + #expect(queueStore.gids == ["450"]) + #expect(download.displayStatus == .queued) + #expect(download.displayStatus == .queued) + } + + @Test + func testDownloadCoordinatorRetryPagesIndexedDownloadUsesQueueIntent() async throws { + let rootURL = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + defer { try? FileManager.default.removeItem(at: rootURL) } + + let storage = DownloadStore(rootURL: rootURL, fileManager: .default) + let queueStore = DownloadQueueStore(fileURL: storage.queueURL()) + let manager = DownloadCoordinator( + storage: storage, + urlSession: .shared, + queueStore: queueStore + ) + + try storage.ensureRootDirectory() + let folderRelativePath = "Folder/[460_token] Retry Pages" + try writeIndexedManifest( + storage: storage, + relativePath: folderRelativePath, + manifest: indexedManifest( + gid: "460", + title: "Retry Pages", + pageHashes: ["sha256:done", ""] + ) + ) + await manager.reloadDownloadIndex() + await manager.testingSetFailedPageErrors( + [ + .init( + index: 2, + relativePath: "460_token_2.jpg", + error: .networkingFailed + ) + ], + gid: "460" + ) + let blockingTask = Task { + do { + try await Task.sleep(for: .seconds(60)) + } catch {} + } + defer { blockingTask.cancel() } + await manager.testingInstallActiveTask(gid: "busy", task: blockingTask) + + let result = await manager.retryPages(gid: "460", pageIndices: [2]) + + guard case .success = result else { + Issue.record("Retry pages should succeed, got \(result).") + return + } + let download = try #require(await manager.fetchDownload(gid: "460")) + #expect(queueStore.gids == ["460"]) + #expect(download.displayStatus == .queued) + #expect(download.displayStatus == .queued) + } + + @Test + func testDownloadCoordinatorFailureSettlesQueueIntent() async throws { + let rootURL = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + defer { try? FileManager.default.removeItem(at: rootURL) } + + let storage = DownloadStore(rootURL: rootURL, fileManager: .default) + let queueStore = DownloadQueueStore(fileURL: storage.queueURL()) + let manager = DownloadCoordinator( + storage: storage, + urlSession: .shared, + queueStore: queueStore + ) + + try storage.ensureRootDirectory() + try writeIndexedManifest( + storage: storage, + relativePath: "Folder/[800_token] Failing", + manifest: indexedManifest( + gid: "800", + title: "Failing", + pageHashes: [""] + ) + ) + await manager.reloadDownloadIndex() + await queueStore.enqueue("800") + let download = try #require(await manager.fetchDownload(gid: "800")) + + await manager.persistFailure( + error: .networkingFailed, + context: .init( + gid: "800", + originalDownload: download, + mode: .initial + ) + ) + + let failedDownload = try #require(await manager.fetchDownload(gid: "800")) + + #expect(queueStore.gids == []) + #expect(failedDownload.displayStatus == .error) + #expect(failedDownload.displayStatus == .error) + #expect(failedDownload.lastError?.code == .networkingFailed) + #expect(failedDownload.badge.status == .error) + } + + @Test + func testDownloadCoordinatorCompletionSettlesQueueIntent() async throws { + let rootURL = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + defer { try? FileManager.default.removeItem(at: rootURL) } + + let storage = DownloadStore(rootURL: rootURL, fileManager: .default) + let queueStore = DownloadQueueStore(fileURL: storage.queueURL()) + let manager = DownloadCoordinator( + storage: storage, + urlSession: .shared, + queueStore: queueStore + ) + + let gallery = sampleGallery() + let detail = sampleGalleryDetail(gid: gallery.gid, title: "Complete") + let folderRelativePath = "Folder/" + storage.makeFolderRelativePath( + gid: gallery.gid, + token: gallery.token, + title: detail.trimmedTitle + ) + + try storage.ensureRootDirectory() + try writeIndexedManifest( + storage: storage, + relativePath: folderRelativePath, + manifest: indexedManifest( + gid: gallery.gid, + title: "Complete", + pageHashes: Array( + repeating: "sha256:done", + count: detail.pageCount + ) + ) + ) + await manager.reloadDownloadIndex() + await queueStore.enqueue(gallery.gid) + await manager.testingSetDownloadError( + .init(code: .networkingFailed, message: "failed"), + gid: gallery.gid + ) + + await manager.settleCompletedDownload(gid: gallery.gid) + + let completedDownload = try #require( + await manager.fetchDownload(gid: gallery.gid) + ) + + #expect(queueStore.gids == []) + #expect(completedDownload.displayStatus == .completed) + #expect(completedDownload.lastError == nil) + } + + @Test + func testDownloadCoordinatorPauseAndResumeMutateQueueIntent() async throws { + let rootURL = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + defer { try? FileManager.default.removeItem(at: rootURL) } + + let storage = DownloadStore(rootURL: rootURL, fileManager: .default) + let queueStore = DownloadQueueStore(fileURL: storage.queueURL()) + let manager = DownloadCoordinator( + storage: storage, + urlSession: .shared, + queueStore: queueStore + ) + + try storage.ensureRootDirectory() + try writeIndexedManifest( + storage: storage, + relativePath: "Folder/[820_token] Pausable", + manifest: indexedManifest( + gid: "820", + title: "Pausable", + pageHashes: ["sha256:1", ""] + ) + ) + await manager.reloadDownloadIndex() + await queueStore.enqueue("820") + await manager.testingSetDownloadError( + .init(code: .networkingFailed, message: "failed"), + gid: "820" + ) + let activeTask = Task { + do { + try await Task.sleep(for: .seconds(60)) + } catch {} + } + await manager.testingInstallActiveTask(gid: "820", task: activeTask) + + let pauseResult = await manager.pause(gid: "820") + + guard case .success = pauseResult else { + Issue.record("Pause should succeed, got \(pauseResult).") + return + } + let pausedDownload = try #require(await manager.fetchDownload(gid: "820")) + #expect(queueStore.gids == []) + #expect(await manager.testingActiveGalleryID() == nil) + #expect(pausedDownload.displayStatus == .inactive) + #expect(pausedDownload.displayStatus == .inactive) + #expect(pausedDownload.lastError == nil) + + await manager.testingInstallActiveTask(gid: "busy", task: Task {}) + let resumeResult = await manager.resume(gid: "820") + + guard case .success = resumeResult else { + Issue.record("Resume should succeed, got \(resumeResult).") + return + } + let resumedDownload = try #require(await manager.fetchDownload(gid: "820")) + #expect(queueStore.gids == ["820"]) + #expect(resumedDownload.displayStatus == .queued) + #expect(resumedDownload.displayStatus == .queued) + #expect(resumedDownload.lastError == nil) + } + + @Test + func testDownloadCoordinatorSchedulesManifestQueueOrder() async throws { + let rootURL = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + defer { try? FileManager.default.removeItem(at: rootURL) } + + let storage = DownloadStore(rootURL: rootURL, fileManager: .default) + let queueStore = DownloadQueueStore(fileURL: storage.queueURL()) + let scheduledRecorder = ScheduledGalleryRecorder() + let taskRunner = DownloadTaskRunner( + recordScheduledGallery: { gid in + scheduledRecorder.record(gid) + }, + runScheduledDownload: { _, _ in + while !Task.isCancelled { + try? await Task.sleep(for: .milliseconds(10)) + } + return .skippedOperation + } + ) + let manager = DownloadCoordinator( + storage: storage, + urlSession: .shared, + queueStore: queueStore, + taskRunner: taskRunner + ) + + try storage.ensureRootDirectory() + try writeIndexedManifest( + storage: storage, + relativePath: "Folder/[830_token] First", + manifest: indexedManifest( + gid: "830", + title: "First", + pageHashes: [""], + modificationDate: Date(timeIntervalSince1970: 100) + ) + ) + try writeIndexedManifest( + storage: storage, + relativePath: "Folder/[831_token] Newer", + manifest: indexedManifest( + gid: "831", + title: "Newer", + pageHashes: [""], + modificationDate: Date(timeIntervalSince1970: 200) + ) + ) + await manager.reloadDownloadIndex() + await queueStore.enqueue("830") + await queueStore.enqueue("831") + + await manager.scheduleNextIfNeeded() + + let scheduledGalleryIDs = scheduledRecorder.snapshot() + #expect(scheduledGalleryIDs == ["830"]) + #expect(await manager.testingActiveGalleryID() == "830") + + guard case .success = await manager.pause(gid: "830") else { + Issue.record("Pause should cancel the active queued test download.") + return + } + } + + @Test + func testDownloadCoordinatorFlushProgressUpdatesManifestPageHash() async throws { + let rootURL = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + defer { try? FileManager.default.removeItem(at: rootURL) } + + let storage = DownloadStore(rootURL: rootURL, fileManager: .default) + let manager = DownloadCoordinator( + storage: storage, + urlSession: .shared + ) + + try storage.ensureRootDirectory() + let folderRelativePath = "Folder/[840_token] Progress" + try writeIndexedManifest( + storage: storage, + relativePath: folderRelativePath, + manifest: indexedManifest( + gid: "840", + title: "Progress", + pageHashes: ["", ""] + ) + ) + let folderURL = storage.folderURL(relativePath: folderRelativePath) + let pageRelativePath = "840_token_1.jpg" + try Data([0x01, 0x02, 0x03]).write( + to: folderURL.appendingPathComponent(pageRelativePath), + options: .atomic + ) + await manager.reloadDownloadIndex() + var pendingResolvedPages = [ + DownloadCoordinator.PageResult( + index: 1, + relativePath: pageRelativePath, + imageURL: nil + ) + ] + var lastFlushDate = Date.distantPast + + try await manager.flushDownloadProgress( + context: .init(gid: "840", folderURL: folderURL), + pendingResolvedPages: &pendingResolvedPages, + lastFlushDate: &lastFlushDate, + force: true + ) + + let manifest = try storage.readManifest(folderURL: folderURL) + let download = try #require(await manager.fetchDownload(gid: "840")) + + #expect(pendingResolvedPages.isEmpty) + #expect(manifest.pages[1]?.hasPrefix("sha256:") == true) + #expect(manifest.pages[2] == "") + #expect(download.completedPageCount == 1) + } + + @Test + func testDownloadCoordinatorLoadInspectionUsesFinalFailedPagesSnapshot() async throws { + let gid = String(Int(Date().timeIntervalSince1970 * 1000)) + let rootURL = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + defer { try? FileManager.default.removeItem(at: rootURL) } + + let storage = DownloadStore(rootURL: rootURL, fileManager: .default) + let manager = DownloadCoordinator( + storage: storage, + urlSession: .shared + ) + + try storage.ensureRootDirectory() + let folderRelativePath = "Folder/[\(gid)_token] Inspect" + try writeIndexedManifest( + storage: storage, + relativePath: folderRelativePath, + manifest: indexedManifest( + gid: gid, + title: "Inspect", + pageHashes: ["sha256:done", ""] + ) + ) + await manager.reloadDownloadIndex() + let folderURL = storage.folderURL(relativePath: folderRelativePath) + try FileManager.default.createDirectory( + at: folderURL, + withIntermediateDirectories: true + ) + try Data([0x01]).write( + to: folderURL.appendingPathComponent("\(gid)_token_1.jpg"), + options: .atomic + ) + await manager.testingSetFailedPageErrors( + [ + .init( + index: 2, + relativePath: "\(gid)_token_2.jpg", + error: .networkingFailed + ) + ], + gid: gid + ) + + let result = await manager.loadInspection(gid: gid) + let inspection = try result.get() + + #expect(inspection.pages[0].status == .downloaded) + #expect(inspection.pages[1].status == .failed) + #expect(inspection.pages[1].failure?.code == .networkingFailed) + } + + @Test + func testDownloadCoordinatorLoadLocalPageURLsPrefersCompletedFolderForCompletedDownload() async throws { + let gid = String(Int(Date().timeIntervalSince1970 * 1000) + 11) + let rootURL = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + defer { try? FileManager.default.removeItem(at: rootURL) } + + let storage = DownloadStore(rootURL: rootURL, fileManager: .default) + let manager = DownloadCoordinator( + storage: storage, + urlSession: .shared + ) + + let completedFolderURL = rootURL.appendingPathComponent("Folder/\(gid) - Pause Race", isDirectory: true) + try FileManager.default.createDirectory( + at: completedFolderURL, + withIntermediateDirectories: true + ) + let manifest = try indexedManifest( + gid: gid, + title: "Pause Race", + pageHashes: ["sha256:1", "sha256:2"] + ) + try JSONEncoder().encode(manifest).write( + to: completedFolderURL.appendingPathComponent(Defaults.FilePath.downloadManifest), + options: .atomic + ) + try Data([0x00]).write( + to: completedFolderURL.appendingPathComponent("\(gid)_token_cover.jpg"), + options: .atomic + ) + let completedPageURL = completedFolderURL.appendingPathComponent("\(gid)_token_1.jpg") + try Data([0x01]).write(to: completedPageURL, options: .atomic) + try Data([0x02]).write( + to: completedFolderURL.appendingPathComponent("\(gid)_token_2.jpg"), + options: .atomic + ) + await manager.reloadDownloadIndex() + + let pageURLs = try await manager.loadLocalPageURLs(gid: gid).get() + + #expect(pageURLs[1] == completedPageURL) + #expect(pageURLs[3] == nil) + } + + @Test + func testDownloadCoordinatorLoadLocalPageURLsUsesReadableCompletedPages() async throws { + let gid = String(Int(Date().timeIntervalSince1970 * 1000) + 12) + let rootURL = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + defer { try? FileManager.default.removeItem(at: rootURL) } + + let storage = DownloadStore(rootURL: rootURL, fileManager: .default) + let manager = DownloadCoordinator( + storage: storage, + urlSession: .shared + ) + + let completedFolderURL = rootURL.appendingPathComponent("Folder/\(gid) - Pause Race", isDirectory: true) + try FileManager.default.createDirectory( + at: completedFolderURL, + withIntermediateDirectories: true + ) + let manifest = try sampleManifest(gid: gid, title: "Pause Race") + try JSONEncoder().encode(manifest).write( + to: completedFolderURL.appendingPathComponent(Defaults.FilePath.downloadManifest), + options: .atomic + ) + try Data([0x00]).write( + to: completedFolderURL.appendingPathComponent("\(gid)_token_cover.jpg"), + options: .atomic + ) + let page1URL = completedFolderURL.appendingPathComponent("\(gid)_token_1.jpg") + let page2URL = completedFolderURL.appendingPathComponent("\(gid)_token_2.jpg") + try Data([0x01]).write( + to: page1URL, + options: .atomic + ) + try Data([0x09]).write( + to: page2URL, + options: .atomic + ) + await manager.reloadDownloadIndex() + + let pageURLs = try await manager.loadLocalPageURLs(gid: gid).get() + + #expect(pageURLs[1] == page1URL) + #expect(pageURLs[2] == page2URL) + } + +} + +private extension DownloadCoordinatorStorageTests { + func writeIndexedManifest( + storage: DownloadStore, + relativePath: String, + manifest: DownloadManifest + ) throws { + let folderURL = storage.folderURL(relativePath: relativePath) + try FileManager.default.createDirectory( + at: folderURL, + withIntermediateDirectories: true + ) + try storage.writeManifest(manifest, folderURL: folderURL) + try FileManager.default.setAttributes( + [.modificationDate: manifest.postedDate], + ofItemAtPath: folderURL.path + ) + } + + func indexedManifest( + gid: String, + title: String, + pageHashes: [String], + modificationDate: Date = .now + ) throws -> DownloadManifest { + DownloadManifest( + gid: gid, + host: .ehentai, + token: "token", + title: title, + jpnTitle: nil, + category: .doujinshi, + language: .japanese, + remoteCoverURL: URL(string: "https://example.com/cover.jpg"), + uploader: "Uploader", + tags: [], + postedDate: modificationDate, + rating: 4, + pages: Dictionary( + uniqueKeysWithValues: + pageHashes.enumerated().map { ($0.offset + 1, $0.element) } + ) + ) + } + + func setFolderModificationDate( + _ date: Date, + storage: DownloadStore, + relativePath: String + ) throws { + try FileManager.default.setAttributes( + [.modificationDate: date], + ofItemAtPath: storage.folderURL(relativePath: relativePath).path + ) + } +} diff --git a/AppPackage/Tests/DownloadsFeatureTests/DownloadEnqueueManifestTests.swift b/AppPackage/Tests/DownloadsFeatureTests/DownloadEnqueueManifestTests.swift new file mode 100644 index 000000000..2d071f1b0 --- /dev/null +++ b/AppPackage/Tests/DownloadsFeatureTests/DownloadEnqueueManifestTests.swift @@ -0,0 +1,149 @@ +import AppTools +import Foundation +import AppModels +import Testing +import DownloadClient +@testable import AppFeature + +struct DownloadEnqueueManifestTests: DownloadFeatureTestCase { + @Test + func testEnqueueWritesInitialManifestAndQueueIntent() async throws { + let rootURL = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + defer { try? FileManager.default.removeItem(at: rootURL) } + + let storage = DownloadStore(rootURL: rootURL, fileManager: .default) + let queueStore = DownloadQueueStore(fileURL: storage.queueURL()) + let manager = DownloadCoordinator( + storage: storage, + urlSession: .shared, + queueStore: queueStore + ) + await manager.testingInstallActiveTask(gid: "busy", task: Task {}) + + let gallery = sampleGallery() + let detail = sampleGalleryDetail(gid: gallery.gid, title: "Downloaded / Gallery") + let payload = DownloadRequestPayload( + gallery: gallery, + galleryDetail: detail, + previewURLs: [:], + previewConfig: .normal(rows: 4), + host: .ehentai, + folderName: "Folder", + mode: .initial + ) + + // Warm the index the way launch does; enqueue then patches it in place. + await manager.reloadDownloadIndex() + let result = await manager.enqueue(payload: payload) + + guard case .success = result else { + Issue.record("Expected enqueue to succeed, got \(result).") + return + } + + let folderRelativePath = "Folder/" + storage.makeFolderRelativePath( + gid: gallery.gid, + token: gallery.token, + title: detail.trimmedTitle + ) + let manifest = try storage.readManifest( + folderURL: storage.folderURL(relativePath: folderRelativePath) + ) + + #expect(queueStore.gids == [gallery.gid]) + #expect(manifest.gid == gallery.gid) + #expect(manifest.token == gallery.token) + #expect(manifest.pageCount == detail.pageCount) + #expect(manifest.remoteCoverURL == detail.coverURL) + #expect(manifest.pages.count == detail.pageCount) + #expect(manifest.pages[1] == "") + + let manifestData = try Data( + contentsOf: storage + .folderURL(relativePath: folderRelativePath) + .appendingPathComponent(Defaults.FilePath.downloadManifest) + ) + let manifestObject = try #require( + JSONSerialization.jsonObject(with: manifestData) as? [String: Any] + ) + #expect(manifestObject["downloadOptions"] == nil) + + let queuedDownload = await manager.fetchDownload(gid: gallery.gid) + #expect(queuedDownload?.displayStatus == .queued) + #expect(queuedDownload?.onlineCoverURL == detail.coverURL) + #expect(queuedDownload?.pageCount == detail.pageCount) + } + + @Test + func testEnqueuePreservesExistingManifestHashes() async throws { + let rootURL = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + defer { try? FileManager.default.removeItem(at: rootURL) } + + let storage = DownloadStore(rootURL: rootURL, fileManager: .default) + let queueStore = DownloadQueueStore(fileURL: storage.queueURL()) + let manager = DownloadCoordinator( + storage: storage, + urlSession: .shared, + queueStore: queueStore + ) + await manager.testingInstallActiveTask(gid: "busy", task: Task {}) + + let gallery = sampleGallery() + let detail = sampleGalleryDetail(gid: gallery.gid, title: gallery.title) + let folderRelativePath = "Folder/" + storage.makeFolderRelativePath( + gid: gallery.gid, + token: gallery.token, + title: detail.trimmedTitle + ) + let folderURL = storage.folderURL(relativePath: folderRelativePath) + try FileManager.default.createDirectory( + at: folderURL, + withIntermediateDirectories: true + ) + let pages = Dictionary( + uniqueKeysWithValues: (1...detail.pageCount).map { + ($0, "sha256:existing-\($0)") + } + ) + let existingManifest = DownloadManifest( + gid: gallery.gid, + host: .ehentai, + token: gallery.token, + title: gallery.title, + jpnTitle: detail.jpnTitle, + category: gallery.category, + language: detail.language, + remoteCoverURL: detail.coverURL, + uploader: detail.uploader, + tags: gallery.tags, + postedDate: detail.postedDate, + rating: detail.rating, + pages: pages + ) + try storage.writeManifest(existingManifest, folderURL: folderURL) + + // Warm the index the way launch does; enqueue then patches it in place. + await manager.reloadDownloadIndex() + let result = await manager.enqueue(payload: .init( + gallery: gallery, + galleryDetail: detail, + previewURLs: [:], + previewConfig: .normal(rows: 4), + host: .ehentai, + folderName: "Folder", + mode: .initial + )) + + guard case .success = result else { + Issue.record("Expected enqueue to succeed, got \(result).") + return + } + + let preservedManifest = try storage.readManifest(folderURL: folderURL) + #expect(preservedManifest.pages == pages) + #expect(queueStore.gids == [gallery.gid]) + #expect(await manager.fetchDownload(gid: gallery.gid)?.completedPageCount == detail.pageCount) + } +} diff --git a/AppPackage/Tests/DownloadsFeatureTests/DownloadFeatureTestFactories.swift b/AppPackage/Tests/DownloadsFeatureTests/DownloadFeatureTestFactories.swift new file mode 100644 index 000000000..8db79af1a --- /dev/null +++ b/AppPackage/Tests/DownloadsFeatureTests/DownloadFeatureTestFactories.swift @@ -0,0 +1,401 @@ +import AppModels +import Foundation +import Testing +import AppTools +import DownloadClient +import AppLaunchAutomationClient +@testable import AppFeature + +// MARK: - Sample Data Factories + +enum DownloadFixtureStatus { + case queued + case downloading + case paused + case partial + case completed + case failed + case updateAvailable + case missingFiles + + var displayStatus: DownloadDisplayStatus { + switch self { + case .queued: + return .queued + case .downloading: + return .active + case .paused: + return .inactive + case .partial: + return .error + case .completed: + return .completed + case .failed, .missingFiles: + return .error + case .updateAvailable: + return .updateAvailable + } + } + + var defaultLastError: DownloadFailure? { + switch self { + case .failed: + return .init(code: .networkingFailed, message: "Network Error") + case .missingFiles: + return .init(code: .fileOperationFailed, message: "Page 2 is missing.") + case .queued, .downloading, .paused, .partial, .completed, .updateAvailable: + return nil + } + } + + func defaultCompletedPageCount(pageCount: Int) -> Int { + switch self { + case .completed, .updateAvailable: + return pageCount + case .queued, .downloading, .paused, .partial, .failed, .missingFiles: + return 0 + } + } +} + +extension DownloadedGallery { + init( + gid: String, + host: GalleryHost, + token: String, + title: String, + jpnTitle: String?, + uploader: String?, + category: AppModels.Category, + tags: [GalleryTag], + pageCount: Int, + postedDate: Date, + rating: Float, + onlineCoverURL: URL?, + folderURL: URL, + folderName: String = "Folder", + localCoverURL: URL? = nil, + localPageURLs: [Int: URL] = [:], + displayStatus: DownloadDisplayStatus, + completedPageCount: Int, + lastDownloadedDate: Date?, + lastError: DownloadFailure? + ) { + let clampedCompletedPageCount = min(max(completedPageCount, 0), pageCount) + let manifest = DownloadManifest( + gid: gid, + host: host, + token: token, + title: title, + jpnTitle: jpnTitle, + category: category, + language: .japanese, + remoteCoverURL: onlineCoverURL, + uploader: uploader, + tags: tags, + postedDate: postedDate, + rating: rating, + pages: pageCount > 0 + ? Dictionary( + uniqueKeysWithValues: (1...pageCount).map { + ($0, $0 <= clampedCompletedPageCount ? "sha256:fixture-\($0)" : "") + } + ) + : [:] + ) + self.init( + manifest: manifest, + folderURL: folderURL, + folderName: folderName, + localCoverURL: localCoverURL, + localPageURLs: localPageURLs, + modificationDate: lastDownloadedDate, + displayStatus: displayStatus, + lastError: lastError + ) + } +} + +extension DownloadFeatureTestCase { + func appLaunchAutomationClient( + _ automation: AppLaunchAutomation? + ) -> AppLaunchAutomationClient { + .init(current: { automation }) + } + + func appLaunchAutomationClient( + autoDownloadGID: String + ) -> AppLaunchAutomationClient { + appLaunchAutomationClient( + AppLaunchAutomation( + initialTab: nil, + autoDownloadGID: autoDownloadGID, + downloadFolderName: nil, + loginCookies: nil, + galleryURL: nil + ) + ) + } + + func sampleManifest( + gid: String, + title: String, + pageCount: Int = 2 + ) throws -> DownloadManifest { + DownloadManifest( + gid: gid, + host: .ehentai, + token: "token", + title: title, + jpnTitle: nil, + category: .doujinshi, + language: .japanese, + remoteCoverURL: URL(string: "https://example.com/cover.jpg"), + uploader: "Uploader", + tags: [], + postedDate: .now, + rating: 4, + pages: pageCount > 0 + ? Dictionary(uniqueKeysWithValues: (1...pageCount).map { ($0, "") }) + : [:] + ) + } + + func sampleInspection( + download: DownloadedGallery + ) -> DownloadInspection { + .init( + download: download, + coverURL: download.coverURL, + pages: [ + .init( + index: 1, + status: .downloaded, + relativePath: "\(download.gid)_\(download.token)_1.jpg", + fileURL: URL(fileURLWithPath: "/tmp/0001.jpg"), + failure: nil + ), + .init( + index: 2, + status: .failed, + relativePath: "\(download.gid)_\(download.token)_2.jpg", + fileURL: nil, + failure: .init(code: .networkingFailed, message: "Network Error") + ) + ] + ) + } + + func sampleDownload( + gid: String, + title: String, + status: DownloadFixtureStatus, + category: AppModels.Category = .doujinshi, + pageCount: Int = 12, + completedPageCount: Int? = nil, + lastDownloadedDate: Date? = .now, + lastError: DownloadFailure? = nil, + folderURL: URL? = nil, + folderName: String = "Folder", + localCoverURL: URL? = nil, + localPageURLs: [Int: URL] = [:] + ) -> DownloadedGallery { + let resolvedFolderURL = folderURL ?? FileUtil.downloadsDirectoryURL + .appendingPathComponent(folderName, isDirectory: true) + .appendingPathComponent("[\(gid)_token] \(title)", isDirectory: true) + return DownloadedGallery( + gid: gid, + host: .ehentai, + token: "token", + title: title, + jpnTitle: nil, + uploader: "Uploader", + category: category, + tags: [], + pageCount: pageCount, + postedDate: .now, + rating: 4, + onlineCoverURL: URL(string: "https://example.com/cover.jpg"), + folderURL: resolvedFolderURL, + folderName: folderName, + localCoverURL: localCoverURL, + localPageURLs: localPageURLs, + displayStatus: status.displayStatus, + completedPageCount: completedPageCount + ?? status.defaultCompletedPageCount(pageCount: pageCount), + lastDownloadedDate: lastDownloadedDate, + lastError: lastError ?? status.defaultLastError + ) + } + + func prepareLocalDownloadFiles( + download: DownloadedGallery, + manifest: DownloadManifest + ) throws -> URL { + let folderURL = download.folderURL + try? FileManager.default.removeItem(at: folderURL) + try FileManager.default.createDirectory( + at: folderURL, + withIntermediateDirectories: true + ) + try JSONEncoder().encode(manifest).write( + to: folderURL.appendingPathComponent(Defaults.FilePath.downloadManifest), + options: .atomic + ) + try Data([0x01]).write( + to: folderURL.appendingPathComponent( + "\(manifest.gid)_\(manifest.token)_1.jpg" + ), + options: .atomic + ) + try Data([0x02]).write( + to: folderURL.appendingPathComponent( + "\(manifest.gid)_\(manifest.token)_2.jpg" + ), + options: .atomic + ) + return folderURL + } +} + +// MARK: - Stub Handler Content + +struct StubHandlerContent: Sendable { + let detailHTML: Data + let mpvHTML: Data + let metadataResponse: Data +} + +// MARK: - Stub Route Context + +struct StubRouteContext: Sendable { + let gid: String + let pageIndex: Int + let content: StubHandlerContent + let recorder: RequestRecorder? +} + +// MARK: - Stub Manager & Handler Helpers + +extension DownloadFeatureTestCase { + func makeStubbedDownloadCoordinator( + rootURL: URL, + sessionID: String, + downloadOptionsProvider: @escaping @Sendable () async -> DownloadRequestOptions = { + DownloadRequestOptions() + }, + taskRunner: DownloadTaskRunner = .init() + ) -> (DownloadStore, DownloadCoordinator) { + let configuration = URLSessionConfiguration.ephemeral + configuration.protocolClasses = [SharedSessionStubURLProtocol.self] + configuration.httpAdditionalHeaders = [ + SharedSessionStubURLProtocol.headerKey: sessionID + ] + let storage = DownloadStore( + rootURL: rootURL, fileManager: .default + ) + let manager = DownloadCoordinator( + storage: storage, + urlSession: URLSession(configuration: configuration), + downloadOptionsProvider: downloadOptionsProvider, + taskRunner: taskRunner + ) + return (storage, manager) + } + + func makeMetadataResponseData( + gid: String, + token: String = "token" + ) throws -> Data { + let gidInt = try #require(Int(gid)) + return try JSONSerialization.data(withJSONObject: [ + "gmetadata": [[ + "gid": gidInt, "token": token, + "current_gid": gidInt, "current_key": "updated-key", + "parent_gid": gidInt, "parent_key": token, + "first_gid": gidInt, "first_key": token + ]] + ]) + } + + func installDownloadStubHandler( + sessionID: String, gid: String, pageIndex: Int, + content: StubHandlerContent, + recorder: RequestRecorder? = nil, + allowedImageURLs: Set = [] + ) { + let context = StubRouteContext( + gid: gid, pageIndex: pageIndex, content: content, recorder: recorder + ) + SharedSessionStubURLProtocol.setHandler(for: sessionID) { request in + guard let url = request.url else { throw URLError(.badURL) } + if url.host == "example.com" || allowedImageURLs.contains(url.absoluteString) { + recorder?.recordImageDownload() + return ( + try DownloadFeatureTestStubRouter.stubResponse( + url: url, + contentType: "image/jpeg" + ), + Data([0xFF, 0xD8, 0xFF, 0xD9]) + ) + } + return try DownloadFeatureTestStubRouter.routeStubRequest( + url: url, + request: request, + context: context + ) + } + URLProtocol.registerClass(SharedSessionStubURLProtocol.self) + } +} + +private enum DownloadFeatureTestStubRouter { + static func routeStubRequest( + url: URL, request: URLRequest, + context: StubRouteContext + ) throws -> (HTTPURLResponse, Data) { + let gid = context.gid + let pageIndex = context.pageIndex + let detailHTML = context.content.detailHTML + let mpvHTML = context.content.mpvHTML + let metadataResponse = context.content.metadataResponse + let recorder = context.recorder + if url.host == "api.e-hentai.org" { + recorder?.recordMetadata() + return (try stubResponse(url: url, contentType: "application/json"), metadataResponse) + } + if url.path.contains("/g/\(gid)/token") { + let pageNum = URLComponents(url: url, resolvingAgainstBaseURL: false)? + .queryItems?.first { $0.name == "p" }?.value.flatMap(Int.init) + if let pageNum { recorder?.recordPreview(pageNum) } else { recorder?.recordDetail() } + return (try stubResponse(url: url, contentType: "text/html; charset=utf-8"), detailHTML) + } + if url.path.contains("/mpv/") { + recorder?.recordMPV() + return (try stubResponse(url: url, contentType: "text/html; charset=utf-8"), mpvHTML) + } + if url.path == "/api.php" { + let body = requestBodyData(from: request) + .flatMap { try? JSONSerialization.jsonObject(with: $0) as? [String: Any] } + if body?["method"] as? String == "gdata" { + recorder?.recordMetadata() + return (try stubResponse(url: url, contentType: "application/json"), metadataResponse) + } + recorder?.recordImageDispatch() + let data = try JSONSerialization.data(withJSONObject: [ + "i": "https://example.com/image-\(pageIndex).jpg" + ]) + return (try stubResponse(url: url, contentType: "application/json"), data) + } + throw URLError(.unsupportedURL) + } + + static func stubResponse( + url: URL, contentType: String + ) throws -> HTTPURLResponse { + try #require(HTTPURLResponse( + url: url, statusCode: 200, httpVersion: nil, + headerFields: ["Content-Type": contentType] + )) + } +} diff --git a/AppPackage/Tests/DownloadsFeatureTests/DownloadFeatureTestHelpers.swift b/AppPackage/Tests/DownloadsFeatureTests/DownloadFeatureTestHelpers.swift new file mode 100644 index 000000000..1505c5399 --- /dev/null +++ b/AppPackage/Tests/DownloadsFeatureTests/DownloadFeatureTestHelpers.swift @@ -0,0 +1,283 @@ +import TestingSupport +import AppTools +import Foundation +import AppModels +import ComposableArchitecture +import Kingfisher +import UIKit +import Testing +import LibraryClient +import DownloadClient +@testable import DetailFeature +@testable import AppFeature + +// MARK: - Shared Test Helper Protocol + +protocol DownloadFeatureTestCase: TestHelper { + func waitUntilCacheReady( + for keys: Keys, + timeout: Duration + ) async where Keys.Element == String + + func waitForTaskValue( + _ task: Task, + timeout: Duration, + description: String + ) async throws -> T + + func sampleGalleryState(gid: String) throws -> GalleryState + func sampleVersionMetadata(gid: String, token: String) -> DownloadVersionMetadata + func makeTestingDownloadCoordinator() -> DownloadCoordinator + func makeResponse( + url: URL, + statusCode: Int, + contentType: String, + contentLength: Int?, + headers: [String: String] + ) throws -> HTTPURLResponse + func writeFixtureToTemporaryFile(filename: HTMLFilename) throws -> URL + func writeFixtureToTemporaryFile(resource: String, pathExtension: String) throws -> URL + func fixtureData(resource: String, pathExtension: String) throws -> Data + func installGalleryVersionMetadataStub(for gallery: Gallery, sessionID: String) throws + func uninstallSharedSessionStub(sessionID: String) + func sampleGallery() -> Gallery + func sampleGalleryDetail(gid: String, title: String) -> GalleryDetail + func sampleManifest( + gid: String, + title: String, + pageCount: Int + ) throws -> DownloadManifest + func sampleInspection(download: DownloadedGallery) -> DownloadInspection + func prepareLocalDownloadFiles( + download: DownloadedGallery, + manifest: DownloadManifest + ) throws -> URL +} + +// MARK: - Default Implementations + +extension DownloadFeatureTestCase { + func waitUntilCacheReady( + for keys: Keys, + timeout: Duration = .seconds(1) + ) async where Keys.Element == String { + let cacheKeys = Array(keys) + let clock = ContinuousClock() + let deadline = clock.now.advanced(by: timeout) + + while !cacheKeys.allSatisfy(LibraryClient.live.isCached), + clock.now < deadline { + try? await clock.sleep(until: clock.now.advanced(by: .milliseconds(10)), tolerance: .zero) + } + + let missingKeys = cacheKeys.filter { !LibraryClient.live.isCached($0) } + #expect( + missingKeys.isEmpty, + "Timed out waiting for cache visibility for keys: \(missingKeys)" + ) + } + + func waitForTaskValue( + _ task: Task, + timeout: Duration = .seconds(1), + description: String + ) async throws -> T { + try await withThrowingTaskGroup(of: T.self) { group in + group.addTask { + await task.value + } + group.addTask { + try await Task.sleep(for: timeout) + task.cancel() + throw NSError( + domain: "DownloadFeatureReducerTests", + code: 1, + userInfo: [NSLocalizedDescriptionKey: "Timed out waiting for \(description)"] + ) + } + + let value = try await group.next() + group.cancelAll() + return try #require(value, "Expected one task group result for \(description).") + } + } + + @MainActor + func drainDetailMetadataEffects( + _ store: TestStoreOf, + timeout: Duration = .seconds(1), + condition: @escaping @MainActor () -> Bool + ) async { + let clock = ContinuousClock() + let deadline = clock.now.advanced(by: timeout) + while !condition() && clock.now < deadline { + await store.skipReceivedActions(strict: false) + try? await Task.sleep(for: .milliseconds(10)) + } + await store.skipReceivedActions(strict: false) + } + + func sampleGalleryState(gid: String) throws -> GalleryState { + var galleryState = GalleryState(gid: gid) + galleryState.previewURLs = [1: try #require(URL(string: "https://example.com/1t.jpg"))] + galleryState.previewConfig = .normal(rows: 4) + return galleryState + } + + func sampleVersionMetadata( + gid: String, + token: String + ) -> DownloadVersionMetadata { + DownloadVersionMetadata( + gid: gid, + token: token, + currentGID: gid, + currentKey: "updated-key", + parentGID: gid, + parentKey: token, + firstGID: gid, + firstKey: token + ) + } + + func makeTestingDownloadCoordinator() -> DownloadCoordinator { + makeTestingDownloadCoordinator(storedCookiesProvider: { _ in [] }) + } + + func makeTestingDownloadCoordinator( + storedCookiesProvider: @escaping @Sendable (URL) -> [HTTPCookie] + ) -> DownloadCoordinator { + let rootURL = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + return DownloadCoordinator( + storage: DownloadStore(rootURL: rootURL, fileManager: .default), + urlSession: .shared, + storedCookiesProvider: storedCookiesProvider + ) + } + + func makeResponse( + url: URL, + statusCode: Int = 200, + contentType: String, + contentLength: Int? = nil, + headers: [String: String] = [:] + ) throws -> HTTPURLResponse { + var headerFields = headers + headerFields["Content-Type"] = contentType + if let contentLength { + headerFields["Content-Length"] = "\(contentLength)" + } + return try #require(HTTPURLResponse( + url: url, + statusCode: statusCode, + httpVersion: nil, + headerFields: headerFields + )) + } + + func writeFixtureToTemporaryFile( + filename: HTMLFilename + ) throws -> URL { + try writeFixtureToTemporaryFile(resource: filename.rawValue, pathExtension: "html") + } + + func writeFixtureToTemporaryFile( + resource: String, + pathExtension: String + ) throws -> URL { + let temporaryURL = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString) + .appendingPathExtension(pathExtension) + try fixtureData(resource: resource, pathExtension: pathExtension) + .write(to: temporaryURL, options: .atomic) + return temporaryURL + } + + func fixtureData( + resource: String, + pathExtension: String + ) throws -> Data { + let fixtureURL = try #require( + TestFixtures.url(forResource: resource, withExtension: pathExtension) + ) + return try Data(contentsOf: fixtureURL) + } + + func installGalleryVersionMetadataStub( + for gallery: Gallery, + sessionID: String + ) throws { + let gid = try #require(Int(gallery.gid)) + let payload: [String: Any] = [ + "gmetadata": [[ + "gid": gid, + "token": gallery.token, + "current_gid": gid, + "current_key": "updated-key", + "parent_gid": gid, + "parent_key": gallery.token, + "first_gid": gid, + "first_key": gallery.token + ]] + ] + let responseData = try JSONSerialization.data(withJSONObject: payload, options: []) + SharedSessionStubURLProtocol.setHandler(for: sessionID) { request in + let response = try #require(HTTPURLResponse( + url: request.url ?? Defaults.URL.api, + statusCode: 200, + httpVersion: nil, + headerFields: ["Content-Type": "application/json"] + )) + return (response, responseData) + } + URLProtocol.registerClass(SharedSessionStubURLProtocol.self) + } + + func uninstallSharedSessionStub(sessionID: String) { + SharedSessionStubURLProtocol.removeHandler(for: sessionID) + } + + func sampleGallery() -> Gallery { + Gallery( + gid: "123456", + token: "token", + title: "Sample Gallery", + rating: 4, + tags: [], + category: .doujinshi, + uploader: "Uploader", + pageCount: 12, + postedDate: .now, + coverURL: URL(string: "https://example.com/cover.jpg"), + galleryURL: URL(string: "https://e-hentai.org/g/123456/token") + ) + } + + func sampleGalleryDetail( + gid: String, + title: String + ) -> GalleryDetail { + GalleryDetail( + gid: gid, + title: title, + jpnTitle: nil, + isFavorited: false, + visibility: .yes, + rating: 4, + userRating: 0, + ratingCount: 10, + category: .doujinshi, + language: .japanese, + uploader: "Uploader", + postedDate: .now, + coverURL: URL(string: "https://example.com/cover.jpg"), + favoritedCount: 2, + pageCount: 12, + sizeCount: 120, + sizeType: "MB", + torrentCount: 0 + ) + } + +} diff --git a/AppPackage/Tests/DownloadsFeatureTests/DownloadFeatureTestSupportTypes.swift b/AppPackage/Tests/DownloadsFeatureTests/DownloadFeatureTestSupportTypes.swift new file mode 100644 index 000000000..9482178af --- /dev/null +++ b/AppPackage/Tests/DownloadsFeatureTests/DownloadFeatureTestSupportTypes.swift @@ -0,0 +1,233 @@ +import Foundation +import Synchronization +@testable import AppFeature + +// MARK: - Supporting Types + +final class UncheckedBox: Sendable { + private let storage: Mutex + + init(_ value: Value) { + storage = Mutex(value) + } + + var value: Value { + get { storage.withLock { $0 } } + set { storage.withLock { $0 = newValue } } + } +} + +struct RequestRecorderSnapshot: Equatable { + var detailRequests = 0 + var metadataRequests = 0 + var mpvRequests = 0 + var imageDispatchRequests = 0 + var imageDownloads = 0 + var previewPageNumbers = [Int]() +} + +final class RequestRecorder: Sendable { + private let state = Mutex(RequestRecorderSnapshot()) + + func recordDetail() { + state.withLock { $0.detailRequests += 1 } + } + + func recordMetadata() { + state.withLock { $0.metadataRequests += 1 } + } + + func recordPreview(_ pageNumber: Int) { + state.withLock { $0.previewPageNumbers.append(pageNumber) } + } + + func recordMPV() { + state.withLock { $0.mpvRequests += 1 } + } + + func recordImageDispatch() { + state.withLock { $0.imageDispatchRequests += 1 } + } + + func recordImageDownload() { + state.withLock { $0.imageDownloads += 1 } + } + + func reset() { + state.withLock { $0 = .init() } + } + + func snapshot() -> RequestRecorderSnapshot { + state.withLock { $0 } + } +} + +final class ScheduledGalleryRecorder: Sendable { + private let state = Mutex([String]()) + + func record(_ gid: String) { + state.withLock { $0.append(gid) } + } + + func snapshot() -> [String] { + state.withLock { $0 } + } +} + +func requestBodyData(from request: URLRequest) -> Data? { + if let httpBody = request.httpBody { + return httpBody + } + + guard let stream = request.httpBodyStream else { + return nil + } + + stream.open() + defer { stream.close() } + + var data = Data() + let bufferSize = 1024 + let buffer = UnsafeMutablePointer.allocate(capacity: bufferSize) + defer { buffer.deallocate() } + + while stream.hasBytesAvailable { + let readCount = stream.read(buffer, maxLength: bufferSize) + guard readCount >= 0 else { + return nil + } + guard readCount > 0 else { + break + } + data.append(buffer, count: readCount) + } + + return data +} + +final class FailFastURLProtocol: URLProtocol { + override static func canInit(with request: URLRequest) -> Bool { + true + } + + override static func canonicalRequest( + for request: URLRequest + ) -> URLRequest { + request + } + + override func startLoading() { + client?.urlProtocol( + self, didFailWithError: URLError(.cancelled) + ) + } + + override func stopLoading() {} +} + +final class HangingURLProtocol: URLProtocol { + override static func canInit(with request: URLRequest) -> Bool { + true + } + + override static func canonicalRequest( + for request: URLRequest + ) -> URLRequest { + request + } + + override func startLoading() {} + + override func stopLoading() { + client?.urlProtocol( + self, + didFailWithError: URLError(.cancelled) + ) + } +} + +final class SharedSessionStubURLProtocol: URLProtocol { + static let headerKey = "X-TestSession-ID" + + private static let handlers = SharedSessionStubHandlers() + + static func setHandler( + for sessionID: String, + handler: @escaping @Sendable (URLRequest) throws -> (HTTPURLResponse, Data) + ) { + handlers.setHandler(for: sessionID, handler: handler) + } + + static func removeHandler(for sessionID: String) { + handlers.removeHandler(for: sessionID) + } + + private static func handler( + for request: URLRequest + ) -> (@Sendable (URLRequest) throws -> (HTTPURLResponse, Data))? { + guard let sessionID = request.value( + forHTTPHeaderField: headerKey + ) else { + return nil + } + return handlers.handler(for: sessionID) + } + + override static func canInit(with request: URLRequest) -> Bool { + handler(for: request) != nil + } + + override static func canonicalRequest( + for request: URLRequest + ) -> URLRequest { + request + } + + override func startLoading() { + guard let handler = Self.handler(for: request) else { + client?.urlProtocol( + self, + didFailWithError: URLError(.badServerResponse) + ) + return + } + + do { + let (response, data) = try handler(request) + client?.urlProtocol( + self, + didReceive: response, + cacheStoragePolicy: .notAllowed + ) + client?.urlProtocol(self, didLoad: data) + client?.urlProtocolDidFinishLoading(self) + } catch { + client?.urlProtocol(self, didFailWithError: error) + } + } + + override func stopLoading() {} +} + +private final class SharedSessionStubHandlers: Sendable { + private let handlers = Mutex< + [String: @Sendable (URLRequest) throws -> (HTTPURLResponse, Data)] + >([:]) + + func setHandler( + for sessionID: String, + handler: @escaping @Sendable (URLRequest) throws -> (HTTPURLResponse, Data) + ) { + handlers.withLock { $0[sessionID] = handler } + } + + func removeHandler(for sessionID: String) { + handlers.withLock { $0[sessionID] = nil } + } + + func handler( + for sessionID: String + ) -> (@Sendable (URLRequest) throws -> (HTTPURLResponse, Data))? { + handlers.withLock { $0[sessionID] } + } +} diff --git a/AppPackage/Tests/DownloadsFeatureTests/DownloadFilterAndBadgeTests.swift b/AppPackage/Tests/DownloadsFeatureTests/DownloadFilterAndBadgeTests.swift new file mode 100644 index 000000000..33a600235 --- /dev/null +++ b/AppPackage/Tests/DownloadsFeatureTests/DownloadFilterAndBadgeTests.swift @@ -0,0 +1,233 @@ +import SwiftUI +import AppModels +import Foundation +import SFSafeSymbols +import ComposableArchitecture +import Testing +import AppTools +@testable import DownloadsFeature +@testable import AppFeature + +struct DownloadFilterAndBadgeTests: DownloadFeatureTestCase { + @Test + func testDownloadsFilterMatchesKeywordAndStatus() { + let activeDownload = sampleDownload( + gid: "101", + title: "Alpha Archive", + status: .downloading, + completedPageCount: 2 + ) + let completedDownload = sampleDownload( + gid: "202", + title: "Beta Collection", + status: .completed + ) + + var state = DownloadsReducer.State() + state.downloads = [activeDownload, completedDownload] + state.keyword = "alpha" + + #expect(state.filteredDownloads == [activeDownload]) + } + + @Test + func testSearchableTextDropsNilAndBlankFields() { + let download = DownloadedGallery( + gid: "111", + host: .ehentai, + token: "token", + title: "Solo Title", + jpnTitle: nil, + uploader: nil, + category: .doujinshi, + tags: [], + pageCount: 1, + postedDate: .now, + rating: 4, + onlineCoverURL: nil, + folderURL: URL(fileURLWithPath: "/tmp/111 - Solo Title", isDirectory: true), + displayStatus: .completed, + completedPageCount: 1, + lastDownloadedDate: .now, + lastError: nil + ) + + #expect(download.searchableText == [ + "Solo Title", String(localized: AppModels.Category.doujinshi.value) + ].joined(separator: " ")) + #expect(!download.searchableText.contains(" ")) + #expect(download.searchableText == download.searchableText.trimmingCharacters(in: .whitespaces)) + } + + @Test + func testQueuedRetryWorkAppearsAsActiveDownloadBadge() { + let queuedRedownload = sampleDownload( + gid: "303", + title: "Gamma Archive", + status: .queued, + completedPageCount: 12 + ) + + #expect(queuedRedownload.badge.status == .queued) + } + + @Test + func testQueuedRepairWorkAppearsAsActiveDownloadBadge() { + let queuedRepair = sampleDownload( + gid: "404", + title: "Broken Archive", + status: .queued, + completedPageCount: 3 + ) + + #expect(queuedRepair.badge.status == .queued) + } + + @Test + func testQueuedUpdateWorkAppearsAsActiveDownloadBadge() { + let queuedUpdate = sampleDownload( + gid: "414", + title: "Updated Archive", + status: .queued, + completedPageCount: 12 + ) + + #expect(queuedUpdate.badge.status == .queued) + } + + @Test + func testQueuedResumedUpdateDoesNotPretendToBeInitialWork() { + let resumedUpdate = sampleDownload( + gid: "415", + title: "Resumed Update", + status: .queued, + pageCount: 26, + completedPageCount: 7 + ) + + #expect(resumedUpdate.isQueuedWorkItem) + #expect(resumedUpdate.badge.status == .queued) + } + + @Test + func testPausedDownloadAppearsAsActiveBadge() { + let pausedDownload = sampleDownload( + gid: "455", + title: "Paused Archive", + status: .paused, + pageCount: 12, + completedPageCount: 4 + ) + + #expect( + pausedDownload.badge == DownloadBadge( + status: .inactive, + progress: .init(completedPageCount: 4, pageCount: 12) + ) + ) + } + + @Test + func testActiveDownloadsDoNotExposeUpdateActions() { + let downloadingUpdate = sampleDownload( + gid: "456", + title: "Downloading Update", + status: .downloading, + completedPageCount: 5 + ) + let pausedUpdate = sampleDownload( + gid: "457", + title: "Paused Update", + status: .paused, + completedPageCount: 5 + ) + let completedUpdate = sampleDownload( + gid: "458", + title: "Completed Update", + status: .updateAvailable + ) + + #expect(downloadingUpdate.canTriggerUpdate == false) + #expect(pausedUpdate.canTriggerUpdate == false) + #expect(completedUpdate.canTriggerUpdate) + } + + @Test + func testSearchPageRangeFilterOmitsInvertedBounds() { + var filter = Filter() + filter.advanced = true + filter.pageRangeActivated = true + filter.pageLowerBound = "50" + filter.pageUpperBound = "10" + + let queryItems = queryItems(for: URLUtil.frontpageList(filter: filter)) + + #expect(queryItems["f_sp"] == "on") + #expect(queryItems["f_spf"] == nil) + #expect(queryItems["f_spt"] == nil) + } + + @Test + func testSearchPageRangeFilterKeepsValidBounds() { + var filter = Filter() + filter.advanced = true + filter.pageRangeActivated = true + filter.pageLowerBound = "10" + filter.pageUpperBound = "50" + + let queryItems = queryItems(for: URLUtil.frontpageList(filter: filter)) + + #expect(queryItems["f_sp"] == "on") + #expect(queryItems["f_spf"] == "10") + #expect(queryItems["f_spt"] == "50") + } + + @Test + func testSearchPageRangeFilterKeepsSingleBounds() { + var lowerOnlyFilter = Filter() + lowerOnlyFilter.advanced = true + lowerOnlyFilter.pageRangeActivated = true + lowerOnlyFilter.pageLowerBound = "10" + + var upperOnlyFilter = Filter() + upperOnlyFilter.advanced = true + upperOnlyFilter.pageRangeActivated = true + upperOnlyFilter.pageUpperBound = "50" + + let lowerOnlyQueryItems = queryItems(for: URLUtil.frontpageList(filter: lowerOnlyFilter)) + let upperOnlyQueryItems = queryItems(for: URLUtil.frontpageList(filter: upperOnlyFilter)) + + #expect(lowerOnlyQueryItems["f_sp"] == "on") + #expect(lowerOnlyQueryItems["f_spf"] == "10") + #expect(lowerOnlyQueryItems["f_spt"] == nil) + #expect(upperOnlyQueryItems["f_sp"] == "on") + #expect(upperOnlyQueryItems["f_spf"] == nil) + #expect(upperOnlyQueryItems["f_spt"] == "50") + } + + @Test + func testPartialDownloadBadgeUsesNeedsAttentionCopy() { + let partialDownload = sampleDownload( + gid: "480", + title: "Incomplete Archive", + status: .partial, + pageCount: 12, + completedPageCount: 5 + ) + + #expect(partialDownload.badge.symbol == .exclamationmarkTriangleFill) + #expect(partialDownload.badge.color == .yellow) + #expect( + partialDownload.badge.progress + == DownloadProgress(completedPageCount: 5, pageCount: 12) + ) + } + + private func queryItems(for url: URL) -> [String: String] { + URLComponents(url: url, resolvingAgainstBaseURL: false)? + .queryItems? + .reduce(into: [String: String]()) { result, item in + result[item.name] = item.value + } ?? [:] + } +} diff --git a/AppPackage/Tests/DownloadsFeatureTests/DownloadFolderOperationTests.swift b/AppPackage/Tests/DownloadsFeatureTests/DownloadFolderOperationTests.swift new file mode 100644 index 000000000..cf66481d5 --- /dev/null +++ b/AppPackage/Tests/DownloadsFeatureTests/DownloadFolderOperationTests.swift @@ -0,0 +1,266 @@ +import Foundation +import AppModels +import Testing +import DownloadClient +@testable import AppFeature + +@Suite(.serialized) +struct DownloadFolderOperationTests: DownloadFeatureTestCase { + @Test + func testCreateFolderListsFolderAndRejectsDuplicatesAndInvalidNames() async throws { + let environment = makeManager() + defer { try? FileManager.default.removeItem(at: environment.rootURL) } + try environment.storage.ensureRootDirectory() + + let created = await environment.manager.createFolder(name: " Favorites ") + guard case .success = created else { + Issue.record("Expected create to succeed, got \(created)") + return + } + #expect(await environment.manager.fetchFolders() == ["Favorites"]) + + let duplicate = await environment.manager.createFolder(name: "Favorites") + guard case .failure = duplicate else { + Issue.record("Expected duplicate create to fail") + return + } + + let invalid = await environment.manager.createFolder(name: " ") + guard case .failure = invalid else { + Issue.record("Expected invalid name to fail") + return + } + + let galleryLike = await environment.manager.createFolder(name: "[123_token] Sample") + guard case .failure = galleryLike else { + Issue.record("Expected gallery-like name to fail") + return + } + } + + @Test + func testRenameFolderRepointsContainedDownloads() async throws { + let environment = makeManager() + defer { try? FileManager.default.removeItem(at: environment.rootURL) } + let gid = "311" + try writeGalleryFolder(storage: environment.storage, folderName: "Old Name", gid: gid) + await environment.manager.reconcileDownloads() + + let result = await environment.manager.renameFolder(oldName: "Old Name", newName: "New Name") + guard case .success = result else { + Issue.record("Expected rename to succeed, got \(result)") + return + } + + let download = await environment.manager.fetchDownload(gid: gid) + #expect(await environment.manager.fetchFolders() == ["New Name"]) + #expect(download?.folderName == "New Name") + #expect(download?.folderURL.path.contains("/New Name/") == true) + } + + @Test + func testRenameFolderRejectsActiveDownloadInside() async throws { + let environment = makeManager() + defer { try? FileManager.default.removeItem(at: environment.rootURL) } + let gid = "312" + try writeGalleryFolder(storage: environment.storage, folderName: "Busy", gid: gid) + _ = await environment.manager.reconcileDownloads() + let blockingTask = Task { _ = try? await Task.sleep(for: .seconds(60)) } + defer { blockingTask.cancel() } + await environment.manager.testingInstallActiveTask(gid: gid, task: blockingTask) + + let result = await environment.manager.renameFolder(oldName: "Busy", newName: "Renamed") + guard case .failure = result else { + Issue.record("Expected rename to fail while downloading") + return + } + #expect(await environment.manager.fetchFolders() == ["Busy"]) + } + + @Test + func testDeleteFolderRemovesContainedDownloadsAndQueueIntents() async throws { + let environment = makeManager() + defer { try? FileManager.default.removeItem(at: environment.rootURL) } + let gid = "313" + let folderURL = try writeGalleryFolder(storage: environment.storage, folderName: "Doomed", gid: gid) + await environment.manager.reconcileDownloads() + await environment.manager.testingSetQueuedGalleryIDs([gid]) + + let result = await environment.manager.deleteFolder(name: "Doomed") + guard case .success = result else { + Issue.record("Expected delete to succeed, got \(result)") + return + } + + #expect(await environment.manager.fetchFolders().isEmpty) + #expect(await environment.manager.fetchDownload(gid: gid) == nil) + #expect(!FileManager.default.fileExists(atPath: folderURL.path)) + } + + @Test + func testDeleteDownloadRemovesSupersededSameIdentityFolders() async throws { + let environment = makeManager() + defer { try? FileManager.default.removeItem(at: environment.rootURL) } + let gid = "314" + let oldFolderURL = try writeGalleryFolder( + storage: environment.storage, + folderName: "Saved", + gid: gid, + galleryFolderName: "[\(gid)_token] Old Title" + ) + let currentFolderURL = try writeGalleryFolder( + storage: environment.storage, + folderName: "Saved", + gid: gid, + galleryFolderName: "[\(gid)_token] Current Title" + ) + await environment.manager.reconcileDownloads() + + let result = await environment.manager.delete(gid: gid) + guard case .success = result else { + Issue.record("Expected delete to succeed, got \(result)") + return + } + + await environment.manager.reconcileDownloads() + #expect(await environment.manager.fetchDownload(gid: gid) == nil) + #expect(!FileManager.default.fileExists(atPath: oldFolderURL.path)) + #expect(!FileManager.default.fileExists(atPath: currentFolderURL.path)) + } + + @Test + func testMoveDownloadRelocatesGalleryFolder() async throws { + let environment = makeManager() + defer { try? FileManager.default.removeItem(at: environment.rootURL) } + let gid = "315" + let sourceURL = try writeGalleryFolder(storage: environment.storage, folderName: "Source", gid: gid) + await environment.manager.reconcileDownloads() + + let result = await environment.manager.moveDownload(gid: gid, toFolderName: "Target") + guard case .success = result else { + Issue.record("Expected move to succeed, got \(result)") + return + } + + let download = await environment.manager.fetchDownload(gid: gid) + #expect(download?.folderName == "Target") + #expect(download?.folderURL.path.contains("/Target/") == true) + #expect(!FileManager.default.fileExists(atPath: sourceURL.path)) + #expect(await environment.manager.fetchFolders() == ["Source", "Target"]) + } + + @Test + func testMoveDownloadIntoSameFolderIsNoOp() async throws { + let environment = makeManager() + defer { try? FileManager.default.removeItem(at: environment.rootURL) } + let gid = "316" + let folderURL = try writeGalleryFolder(storage: environment.storage, folderName: "Home", gid: gid) + await environment.manager.reconcileDownloads() + + let result = await environment.manager.moveDownload(gid: gid, toFolderName: "Home") + guard case .success = result else { + Issue.record("Expected same-folder move to succeed, got \(result)") + return + } + #expect(FileManager.default.fileExists(atPath: folderURL.path)) + } + + @Test + func testMoveDownloadRejectsActivelyDownloadingGallery() async throws { + let environment = makeManager() + defer { try? FileManager.default.removeItem(at: environment.rootURL) } + let gid = "317" + let folderURL = try writeGalleryFolder(storage: environment.storage, folderName: "Working", gid: gid) + _ = await environment.manager.reconcileDownloads() + let blockingTask = Task { _ = try? await Task.sleep(for: .seconds(60)) } + defer { blockingTask.cancel() } + await environment.manager.testingInstallActiveTask(gid: gid, task: blockingTask) + + let result = await environment.manager.moveDownload(gid: gid, toFolderName: "Elsewhere") + guard case .failure = result else { + Issue.record("Expected move of active download to fail") + return + } + #expect(FileManager.default.fileExists(atPath: folderURL.path)) + } + + @Test + func testEnqueueKeepsExistingDownloadInItsFolder() async throws { + let environment = makeManager() + defer { try? FileManager.default.removeItem(at: environment.rootURL) } + await environment.manager.testingInstallActiveTask(gid: "busy", task: Task {}) + + let gallery = sampleGallery() + let detail = sampleGalleryDetail(gid: gallery.gid, title: gallery.title) + let galleryFolderName = environment.storage.makeFolderRelativePath( + gid: gallery.gid, + token: gallery.token, + title: detail.trimmedTitle + ) + try writeGalleryFolder( + storage: environment.storage, + folderName: "Original", + gid: gallery.gid, + galleryFolderName: galleryFolderName + ) + _ = await environment.manager.reconcileDownloads() + + let payload = DownloadRequestPayload( + gallery: gallery, + galleryDetail: detail, + previewURLs: [:], + previewConfig: .normal(rows: 4), + host: .ehentai, + folderName: "Requested Elsewhere", + mode: .initial + ) + let result = await environment.manager.enqueue(payload: payload) + guard case .success = result else { + Issue.record("Expected enqueue to succeed, got \(result)") + return + } + + let download = await environment.manager.fetchDownload(gid: gallery.gid) + #expect(download?.folderName == "Original") + } +} + +// MARK: - Setup Helpers + +private struct DownloadFolderOperationTestEnvironment { + let storage: DownloadStore + let manager: DownloadCoordinator + let rootURL: URL +} + +private extension DownloadFolderOperationTests { + func makeManager() -> DownloadFolderOperationTestEnvironment { + let rootURL = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + let storage = DownloadStore(rootURL: rootURL, fileManager: .default) + let manager = DownloadCoordinator(storage: storage, urlSession: .shared) + return .init(storage: storage, manager: manager, rootURL: rootURL) + } + + @discardableResult + func writeGalleryFolder( + storage: DownloadStore, + folderName: String, + gid: String, + galleryFolderName: String? = nil + ) throws -> URL { + try storage.ensureRootDirectory() + let folderURL = storage.folderURL( + relativePath: "\(folderName)/\(galleryFolderName ?? "[\(gid)_token] Sample")" + ) + try FileManager.default.createDirectory( + at: folderURL, + withIntermediateDirectories: true + ) + try storage.writeManifest( + sampleManifest(gid: gid, title: "Sample", pageCount: 2), + folderURL: folderURL + ) + return folderURL + } +} diff --git a/AppPackage/Tests/DownloadsFeatureTests/DownloadImageErrorTests.swift b/AppPackage/Tests/DownloadsFeatureTests/DownloadImageErrorTests.swift new file mode 100644 index 000000000..2309f287e --- /dev/null +++ b/AppPackage/Tests/DownloadsFeatureTests/DownloadImageErrorTests.swift @@ -0,0 +1,176 @@ +import AppModels +import Foundation +import Testing +@testable import AppFeature + +@Suite(.serialized) +struct DownloadImageErrorTests: DownloadFeatureTestCase { + @Test + func testFileBasedInvalidPageMapsToNotFound() async throws { + let fileURL = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString) + .appendingPathExtension("html") + defer { try? FileManager.default.removeItem(at: fileURL) } + + let invalidPageData = Data(""" +

Invalid page

Gallery not found

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

Keep trying

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

Gallery Not Available

+ + """.utf8) + try galleryNotAvailableData.write(to: fileURL, options: .atomic) + + let manager = makeTestingDownloadCoordinator() + let galleryURL = try #require(URL(string: "https://e-hentai.org/g/1/1/")) + let response = try makeResponse( + url: galleryURL, + statusCode: 404, + contentType: "text/html" + ) + let error = await manager.detectResponseError( + fileURL: fileURL, + response: response, + requestURL: galleryURL + ) + + #expect(error == .notFound) + } + + @Test + func testFileBasedHTMLBanPageStillParsesThroughParserInsteadOfParseFailed() async throws { + let fileURL = try writeFixtureToTemporaryFile(filename: .ipBanned) + defer { try? FileManager.default.removeItem(at: fileURL) } + + let manager = makeTestingDownloadCoordinator() + let bannedURL = try #require(URL(string: "https://example.com/banned")) + let response = try makeResponse( + url: bannedURL, + contentType: "text/html; charset=utf-8" + ) + let error = await manager.detectResponseError( + fileURL: fileURL, + response: response, + requestURL: bannedURL + ) + + #expect(error != .parseFailed) + guard case .ipBanned = error else { + Issue.record("Expected ipBanned, got \(String(describing: error))") + return + } + } + + @Test + func testTextHTMLJSONAPIResponseDoesNotMapToParseFailed() async throws { + let manager = makeTestingDownloadCoordinator() + let apiURL = try #require(URL(string: "https://e-hentai.org/api.php")) + let response = try makeResponse( + url: apiURL, + contentType: "text/html; charset=UTF-8" + ) + let responsePayload: [String: String] = [ + "d": "1184 x 1728 :: 14.78 MiB", + "o": "org", + "lf": #"fullimg/3861928/1/99j92okaldl/Karin_1.webp"#, + "ls": "?f_shash=6aa741ba4e302352139ae2fc7377c846e68d9093", + "ll": "6aa741ba4e302352139ae2fc7377c846e68d9093" + + "-15497378-1184-1728-wbp/forumtoken/3861928-1/Karin_1.webp", + "lo": "s/6aa741ba4e/3861928-1", + "xres": "1184", + "yres": "1728", + "i": "https://mrfmlfe.vzpqazmbjydh.hath.network:60000/h/" + + "6aa741ba4e302352139ae2fc7377c846e68d9093-15497378-1184-1728-wbp/" + + "keystamp=1779356100-f4c09dd971;fileindex=232157952;xres=org/Karin_1.webp", + "s": "48803" + ] + let data = try JSONSerialization.data(withJSONObject: responsePayload) + + let error = await manager.detectResponseError( + data: data, + response: response, + requestURL: apiURL + ) + + #expect(error == nil) + } + +} diff --git a/AppPackage/Tests/DownloadsFeatureTests/DownloadImageParsingCacheTests.swift b/AppPackage/Tests/DownloadsFeatureTests/DownloadImageParsingCacheTests.swift new file mode 100644 index 000000000..19eb28a54 --- /dev/null +++ b/AppPackage/Tests/DownloadsFeatureTests/DownloadImageParsingCacheTests.swift @@ -0,0 +1,88 @@ +import AppModels +import Kingfisher +import UIKit +import Foundation +import Testing +import AppTools +@testable import AppFeature + +@Suite(.serialized) +struct DownloadImageParsingCacheTests: DownloadFeatureTestCase { + @Test + func testCachedKokomadePlaceholderStoredUnderNormalImageURLIsRejected() async throws { + let gid = String(Int(Date().timeIntervalSince1970 * 1_000_000) + 33) + let manager = makeTestingDownloadCoordinator() + let normalImageURL = try #require( + URL(string: "https://exhentai.org/fullimg.php?gid=\(gid)&page=1&key=normal-cache-key") + ) + + let imageData = try fixtureData(resource: "Kokomade", pathExtension: "jpg") + let cacheKeys = normalImageURL.imageCacheKeys + for cacheKey in cacheKeys { + try await KingfisherManager.shared.cache.storeToDisk(imageData, forKey: cacheKey) + } + defer { cacheKeys.forEach { KingfisherManager.shared.cache.removeImage(forKey: $0) } } + await waitUntilCacheReady(for: cacheKeys) + + let cachedData = await manager.validatedCachedAssetData( + for: [normalImageURL] + ) + + #expect(cachedData == nil) + } + + @Test + func testFileBasedEmptyExResponseMapsToAuthenticationRequired() async throws { + let fileURL = try writeFixtureToTemporaryFile(filename: .exLoginRequired) + defer { try? FileManager.default.removeItem(at: fileURL) } + + let manager = makeTestingDownloadCoordinator() + let response = try makeResponse( + url: Defaults.URL.exhentai, + contentType: "text/html", + headers: [ + "Set-Cookie": "\(Defaults.Cookie.yay)=louder; Path=/" + ] + ) + let error = await manager.detectResponseError( + fileURL: fileURL, + response: response, + requestURL: URL(string: "https://exhentai.org/g/1/1/") + ) + + #expect(error == .authenticationRequired) + } + + @Test + func testFileBasedAuthHTMLMarkersMapToAuthenticationRequired() async throws { + let fileURL = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString) + .appendingPathExtension("html") + defer { try? FileManager.default.removeItem(at: fileURL) } + + let authHTMLData = Data(""" + + + Login + +

Access to ExHentai.org is restricted.

+ + + """.utf8) + try authHTMLData.write(to: fileURL, options: .atomic) + + let manager = makeTestingDownloadCoordinator() + let response = try makeResponse( + url: Defaults.URL.exhentai, + contentType: "text/html" + ) + let error = await manager.detectResponseError( + fileURL: fileURL, + response: response, + requestURL: URL(string: "https://exhentai.org/g/1/1/") + ) + + #expect(error == .authenticationRequired) + } + +} diff --git a/AppPackage/Tests/DownloadsFeatureTests/DownloadImageParsingTests.swift b/AppPackage/Tests/DownloadsFeatureTests/DownloadImageParsingTests.swift new file mode 100644 index 000000000..f161e4b8f --- /dev/null +++ b/AppPackage/Tests/DownloadsFeatureTests/DownloadImageParsingTests.swift @@ -0,0 +1,334 @@ +import AppModels +import Kingfisher +import UIKit +import Foundation +import Testing +import AppTools +import DownloadClient +@testable import AppFeature + +@Suite(.serialized) +struct DownloadImageParsingTests: DownloadFeatureTestCase { + @Test + func testFileBasedQuotaImageMapsToQuotaExceeded() async throws { + let fileURL = try writeFixtureToTemporaryFile(filename: .bandwidthExceeded) + defer { try? FileManager.default.removeItem(at: fileURL) } + + let manager = makeTestingDownloadCoordinator() + let quotaImageURL = try #require(URL(string: "https://ehgt.org/g/509.gif")) + let response = try makeResponse( + url: quotaImageURL, + contentType: "image/gif", + contentLength: 28658 + ) + let error = await manager.detectResponseError( + fileURL: fileURL, + response: response, + requestURL: quotaImageURL + ) + + #expect(error == .quotaExceeded) + } + + @Test + func testFileBasedQuotaImageRequiresKnown509Signature() async throws { + let fileURL = try writeFixtureToTemporaryFile(filename: .bandwidthExceeded) + defer { try? FileManager.default.removeItem(at: fileURL) } + + let manager = makeTestingDownloadCoordinator() + var data = try Data(contentsOf: fileURL) + data[0] = 0 + try data.write(to: fileURL, options: .atomic) + let quotaImageURL = try #require(URL(string: "https://ehgt.org/g/509.gif")) + let response = try makeResponse( + url: quotaImageURL, + contentType: "image/gif", + contentLength: data.count + ) + let error = await manager.detectResponseError( + fileURL: fileURL, + response: response, + requestURL: quotaImageURL + ) + + #expect(error == nil) + } + + @Test + func testFileBasedBinaryKokomadeImageMapsToAuthenticationRequired() async throws { + let fileURL = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString) + .appendingPathExtension("gif") + defer { try? FileManager.default.removeItem(at: fileURL) } + + let imageData = try #require(Data(base64Encoded: "R0lGODlhAQABAIABAP///wAAACwAAAAAAQABAAACAkQBADs=")) + try imageData.write(to: fileURL, options: .atomic) + + let manager = makeTestingDownloadCoordinator() + let kokomadeURL = try #require(URL(string: "https://exhentai.org/img/kokomade.jpg")) + let response = try makeResponse( + url: kokomadeURL, + contentType: "image/gif", + contentLength: imageData.count + ) + let error = await manager.detectResponseError( + fileURL: fileURL, + response: response, + requestURL: URL(string: "https://exhentai.org/fullimg.php?gid=1&page=1") + ) + + #expect(error == .authenticationRequired) + } + + @Test + func testFileBasedQuotaImageFingerprintMapsToQuotaExceededEvenWhenURLLooksNormal() async throws { + let fileURL = try writeFixtureToTemporaryFile(filename: .bandwidthExceeded) + defer { try? FileManager.default.removeItem(at: fileURL) } + + let manager = makeTestingDownloadCoordinator() + let normalImageURL = try #require(URL(string: "https://ehgt.org/h/normal-image-cache-key/1")) + let response = try makeResponse( + url: normalImageURL, + contentType: "image/gif", + contentLength: 28658 + ) + let error = await manager.detectResponseError( + fileURL: fileURL, + response: response, + requestURL: normalImageURL + ) + + #expect(error == .quotaExceeded) + } + + @Test + func testFileBasedKokomadeImageFingerprintMapsToAuthenticationRequiredEvenWhenURLLooksNormal() async throws { + let fileURL = try writeFixtureToTemporaryFile(resource: "Kokomade", pathExtension: "jpg") + defer { try? FileManager.default.removeItem(at: fileURL) } + + let manager = makeTestingDownloadCoordinator() + let normalImageURL = try #require( + URL(string: "https://exhentai.org/fullimg.php?gid=1&page=1&key=normal-cache-key") + ) + let error = await manager.detectResponseError( + fileURL: fileURL, + response: try makeResponse( + url: normalImageURL, + contentType: "image/jpeg", + contentLength: 144844 + ), + requestURL: normalImageURL + ) + + #expect(error == .authenticationRequired) + } + + @Test + func testFileBasedTextImageLimitMapsToQuotaExceeded() async throws { + let fileURL = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString) + .appendingPathExtension("html") + defer { try? FileManager.default.removeItem(at: fileURL) } + + let htmlData = Data(""" + You have exceeded your image viewing limits + """.utf8) + try htmlData.write(to: fileURL, options: .atomic) + + let manager = makeTestingDownloadCoordinator() + let quotaURL = try #require(URL(string: "https://e-hentai.org/s/1/1-1")) + let response = try makeResponse( + url: quotaURL, + contentType: "text/html" + ) + let error = await manager.detectResponseError( + fileURL: fileURL, + response: response, + requestURL: quotaURL + ) + + #expect(error == .quotaExceeded) + } + + @Test + func testFatalAccountPageFailureStopsSchedulingRemainingPages() async throws { + let sessionID = UUID().uuidString + let rootURL = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + defer { try? FileManager.default.removeItem(at: rootURL) } + + let configuration = URLSessionConfiguration.ephemeral + configuration.protocolClasses = [SharedSessionStubURLProtocol.self] + configuration.httpAdditionalHeaders = [ + SharedSessionStubURLProtocol.headerKey: sessionID + ] + let storage = DownloadStore(rootURL: rootURL, fileManager: .default) + let manager = DownloadCoordinator( + storage: storage, + urlSession: URLSession(configuration: configuration) + ) + let recorder = RequestRecorder() + let quotaHTML = Data(""" + You have exceeded your image viewing limits + """.utf8) + SharedSessionStubURLProtocol.setHandler(for: sessionID) { request in + recorder.recordImageDownload() + return ( + try #require(HTTPURLResponse( + url: request.url ?? Defaults.URL.ehentai, + statusCode: 200, + httpVersion: nil, + headerFields: ["Content-Type": "text/html"] + )), + quotaHTML + ) + } + defer { + SharedSessionStubURLProtocol.removeHandler(for: sessionID) + } + + var gallery = sampleGallery() + gallery.pageCount = 3 + var detail = sampleGalleryDetail(gid: gallery.gid, title: gallery.title) + detail.pageCount = 3 + var options = DownloadRequestOptions() + options.threadLimit = 1 + options.autoRetryFailedPages = false + let payload = DownloadRequestPayload( + gallery: gallery, + galleryDetail: detail, + previewURLs: [:], + previewConfig: .normal(rows: 4), + host: .ehentai, + folderName: "Folder", + mode: .initial + ) + let galleryFolderName = storage.makeFolderRelativePath( + gid: gallery.gid, + token: gallery.token, + title: detail.trimmedTitle + ) + let folderURL = storage.folderURL( + relativePath: "Folder/\(galleryFolderName)" + ) + try FileManager.default.createDirectory( + at: folderURL, + withIntermediateDirectories: true + ) + let manifest = try sampleManifest( + gid: gallery.gid, + title: gallery.title, + pageCount: detail.pageCount + ) + let batchResult = try await manager.downloadPages( + context: .init( + payload: payload, + options: options, + source: .normal([ + 1: try #require(URL(string: "https://example.com/1.html")), + 2: try #require(URL(string: "https://example.com/2.html")), + 3: try #require(URL(string: "https://example.com/3.html")) + ]), + folderURL: folderURL + ), + pendingPageIndices: [1, 2, 3], + existingManifest: manifest, + existingPageRelativePaths: [:] + ) + + #expect(batchResult.failedPages.map(\.index) == [1]) + #expect(batchResult.failedPages.first?.error == .quotaExceeded) + #expect(recorder.snapshot().imageDownloads == 1) + } + + @MainActor + @Test + func testCachedQuotaPlaceholderStoredUnderNormalImageURLIsRejected() async throws { + let gid = String(Int(Date().timeIntervalSince1970 * 1_000_000) + 32) + let manager = makeTestingDownloadCoordinator() + let normalImageURL = try #require( + URL(string: "https://ehgt.org/h/quota-placeholder-cache-\(gid)/1") + ) + + let placeholderURL = try writeFixtureToTemporaryFile(filename: .bandwidthExceeded) + defer { try? FileManager.default.removeItem(at: placeholderURL) } + let placeholderData = try Data(contentsOf: placeholderURL) + let cacheKeys = normalImageURL.imageCacheKeys + for cacheKey in cacheKeys { + try await KingfisherManager.shared.cache.storeToDisk(placeholderData, forKey: cacheKey) + } + defer { cacheKeys.forEach { KingfisherManager.shared.cache.removeImage(forKey: $0) } } + await waitUntilCacheReady(for: cacheKeys) + + let cachedData = await manager.validatedCachedAssetData( + for: [normalImageURL] + ) + + #expect(cachedData == nil) + } + + @Test + func testMPVImageResolutionFailsOverWithSkipServerTokenOnRetry() async throws { + let sessionID = UUID().uuidString + let rootURL = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + defer { try? FileManager.default.removeItem(at: rootURL) } + + let configuration = URLSessionConfiguration.ephemeral + configuration.protocolClasses = [SharedSessionStubURLProtocol.self] + configuration.httpAdditionalHeaders = [ + SharedSessionStubURLProtocol.headerKey: sessionID + ] + let storage = DownloadStore(rootURL: rootURL, fileManager: .default) + let manager = DownloadCoordinator( + storage: storage, + urlSession: URLSession(configuration: configuration) + ) + + let receivedSkipServerTokens = UncheckedBox([String?]()) + SharedSessionStubURLProtocol.setHandler(for: sessionID) { request in + let body = requestBodyData(from: request) + .flatMap { try? JSONSerialization.jsonObject(with: $0) as? [String: Any] } + let skipServer = body?["nl"] as? String + receivedSkipServerTokens.value.append(skipServer) + let imageURL = skipServer == nil + ? "https://example.com/server-a.jpg" + : "https://example.com/server-b.jpg" + let data = try JSONSerialization.data(withJSONObject: ["i": imageURL, "s": "42"]) + return ( + try #require(HTTPURLResponse( + url: request.url ?? Defaults.URL.api, + statusCode: 200, + httpVersion: nil, + headerFields: ["Content-Type": "application/json"] + )), + data + ) + } + defer { SharedSessionStubURLProtocol.removeHandler(for: sessionID) } + + let payload = DownloadRequestPayload( + gallery: sampleGallery(), + galleryDetail: sampleGalleryDetail(gid: "123456", title: "Sample Gallery"), + previewURLs: [:], + previewConfig: .normal(rows: 4), + host: .ehentai, + folderName: "Folder", + mode: .initial + ) + let source = DownloadCoordinator.ResolvedSource.mpv("mpvkey", [1: "imgkey1"]) + + let first = try await manager.resolvedImageSource( + index: 1, payload: payload, options: .init(), source: source, failover: nil + ) + let second = try await manager.resolvedImageSource( + index: 1, payload: payload, options: .init(), source: source, failover: first + ) + + #expect(receivedSkipServerTokens.value == [nil, "42"]) + #expect(first.imageURL.absoluteString == "https://example.com/server-a.jpg") + #expect(first.mpvSkipServerIdentifier == "42") + #expect(second.imageURL.absoluteString == "https://example.com/server-b.jpg") + } + +} diff --git a/AppPackage/Tests/DownloadsFeatureTests/DownloadInspectorLoadTests.swift b/AppPackage/Tests/DownloadsFeatureTests/DownloadInspectorLoadTests.swift new file mode 100644 index 000000000..58ae095e9 --- /dev/null +++ b/AppPackage/Tests/DownloadsFeatureTests/DownloadInspectorLoadTests.swift @@ -0,0 +1,359 @@ +import Foundation +import AppModels +import Resources +import ComposableArchitecture +import Testing +import DownloadClient +@testable import DownloadsFeature +@testable import AppFeature + +@Suite(.serialized) +@MainActor +struct DownloadInspectorLoadTests: DownloadFeatureTestCase { + @MainActor + @Test + func testDownloadInspectorReducerLoadsInspection() async { + let download = sampleDownload( + gid: "246810", title: "Inspector Gallery", + status: .failed, completedPageCount: 1 + ) + let inspection = sampleInspection(download: download) + let store = makeInspectorStore( + gid: download.gid, + loadInspection: { _ in inspection } + ) + store.exhaustivity = .off + + await store.send(.loadInspection) + await store.receive(\.loadInspectionDone) { + $0.inspection = inspection + $0.stableInspection = inspection + $0.loadingState = .idle + } + } + + @MainActor + @Test + func testDownloadInspectorReducerRetryPagesUsesDownloadClientRetryPages() async { + await confirmation(expectedCount: 1) { confirm in + let retried = UncheckedBox<[Int]>([]) + let download = sampleDownload( + gid: "112233", title: "Retry Page Gallery", + status: .failed, completedPageCount: 1 + ) + var initialState = DownloadInspectorReducer.State(gid: download.gid) + initialState.inspection = sampleInspection(download: download) + initialState.loadingState = .idle + let store = makeInspectorStore( + gid: download.gid, + initialInspection: initialState.inspection, + retryPages: { _, pageIndices in + retried.value = pageIndices + confirm() + }, + loadInspection: { [initialState] _ in + guard let inspection = initialState.inspection else { + throw AppError.notFound + } + return inspection + } + ) + store.exhaustivity = .off + + await store.send(.retryPages([2])) + #expect(retried.value == [2]) + } + } + + @MainActor + @Test + func testDownloadInspectorReducerRetryFailedPagesMarksFailedPagesPending() async { + let retried = UncheckedBox<[Int]>([]) + let download = sampleDownload( + gid: "112235", title: "Retry Failed Pages Gallery", + status: .partial, completedPageCount: 1 + ) + var initialState = DownloadInspectorReducer.State(gid: download.gid) + initialState.inspection = sampleInspection(download: download) + initialState.loadingState = .idle + let store = makeInspectorStore( + gid: download.gid, + initialInspection: initialState.inspection, + retryPages: { _, pageIndices in + retried.value = pageIndices + }, + loadInspection: { [initialState] _ in + guard let inspection = initialState.inspection else { + throw AppError.notFound + } + return inspection + } + ) + store.exhaustivity = .off + + await store.send(.retryPages([2])) { + guard let inspection = $0.inspection else { return } + $0.inspection = .init( + download: inspection.download, + coverURL: inspection.coverURL, + pages: [ + .init( + index: 1, status: .downloaded, + relativePath: "\(download.gid)_\(download.token)_1.jpg", + fileURL: URL(fileURLWithPath: "/tmp/0001.jpg"), failure: nil + ), + .init( + index: 2, status: .pending, + relativePath: "\(download.gid)_\(download.token)_2.jpg", + fileURL: nil, failure: nil + ) + ] + ) + } + + #expect(retried.value == [2]) + } + + @MainActor + @Test + func testDownloadInspectorReducerValidateImageDataUsesCurrentGallery() async { + let validatedGID = UncheckedBox(nil) + let download = sampleDownload( + gid: "112236", title: "Validate Gallery", + status: .completed, pageCount: 2 + ) + let inspection = sampleInspection(download: download) + let refreshedInspection = DownloadInspection( + download: download, + coverURL: inspection.coverURL, + pages: inspection.pages.map { + .init( + index: $0.index, + status: .downloaded, + relativePath: $0.relativePath, + fileURL: $0.fileURL, + failure: nil + ) + } + ) + let store = makeInspectorStore( + gid: download.gid, + initialInspection: inspection, + validateImageData: { gid in + validatedGID.value = gid + return .valid + }, + loadInspection: { gid in + guard gid == download.gid else { throw AppError.notFound } + return refreshedInspection + } + ) + store.exhaustivity = .off + + await store.send(.validateImageData) { + $0.isValidatingImageData = true + } + await store.receive(\.validateImageDataDone) { + $0.isValidatingImageData = false + $0.toast = .success( + caption: String(localized: .imageDataValid) + ) + } + await store.receive(\.loadInspection) + await store.receive(\.loadInspectionDone) { + $0.inspection = refreshedInspection + $0.stableInspection = refreshedInspection + $0.loadingState = .idle + } + + #expect(validatedGID.value == download.gid) + } + + @MainActor + @Test + func testDownloadInspectorReducerTogglePauseUsesCurrentGallery() async { + let toggledGID = UncheckedBox(nil) + let download = sampleDownload( + gid: "112238", title: "Toggle Pause Gallery", + status: .downloading, completedPageCount: 1 + ) + let inspection = sampleInspection(download: download) + let store = makeInspectorStore( + gid: download.gid, + initialInspection: inspection, + togglePause: { gid in + toggledGID.value = gid + }, + loadInspection: { _ in inspection } + ) + store.exhaustivity = .off + + await store.send(.toggleDownloadPause) + await store.receive(\.toggleDownloadPauseDone) + + #expect(toggledGID.value == download.gid) + } + + @MainActor + @Test + func testDownloadInspectorReducerTogglePauseUsesQueuedGallery() async { + let toggledGID = UncheckedBox(nil) + let download = sampleDownload( + gid: "112240", title: "Queued Gallery", + status: .queued, completedPageCount: 0 + ) + let inspection = sampleInspection(download: download) + let store = makeInspectorStore( + gid: download.gid, + initialInspection: inspection, + togglePause: { gid in + toggledGID.value = gid + }, + loadInspection: { _ in inspection } + ) + store.exhaustivity = .off + + await store.send(.toggleDownloadPause) + await store.receive(\.toggleDownloadPauseDone) + + #expect(toggledGID.value == download.gid) + } + + @MainActor + @Test + func testDownloadInspectorReducerTogglePauseIgnoredForNonPauseableStatus() async { + let didToggle = UncheckedBox(false) + let download = sampleDownload( + gid: "112239", title: "Completed Gallery", + status: .completed, pageCount: 2 + ) + let inspection = sampleInspection(download: download) + let store = makeInspectorStore( + gid: download.gid, + initialInspection: inspection, + togglePause: { _ in + didToggle.value = true + }, + loadInspection: { _ in inspection } + ) + store.exhaustivity = .off + + await store.send(.toggleDownloadPause) + + #expect(!didToggle.value) + } + +} + +extension DownloadInspectorLoadTests { + @MainActor + @Test + func testDownloadInspectorReducerValidateImageDataIgnoredWithoutDownloadedPages() async { + let didValidate = UncheckedBox(false) + let download = sampleDownload( + gid: "112237", title: "Validate Empty Gallery", + status: .completed, pageCount: 2 + ) + let inspection = DownloadInspection( + download: download, + coverURL: download.coverURL, + pages: [ + .init( + index: 1, status: .pending, relativePath: nil, + fileURL: nil, failure: nil + ), + .init( + index: 2, status: .failed, + relativePath: "\(download.gid)_\(download.token)_2.jpg", + fileURL: nil, + failure: .init(code: .networkingFailed, message: "Network Error") + ) + ] + ) + let store = makeInspectorStore( + gid: download.gid, + initialInspection: inspection, + validateImageData: { _ in + didValidate.value = true + return .valid + }, + loadInspection: { _ in inspection } + ) + store.exhaustivity = .off + + await store.send(.validateImageData) + + #expect(!didValidate.value) + } + + @MainActor + @Test + func testDownloadInspectorReducerValidateImageDataShowsMissingFilesToast() async { + let download = sampleDownload( + gid: "112241", title: "Missing Image Data Gallery", + status: .completed, pageCount: 2 + ) + let inspection = sampleInspection(download: download) + let store = makeInspectorStore( + gid: download.gid, + initialInspection: inspection, + validateImageData: { _ in + .missingFiles("Page 2 image data is corrupted.") + }, + loadInspection: { _ in inspection } + ) + store.exhaustivity = .off + + await store.send(.validateImageData) { + $0.isValidatingImageData = true + } + await store.receive(\.validateImageDataDone) { + $0.isValidatingImageData = false + $0.toast = .error(caption: "Page 2 image data is corrupted.") + } + await store.receive(\.loadInspection) + await store.receive(\.loadInspectionDone) { + $0.inspection = inspection + $0.stableInspection = inspection + $0.loadingState = .idle + } + } +} + +// MARK: - Store Factory Helpers + +private extension DownloadInspectorLoadTests { + func makeInspectorStore( + gid: String, + initialInspection: DownloadInspection? = nil, + retryPages: (@Sendable (String, [Int]) async throws -> Void)? = nil, + validateImageData: (@Sendable (String) async -> DownloadValidationState?)? = nil, + togglePause: (@Sendable (String) async throws -> Void)? = nil, + loadInspection: @escaping @Sendable (String) async throws -> DownloadInspection + ) -> TestStoreOf { + var initialState = DownloadInspectorReducer.State(gid: gid) + initialState.inspection = initialInspection + if initialInspection != nil { initialState.loadingState = .idle } + return TestStore( + initialState: initialState, + reducer: DownloadInspectorReducer.init, + withDependencies: { + $0.downloadClient = DownloadClient() + $0.downloadClient.observeDownloads = { + AsyncStream { continuation in continuation.finish() } + } + $0.downloadClient.fetchDownloads = { [] } + $0.downloadClient.fetchDownload = { _ in nil } + $0.downloadClient.refreshDownloads = {} + $0.downloadClient.validateImageData = validateImageData ?? { _ in nil } + $0.downloadClient.enqueue = { _ in } + $0.downloadClient.togglePause = togglePause ?? { _ in } + $0.downloadClient.retry = { _, _ in } + $0.downloadClient.retryPages = retryPages ?? { _, _ in } + $0.downloadClient.delete = { _ in } + $0.downloadClient.loadManifest = { _ in throw AppError.notFound } + $0.downloadClient.loadInspection = loadInspection + } + ) + } +} diff --git a/AppPackage/Tests/DownloadsFeatureTests/DownloadInspectorRetryTests.swift b/AppPackage/Tests/DownloadsFeatureTests/DownloadInspectorRetryTests.swift new file mode 100644 index 000000000..9284869f2 --- /dev/null +++ b/AppPackage/Tests/DownloadsFeatureTests/DownloadInspectorRetryTests.swift @@ -0,0 +1,153 @@ +import Foundation +import AppModels +import ComposableArchitecture +import Testing +import DownloadClient +@testable import DownloadsFeature +@testable import AppFeature + +@Suite(.serialized) +@MainActor +struct DownloadInspectorRetryTests: DownloadFeatureTestCase { + @MainActor + @Test + func testDownloadInspectorKeepsRetriedPagesPendingWhileRetryWorkRemainsActive() async { + let download = sampleDownload( + gid: "112236", title: "Retry Pending Gallery", + status: .partial, completedPageCount: 1 + ) + let refreshedInspection = sampleInspection(download: download) + var initialState = DownloadInspectorReducer.State(gid: download.gid) + initialState.inspection = sampleInspection(download: download) + initialState.stableInspection = sampleInspection(download: download) + initialState.retryingPageIndices = [2] + initialState.loadingState = .idle + + let store = makeRetryTestStore( + initialState: initialState, + loadInspection: { _ in refreshedInspection } + ) + store.exhaustivity = .off + + await store.send(.loadInspection) + let requestID = store.state.inspectionRequestID + await store.send(.loadInspectionDone(requestID, .success(refreshedInspection))) { + $0.inspection = .init( + download: download, coverURL: refreshedInspection.coverURL, + pages: [ + refreshedInspection.pages[0], + .init( + index: 2, status: .pending, + relativePath: "\(download.gid)_\(download.token)_2.jpg", + fileURL: nil, failure: nil + ) + ] + ) + $0.loadingState = .idle + $0.retryingPageIndices = [2] + } + } + + @MainActor + @Test + func testDownloadInspectorClearsRetryingPagesAfterRetrySettlesWithFailure() async { + let initialDownload = sampleDownload( + gid: "112237", title: "Retry Failure Gallery", + status: .partial, completedPageCount: 1 + ) + let settledDownload = sampleDownload( + gid: "112237", title: "Retry Failure Gallery", status: .partial, + completedPageCount: 1, lastError: .init(code: .networkingFailed, message: "Network Error") + ) + let settledInspection = sampleInspection(download: settledDownload) + var initialState = DownloadInspectorReducer.State(gid: initialDownload.gid) + initialState.inspection = sampleInspection(download: initialDownload) + initialState.stableInspection = sampleInspection(download: initialDownload) + initialState.retryingPageIndices = [2] + initialState.loadingState = .idle + + let store = makeRetryTestStore( + initialState: initialState, + loadInspection: { _ in settledInspection } + ) + store.exhaustivity = .off + + await store.send(.loadInspection) + let requestID = store.state.inspectionRequestID + await store.send(.loadInspectionDone(requestID, .success(settledInspection))) { + $0.inspection = settledInspection + $0.stableInspection = settledInspection + $0.loadingState = .idle + $0.retryingPageIndices = [] + } + } + + @MainActor + @Test + func testDownloadInspectorRestoresStableInspectionWhenRetryReloadFails() async { + let download = sampleDownload( + gid: "112238", title: "Retry Reload Failure Gallery", + status: .partial, completedPageCount: 1 + ) + let stableInspection = sampleInspection(download: download) + var initialState = DownloadInspectorReducer.State(gid: download.gid) + initialState.inspection = .init( + download: download, coverURL: stableInspection.coverURL, + pages: [ + stableInspection.pages[0], + .init( + index: 2, status: .pending, + relativePath: "\(download.gid)_\(download.token)_2.jpg", + fileURL: nil, failure: nil + ) + ] + ) + initialState.stableInspection = stableInspection + initialState.retryingPageIndices = [2] + initialState.loadingState = .idle + + let store = makeRetryTestStore( + initialState: initialState, + loadInspection: { _ in throw AppError.networkingFailed } + ) + store.exhaustivity = .off + + let requestID = store.state.inspectionRequestID + await store.send(.loadInspectionDone(requestID, .failure(.networkingFailed))) { + $0.inspection = stableInspection + $0.loadingState = .failed(.networkingFailed) + $0.retryingPageIndices = [] + } + } + +} + +// MARK: - Store Factory Helpers + +private extension DownloadInspectorRetryTests { + func makeRetryTestStore( + initialState: DownloadInspectorReducer.State, + loadInspection: @escaping @Sendable (String) async throws -> DownloadInspection + ) -> TestStoreOf { + TestStore( + initialState: initialState, + reducer: DownloadInspectorReducer.init, + withDependencies: { + $0.downloadClient = DownloadClient() + $0.downloadClient.observeDownloads = { + AsyncStream { continuation in continuation.finish() } + } + $0.downloadClient.fetchDownloads = { [] } + $0.downloadClient.fetchDownload = { _ in nil } + $0.downloadClient.refreshDownloads = {} + $0.downloadClient.enqueue = { _ in } + $0.downloadClient.togglePause = { _ in } + $0.downloadClient.retry = { _, _ in } + $0.downloadClient.retryPages = { _, _ in } + $0.downloadClient.delete = { _ in } + $0.downloadClient.loadManifest = { _ in throw AppError.notFound } + $0.downloadClient.loadInspection = loadInspection + } + ) + } +} diff --git a/AppPackage/Tests/DownloadsFeatureTests/DownloadInspectorSkipTests.swift b/AppPackage/Tests/DownloadsFeatureTests/DownloadInspectorSkipTests.swift new file mode 100644 index 000000000..3471f8c03 --- /dev/null +++ b/AppPackage/Tests/DownloadsFeatureTests/DownloadInspectorSkipTests.swift @@ -0,0 +1,95 @@ +import Foundation +import AppModels +import ComposableArchitecture +import Testing +import DownloadClient +@testable import DownloadsFeature +@testable import AppFeature + +@Suite(.serialized) +struct DownloadInspectorSkipTests: DownloadFeatureTestCase { + @MainActor + @Test + func testDownloadInspectorSkipsReloadWhenObservedDownloadDidNotChange() async { + let download = sampleDownload( + gid: "112244", + title: "Stable Inspector Gallery", + status: .partial, + completedPageCount: 1 + ) + let inspection = sampleInspection(download: download) + let loadInspectionCount = UncheckedBox(0) + + var initialState = DownloadInspectorReducer.State(gid: download.gid) + initialState.inspection = inspection + initialState.loadingState = .idle + + let store = TestStore( + initialState: initialState, + reducer: DownloadInspectorReducer.init, + withDependencies: { + $0.downloadClient = DownloadClient() + $0.downloadClient.observeDownloads = { + AsyncStream { continuation in + continuation.finish() + } + } + $0.downloadClient.fetchDownloads = { [] } + $0.downloadClient.fetchDownload = { _ in nil } + $0.downloadClient.refreshDownloads = {} + $0.downloadClient.enqueue = { _ in } + $0.downloadClient.togglePause = { _ in } + $0.downloadClient.retry = { _, _ in } + $0.downloadClient.retryPages = { _, _ in } + $0.downloadClient.delete = { _ in } + $0.downloadClient.loadManifest = { _ in throw AppError.notFound } + $0.downloadClient.loadInspection = { _ in + loadInspectionCount.value += 1 + return inspection + } + } + ) + store.exhaustivity = .off + + await store.send(.observeDownloadsDone([download])) + #expect(loadInspectionCount.value == 0) + } + + @MainActor + @Test + func testDownloadInspectorIgnoresStaleInspectionResponses() async { + let originalDownload = sampleDownload( + gid: "112245", + title: "Stale Inspector Gallery", + status: .partial, + completedPageCount: 1 + ) + let refreshedDownload = sampleDownload( + gid: "112245", + title: "Stale Inspector Gallery", + status: .partial, + completedPageCount: 2 + ) + let staleInspection = sampleInspection(download: originalDownload) + let refreshedInspection = sampleInspection(download: refreshedDownload) + + let firstRequestID = UUID() + let secondRequestID = UUID() + var initialState = DownloadInspectorReducer.State(gid: originalDownload.gid) + initialState.loadingState = .loading + initialState.inspectionRequestID = secondRequestID + + let store = TestStore(initialState: initialState, reducer: DownloadInspectorReducer.init) + store.exhaustivity = .off + + await store.send(.loadInspectionDone(firstRequestID, .success(staleInspection))) + #expect(store.state.inspection == nil) + + await store.send(.loadInspectionDone(secondRequestID, .success(refreshedInspection))) { + $0.inspection = refreshedInspection + $0.stableInspection = refreshedInspection + $0.loadingState = .idle + } + } + +} diff --git a/AppPackage/Tests/DownloadsFeatureTests/DownloadInterruptedResumeTests.swift b/AppPackage/Tests/DownloadsFeatureTests/DownloadInterruptedResumeTests.swift new file mode 100644 index 000000000..4b7b3f2f4 --- /dev/null +++ b/AppPackage/Tests/DownloadsFeatureTests/DownloadInterruptedResumeTests.swift @@ -0,0 +1,236 @@ +import Foundation +import AppModels +import Testing +import DownloadClient +@testable import AppFeature + +@Suite +struct DownloadInterruptedResumeTests: DownloadFeatureTestCase { + @Test + func testInterruptedSessionResolvesNonDestructiveResumeMode() async throws { + let manager = makeTestingDownloadCoordinator() + + let queuedPartial = sampleDownload( + gid: "913000001", title: "Interrupted", + status: .queued, pageCount: 26, completedPageCount: 7 + ) + let activePartial = sampleDownload( + gid: "913000002", title: "Interrupted", + status: .downloading, pageCount: 26, completedPageCount: 7 + ) + let queuedUntouched = sampleDownload( + gid: "913000003", title: "Interrupted", + status: .queued, pageCount: 26, completedPageCount: 0 + ) + let queuedComplete = sampleDownload( + gid: "913000004", title: "Interrupted", + status: .queued, pageCount: 26, completedPageCount: 26 + ) + + #expect(await manager.queuedMode(for: queuedPartial) == .repair) + #expect(await manager.queuedMode(for: activePartial) == .repair) + #expect(await manager.queuedMode(for: queuedUntouched) == .initial) + #expect(await manager.queuedMode(for: queuedComplete) == .repair) + } + + @Test + func testWipedWorkingFolderStaysIndexedWithFreshManifest() async throws { + let gid = "913000005" + let rootURL = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + defer { try? FileManager.default.removeItem(at: rootURL) } + + let storage = DownloadStore(rootURL: rootURL, fileManager: .default) + let manager = DownloadCoordinator(storage: storage, urlSession: .shared) + + let folderURL = try writeManifestFolder( + storage: storage, + gid: gid, + title: "Redownload", + pageHashes: ["sha256:done", ""] + ) + await manager.reloadDownloadIndex() + let stalePageURL = folderURL.appendingPathComponent("\(gid)_token_1.jpg") + let download = sampleDownload( + gid: gid, title: "Redownload", + status: .queued, pageCount: 2, completedPageCount: 1, + folderURL: folderURL + ) + + let workingSeed = try await manager.prepareWorkingSeed( + payload: makePayload(gid: gid, title: "Redownload", mode: .redownload), + existingDownload: download, + folderURL: folderURL + ) + + #expect(!FileManager.default.fileExists(atPath: stalePageURL.path)) + let persistedManifest = try storage.readManifest(folderURL: folderURL) + #expect(persistedManifest == workingSeed.manifest) + #expect(persistedManifest.pageCount == 2) + #expect(persistedManifest.completedPageCount == 0) + let stored = await manager.fetchDownload(gid: gid) + #expect(stored != nil) + } + + @Test + func testPauseAfterInterruptedRedownloadKeepsDownloadListed() async throws { + let gid = "913000006" + let rootURL = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + defer { try? FileManager.default.removeItem(at: rootURL) } + + let storage = DownloadStore(rootURL: rootURL, fileManager: .default) + let manager = DownloadCoordinator(storage: storage, urlSession: .shared) + + let folderURL = try writeManifestFolder( + storage: storage, + gid: gid, + title: "Pause Survives", + pageHashes: ["sha256:done", ""] + ) + await manager.reloadDownloadIndex() + let download = sampleDownload( + gid: gid, title: "Pause Survives", + status: .queued, pageCount: 2, completedPageCount: 1, + folderURL: folderURL + ) + + _ = try await manager.prepareWorkingSeed( + payload: makePayload(gid: gid, title: "Pause Survives", mode: .redownload), + existingDownload: download, + folderURL: folderURL + ) + await manager.testingSetQueuedGalleryIDs([gid]) + let activeTask = Task { + do { + try await Task.sleep(for: .seconds(60)) + } catch {} + } + await manager.testingInstallActiveTask(gid: gid, task: activeTask) + + let result = await manager.togglePause(gid: gid) + + guard case .success = result else { + Issue.record("Pause should succeed, got \(result)") + return + } + let stored = await manager.fetchDownload(gid: gid) + #expect(stored?.displayStatus == .inactive) + #expect( + stored?.badge == DownloadBadge( + status: .inactive, + progress: .init(completedPageCount: 0, pageCount: 2) + ) + ) + #expect(FileManager.default.fileExists(atPath: folderURL.path)) + } + + @Test + func testRemoveSupersededFoldersKeepsOnlyCompletedFolder() async throws { + let gid = "913000007" + let rootURL = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + defer { try? FileManager.default.removeItem(at: rootURL) } + + let storage = DownloadStore(rootURL: rootURL, fileManager: .default) + let manager = DownloadCoordinator(storage: storage, urlSession: .shared) + + let oldFolderURL = try writeManifestFolder( + storage: storage, gid: gid, title: "Old Title", + pageHashes: ["sha256:done", "sha256:done"] + ) + let completedFolderURL = try writeManifestFolder( + storage: storage, gid: gid, title: "New Title", + pageHashes: ["sha256:done", "sha256:done"] + ) + let unrelatedFolderURL = try writeManifestFolder( + storage: storage, gid: "913000008", title: "Unrelated", + pageHashes: ["sha256:done"] + ) + + await manager.removeSupersededFolders( + gid: gid, + token: "token", + keeping: completedFolderURL + ) + + #expect(!FileManager.default.fileExists(atPath: oldFolderURL.path)) + #expect(FileManager.default.fileExists(atPath: completedFolderURL.path)) + #expect(FileManager.default.fileExists(atPath: unrelatedFolderURL.path)) + } +} + +// MARK: - Setup Helpers + +private extension DownloadInterruptedResumeTests { + @discardableResult + func writeManifestFolder( + storage: DownloadStore, + gid: String, + title: String, + pageHashes: [String] + ) throws -> URL { + try storage.ensureRootDirectory() + let folderURL = storage.folderURL(relativePath: "Folder/[\(gid)_token] \(title)") + try FileManager.default.createDirectory( + at: folderURL, + withIntermediateDirectories: true + ) + try storage.writeManifest( + DownloadManifest( + gid: gid, + host: .ehentai, + token: "token", + title: title, + jpnTitle: nil, + category: .doujinshi, + language: .japanese, + remoteCoverURL: nil, + uploader: "Uploader", + tags: [], + postedDate: .now, + rating: 4, + pages: Dictionary( + uniqueKeysWithValues: + pageHashes.enumerated().map { ($0.offset + 1, $0.element) } + ) + ), + folderURL: folderURL + ) + for (offset, hash) in pageHashes.enumerated() where !hash.isEmpty { + try Data([UInt8(offset + 1)]).write( + to: folderURL.appendingPathComponent("\(gid)_token_\(offset + 1).jpg"), + options: .atomic + ) + } + return folderURL + } + + func makePayload( + gid: String, + title: String, + mode: DownloadStartMode + ) -> DownloadRequestPayload { + DownloadRequestPayload( + gallery: Gallery( + gid: gid, token: "token", title: title, + rating: 4, tags: [], category: .doujinshi, + uploader: "Uploader", pageCount: 2, postedDate: .now, + coverURL: nil, + galleryURL: URL(string: "https://e-hentai.org/g/\(gid)/token") + ), + galleryDetail: GalleryDetail( + gid: gid, title: title, jpnTitle: nil, + isFavorited: false, visibility: .yes, + rating: 4, userRating: 0, ratingCount: 1, + category: .doujinshi, language: .japanese, + uploader: "Uploader", postedDate: .now, + coverURL: nil, + favoritedCount: 0, pageCount: 2, + sizeCount: 1, sizeType: "MB", torrentCount: 0 + ), + previewURLs: [:], previewConfig: .normal(rows: 4), + host: .ehentai, folderName: "Folder", mode: mode + ) + } +} diff --git a/AppPackage/Tests/DownloadsFeatureTests/DownloadIpBanTests.swift b/AppPackage/Tests/DownloadsFeatureTests/DownloadIpBanTests.swift new file mode 100644 index 000000000..53fa471b3 --- /dev/null +++ b/AppPackage/Tests/DownloadsFeatureTests/DownloadIpBanTests.swift @@ -0,0 +1,67 @@ +import TestingSupport +import Foundation +import AppModels +import Testing +import DownloadClient +@testable import AppFeature + +@Suite(.serialized) +struct DownloadIpBanTests: DownloadFeatureTestCase { + @Test + func testIpBannedDoesNotRetryImmediately() async throws { + let sessionID = UUID().uuidString + let configuration = URLSessionConfiguration.ephemeral + configuration.protocolClasses = [SharedSessionStubURLProtocol.self] + configuration.httpAdditionalHeaders = [SharedSessionStubURLProtocol.headerKey: sessionID] + let manager = DownloadCoordinator( + storage: DownloadStore( + rootURL: FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true), + fileManager: .default + ), + urlSession: URLSession(configuration: configuration) + ) + let recorder = RequestRecorder() + let ipBannedHTML = try fixtureData(resource: HTMLFilename.ipBanned.rawValue, pathExtension: "html") + let fallbackBannedURL = try #require(URL(string: "https://example.com/banned")) + SharedSessionStubURLProtocol.setHandler(for: sessionID) { request in + recorder.recordDetail() + return ( + try #require(HTTPURLResponse( + url: request.url ?? fallbackBannedURL, + statusCode: 200, + httpVersion: nil, + headerFields: ["Content-Type": "text/html; charset=utf-8"] + )), + ipBannedHTML + ) + } + defer { + SharedSessionStubURLProtocol.removeHandler(for: sessionID) + } + + let download = sampleDownload( + gid: "123456", + title: "Banned Gallery", + status: .partial + ) + + do { + _ = try await manager.fetchLatestPayload( + for: download, + mode: .redownload, + options: .init(), + pageSelection: nil + ) + Issue.record("Expected ipBanned error") + } catch let error as AppError { + guard case .ipBanned = error else { + Issue.record("Expected ipBanned, got \(error)") + return + } + } + + #expect(recorder.snapshot().detailRequests == 1) + } + +} diff --git a/AppPackage/Tests/DownloadsFeatureTests/DownloadObserverBatchTests.swift b/AppPackage/Tests/DownloadsFeatureTests/DownloadObserverBatchTests.swift new file mode 100644 index 000000000..e31d793d2 --- /dev/null +++ b/AppPackage/Tests/DownloadsFeatureTests/DownloadObserverBatchTests.swift @@ -0,0 +1,257 @@ +import Foundation +import AppModels +import ComposableArchitecture +import Testing +import DownloadClient +@testable import DownloadsFeature +@testable import AppFeature + +@Suite(.serialized) +struct DownloadObserverBatchTests: DownloadFeatureTestCase { + @MainActor + @Test + func testDownloadInspectorClearsInspectionWhenObservedDownloadDisappears() async { + let download = sampleDownload( + gid: "9988", + title: "Observed Archive", + status: .completed + ) + let inspection = sampleInspection(download: download) + var initialState = DownloadInspectorReducer.State(gid: download.gid) + initialState.inspection = inspection + initialState.stableInspection = inspection + initialState.retryingPageIndices = [2] + initialState.loadingState = .idle + + let store = TestStore( + initialState: initialState, + reducer: DownloadInspectorReducer.init, + withDependencies: { + $0.downloadClient = DownloadClient() + $0.downloadClient.observeDownloads = { + AsyncStream { continuation in + continuation.yield([download]) + continuation.yield([]) + continuation.finish() + } + } + $0.downloadClient.fetchDownloads = { [download] } + $0.downloadClient.fetchDownload = { gid in gid == download.gid ? download : nil } + $0.downloadClient.refreshDownloads = {} + $0.downloadClient.enqueue = { _ in } + $0.downloadClient.togglePause = { _ in } + $0.downloadClient.retry = { _, _ in } + $0.downloadClient.delete = { _ in } + $0.downloadClient.loadManifest = { _ in throw AppError.notFound } + $0.downloadClient.loadInspection = { _ in inspection } + } + ) + store.exhaustivity = .off + + await store.send(.observeDownloads) + await store.receive(\.observeDownloadsDone, [download]) + await store.receive(\.observeDownloadsDone, []) { + $0.inspection = nil + $0.stableInspection = nil + $0.loadingState = .idle + $0.retryingPageIndices = [] + } + } + + @MainActor + @Test + func testDownloadCoordinatorBatchesObserverUpdatesDuringProgressFlush() async throws { + let pageCount = 20 + let gid = String(Int(Date().timeIntervalSince1970 * 1000) + 104) + let rootURL = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + defer { try? FileManager.default.removeItem(at: rootURL) } + + let storage = DownloadStore(rootURL: rootURL, fileManager: .default) + let manager = DownloadCoordinator(storage: storage, urlSession: .shared) + + // Warm the (empty) index before seeding so the gallery surfaces only + // through flush updates, mirroring an active download whose folder is + // patched into the index rather than re-scanned per progress tick. + await manager.reloadDownloadIndex() + + let folderRelativePath = "Folder/\(gid) - Progress Flush" + let folderURL = storage.folderURL(relativePath: folderRelativePath) + try FileManager.default.createDirectory( + at: folderURL, + withIntermediateDirectories: true + ) + try storage.writeManifest( + sampleManifest( + gid: gid, + title: "Progress Flush", + pageCount: pageCount + ), + folderURL: folderURL + ) + + let observationStream = await manager.observeDownloads() + let emissionTask = Task { + var emissionCount = 0 + for await downloads in observationStream { + guard let relevantDownload = downloads.first(where: { $0.gid == gid }) else { continue } + emissionCount += 1 + if relevantDownload.completedPageCount == pageCount { break } + } + return emissionCount + } + + var pendingResolvedPages = [DownloadCoordinator.PageResult]() + var lastFlushDate = Date.distantPast + for index in 1...pageCount { + let relativePath = storage.makePageRelativePath( + gid: gid, + token: "token", + index: index, + fileExtension: "jpg" + ) + try Data([UInt8(index)]).write( + to: folderURL.appendingPathComponent(relativePath), + options: .atomic + ) + pendingResolvedPages.append( + .init(index: index, relativePath: relativePath, imageURL: nil) + ) + try await manager.flushDownloadProgress( + context: .init(gid: gid, folderURL: folderURL), + pendingResolvedPages: &pendingResolvedPages, + lastFlushDate: &lastFlushDate, + force: false + ) + } + try await manager.flushDownloadProgress( + context: .init(gid: gid, folderURL: folderURL), + pendingResolvedPages: &pendingResolvedPages, + lastFlushDate: &lastFlushDate, + force: true + ) + + let emissionCount = try await waitForTaskValue( + emissionTask, + timeout: .seconds(2), + description: "observer updates for progress flush" + ) + let stored = await manager.fetchDownload(gid: gid) + + #expect(stored?.completedPageCount == pageCount) + #expect(emissionCount < pageCount) + #expect(emissionCount <= 2 + Int(ceil(Double(pageCount) / 8.0))) + } + + @Test + func testObserverHubBroadcastIsNotSuppressedByLateObserverInitialSnapshot() async throws { + let hub = DownloadObserverHub() + let initialDownload = sampleDownload( + gid: "observer-race", + title: "Observer Race", + status: .queued, + completedPageCount: 0 + ) + let updatedDownload = sampleDownload( + gid: initialDownload.gid, + title: initialDownload.title, + status: .completed, + completedPageCount: initialDownload.pageCount + ) + + await hub.notify([initialDownload]) + let existingSnapshot = [initialDownload] + let existingObserverStream = await hub.observe { existingSnapshot } + let existingObserverTask = collectEmissions( + from: existingObserverStream, + count: 2 + ) + + let lateSnapshot = [updatedDownload] + let lateObserverStream = await hub.observe { lateSnapshot } + let lateObserverTask = collectEmissions( + from: lateObserverStream, + count: 1 + ) + let lateObserverEmissions = try await waitForTaskValue( + lateObserverTask, + timeout: .seconds(1), + description: "late observer initial snapshot" + ) + #expect(lateObserverEmissions == [[updatedDownload]]) + + await hub.notify([updatedDownload]) + + let existingObserverEmissions = try await waitForTaskValue( + existingObserverTask, + timeout: .seconds(1), + description: "existing observer update after late observer registration" + ) + #expect(existingObserverEmissions == [[initialDownload], [updatedDownload]]) + } + + @Test + func testObserverHubRegistersObserverBeforeReturningStream() async { + let hub = DownloadObserverHub() + let initialDownload = sampleDownload( + gid: "observer-registration", + title: "Observer Registration", + status: .completed + ) + let registrationSnapshot = [initialDownload] + let stream = await hub.observe { registrationSnapshot } + let observerTask = collectEmissions(from: stream, count: 1) + + let emissions = await observerTask.value + #expect(emissions == [[initialDownload]]) + } + + @Test + func testObserveDeliversNotifyArrivingDuringSnapshotResolution() async throws { + let hub = DownloadObserverHub() + let initialDownload = sampleDownload( + gid: "observer-window", + title: "Observer Window", + status: .queued, + completedPageCount: 0 + ) + let updatedDownload = sampleDownload( + gid: initialDownload.gid, + title: initialDownload.title, + status: .completed, + completedPageCount: initialDownload.pageCount + ) + + // The provider notifies the hub mid-resolution, reproducing a state change + // landing in the capture->register window. The observer must receive the + // notify value, not the stale snapshot captured before it. + let stream = await hub.observe { + await hub.notify([updatedDownload]) + return [initialDownload] + } + let observerTask = collectEmissions(from: stream, count: 1) + + let emissions = try await waitForTaskValue( + observerTask, + timeout: .seconds(1), + description: "observer notify during snapshot resolution" + ) + #expect(emissions == [[updatedDownload]]) + } +} + +private func collectEmissions( + from stream: AsyncStream<[DownloadedGallery]>, + count: Int +) -> Task<[[DownloadedGallery]], Never> { + Task { + var emissions = [[DownloadedGallery]]() + for await downloads in stream { + emissions.append(downloads) + if emissions.count == count { + break + } + } + return emissions + } +} diff --git a/AppPackage/Tests/DownloadsFeatureTests/DownloadObserverReadingTests.swift b/AppPackage/Tests/DownloadsFeatureTests/DownloadObserverReadingTests.swift new file mode 100644 index 000000000..4090e25f4 --- /dev/null +++ b/AppPackage/Tests/DownloadsFeatureTests/DownloadObserverReadingTests.swift @@ -0,0 +1,229 @@ +import Foundation +import AppModels +import ComposableArchitecture +import Testing +import AppTools +import URLClient +import HapticsClient +import ImageClient +import DownloadClient +import ClipboardClient +import CookieClient +import DeviceClient +import AppDelegateClient +@testable import ReadingFeature +@testable import DetailFeature +@testable import AppFeature + +@Suite(.serialized) +@MainActor +struct DownloadObserverReadingTests: DownloadFeatureTestCase { + @MainActor + @Test + func testReadingReducerLocalSourceWithoutGalleryStateDoesNotStayLoading() async throws { + let gid = "700001" + let title = "Offline Gallery" + let folderURL = FileUtil.downloadsDirectoryURL + .appendingPathComponent("[\(gid)_token] \(title)", isDirectory: true) + let localPageURLs = [ + 1: folderURL.appendingPathComponent("123_token_1.jpg"), + 2: folderURL.appendingPathComponent("123_token_2.jpg") + ] + let download = sampleDownload( + gid: gid, title: title, status: .completed, pageCount: 2, completedPageCount: 2, + folderURL: folderURL, localPageURLs: localPageURLs + ) + let manifest = try sampleManifest(gid: download.gid, title: download.title) + let store = TestStore( + initialState: ReadingReducer.State(gallery: .preview, contentSource: .local(download, manifest)) + ) { + ReadingReducer() + } withDependencies: { + $0.appDelegateClient = .noop + $0.clipboardClient = .noop + $0.cookieClient = .noop + $0.deviceClient = .noop + $0.downloadClient = .noop + $0.hapticsClient = .noop + $0.imageClient = .noop + $0.urlClient = .noop + } + store.exhaustivity = .off + defer { try? FileManager.default.removeItem(at: folderURL) } + try FileManager.default.createDirectory( + at: folderURL, + withIntermediateDirectories: true + ) + try Data([0x01]).write( + to: folderURL.appendingPathComponent("123_token_1.jpg"), + options: .atomic + ) + try Data([0x02]).write( + to: folderURL.appendingPathComponent("123_token_2.jpg"), + options: .atomic + ) + + // State.init applies the local source and seeds the resume page at construction, so the reader + // is already offline-seeded — there is no separate restore step. + #expect(store.state.gallery == download.gallery) + #expect(store.state.language == manifest.language) + #expect(store.state.localPageURLs == [ + 1: folderURL.appendingPathComponent("123_token_1.jpg"), + 2: folderURL.appendingPathComponent("123_token_2.jpg") + ]) + #expect(store.state.readingProgress == 0) + await store.finish() + } + + @MainActor + @Test + func testReadingReducerDoesNotReloadLocalPagesWhenOnlyOtherGalleryChanges() async { + let gallery = sampleGallery() + let relevantDownload = sampleDownload(gid: gallery.gid, title: gallery.title, status: .completed) + let otherDownload = sampleDownload(gid: "900001", title: "Other Gallery", status: .queued) + let updatedOtherDownload = sampleDownload( + gid: otherDownload.gid, title: otherDownload.title, + status: .downloading, pageCount: 12, completedPageCount: 4 + ) + let (stream, continuation) = makeObserverStream() + let loadCount = UncheckedBox(0) + + let initialState = ReadingReducer.State(gallery: gallery, contentSource: .remote) + + let store = makeReadingStoreWithLoadCount( + initialState: initialState, stream: stream, + expectedGID: gallery.gid, loadCount: loadCount + ) + + await store.send(.observeDownloads(gallery.gid)) + continuation.yield([relevantDownload, otherDownload]) + await store.receive(\.observeDownloadsDone, [relevantDownload]) + await store.receive(\.loadLocalPageURLs, gallery.gid) + await store.receive(\.loadLocalPageURLsDone) + #expect(loadCount.value == 1) + + continuation.yield([relevantDownload, updatedOtherDownload]) + try? await Task.sleep(for: .milliseconds(50)) + #expect(loadCount.value == 1) + + continuation.finish() + await store.finish() + } + + @MainActor + @Test + func testPreviewsReducerDoesNotReloadLocalPreviewsWhenOnlyOtherGalleryChanges() async { + let gallery = sampleGallery() + let relevantDownload = sampleDownload(gid: gallery.gid, title: gallery.title, status: .completed) + let otherDownload = sampleDownload(gid: "900002", title: "Other Preview Gallery", status: .queued) + let updatedOtherDownload = sampleDownload( + gid: otherDownload.gid, title: otherDownload.title, + status: .paused, pageCount: 12, completedPageCount: 2 + ) + let (stream, continuation) = makeObserverStream() + let loadCount = UncheckedBox(0) + + let initialState = PreviewsReducer.State(gallery: gallery) + + let store = makePreviewsStoreWithLoadCount( + initialState: initialState, stream: stream, + expectedGID: gallery.gid, loadCount: loadCount + ) + + await store.send(.observeDownloads(gallery.gid)) + continuation.yield([relevantDownload, otherDownload]) + await store.receive(\.observeDownloadsDone, [relevantDownload]) + await store.receive(\.loadLocalPreviewURLs, gallery.gid) + await store.receive(\.loadLocalPreviewURLsDone) + #expect(loadCount.value == 1) + + continuation.yield([relevantDownload, updatedOtherDownload]) + try? await Task.sleep(for: .milliseconds(50)) + #expect(loadCount.value == 1) + + continuation.finish() + await store.finish() + } + +} + +// MARK: - Store Factory Helpers + +private extension DownloadObserverReadingTests { + func makeObserverStream() + -> (AsyncStream<[DownloadedGallery]>, AsyncStream<[DownloadedGallery]>.Continuation) { + var continuation: AsyncStream<[DownloadedGallery]>.Continuation! + let stream = AsyncStream<[DownloadedGallery]> { continuation = $0 } + return (stream, continuation) + } + + func makeReadingStoreWithLoadCount( + initialState: ReadingReducer.State, + stream: AsyncStream<[DownloadedGallery]>, + expectedGID: String, + loadCount: UncheckedBox + ) -> TestStoreOf { + let store = TestStore( + initialState: initialState, + reducer: ReadingReducer.init, + withDependencies: { + $0.appDelegateClient = .noop + $0.clipboardClient = .noop + $0.cookieClient = .noop + $0.deviceClient = .noop + $0.downloadClient = DownloadClient() + $0.downloadClient.observeDownloads = { stream } + $0.downloadClient.fetchDownloads = { [] } + $0.downloadClient.fetchDownload = { _ in nil } + $0.downloadClient.refreshDownloads = {} + $0.downloadClient.enqueue = { _ in } + $0.downloadClient.togglePause = { _ in } + $0.downloadClient.retry = { _, _ in } + $0.downloadClient.delete = { _ in } + $0.downloadClient.loadManifest = { _ in throw AppError.notFound } + $0.downloadClient.loadLocalPageURLs = { gid in + #expect(gid == expectedGID) + loadCount.value += 1 + return [:] + } + $0.hapticsClient = .noop + $0.imageClient = .noop + $0.urlClient = .noop + } + ) + store.exhaustivity = .off + return store + } + + func makePreviewsStoreWithLoadCount( + initialState: PreviewsReducer.State, + stream: AsyncStream<[DownloadedGallery]>, + expectedGID: String, + loadCount: UncheckedBox + ) -> TestStoreOf { + let store = TestStore( + initialState: initialState, + reducer: PreviewsReducer.init, + withDependencies: { + $0.downloadClient = DownloadClient() + $0.downloadClient.observeDownloads = { stream } + $0.downloadClient.fetchDownloads = { [] } + $0.downloadClient.fetchDownload = { _ in nil } + $0.downloadClient.refreshDownloads = {} + $0.downloadClient.enqueue = { _ in } + $0.downloadClient.togglePause = { _ in } + $0.downloadClient.retry = { _, _ in } + $0.downloadClient.delete = { _ in } + $0.downloadClient.loadManifest = { _ in throw AppError.notFound } + $0.downloadClient.loadLocalPageURLs = { gid in + #expect(gid == expectedGID) + loadCount.value += 1 + return [:] + } + $0.hapticsClient = .noop + } + ) + store.exhaustivity = .off + return store + } +} diff --git a/AppPackage/Tests/DownloadsFeatureTests/DownloadObserverRefreshTests.swift b/AppPackage/Tests/DownloadsFeatureTests/DownloadObserverRefreshTests.swift new file mode 100644 index 000000000..974eaaeb8 --- /dev/null +++ b/AppPackage/Tests/DownloadsFeatureTests/DownloadObserverRefreshTests.swift @@ -0,0 +1,162 @@ +import Foundation +import AppModels +import ComposableArchitecture +import Testing +import URLClient +import HapticsClient +import ImageClient +import DownloadClient +import ClipboardClient +import CookieClient +import DeviceClient +import AppDelegateClient +@testable import ReadingFeature +@testable import DetailFeature +@testable import AppFeature + +@Suite(.serialized) +@MainActor +struct DownloadObserverRefreshTests: DownloadFeatureTestCase { + @MainActor + @Test + func testReadingReducerEmitsOneFinalRefreshWhenRelevantDownloadDisappears() async { + let gallery = sampleGallery() + let relevantDownload = sampleDownload(gid: gallery.gid, title: gallery.title, status: .completed) + let (stream, continuation) = makeObserverStream() + let loadCount = UncheckedBox(0) + + let initialState = ReadingReducer.State(gallery: gallery, contentSource: .remote) + + let store = makeReadingObserverStore( + initialState: initialState, + stream: stream, + loadLocalPageURLs: { _ in + loadCount.value += 1 + return [:] + } + ) + + await store.send(.observeDownloads(gallery.gid)) + continuation.yield([relevantDownload]) + await store.receive(\.observeDownloadsDone, [relevantDownload]) + await store.receive(\.loadLocalPageURLs, gallery.gid) + await store.receive(\.loadLocalPageURLsDone) + + continuation.yield([]) + await store.receive(\.observeDownloadsDone, []) + await store.receive(\.loadLocalPageURLs, gallery.gid) + await store.receive(\.loadLocalPageURLsDone) + + #expect(loadCount.value == 2) + continuation.finish() + await store.finish() + } + + @MainActor + @Test + func testPreviewsReducerEmitsOneFinalRefreshWhenRelevantDownloadDisappears() async { + let gallery = sampleGallery() + let relevantDownload = sampleDownload(gid: gallery.gid, title: gallery.title, status: .completed) + let (stream, continuation) = makeObserverStream() + let loadCount = UncheckedBox(0) + + let initialState = PreviewsReducer.State(gallery: gallery) + + let store = makePreviewsObserverStore( + initialState: initialState, + stream: stream, + loadLocalPageURLs: { _ in + loadCount.value += 1 + return [:] + } + ) + + await store.send(.observeDownloads(gallery.gid)) + continuation.yield([relevantDownload]) + await store.receive(\.observeDownloadsDone, [relevantDownload]) + await store.receive(\.loadLocalPreviewURLs, gallery.gid) + await store.receive(\.loadLocalPreviewURLsDone) + + continuation.yield([]) + await store.receive(\.observeDownloadsDone, []) + await store.receive(\.loadLocalPreviewURLs, gallery.gid) + await store.receive(\.loadLocalPreviewURLsDone) + + #expect(loadCount.value == 2) + continuation.finish() + await store.finish() + } + +} + +// MARK: - Store Factory Helpers + +private extension DownloadObserverRefreshTests { + func makeObserverStream() -> (AsyncStream<[DownloadedGallery]>, AsyncStream<[DownloadedGallery]>.Continuation) { + var continuation: AsyncStream<[DownloadedGallery]>.Continuation! + let stream = AsyncStream<[DownloadedGallery]> { continuation = $0 } + return (stream, continuation) + } + + func makeReadingObserverStore( + initialState: ReadingReducer.State, + stream: AsyncStream<[DownloadedGallery]>, + loadLocalPageURLs: @escaping @Sendable (String) async -> [Int: URL]? + ) -> TestStoreOf { + let store = TestStore( + initialState: initialState, + reducer: ReadingReducer.init, + withDependencies: { + $0.appDelegateClient = .noop + $0.clipboardClient = .noop + $0.cookieClient = .noop + $0.deviceClient = .noop + $0.downloadClient = makeObserveDownloadClient( + stream: stream, loadLocalPageURLs: loadLocalPageURLs + ) + $0.hapticsClient = .noop + $0.imageClient = .noop + $0.urlClient = .noop + } + ) + store.exhaustivity = .off + return store + } + + func makePreviewsObserverStore( + initialState: PreviewsReducer.State, + stream: AsyncStream<[DownloadedGallery]>, + loadLocalPageURLs: @escaping @Sendable (String) async -> [Int: URL]? + ) -> TestStoreOf { + let store = TestStore( + initialState: initialState, + reducer: PreviewsReducer.init, + withDependencies: { + $0.downloadClient = makeObserveDownloadClient( + stream: stream, loadLocalPageURLs: loadLocalPageURLs + ) + $0.hapticsClient = .noop + } + ) + store.exhaustivity = .off + return store + } + + func makeObserveDownloadClient( + stream: AsyncStream<[DownloadedGallery]>, + loadLocalPageURLs: @escaping @Sendable (String) async -> [Int: URL]? + ) -> DownloadClient { + var client = DownloadClient() + client.observeDownloads = { stream } + client.fetchDownloads = { [] } + client.fetchDownload = { _ in nil } + client.refreshDownloads = {} + client.enqueue = { _ in } + client.togglePause = { _ in } + client.retry = { _, _ in } + client.delete = { _ in } + client.loadManifest = { _ in throw AppError.notFound } + client.loadLocalPageURLs = loadLocalPageURLs + return client + } +} diff --git a/AppPackage/Tests/DownloadsFeatureTests/DownloadPauseAndReconcileTests.swift b/AppPackage/Tests/DownloadsFeatureTests/DownloadPauseAndReconcileTests.swift new file mode 100644 index 000000000..b95d9452c --- /dev/null +++ b/AppPackage/Tests/DownloadsFeatureTests/DownloadPauseAndReconcileTests.swift @@ -0,0 +1,347 @@ +import Foundation +import AppModels +import ComposableArchitecture +import Kingfisher +import UIKit +import Testing +import DownloadClient +@testable import AppFeature + +@Suite(.serialized) +struct DownloadPauseAndReconcileTests: DownloadFeatureTestCase { + @Test + func testQuickSearchWordUsesNameWhenContentIsEmpty() { + let word = QuickSearchWord(name: "artist:hossy", content: "") + + #expect(word.effectiveSearchText == "artist:hossy") + } + + @Test + func testPauseKeepsActiveDownloadPausedWhenDeferredSchedulingRuns() async throws { + let gid = String(Int(Date().timeIntervalSince1970 * 1000)) + let rootURL = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + defer { try? FileManager.default.removeItem(at: rootURL) } + + let configuration = URLSessionConfiguration.ephemeral + configuration.protocolClasses = [FailFastURLProtocol.self] + let storage = DownloadStore(rootURL: rootURL, fileManager: .default) + let manager = DownloadCoordinator( + storage: storage, + urlSession: URLSession(configuration: configuration) + ) + + try writeManifestFolder( + storage: storage, + gid: gid, + title: "Pausable", + pageHashes: Array(repeating: "sha256:done", count: 7) + + Array(repeating: "", count: 19) + ) + await manager.reloadDownloadIndex() + + let activeTask = Task { [manager] in + do { + try await Task.sleep(for: .seconds(60)) + } catch is CancellationError { + await manager.scheduleNextIfNeeded() + } catch {} + } + await manager.testingInstallActiveTask(gid: gid, task: activeTask) + + let result = await manager.togglePause(gid: gid) + + guard case .success = result else { + Issue.record("Pause should succeed, got \(result)") + return + } + + try await Task.sleep(for: .milliseconds(100)) + + let stored = await manager.fetchDownload(gid: gid) + let activeGalleryID = await manager.testingActiveGalleryID() + #expect(stored?.displayStatus == .inactive) + #expect( + stored?.badge == DownloadBadge( + status: .inactive, + progress: .init(completedPageCount: 7, pageCount: 26) + ) + ) + #expect(activeGalleryID == nil) + } + + @Test + func testTogglePausePausesActiveRetryBeforeClearingQueuedIntent() async throws { + let gid = String(Int(Date().timeIntervalSince1970 * 1000) + 10) + let rootURL = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + defer { try? FileManager.default.removeItem(at: rootURL) } + + let storage = DownloadStore(rootURL: rootURL, fileManager: .default) + let manager = DownloadCoordinator(storage: storage, urlSession: .shared) + + try writeManifestFolder( + storage: storage, + gid: gid, + title: "Active Repair", + pageHashes: ["sha256:done", ""] + ) + await manager.reloadDownloadIndex() + + let activeTask = Task { + while !Task.isCancelled { + try? await Task.sleep(for: .milliseconds(10)) + } + } + await manager.testingInstallActiveTask(gid: gid, task: activeTask) + + guard case .success = await manager.retry(gid: gid, mode: .repair) else { + Issue.record("Retry should set the active gallery's queued intent.") + return + } + guard case .success = await manager.togglePause(gid: gid) else { + Issue.record("First pause tap should pause active retry work.") + return + } + + let stored = await manager.fetchDownload(gid: gid) + let activeGalleryID = await manager.testingActiveGalleryID() + let hasActiveTask = await manager.testingHasActiveTask() + #expect(stored?.displayStatus == .inactive) + #expect(activeGalleryID == nil) + #expect(!hasActiveTask) + } + + @Test + func testPauseKeepsIndexedManifestProgressWhenCancelling() async throws { + let gid = String(Int(Date().timeIntervalSince1970 * 1000) + 1) + let rootURL = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + defer { try? FileManager.default.removeItem(at: rootURL) } + + let configuration = URLSessionConfiguration.ephemeral + configuration.protocolClasses = [FailFastURLProtocol.self] + let storage = DownloadStore(rootURL: rootURL, fileManager: .default) + let manager = DownloadCoordinator( + storage: storage, + urlSession: URLSession(configuration: configuration) + ) + + try writeManifestFolder( + storage: storage, + gid: gid, + title: "Pausable", + pageHashes: ["sha256:done", ""] + ) + + let folderURL = storage.folderURL(relativePath: "Folder/[\(gid)_token] Pausable") + try FileManager.default.createDirectory( + at: folderURL, + withIntermediateDirectories: true + ) + try Data([0x01]).write( + to: folderURL.appendingPathComponent("\(gid)_token_1.jpg"), + options: .atomic + ) + try Data([0x02]).write( + to: folderURL.appendingPathComponent("\(gid)_token_2.jpg"), + options: .atomic + ) + await manager.reloadDownloadIndex() + + let activeTask = Task { [manager] in + do { + try await Task.sleep(for: .seconds(60)) + } catch is CancellationError { + await manager.scheduleNextIfNeeded() + } catch {} + } + await manager.testingInstallActiveTask(gid: gid, task: activeTask) + + let result = await manager.togglePause(gid: gid) + + guard case .success = result else { + Issue.record("Pause should succeed, got \(result)") + return + } + + let stored = await manager.fetchDownload(gid: gid) + #expect(stored?.displayStatus == .inactive) + #expect(stored?.completedPageCount == 1) + #expect( + stored?.badge == DownloadBadge( + status: .inactive, + progress: .init(completedPageCount: 1, pageCount: 2) + ) + ) + #expect(FileManager.default.fileExists(atPath: folderURL.path)) + } + + @Test + func testReconcileDownloadsKeepsIndexedSessionFailure() async throws { + let gid = String(Int(Date().timeIntervalSince1970 * 1000) + 2) + let rootURL = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + defer { try? FileManager.default.removeItem(at: rootURL) } + + let configuration = URLSessionConfiguration.ephemeral + configuration.protocolClasses = [FailFastURLProtocol.self] + let storage = DownloadStore(rootURL: rootURL, fileManager: .default) + let manager = DownloadCoordinator( + storage: storage, + urlSession: URLSession(configuration: configuration) + ) + + try writeManifestFolder( + storage: storage, + gid: gid, + title: "Failed", + pageHashes: Array(repeating: "", count: 18) + ) + await manager.testingSetDownloadError( + .init(code: .networkingFailed, message: "Network Error"), + gid: gid + ) + + await manager.reconcileDownloads() + + let stored = await manager.fetchDownload(gid: gid) + #expect(stored?.displayStatus == .error) + #expect(stored?.lastError?.code == .networkingFailed) + } + + @Test + func testReconcileDownloadsClearsCancellationLikeGalleryError() async throws { + let gid = String(Int(Date().timeIntervalSince1970 * 1000) + 3) + let rootURL = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + defer { try? FileManager.default.removeItem(at: rootURL) } + + let configuration = URLSessionConfiguration.ephemeral + configuration.protocolClasses = [FailFastURLProtocol.self] + let storage = DownloadStore(rootURL: rootURL, fileManager: .default) + let manager = DownloadCoordinator( + storage: storage, + urlSession: URLSession(configuration: configuration) + ) + + try writeManifestFolder( + storage: storage, + gid: gid, + title: "Cancelled", + pageHashes: Array(repeating: "sha256:done", count: 4) + + Array(repeating: "", count: 14) + ) + await manager.testingSetDownloadError( + .init( + code: .fileOperationFailed, + message: "The operation could not be completed. (Swift.CancellationError error 1.)" + ), + gid: gid + ) + + await manager.reconcileDownloads() + + let stored = await manager.fetchDownload(gid: gid) + #expect(stored?.lastError == nil) + #expect(stored?.displayStatus == .inactive) + } + + @Test + func testLoadInspectionFiltersCancellationFailuresIntoPendingPages() async throws { + let gid = String(Int(Date().timeIntervalSince1970 * 1000) + 4) + let rootURL = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + defer { try? FileManager.default.removeItem(at: rootURL) } + + let configuration = URLSessionConfiguration.ephemeral + configuration.protocolClasses = [FailFastURLProtocol.self] + let storage = DownloadStore(rootURL: rootURL, fileManager: .default) + let manager = DownloadCoordinator( + storage: storage, urlSession: URLSession(configuration: configuration) + ) + try writeManifestFolder( + storage: storage, + gid: gid, + title: "Inspection", + pageHashes: ["sha256:done", ""] + ) + try setupCancellationFilterTestFolder(storage: storage, gid: gid) + await manager.reloadDownloadIndex() + await manager.testingSetFailedPageErrors( + [ + .init( + index: 2, + relativePath: "\(gid)_token_2.jpg", + error: .fileOperationFailed( + "The operation could not be completed. (Swift.CancellationError error 1.)" + ) + ) + ], + gid: gid + ) + + let result = await manager.loadInspection(gid: gid) + guard case .success(let inspection) = result else { + Issue.record("Expected inspection to load successfully, got \(result)") + return + } + + #expect(inspection.pages[0].status == .downloaded) + #expect(inspection.pages[1].status == .pending) + } +} + +// MARK: - Setup Helpers + +private extension DownloadPauseAndReconcileTests { + func writeManifestFolder( + storage: DownloadStore, + gid: String, + title: String, + pageHashes: [String] + ) throws { + try storage.ensureRootDirectory() + let folderURL = storage.folderURL(relativePath: "Folder/[\(gid)_token] \(title)") + try FileManager.default.createDirectory( + at: folderURL, + withIntermediateDirectories: true + ) + try storage.writeManifest( + DownloadManifest( + gid: gid, + host: .ehentai, + token: "token", + title: title, + jpnTitle: nil, + category: .doujinshi, + language: .japanese, + remoteCoverURL: URL(string: "https://example.com/cover.jpg"), + uploader: "Uploader", + tags: [], + postedDate: .now, + rating: 4, + pages: Dictionary( + uniqueKeysWithValues: + pageHashes.enumerated().map { ($0.offset + 1, $0.element) } + ) + ), + folderURL: folderURL + ) + } + + func setupCancellationFilterTestFolder( + storage: DownloadStore, + gid: String + ) throws { + let folderURL = storage.folderURL(relativePath: "Folder/[\(gid)_token] Inspection") + try FileManager.default.createDirectory( + at: folderURL, + withIntermediateDirectories: true + ) + try Data([0x01]).write( + to: folderURL.appendingPathComponent("\(gid)_token_1.jpg"), + options: .atomic + ) + } +} diff --git a/AppPackage/Tests/DownloadsFeatureTests/DownloadProcessCacheTests.swift b/AppPackage/Tests/DownloadsFeatureTests/DownloadProcessCacheTests.swift new file mode 100644 index 000000000..ee7ace97c --- /dev/null +++ b/AppPackage/Tests/DownloadsFeatureTests/DownloadProcessCacheTests.swift @@ -0,0 +1,316 @@ +import UIKit +import AppModels +import Foundation +import Testing +import AppTools +import LibraryClient +import DownloadClient +@testable import AppFeature + +@Suite(.serialized) +struct DownloadProcessCacheTests: DownloadFeatureTestCase { + @MainActor + @Test + func testProcessDownloadClearsRemoteAssetCacheAfterSuccessfulDownload() async throws { + let sessionID = UUID().uuidString + let gid = String(Int(Date().timeIntervalSince1970 * 1000) + 402) + let pageIndex = 42 + let rootURL = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + defer { try? FileManager.default.removeItem(at: rootURL) } + let cachedKeysBox = UncheckedBox(Set()) + let libraryClient = try makeCacheLibraryClient( + cachedKeys: cachedKeysBox + ) + + let cacheTestManager = try makeCacheTestManager( + rootURL: rootURL, + sessionID: sessionID, + gid: gid, + pageIndex: pageIndex, + libraryClient: libraryClient + ) + let storage = cacheTestManager.storage + let manager = cacheTestManager.manager + defer { SharedSessionStubURLProtocol.removeHandler(for: sessionID) } + + let cachedKeys = try await prepareCacheTestAssets( + manager: manager, gid: gid, + pageIndex: pageIndex, + cachedKeysBox: cachedKeysBox + ) + + #expect(cachedKeys.allSatisfy(libraryClient.isCached)) + + let updatedPageCount = try await setupCacheTestDownload( + .init( + storage: storage, + manager: manager, + gid: gid, + pageIndex: pageIndex + ) + ) + await manager.reloadDownloadIndex() + + await manager.processDownload(gid: gid) + + let completedDownload = await manager.fetchDownload(gid: gid) + #expect(completedDownload?.displayStatus == .completed) + + try await waitUntilCacheCleared( + cachedKeys: cachedKeys, + isCached: libraryClient.isCached + ) + + for cacheKey in cachedKeys { + #expect( + libraryClient.isCached(cacheKey) == false, + "Expected cache key to be removed after successful download: \(cacheKey)" + ) + } + _ = updatedPageCount + } + +} + +// MARK: - Cache Test Manager Result + +struct CacheTestManagerResult { + let storage: DownloadStore + let manager: DownloadCoordinator + let metadataResponse: Data +} + +private struct CacheTestDownloadSetup { + let storage: DownloadStore + let manager: DownloadCoordinator + let gid: String + let pageIndex: Int +} + +// MARK: - Cache Test Helpers + +private extension DownloadProcessCacheTests { + func makeCacheTestManager( + rootURL: URL, + sessionID: String, + gid: String, + pageIndex: Int, + libraryClient: LibraryClient + ) throws -> CacheTestManagerResult { + let configuration = URLSessionConfiguration.ephemeral + configuration.protocolClasses = [SharedSessionStubURLProtocol.self] + configuration.httpAdditionalHeaders = [SharedSessionStubURLProtocol.headerKey: sessionID] + let storage = DownloadStore(rootURL: rootURL, fileManager: .default) + let manager = DownloadCoordinator( + storage: storage, + urlSession: URLSession(configuration: configuration), + libraryClient: libraryClient + ) + let content = StubHandlerContent( + detailHTML: try makeUniqueDetailHTML(gid: gid), + mpvHTML: try fixtureData(resource: "GalleryMPVKeys", pathExtension: "html"), + metadataResponse: try makeMetadataResponseData(gid: gid) + ) + installCacheTestStubHandler( + sessionID: sessionID, gid: gid, pageIndex: pageIndex, + content: content, allowedImageURLs: [] + ) + URLProtocol.registerClass(SharedSessionStubURLProtocol.self) + return CacheTestManagerResult(storage: storage, manager: manager, metadataResponse: content.metadataResponse) + } + + func installCacheTestStubHandler( + sessionID: String, gid: String, pageIndex: Int, + content: StubHandlerContent, + allowedImageURLs: Set + ) { + let detailHTML = content.detailHTML + let mpvHTML = content.mpvHTML + let metadataResponse = content.metadataResponse + let currentPageImageURL = Self.currentPageImageURL(gid: gid, pageIndex: pageIndex) + SharedSessionStubURLProtocol.setHandler(for: sessionID) { request in + guard let url = request.url else { throw URLError(.badURL) } + if url.path.contains("/g/\(gid)/token") { + return (try Self.makeCacheHTMLResponse(url: url), detailHTML) + } + if url.path.contains("/mpv/") { + return (try Self.makeCacheHTMLResponse(url: url), mpvHTML) + } + if url.path == "/api.php" { + return try Self.makeCacheAPIResponse( + url: url, request: request, + metadataResponse: metadataResponse, + imageURLString: currentPageImageURL?.absoluteString ?? "" + ) + } + if url.host == "example.com" || allowedImageURLs.contains(url.absoluteString) { + return (try Self.makeCacheImageResponse(url: url), Data([0xFF, 0xD8, 0xFF, 0xD9])) + } + throw URLError(.unsupportedURL) + } + } + + func makeUniqueDetailHTML(gid: String) throws -> Data { + let fixtureCoverURL = + "https://ehgt.org/03/08/0308268821e99628b05a19fa54e2fc0fa9ad8f4b-1705560-1012-1470-png_250.jpg" + let uniqueCoverURL = "https://example.com/download-cache/\(gid)/cover.jpg" + let fixtureHTML = try fixtureData(resource: "GalleryDetail", pathExtension: "html") + let detailHTML = try #require(String(bytes: fixtureHTML, encoding: .utf8)) + .replacingOccurrences(of: fixtureCoverURL, with: uniqueCoverURL) + return Data(detailHTML.utf8) + } + + static func currentPageImageURL(gid: String, pageIndex: Int) -> URL? { + URL(string: "https://example.com/download-cache/\(gid)/image-\(pageIndex).jpg") + } + + static func makeCacheHTMLResponse(url: URL) throws -> HTTPURLResponse { + try #require(HTTPURLResponse( + url: url, statusCode: 200, httpVersion: nil, + headerFields: ["Content-Type": "text/html; charset=utf-8"] + )) + } + + static func makeCacheImageResponse(url: URL) throws -> HTTPURLResponse { + try #require(HTTPURLResponse( + url: url, statusCode: 200, httpVersion: nil, + headerFields: ["Content-Type": "image/jpeg"] + )) + } + + static func makeCacheJSONResponse(url: URL) throws -> HTTPURLResponse { + try #require(HTTPURLResponse( + url: url, statusCode: 200, httpVersion: nil, + headerFields: ["Content-Type": "application/json"] + )) + } + + static func makeCacheAPIResponse( + url: URL, request: URLRequest, + metadataResponse: Data, imageURLString: String + ) throws -> (HTTPURLResponse, Data) { + let body = requestBodyData(from: request) + .flatMap { try? JSONSerialization.jsonObject(with: $0) as? [String: Any] } + if body?["method"] as? String == "gdata" { + return (try makeCacheJSONResponse(url: url), metadataResponse) + } + let responseData = try JSONSerialization.data(withJSONObject: ["i": imageURLString]) + return (try makeCacheJSONResponse(url: url), responseData) + } + + @MainActor + func prepareCacheTestAssets( + manager: DownloadCoordinator, gid: String, + pageIndex: Int, + cachedKeysBox: UncheckedBox> + ) async throws -> Set { + let currentPageImageURL = try #require( + Self.currentPageImageURL(gid: gid, pageIndex: pageIndex) + ) + let scaffoldDownload = sampleDownload( + gid: gid, title: "Pause Race", status: .partial, + pageCount: 156, completedPageCount: 155 + ) + let latestPayload = try await manager.fetchLatestPayload( + for: scaffoldDownload, mode: .redownload, options: .init(), pageSelection: [pageIndex] + ) + let coverURL = try #require( + latestPayload.galleryDetail.coverURL ?? latestPayload.gallery.coverURL + ) + let cachedURLs = [currentPageImageURL, coverURL] + let cachedKeys = Set(cachedURLs.flatMap { $0.imageCacheKeys }) + cachedKeysBox.value = cachedKeys + return cachedKeys + } + + func setupCacheTestDownload(_ setup: CacheTestDownloadSetup) async throws -> Int { + let scaffoldDownload = sampleDownload( + gid: setup.gid, title: "Pause Race", status: .partial, + pageCount: 156, completedPageCount: 155 + ) + let latestPayload = try await setup.manager.fetchLatestPayload( + for: scaffoldDownload, mode: .redownload, + options: .init(), pageSelection: [setup.pageIndex] + ) + let updatedPageCount = latestPayload.galleryDetail.pageCount + #expect(updatedPageCount > setup.pageIndex) + + try setupCacheTestFinalFolder( + storage: setup.storage, gid: setup.gid, + pageCount: updatedPageCount, + missingPageIndex: setup.pageIndex + ) + return updatedPageCount + } + + func setupCacheTestFinalFolder( + storage: DownloadStore, gid: String, + pageCount: Int, + missingPageIndex: Int + ) throws { + let completedFolderURL = storage.folderURL(relativePath: "Folder/\(gid) - Pause Race") + try FileManager.default.createDirectory( + at: completedFolderURL, + withIntermediateDirectories: true + ) + let staleManifest = try sampleManifest( + gid: gid, title: "Pause Race", + pageCount: pageCount + ) + try Data([0x00]).write( + to: completedFolderURL.appendingPathComponent("\(gid)_token_cover.jpg"), + options: .atomic + ) + for index in staleManifest.pages.keys where index != missingPageIndex { + try Data([UInt8(index % 255)]).write( + to: completedFolderURL.appendingPathComponent("\(gid)_token_\(index).jpg"), + options: .atomic + ) + } + try storage.writeManifest(staleManifest, folderURL: completedFolderURL) + } + + func waitUntilCacheCleared( + cachedKeys: Set, + isCached: @Sendable (String) -> Bool + ) async throws { + let clock = ContinuousClock() + let deadline = clock.now.advanced(by: .seconds(1)) + while cachedKeys.contains(where: isCached), + clock.now < deadline { + try? await Task.sleep(for: .milliseconds(10)) + } + } + + @MainActor + func makeCacheLibraryClient( + cachedKeys: UncheckedBox> + ) throws -> LibraryClient { + let cachedImage = UIGraphicsImageRenderer(size: .init(width: 1, height: 1)).image { context in + UIColor.systemTeal.setFill() + context.fill(.init(x: 0, y: 0, width: 1, height: 1)) + } + let cachedImageData = try #require(cachedImage.jpegData(compressionQuality: 1)) + return .init( + initializeWebImage: {}, + removeAllCachedImages: { + cachedKeys.value = [] + }, + cachedImage: { _ in nil }, + cachedImageData: { key in + cachedKeys.value.contains(key) ? cachedImageData : nil + }, + removeCachedImage: { key in + var keys = cachedKeys.value + keys.remove(key) + cachedKeys.value = keys + }, + isCached: { key in + cachedKeys.value.contains(key) + }, + analyzeImageColors: { _ in nil }, + calculateWebImageDiskCacheSize: { 0 } + ) + } +} diff --git a/AppPackage/Tests/DownloadsFeatureTests/DownloadProcessTests.swift b/AppPackage/Tests/DownloadsFeatureTests/DownloadProcessTests.swift new file mode 100644 index 000000000..74991c49c --- /dev/null +++ b/AppPackage/Tests/DownloadsFeatureTests/DownloadProcessTests.swift @@ -0,0 +1,320 @@ +import Foundation +import AppModels +import Testing +import DownloadClient +@testable import AppFeature + +@Suite(.serialized) +struct DownloadProcessTests: DownloadFeatureTestCase { + @Test + func testFailurePersistenceCompletesBeforeRescheduling() async throws { + let sessionID = UUID().uuidString + let gid = "100010" + let rootURL = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + defer { try? FileManager.default.removeItem(at: rootURL) } + + let persistenceGate = FailurePersistenceGate() + let scheduledRecorder = ScheduledGalleryRecorder() + let taskRunner = DownloadTaskRunner( + recordScheduledGallery: { gid in + scheduledRecorder.record(gid) + }, + beforeFailurePersistence: { + await persistenceGate.waitAtGate() + } + ) + let (storage, manager) = makeStubbedDownloadCoordinator( + rootURL: rootURL, + sessionID: sessionID, + taskRunner: taskRunner + ) + SharedSessionStubURLProtocol.setHandler(for: sessionID) { _ in + throw URLError(.notConnectedToInternet) + } + defer { SharedSessionStubURLProtocol.removeHandler(for: sessionID) } + + try writeProcessManifestFolder( + storage: storage, + gid: gid, + title: "Queued Failure", + pageCount: 2 + ) + await manager.reloadDownloadIndex() + await manager.testingSetQueuedGalleryIDs([gid]) + + let completionProbe = ProcessCompletionProbe() + let processTask = Task { + await manager.processDownload(gid: gid) + await completionProbe.finish() + } + + await persistenceGate.waitForArrival() + let completedBeforePersistence = await completionProbe + .isFinished() + let scheduledBeforePersistence = scheduledRecorder.snapshot() + #expect(completedBeforePersistence == false) + #expect(scheduledBeforePersistence.isEmpty) + + await persistenceGate.release() + await processTask.value + + let stored = await manager.fetchDownload(gid: gid) + #expect(stored?.displayStatus == .error) + #expect(stored?.lastError?.code == .networkingFailed) + + await manager.scheduleNextIfNeeded() + let scheduledAfterFailure = scheduledRecorder.snapshot() + #expect(scheduledAfterFailure.isEmpty) + } + + @Test + func testProcessDownloadClearsStalePageSelectionWhenLatestPayloadRevealsUpdate() async throws { + let sessionID = UUID().uuidString + let gid = String(Int(Date().timeIntervalSince1970 * 1000) + 401) + let pageIndex = 42 + let rootURL = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + defer { try? FileManager.default.removeItem(at: rootURL) } + + let (storage, manager) = makeStubbedDownloadCoordinator( + rootURL: rootURL, sessionID: sessionID + ) + defer { SharedSessionStubURLProtocol.removeHandler(for: sessionID) } + + let updatedPageCount = try await fetchAndInstallStub( + manager: manager, sessionID: sessionID, gid: gid, + pageIndex: pageIndex + ) + let oldPageCount = updatedPageCount - 5 + + let staleFolderURL = try prepareStaleExistingFolder( + storage: storage, gid: gid, pageIndex: pageIndex, + oldPageCount: oldPageCount + ) + await manager.reloadDownloadIndex() + let beforeProcess = await manager.fetchDownload(gid: gid) + #expect(beforeProcess?.hasUpdate ?? true == false) + + await manager.processDownload(gid: gid) + + try await verifyCompletedProcess( + manager: manager, storage: storage, + context: ProcessVerificationContext( + gid: gid, + updatedPageCount: updatedPageCount, + staleFolderURL: staleFolderURL + ) + ) + } + + @Test + func testProcessDownloadUsesLiveOptionsWhenQueuedDownloadStarts() async throws { + let sessionID = UUID().uuidString + let gid = String(Int(Date().timeIntervalSince1970 * 1000) + 403) + let latestOptions = DownloadRequestOptions( + threadLimit: 3, + allowCellular: false, + autoRetryFailedPages: false + ) + let optionsBox = UncheckedBox(DownloadRequestOptions(allowCellular: true)) + let detailAllowsCellular = UncheckedBox(nil) + let rootURL = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + defer { try? FileManager.default.removeItem(at: rootURL) } + + let (storage, manager) = makeStubbedDownloadCoordinator( + rootURL: rootURL, + sessionID: sessionID, + downloadOptionsProvider: { optionsBox.value } + ) + defer { SharedSessionStubURLProtocol.removeHandler(for: sessionID) } + + let detailHTML = try fixtureData(resource: "GalleryDetail", pathExtension: "html") + SharedSessionStubURLProtocol.setHandler(for: sessionID) { request in + guard let url = request.url else { throw URLError(.badURL) } + if url.path.contains("/g/\(gid)/token") { + detailAllowsCellular.value = request.allowsCellularAccess + return ( + try #require(HTTPURLResponse( + url: url, + statusCode: 200, + httpVersion: nil, + headerFields: ["Content-Type": "text/html; charset=utf-8"] + )), + detailHTML + ) + } + throw URLError(.notConnectedToInternet) + } + + try writeProcessManifestFolder( + storage: storage, + gid: gid, + title: "Queued Options", + pageCount: 2 + ) + await manager.reloadDownloadIndex() + await manager.testingSetQueuedGalleryIDs([gid]) + + optionsBox.value = latestOptions + await manager.processDownload(gid: gid) + + #expect(detailAllowsCellular.value == false) + } +} + +private actor FailurePersistenceGate { + private var didArrive = false + private var arrivalContinuation: CheckedContinuation? + private var releaseContinuation: CheckedContinuation? + + func waitAtGate() async { + didArrive = true + arrivalContinuation?.resume() + arrivalContinuation = nil + await withCheckedContinuation { continuation in + releaseContinuation = continuation + } + } + + func waitForArrival() async { + guard !didArrive else { return } + await withCheckedContinuation { continuation in + arrivalContinuation = continuation + } + } + + func release() { + releaseContinuation?.resume() + releaseContinuation = nil + } +} + +private actor ProcessCompletionProbe { + private var finished = false + + func finish() { + finished = true + } + + func isFinished() -> Bool { + finished + } +} + +private struct ProcessVerificationContext { + let gid: String + let updatedPageCount: Int + let staleFolderURL: URL +} + +private extension DownloadProcessTests { + func writeProcessManifestFolder( + storage: DownloadStore, + gid: String, + title: String, + pageCount: Int + ) throws { + try storage.ensureRootDirectory() + let folderURL = storage.folderURL(relativePath: "Folder/[\(gid)_token] \(title)") + try FileManager.default.createDirectory( + at: folderURL, + withIntermediateDirectories: true + ) + try storage.writeManifest( + sampleManifest(gid: gid, title: title, pageCount: pageCount), + folderURL: folderURL + ) + } + + func fetchAndInstallStub( + manager: DownloadCoordinator, sessionID: String, gid: String, + pageIndex: Int + ) async throws -> Int { + let stubContent = StubHandlerContent( + detailHTML: try fixtureData(resource: "GalleryDetail", pathExtension: "html"), + mpvHTML: try fixtureData(resource: "GalleryMPVKeys", pathExtension: "html"), + metadataResponse: try makeMetadataResponseData(gid: gid) + ) + var allowedImageURLs = Set() + installDownloadStubHandler( + sessionID: sessionID, gid: gid, pageIndex: pageIndex, + content: stubContent, allowedImageURLs: allowedImageURLs + ) + let scaffoldDownload = sampleDownload( + gid: gid, title: "Pause Race", status: .partial, + pageCount: 156, completedPageCount: 155 + ) + let latestPayload = try await manager.fetchLatestPayload( + for: scaffoldDownload, mode: .redownload, options: .init(), pageSelection: [pageIndex] + ) + if let coverURL = latestPayload.galleryDetail.coverURL ?? latestPayload.gallery.coverURL { + allowedImageURLs.insert(coverURL.absoluteString) + installDownloadStubHandler( + sessionID: sessionID, gid: gid, pageIndex: pageIndex, + content: stubContent, allowedImageURLs: allowedImageURLs + ) + } + let updatedPageCount = latestPayload.galleryDetail.pageCount + #expect(updatedPageCount > pageIndex) + #expect(updatedPageCount > 5) + return updatedPageCount + } + + func prepareStaleExistingFolder( + storage: DownloadStore, gid: String, pageIndex: Int, + oldPageCount: Int + ) throws -> URL { + let staleManifest = try sampleManifest( + gid: gid, title: "Pause Race", + pageCount: oldPageCount + ) + let folderURL = storage.folderURL(relativePath: "Folder/\(gid) - Pause Race") + try? FileManager.default.removeItem(at: folderURL) + try FileManager.default.createDirectory( + at: folderURL, + withIntermediateDirectories: true + ) + try storage.writeManifest(staleManifest, folderURL: folderURL) + try Data([0x00]).write( + to: folderURL.appendingPathComponent("123_token_cover.jpg"), + options: .atomic + ) + try Data([UInt8(pageIndex % 255)]).write( + to: folderURL.appendingPathComponent("\(gid)_token_\(pageIndex).jpg"), + options: .atomic + ) + return folderURL + } + + func verifyCompletedProcess( + manager: DownloadCoordinator, + storage: DownloadStore, + context: ProcessVerificationContext + ) async throws { + let completedDownload = await manager.fetchDownload(gid: context.gid) + let unwrapped = try #require(completedDownload) + #expect(unwrapped.displayStatus == .completed) + #expect(unwrapped.pageCount == context.updatedPageCount) + #expect(unwrapped.completedPageCount == context.updatedPageCount) + + let completedFolderURL = unwrapped.folderURL + let manifest = try storage.readManifest(folderURL: completedFolderURL) + #expect(manifest.pageCount == context.updatedPageCount) + #expect(manifest.pages.count == context.updatedPageCount) + #expect( + FileManager.default.fileExists( + atPath: completedFolderURL.appendingPathComponent("\(context.gid)_token_1.jpg").path + ) + ) + #expect( + FileManager.default.fileExists( + atPath: completedFolderURL.appendingPathComponent("123_token_1.jpg").path + ) == false + ) + + #expect(FileManager.default.fileExists(atPath: context.staleFolderURL.path) == false) + #expect(FileManager.default.fileExists(atPath: completedFolderURL.path)) + } +} diff --git a/AppPackage/Tests/DownloadsFeatureTests/DownloadQueueStoreTests.swift b/AppPackage/Tests/DownloadsFeatureTests/DownloadQueueStoreTests.swift new file mode 100644 index 000000000..d8f386169 --- /dev/null +++ b/AppPackage/Tests/DownloadsFeatureTests/DownloadQueueStoreTests.swift @@ -0,0 +1,43 @@ +import Foundation +import Testing +import DownloadClient +@testable import AppFeature + +struct DownloadQueueStoreTests { + @Test + func testEnqueueDeduplicatesAndPreservesOrder() async { + let (store, rootURL) = makeStore() + defer { try? FileManager.default.removeItem(at: rootURL) } + + await store.enqueue("123") + await store.enqueue("456") + await store.enqueue("123") + + #expect(store.gids == ["123", "456"]) + } + + @Test + func testRemoveAndRemoveAllUpdateQueue() async { + let (store, rootURL) = makeStore() + defer { try? FileManager.default.removeItem(at: rootURL) } + + await store.enqueue("123") + await store.enqueue("456") + await store.remove("123") + + #expect(store.gids == ["456"]) + + await store.removeAll() + + #expect(store.gids.isEmpty) + } +} + +private extension DownloadQueueStoreTests { + func makeStore() -> (DownloadQueueStore, URL) { + let rootURL = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + let fileURL = rootURL.appendingPathComponent(".queue.json") + return (DownloadQueueStore(fileURL: fileURL), rootURL) + } +} diff --git a/AppPackage/Tests/DownloadsFeatureTests/DownloadRetryMinimalSourceTests.swift b/AppPackage/Tests/DownloadsFeatureTests/DownloadRetryMinimalSourceTests.swift new file mode 100644 index 000000000..c54631415 --- /dev/null +++ b/AppPackage/Tests/DownloadsFeatureTests/DownloadRetryMinimalSourceTests.swift @@ -0,0 +1,232 @@ +import Foundation +import AppModels +import Testing +import DownloadClient +@testable import AppFeature + +@Suite(.serialized) +struct DownloadRetryMinimalSourceTests: DownloadFeatureTestCase { + @Test + func testRetryPagesUsesMinimalSourceResolutionAndSkipsWhenNoPendingPages() async throws { + let sessionID = UUID().uuidString + let gid = String(Int(Date().timeIntervalSince1970 * 1000) + 200) + let pageIndex = 40 + let rootURL = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + defer { try? FileManager.default.removeItem(at: rootURL) } + + let (storage, manager) = makeStubbedDownloadCoordinator( + rootURL: rootURL, sessionID: sessionID + ) + let setup = try await setupMinimalSourceTest( + manager: manager, sessionID: sessionID, gid: gid, pageIndex: pageIndex + ) + defer { SharedSessionStubURLProtocol.removeHandler(for: sessionID) } + + let manifest = try sampleManifest( + gid: gid, title: "Pause Race", + pageCount: setup.pageCount + ) + try writeFinalManifest( + storage: storage, + gid: gid, + manifest: manifest, + missingPageIndex: pageIndex + ) + await manager.reloadDownloadIndex() + await manager.testingSetDownloadError( + .init(code: .fileOperationFailed, message: "Page \(pageIndex) is missing."), + gid: gid + ) + let blocker = Task { + try? await Task.sleep(for: .seconds(60)) + } + defer { blocker.cancel() } + await manager.testingInstallActiveTask(gid: "busy", task: blocker) + guard case .success = await manager.retryPages(gid: gid, pageIndices: [pageIndex]) else { + Issue.record("retryPages should queue the selected page.") + return + } + await manager.processDownload(gid: gid) + + let firstRunSnapshot = setup.recorder.snapshot() + #expect( + firstRunSnapshot.previewPageNumbers == [0], + "\(firstRunSnapshot)" + ) + + setup.recorder.reset() + try await assertRetrySkipsCompletedSelection( + .init( + storage: storage, + manager: manager, + gid: gid, + pageIndex: pageIndex, + setup: setup, + manifest: manifest + ) + ) + } + + @Test + func testPartialRetryDoesNotOverwriteLastErrorWhenUnselectedPagesRemainMissing() async throws { + let sessionID = UUID().uuidString + let gid = String(Int(Date().timeIntervalSince1970 * 1000) + 201) + let pageIndex = 40 + let remainingMissingPageIndex = 41 + let rootURL = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + defer { try? FileManager.default.removeItem(at: rootURL) } + + let (storage, manager) = makeStubbedDownloadCoordinator( + rootURL: rootURL, sessionID: sessionID + ) + let setup = try await setupMinimalSourceTest( + manager: manager, sessionID: sessionID, gid: gid, pageIndex: pageIndex + ) + defer { SharedSessionStubURLProtocol.removeHandler(for: sessionID) } + + let manifest = try sampleManifest( + gid: gid, title: "Pause Race", + pageCount: setup.pageCount + ) + try writeFinalManifest( + storage: storage, + gid: gid, + manifest: manifest, + missingPageIndices: [pageIndex, remainingMissingPageIndex] + ) + await manager.reloadDownloadIndex() + await manager.testingSetDownloadError( + .init(code: .networkingFailed, message: "Original retry failure."), + gid: gid + ) + let blocker = Task { + try? await Task.sleep(for: .seconds(60)) + } + defer { blocker.cancel() } + await manager.testingInstallActiveTask(gid: "busy", task: blocker) + guard case .success = await manager.retryPages(gid: gid, pageIndices: [pageIndex]) else { + Issue.record("retryPages should queue the selected page.") + return + } + + await manager.processDownload(gid: gid) + + let snapshot = setup.recorder.snapshot() + #expect(snapshot.previewPageNumbers == [0], "\(snapshot)") + + let stored = try #require(await manager.fetchDownload(gid: gid)) + #expect(stored.displayStatus == .inactive) + #expect(stored.lastError == nil) + #expect(stored.completedPageCount == setup.pageCount - 1) + } +} + +// MARK: - Minimal Source Test Result + +private struct MinimalSourceTestResult { + let recorder: RequestRecorder + let pageCount: Int +} + +private struct MinimalSourceRetrySkipContext { + let storage: DownloadStore + let manager: DownloadCoordinator + let gid: String + let pageIndex: Int + let setup: MinimalSourceTestResult + let manifest: DownloadManifest +} + +// MARK: - Setup Helpers + +private extension DownloadRetryMinimalSourceTests { + func assertRetrySkipsCompletedSelection( + _ context: MinimalSourceRetrySkipContext + ) async throws { + let blocker = Task { + try? await Task.sleep(for: .seconds(60)) + } + defer { blocker.cancel() } + await context.manager.testingInstallActiveTask(gid: "busy", task: blocker) + guard case .success = await context.manager.retryPages( + gid: context.gid, + pageIndices: [context.pageIndex] + ) else { + Issue.record("retryPages should queue the selected page.") + return + } + await context.manager.processDownload(gid: context.gid) + let snapshot = context.setup.recorder.snapshot() + #expect(snapshot.previewPageNumbers.isEmpty, "\(snapshot)") + #expect(snapshot.mpvRequests == 0) + #expect(snapshot.imageDispatchRequests == 0) + } + + func writeFinalManifest( + storage: DownloadStore, + gid: String, + manifest: DownloadManifest, + missingPageIndex: Int + ) throws { + try writeFinalManifest( + storage: storage, + gid: gid, + manifest: manifest, + missingPageIndices: [missingPageIndex] + ) + } + + func writeFinalManifest( + storage: DownloadStore, + gid: String, + manifest: DownloadManifest, + missingPageIndices: Set + ) throws { + try storage.ensureRootDirectory() + let folderURL = storage.folderURL(relativePath: "Folder/[\(gid)_token] Pause Race") + try FileManager.default.createDirectory( + at: folderURL, + withIntermediateDirectories: true + ) + try Data([0x00]).write( + to: folderURL.appendingPathComponent("\(gid)_token_cover.jpg"), + options: .atomic + ) + for index in manifest.pages.keys where !missingPageIndices.contains(index) { + try Data([UInt8(index % 255)]).write( + to: folderURL.appendingPathComponent("\(gid)_token_\(index).jpg"), + options: .atomic + ) + } + try storage.writeManifest(manifest, folderURL: folderURL) + } + + func setupMinimalSourceTest( + manager: DownloadCoordinator, sessionID: String, gid: String, pageIndex: Int + ) async throws -> MinimalSourceTestResult { + let recorder = RequestRecorder() + let stubContent = StubHandlerContent( + detailHTML: try fixtureData(resource: "GalleryDetail", pathExtension: "html"), + mpvHTML: try fixtureData(resource: "GalleryMPVKeys", pathExtension: "html"), + metadataResponse: try makeMetadataResponseData(gid: gid) + ) + installDownloadStubHandler( + sessionID: sessionID, gid: gid, pageIndex: pageIndex, + content: stubContent, recorder: recorder + ) + let scaffoldDownload = sampleDownload( + gid: gid, title: "Pause Race", status: .partial, + pageCount: 156, completedPageCount: 155 + ) + let fetchedPayload = try await manager.fetchLatestPayload( + for: scaffoldDownload, mode: .redownload, options: .init(), pageSelection: [pageIndex] + ) + recorder.reset() + return MinimalSourceTestResult( + recorder: recorder, + pageCount: fetchedPayload.galleryDetail.pageCount + ) + } +} diff --git a/AppPackage/Tests/DownloadsFeatureTests/DownloadRetryPagesTests.swift b/AppPackage/Tests/DownloadsFeatureTests/DownloadRetryPagesTests.swift new file mode 100644 index 000000000..b6eaf53c2 --- /dev/null +++ b/AppPackage/Tests/DownloadsFeatureTests/DownloadRetryPagesTests.swift @@ -0,0 +1,134 @@ +import Foundation +import AppModels +import Testing +import DownloadClient +@testable import AppFeature + +@Suite(.serialized) +struct DownloadRetryPagesTests: DownloadFeatureTestCase { + @Test + func testRetryPagesQueuesWorkWhenAnotherDownloadIsActive() async throws { + let gid = String(Int(Date().timeIntervalSince1970 * 1000) + 2) + let rootURL = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + defer { try? FileManager.default.removeItem(at: rootURL) } + + let storage = DownloadStore(rootURL: rootURL, fileManager: .default) + let manager = DownloadCoordinator(storage: storage, urlSession: .shared) + try writeManifestFolder( + storage: storage, + gid: gid, + title: "Retry Pages", + pageHashes: ["sha256:done", ""] + ) + await manager.reloadDownloadIndex() + await manager.testingSetFailedPageErrors( + [ + .init( + index: 2, + relativePath: "123_token_2.jpg", + error: .networkingFailed + ) + ], + gid: gid + ) + + let blockingTask = Task { _ = try? await Task.sleep(for: .seconds(60)) } + defer { blockingTask.cancel() } + await manager.testingInstallActiveTask(gid: "other-active-download", task: blockingTask) + + let result = await manager.retryPages(gid: gid, pageIndices: [2]) + guard case .success = result else { + Issue.record("Retry pages should succeed, got \(result)") + return + } + + let stored = await manager.fetchDownload(gid: gid) + #expect(stored?.displayStatus == .queued) + #expect(stored?.badge.status == .queued) + #expect(stored?.lastError == nil) + + } + + @Test + func testCancelQueuedWorkClearsQueueIntent() async throws { + let gid = "cancel-repair-\(UUID().uuidString)" + let rootURL = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + defer { try? FileManager.default.removeItem(at: rootURL) } + + let storage = DownloadStore(rootURL: rootURL, fileManager: .default) + let manager = DownloadCoordinator( + storage: storage, + urlSession: .shared + ) + + try writeManifestFolder( + storage: storage, + gid: gid, + title: "Queued", + pageHashes: ["sha256:done", ""] + ) + await manager.reloadDownloadIndex() + await manager.testingSetQueuedGalleryIDs([gid]) + + let result = await manager.togglePause(gid: gid) + guard case .success = result else { + Issue.record("Cancelling queued work should succeed, got \(result)") + return + } + + let stored = await manager.fetchDownload(gid: gid) + #expect(stored?.displayStatus == .inactive) + #expect(stored?.completedPageCount == 1) + #expect( + stored?.badge == DownloadBadge( + status: .inactive, + progress: .init(completedPageCount: 1, pageCount: 2) + ) + ) + } + +} + +// MARK: - Setup Helpers + +private extension DownloadRetryPagesTests { + @discardableResult + func writeManifestFolder( + storage: DownloadStore, + gid: String, + title: String, + pageHashes: [String] + ) throws -> URL { + try storage.ensureRootDirectory() + let folderURL = storage.folderURL(relativePath: "Folder/[\(gid)_token] \(title)") + try FileManager.default.createDirectory( + at: folderURL, + withIntermediateDirectories: true + ) + try storage.writeManifest( + DownloadManifest( + gid: gid, + host: .ehentai, + token: "token", + title: title, + jpnTitle: nil, + category: .doujinshi, + language: .japanese, + remoteCoverURL: URL(string: "https://example.com/cover.jpg"), + uploader: "Uploader", + tags: [], + postedDate: .now, + rating: 4, + pages: Dictionary( + uniqueKeysWithValues: + pageHashes.enumerated().map { ($0.offset + 1, $0.element) } + ) + ), + folderURL: folderURL + ) + return folderURL + } + +} diff --git a/AppPackage/Tests/DownloadsFeatureTests/DownloadRetryUpdateFallbackTests.swift b/AppPackage/Tests/DownloadsFeatureTests/DownloadRetryUpdateFallbackTests.swift new file mode 100644 index 000000000..a39042aca --- /dev/null +++ b/AppPackage/Tests/DownloadsFeatureTests/DownloadRetryUpdateFallbackTests.swift @@ -0,0 +1,201 @@ +import Foundation +import AppModels +import Testing +import DownloadClient +@testable import AppFeature + +@Suite(.serialized) +struct DownloadRetryUpdateFallbackTests: DownloadFeatureTestCase { + @Test + func testRetryPagesQueuesFullUpdateWhenGalleryHasUpdate() async throws { + let sessionID = UUID().uuidString + let gid = String(Int(Date().timeIntervalSince1970 * 1000) + 400) + let pageIndex = 42 + let rootURL = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + defer { try? FileManager.default.removeItem(at: rootURL) } + + let (storage, queueingManager) = makeStubbedDownloadCoordinator( + rootURL: rootURL, sessionID: sessionID + ) + defer { SharedSessionStubURLProtocol.removeHandler(for: sessionID) } + + let fallbackResult = try await fetchUpdateFallbackPayload( + manager: queueingManager, sessionID: sessionID, gid: gid, + pageIndex: pageIndex + ) + let pageCount = fallbackResult.pageCount + let oldCount = pageCount - 5 + + try writeFinalManifest( + storage: storage, + gid: gid, + pageCount: oldCount + ) + await queueingManager.reloadDownloadIndex() + await queueingManager.testingSetUpdatedGalleryIDs([gid]) + let queuedCandidate = await queueingManager.fetchDownload(gid: gid) + #expect(queuedCandidate?.hasUpdate == true) + + let blockerTask = Task { try? await Task.sleep(nanoseconds: 5_000_000_000) } + await queueingManager.testingInstallActiveTask(gid: "blocker", task: blockerTask) + defer { blockerTask.cancel() } + + let retryResult = await queueingManager.retryPages(gid: gid, pageIndices: [pageIndex]) + guard case .success = retryResult else { + Issue.record("retryPages should succeed, got \(retryResult)") + return + } + + let queued = await queueingManager.fetchDownload(gid: gid) + #expect(queued?.displayStatus == .queued) + #expect(queued?.badge.status == .queued) + #expect(queued?.lastError == nil) + } + + @Test + func testRetryPagesNormalizesImmediateUpdateWhenGalleryHasUpdate() async throws { + let sessionID = UUID().uuidString + let gid = String(Int(Date().timeIntervalSince1970 * 1000) + 400) + let pageIndex = 42 + let rootURL = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + defer { try? FileManager.default.removeItem(at: rootURL) } + + let (storage, immediateManager) = makeStubbedDownloadCoordinator( + rootURL: rootURL, sessionID: sessionID + ) + defer { SharedSessionStubURLProtocol.removeHandler(for: sessionID) } + + let updateResult = try await fetchUpdateFallbackPayload( + manager: immediateManager, sessionID: sessionID, gid: gid, + pageIndex: pageIndex + ) + let pageCount = updateResult.pageCount + + try setupImmediateUpdateTestState( + storage: storage, + context: DownloadPageContext(gid: gid, pageIndex: pageIndex, pageCount: pageCount) + ) + await immediateManager.reloadDownloadIndex() + await immediateManager.testingSetUpdatedGalleryIDs([gid]) + + let immediateBlockerTask = Task { + try? await Task.sleep(nanoseconds: 5_000_000_000) + } + await immediateManager.testingInstallActiveTask(gid: gid, task: immediateBlockerTask) + defer { immediateBlockerTask.cancel() } + + let result = await immediateManager.retryPages(gid: gid, pageIndices: [pageIndex]) + guard case .success = result else { + Issue.record("Immediate retryPages should succeed, got \(result)") + return + } + + let resumedDownload = await immediateManager.fetchDownload(gid: gid) + #expect(resumedDownload?.displayStatus == .active) + #expect(resumedDownload?.lastError == nil) + } +} + +// MARK: - Update Fallback Payload Result + +private struct UpdateFallbackPayloadResult { + let pageCount: Int +} + +// MARK: - Download Page Context + +private struct DownloadPageContext { + let gid: String + let pageIndex: Int + let pageCount: Int +} + +// MARK: - Setup Helpers + +private extension DownloadRetryUpdateFallbackTests { + func fetchUpdateFallbackPayload( + manager: DownloadCoordinator, sessionID: String, gid: String, + pageIndex: Int + ) async throws -> UpdateFallbackPayloadResult { + let stubContent = StubHandlerContent( + detailHTML: try fixtureData(resource: "GalleryDetail", pathExtension: "html"), + mpvHTML: try fixtureData(resource: "GalleryMPVKeys", pathExtension: "html"), + metadataResponse: try makeMetadataResponseData(gid: gid) + ) + installDownloadStubHandler( + sessionID: sessionID, gid: gid, pageIndex: pageIndex, content: stubContent + ) + let scaffoldDownload = sampleDownload( + gid: gid, title: "Pause Race", status: .partial, + pageCount: 156, completedPageCount: 155 + ) + let fetchedPayload = try await manager.fetchLatestPayload( + for: scaffoldDownload, mode: .update, options: .init(), pageSelection: nil + ) + let pageCount = fetchedPayload.galleryDetail.pageCount + #expect(pageCount > pageIndex) + #expect(pageCount > 5) + return UpdateFallbackPayloadResult(pageCount: pageCount) + } + + func setupImmediateUpdateTestState( + storage: DownloadStore, + context: DownloadPageContext + ) throws { + let oldCount = context.pageCount - 5 + try writeFinalManifest( + storage: storage, + gid: context.gid, + pageCount: oldCount + ) + } + + func writeFinalManifest( + storage: DownloadStore, + gid: String, + pageCount: Int + ) throws { + try storage.ensureRootDirectory() + let folderURL = storage.folderURL(relativePath: "Folder/[\(gid)_token] Pause Race") + try FileManager.default.createDirectory( + at: folderURL, + withIntermediateDirectories: true + ) + try storage.writeManifest( + completeManifest(gid: gid, title: "Pause Race", pageCount: pageCount), + folderURL: folderURL + ) + } + + func completeManifest( + gid: String, + title: String, + pageCount: Int + ) throws -> DownloadManifest { + let manifest = try sampleManifest( + gid: gid, + title: title, + pageCount: pageCount + ) + return DownloadManifest( + gid: manifest.gid, + host: manifest.host, + token: manifest.token, + title: manifest.title, + jpnTitle: manifest.jpnTitle, + category: manifest.category, + language: manifest.language, + remoteCoverURL: manifest.remoteCoverURL, + uploader: manifest.uploader, + tags: manifest.tags, + postedDate: manifest.postedDate, + rating: manifest.rating, + pages: Dictionary( + uniqueKeysWithValues: + manifest.pages.keys.sorted().map { ($0, "sha256:\($0)") } + ) + ) + } +} diff --git a/AppPackage/Tests/DownloadsFeatureTests/DownloadSchedulingTests.swift b/AppPackage/Tests/DownloadsFeatureTests/DownloadSchedulingTests.swift new file mode 100644 index 000000000..4c081709c --- /dev/null +++ b/AppPackage/Tests/DownloadsFeatureTests/DownloadSchedulingTests.swift @@ -0,0 +1,286 @@ +import Foundation +import AppModels +import Testing +import DownloadClient +@testable import AppFeature + +@Suite +struct DownloadSchedulingTests: DownloadFeatureTestCase { + @Test + func testConcurrentSchedulingCreatesOnlyOneActiveTask() async throws { + let gid = "100001" + let rootURL = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + defer { try? FileManager.default.removeItem(at: rootURL) } + + let storage = DownloadStore( + rootURL: rootURL, + fileManager: .default + ) + let gate = ScheduleFetchGate() + let scheduledRecorder = ScheduledGalleryRecorder() + let taskRunner = DownloadTaskRunner( + beforeActiveTaskCheck: { + await gate.waitAtGate() + }, + recordScheduledGallery: { gid in + scheduledRecorder.record(gid) + }, + runScheduledDownload: { _, _ in + while !Task.isCancelled { + try? await Task.sleep(for: .milliseconds(10)) + } + return .skippedOperation + } + ) + let manager = DownloadCoordinator( + storage: storage, + urlSession: .shared, + taskRunner: taskRunner + ) + + try storage.ensureRootDirectory() + let folderURL = storage.folderURL(relativePath: "Folder/[\(gid)_token] Queued") + try FileManager.default.createDirectory( + at: folderURL, + withIntermediateDirectories: true + ) + try storage.writeManifest( + DownloadManifest( + gid: gid, + host: .ehentai, + token: "token", + title: "Queued", + jpnTitle: nil, + category: .doujinshi, + language: .japanese, + remoteCoverURL: URL(string: "https://example.com/cover.jpg"), + uploader: "Uploader", + tags: [], + postedDate: .now, + rating: 4, + pages: [1: ""] + ), + folderURL: folderURL + ) + await manager.reloadDownloadIndex() + await manager.testingSetQueuedGalleryIDs([gid]) + + async let firstSchedule: Void = + manager.scheduleNextIfNeeded() + async let secondSchedule: Void = + manager.scheduleNextIfNeeded() + + await gate.waitForBothArrivals() + await gate.releaseAll() + _ = await (firstSchedule, secondSchedule) + + let scheduledGalleryIDs = scheduledRecorder.snapshot() + let hasActiveTask = await manager.testingHasActiveTask() + let activeGalleryID = await manager.testingActiveGalleryID() + #expect(scheduledGalleryIDs.count == 1) + #expect(hasActiveTask) + #expect(scheduledGalleryIDs.first == activeGalleryID) + + guard case .success = await manager.pause(gid: gid) else { + Issue.record("Pause should succeed for the active test download.") + return + } + } + + @Test + func testCancelledProcessCleanupDoesNotClearNewerActiveTask() async throws { + let firstGID = "100011" + let secondGID = "100012" + let rootURL = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + defer { try? FileManager.default.removeItem(at: rootURL) } + + let storage = DownloadStore( + rootURL: rootURL, + fileManager: .default + ) + let gate = ScheduledProcessCleanupGate(firstGID: firstGID) + let taskRunner = DownloadTaskRunner( + runScheduledDownload: { gid, _ in + await gate.run(gid: gid) + return .skippedOperation + } + ) + let manager = DownloadCoordinator( + storage: storage, + urlSession: .shared, + taskRunner: taskRunner + ) + + try writeQueuedManifest(storage: storage, gid: firstGID, title: "First") + try writeQueuedManifest(storage: storage, gid: secondGID, title: "Second") + await manager.reloadDownloadIndex() + await manager.testingSetQueuedGalleryIDs([firstGID, secondGID]) + + await manager.scheduleNextIfNeeded() + await gate.waitForFirstArrival() + + let pauseTask = Task { + await manager.pause(gid: firstGID) + } + // Pause cancels the first task and clears the active slot synchronously + // before suspending on the cancelled task's completion. Awaiting that + // cancellation event (instead of polling activeGalleryID against a + // wall-clock deadline) lets actor mutual exclusion guarantee the slot is + // already clear by the time the next scheduleNextIfNeeded() runs. + await gate.waitForFirstCancellation() + + await manager.scheduleNextIfNeeded() + await gate.waitForSecondStart() + await gate.releaseFirst() + + guard case .success = await pauseTask.value else { + Issue.record("Pause should succeed for the canceled first download.") + return + } + + let activeGalleryID = await manager.testingActiveGalleryID() + let hasActiveTask = await manager.testingHasActiveTask() + #expect(activeGalleryID == secondGID) + #expect(hasActiveTask) + + guard case .success = await manager.pause(gid: secondGID) else { + Issue.record("Cleanup pause should succeed for the second download.") + return + } + } +} + +private extension DownloadSchedulingTests { + func writeQueuedManifest( + storage: DownloadStore, + gid: String, + title: String + ) throws { + try storage.ensureRootDirectory() + let folderURL = storage.folderURL(relativePath: "Folder/[\(gid)_token] \(title)") + try FileManager.default.createDirectory( + at: folderURL, + withIntermediateDirectories: true + ) + try storage.writeManifest( + sampleManifest(gid: gid, title: title), + folderURL: folderURL + ) + } +} + +private actor ScheduleFetchGate { + private var arrivalCount = 0 + private var isReleased = false + private var bothArrivedContinuation: CheckedContinuation? + private var releaseContinuations = [CheckedContinuation]() + + func waitAtGate() async { + guard !isReleased else { return } + arrivalCount += 1 + if arrivalCount == 2 { + bothArrivedContinuation?.resume() + bothArrivedContinuation = nil + } + await withCheckedContinuation { continuation in + releaseContinuations.append(continuation) + } + } + + func waitForBothArrivals() async { + guard arrivalCount < 2 else { return } + await withCheckedContinuation { continuation in + bothArrivedContinuation = continuation + } + } + + func releaseAll() { + isReleased = true + releaseContinuations.forEach { $0.resume() } + releaseContinuations.removeAll() + } +} + +private actor ScheduledProcessCleanupGate { + private let firstGID: String + private var firstArrived = false + private var firstCancelled = false + private var secondStarted = false + private var firstArrivalContinuation: CheckedContinuation? + private var firstCancellationContinuation: CheckedContinuation? + private var secondStartContinuation: CheckedContinuation? + private var releaseFirstContinuation: CheckedContinuation? + + init(firstGID: String) { + self.firstGID = firstGID + } + + func run(gid: String) async { + if gid == firstGID { + await waitForRelease() + } else { + startSecond() + while !Task.isCancelled { + try? await Task.sleep(for: .milliseconds(10)) + } + } + } + + func waitForFirstArrival() async { + guard !firstArrived else { return } + await withCheckedContinuation { continuation in + firstArrivalContinuation = continuation + } + } + + func waitForFirstCancellation() async { + guard !firstCancelled else { return } + await withCheckedContinuation { continuation in + firstCancellationContinuation = continuation + } + } + + func waitForSecondStart() async { + guard !secondStarted else { return } + await withCheckedContinuation { continuation in + secondStartContinuation = continuation + } + } + + func releaseFirst() { + releaseFirstContinuation?.resume() + releaseFirstContinuation = nil + } + + private func waitForRelease() async { + firstArrived = true + firstArrivalContinuation?.resume() + firstArrivalContinuation = nil + // Stay parked past cancellation: the first task's cleanup must fire only + // after the second task becomes active. The cancellation handler merely + // reports that pause() has cancelled this task, which is the point at + // which it has cleared the active slot. + await withTaskCancellationHandler { + await withCheckedContinuation { continuation in + releaseFirstContinuation = continuation + } + } onCancel: { + Task { await self.markFirstCancelled() } + } + } + + private func markFirstCancelled() { + guard !firstCancelled else { return } + firstCancelled = true + firstCancellationContinuation?.resume() + firstCancellationContinuation = nil + } + + private func startSecond() { + secondStarted = true + secondStartContinuation?.resume() + secondStartContinuation = nil + } +} diff --git a/AppPackage/Tests/DownloadsFeatureTests/DownloadStoreHashTests.swift b/AppPackage/Tests/DownloadsFeatureTests/DownloadStoreHashTests.swift new file mode 100644 index 000000000..39b17cf2b --- /dev/null +++ b/AppPackage/Tests/DownloadsFeatureTests/DownloadStoreHashTests.swift @@ -0,0 +1,163 @@ +import Foundation +import AppModels +import Resources +import Testing +import DownloadClient +@testable import AppFeature + +struct DownloadStoreHashTests { + @Test + func testValidateReportsCorruptedPageImageData() throws { + let (storage, rootURL) = makeStorage() + defer { try? FileManager.default.removeItem(at: rootURL) } + + let (download, folderURL) = try makePreparedDownload(storage: storage) + let pageTwoURL = folderURL.appendingPathComponent("123_token_2.jpg") + + let manifest = try storage.addingCurrentFileHashes( + to: sampleManifest(pageCount: 2), + folderURL: folderURL + ) + try storage.writeManifest(manifest, folderURL: folderURL) + try Data([0x03]).write(to: pageTwoURL, options: .atomic) + + #expect( + storage.validate(download: download, verifiesContentHashes: true) + == .missingFiles(.RLocalizable.downloadStorePageImageCorrupted(page: 2)) + ) + } + + @Test + func testRefreshManifestPageFileHashUpdatesSinglePageHash() throws { + let (storage, rootURL) = makeStorage() + defer { try? FileManager.default.removeItem(at: rootURL) } + + let (download, folderURL) = try makePreparedDownload(storage: storage) + let pageTwoURL = folderURL.appendingPathComponent("123_token_2.jpg") + + let manifest = try storage.addingCurrentFileHashes( + to: sampleManifest(pageCount: 2), + folderURL: folderURL + ) + try storage.writeManifest(manifest, folderURL: folderURL) + try Data([0x03]).write(to: pageTwoURL, options: .atomic) + + let refreshedManifest = try storage.refreshManifestPageFileHash( + folderURL: folderURL, + pageIndex: 2 + ) + + #expect(refreshedManifest.pages[1] == manifest.pages[1]) + #expect(refreshedManifest.pages[2] != manifest.pages[2]) + #expect(storage.validate(download: download, verifiesContentHashes: true) == .valid) + } + + @Test + func testAddingCurrentFileHashesPreservesExistingHashesAndFillsEmptyHashes() throws { + let (storage, rootURL) = makeStorage() + defer { try? FileManager.default.removeItem(at: rootURL) } + + let (_, folderURL) = try makePreparedDownload(storage: storage) + let pageOneURL = folderURL.appendingPathComponent("123_token_1.jpg") + let existingPageOneHash = "sha256:already-flushed" + try Data([0x09]).write(to: pageOneURL, options: .atomic) + let manifest = try sampleManifest( + pageHashes: [ + 1: existingPageOneHash, + 2: "" + ] + ) + + let hashedManifest = try storage.addingCurrentFileHashes( + to: manifest, + folderURL: folderURL + ) + + #expect(hashedManifest.pages[1] == existingPageOneHash) + #expect(hashedManifest.pages[2]?.hasPrefix("sha256:") == true) + #expect(hashedManifest.pages[2]?.isEmpty == false) + } + + private func makePreparedDownload( + storage: DownloadStore + ) throws -> (DownloadedGallery, URL) { + try storage.ensureRootDirectory() + let folderURL = storage.folderURL(relativePath: "123 - Sample") + let download = sampleDownload(folderURL: folderURL) + try FileManager.default.createDirectory(at: folderURL, withIntermediateDirectories: true) + try FileManager.default.createDirectory( + at: folderURL, + withIntermediateDirectories: true + ) + try Data([0xFF, 0xD8, 0xFF]).write( + to: folderURL.appendingPathComponent("123_token_cover.jpg"), + options: .atomic + ) + try Data([0x01]).write( + to: folderURL.appendingPathComponent("123_token_1.jpg"), + options: .atomic + ) + try Data([0x02]).write( + to: folderURL.appendingPathComponent("123_token_2.jpg"), + options: .atomic + ) + return (download, folderURL) + } + + private func makeStorage() -> (DownloadStore, URL) { + let rootURL = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + return ( + DownloadStore(rootURL: rootURL, fileManager: .default), + rootURL + ) + } + + private func sampleDownload(folderURL: URL) -> DownloadedGallery { + DownloadedGallery( + gid: "123", + host: .ehentai, + token: "token", + title: "Sample", + jpnTitle: nil, + uploader: "Uploader", + category: .doujinshi, + tags: [], + pageCount: 2, + postedDate: .now, + rating: 4, + onlineCoverURL: URL(string: "https://example.com/cover.jpg"), + folderURL: folderURL, + displayStatus: .completed, + completedPageCount: 2, + lastDownloadedDate: .now, + lastError: nil + ) + } + + private func sampleManifest(pageCount: Int) throws -> DownloadManifest { + try sampleManifest( + pageHashes: pageCount > 0 + ? Dictionary(uniqueKeysWithValues: (1...pageCount).map { ($0, "") }) + : [:] + ) + } + + private func sampleManifest(pageHashes: [Int: String]) throws -> DownloadManifest { + DownloadManifest( + gid: "123", + host: .ehentai, + token: "token", + title: "Sample", + jpnTitle: nil, + category: .doujinshi, + language: .japanese, + remoteCoverURL: URL(string: "https://example.com/cover.jpg"), + uploader: "Uploader", + tags: [], + postedDate: .now, + rating: 4, + pages: pageHashes + ) + } +} diff --git a/AppPackage/Tests/DownloadsFeatureTests/DownloadStoreRepairTests.swift b/AppPackage/Tests/DownloadsFeatureTests/DownloadStoreRepairTests.swift new file mode 100644 index 000000000..2e988fe6c --- /dev/null +++ b/AppPackage/Tests/DownloadsFeatureTests/DownloadStoreRepairTests.swift @@ -0,0 +1,212 @@ +import AppTools +import Foundation +import AppModels +import Testing +import DownloadClient +@testable import AppFeature + +struct DownloadStoreRepairTests { + @Test + func testMaterializeRepairSeedCopiesOnlyManifestCoverAndExistingPageFiles() throws { + let (storage, rootURL) = makeStorage() + defer { try? FileManager.default.removeItem(at: rootURL) } + + try storage.ensureRootDirectory() + let sourceFolderURL = storage.folderURL(relativePath: "123 - Source") + let destinationFolderURL = storage.folderURL(relativePath: "Folder/[123_token] Destination") + let manifest = try sampleManifest(pageCount: 3) + try setupRepairSourceFiles( + sourceFolderURL: sourceFolderURL, storage: storage, manifest: manifest + ) + + try storage.materializeRepairSeed( + from: sourceFolderURL, manifest: manifest, to: destinationFolderURL + ) + + verifyRepairSeedResult(destinationFolderURL: destinationFolderURL) + } + + @Test + func testMaterializeRepairSeedRejectsTraversalPathsInManifestPages() throws { + let sourceRootURL = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + let destRootURL = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + defer { + try? FileManager.default.removeItem(at: sourceRootURL) + try? FileManager.default.removeItem(at: destRootURL) + } + + let env = try setupTraversalTestEnvironment( + sourceRootURL: sourceRootURL, destRootURL: destRootURL + ) + + try env.destStorage.materializeRepairSeed( + from: env.sourceFolderURL, manifest: env.manifest, to: env.destinationFolderURL + ) + + #expect(FileManager.default.fileExists( + atPath: env.destinationFolderURL.appendingPathComponent("123_token_1.jpg").path + )) + #expect(FileManager.default.fileExists( + atPath: env.destinationFolderURL.appendingPathComponent("../escape.jpg").standardizedFileURL.path + ) == false) + #expect(FileManager.default.fileExists( + atPath: destRootURL.appendingPathComponent("escape.jpg").path + ) == false) + } + + @Test + func testLinkOrCopyReadableAssetFallsBackToCopyWhenHardLinkFails() throws { + let rootURL = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + defer { try? FileManager.default.removeItem(at: rootURL) } + + let fileManager = LinkFailingFileManager() + let storage = DownloadStore(rootURL: rootURL, fileManager: fileManager) + try storage.ensureRootDirectory() + + let sourceURL = rootURL.appendingPathComponent("source.bin") + let destinationURL = rootURL.appendingPathComponent("nested/destination.bin") + try Data([0x01, 0x02, 0x03]).write(to: sourceURL, options: .atomic) + + try storage.linkOrCopyReadableAsset(at: sourceURL, to: destinationURL) + + #expect(FileManager.default.fileExists(atPath: destinationURL.path)) + #expect(try Data(contentsOf: destinationURL) == Data([0x01, 0x02, 0x03])) + } +} + +private final class LinkFailingFileManager: FileManager { + override func linkItem(at srcURL: URL, to dstURL: URL) throws { + throw NSError(domain: NSCocoaErrorDomain, code: NSFileWriteUnknownError) + } +} + +private struct TraversalTestEnvironment { + let sourceStorage: DownloadStore + let destStorage: DownloadStore + let sourceFolderURL: URL + let destinationFolderURL: URL + let manifest: DownloadManifest +} + +private extension DownloadStoreRepairTests { + func setupTraversalTestEnvironment( + sourceRootURL: URL, destRootURL: URL + ) throws -> TraversalTestEnvironment { + let sourceStorage = DownloadStore(rootURL: sourceRootURL, fileManager: .default) + let destStorage = DownloadStore(rootURL: destRootURL, fileManager: .default) + try sourceStorage.ensureRootDirectory() + try destStorage.ensureRootDirectory() + let sourceFolderURL = sourceStorage.folderURL(relativePath: "123 - Source") + let destinationFolderURL = destStorage.folderURL(relativePath: "Folder/[123_token] Destination") + try FileManager.default.createDirectory( + at: sourceFolderURL, + withIntermediateDirectories: true + ) + let manifest = DownloadManifest( + gid: "123", host: .ehentai, token: "token", title: "Sample", jpnTitle: nil, + category: .doujinshi, language: .japanese, + remoteCoverURL: URL(string: "https://example.com/cover.jpg"), + uploader: "Uploader", tags: [], + postedDate: .now, + rating: 4, + pages: [ + 1: "", + 2: "" + ] + ) + try sourceStorage.writeManifest(manifest, folderURL: sourceFolderURL) + try Data([0xFF, 0xD8, 0xFF]).write( + to: sourceFolderURL.appendingPathComponent("123_token_cover.jpg"), options: .atomic + ) + try Data([0x01]).write( + to: sourceFolderURL.appendingPathComponent("123_token_1.jpg"), options: .atomic + ) + let escapeURL = sourceFolderURL.deletingLastPathComponent().appendingPathComponent("escape.jpg") + try Data([0x99]).write(to: escapeURL, options: .atomic) + return TraversalTestEnvironment( + sourceStorage: sourceStorage, destStorage: destStorage, + sourceFolderURL: sourceFolderURL, destinationFolderURL: destinationFolderURL, manifest: manifest + ) + } + + func setupRepairSourceFiles( + sourceFolderURL: URL, + storage: DownloadStore, + manifest: DownloadManifest + ) throws { + try FileManager.default.createDirectory( + at: sourceFolderURL, + withIntermediateDirectories: true + ) + try storage.writeManifest(manifest, folderURL: sourceFolderURL) + try Data([0xFF, 0xD8, 0xFF]).write( + to: sourceFolderURL.appendingPathComponent("123_token_cover.jpg"), options: .atomic + ) + try Data([0x01]).write( + to: sourceFolderURL.appendingPathComponent("123_token_1.jpg"), options: .atomic + ) + try Data([0x03]).write( + to: sourceFolderURL.appendingPathComponent("123_token_3.jpg"), options: .atomic + ) + try FileManager.default.createDirectory( + at: sourceFolderURL.appendingPathComponent("nested", isDirectory: true), + withIntermediateDirectories: true + ) + try Data([0x09]).write( + to: sourceFolderURL.appendingPathComponent("nested/ignored.bin"), options: .atomic + ) + } + + func verifyRepairSeedResult(destinationFolderURL: URL) { + #expect(FileManager.default.fileExists( + atPath: destinationFolderURL.appendingPathComponent(Defaults.FilePath.downloadManifest).path + )) + #expect(FileManager.default.fileExists( + atPath: destinationFolderURL.appendingPathComponent("123_token_cover.jpg").path + )) + #expect(FileManager.default.fileExists( + atPath: destinationFolderURL.appendingPathComponent("123_token_1.jpg").path + )) + #expect(FileManager.default.fileExists( + atPath: destinationFolderURL.appendingPathComponent("123_token_2.jpg").path + ) == false) + #expect(FileManager.default.fileExists( + atPath: destinationFolderURL.appendingPathComponent("123_token_3.jpg").path + )) + #expect(FileManager.default.fileExists( + atPath: destinationFolderURL.appendingPathComponent("nested/ignored.bin").path + ) == false) + } + + func makeStorage() -> (DownloadStore, URL) { + let rootURL = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + return ( + DownloadStore(rootURL: rootURL, fileManager: .default), + rootURL + ) + } + + func sampleManifest(pageCount: Int) throws -> DownloadManifest { + DownloadManifest( + gid: "123", + host: .ehentai, + token: "token", + title: "Sample", + jpnTitle: nil, + category: .doujinshi, + language: .japanese, + remoteCoverURL: URL(string: "https://example.com/cover.jpg"), + uploader: "Uploader", + tags: [], + postedDate: .now, + rating: 4, + pages: pageCount > 0 + ? Dictionary(uniqueKeysWithValues: (1...pageCount).map { ($0, "") }) + : [:] + ) + } +} diff --git a/AppPackage/Tests/DownloadsFeatureTests/DownloadStoreTests.swift b/AppPackage/Tests/DownloadsFeatureTests/DownloadStoreTests.swift new file mode 100644 index 000000000..f39a3e30b --- /dev/null +++ b/AppPackage/Tests/DownloadsFeatureTests/DownloadStoreTests.swift @@ -0,0 +1,554 @@ +import Foundation +import AppModels +import Resources +import Testing +import DownloadClient +@testable import AppFeature + +struct DownloadStoreTests { + @Test + func testWriteReadAndValidateManifest() throws { + let (storage, rootURL) = makeStorage() + defer { try? FileManager.default.removeItem(at: rootURL) } + + try storage.ensureRootDirectory() + let folderURL = storage.folderURL(relativePath: "123 - Sample") + let download = sampleDownload(folderURL: folderURL) + try FileManager.default.createDirectory(at: folderURL, withIntermediateDirectories: true) + try Data([0xFF, 0xD8, 0xFF]).write( + to: folderURL.appendingPathComponent("123_token_cover.jpg"), + options: .atomic + ) + try Data([0x01]).write( + to: folderURL.appendingPathComponent("123_token_1.jpg"), + options: .atomic + ) + try Data([0x02]).write( + to: folderURL.appendingPathComponent("123_token_2.jpg"), + options: .atomic + ) + let manifest = try storage.addingCurrentFileHashes( + to: sampleManifest(pageCount: 2), + folderURL: folderURL + ) + try storage.writeManifest(manifest, folderURL: folderURL) + + let loadedManifest = try storage.readManifest(folderURL: folderURL) + + #expect(loadedManifest == manifest) + #expect(storage.validate(download: download, verifiesContentHashes: true) == .valid) + } + + @Test + func testPurgeBackgroundTransferHoldingDirectoryRemovesOrphans() throws { + let (storage, rootURL) = makeStorage() + defer { try? FileManager.default.removeItem(at: rootURL) } + + let holdingDirectory = storage.backgroundTransferHoldingDirectoryURL() + try FileManager.default.createDirectory( + at: holdingDirectory, withIntermediateDirectories: true + ) + let orphan = holdingDirectory.appendingPathComponent("orphan.tmp") + try Data([0x01]).write(to: orphan, options: .atomic) + #expect(FileManager.default.fileExists(atPath: orphan.path)) + + storage.purgeBackgroundTransferHoldingDirectory() + + #expect(FileManager.default.fileExists(atPath: orphan.path) == false) + #expect(FileManager.default.fileExists(atPath: holdingDirectory.path) == false) + } + + @Test + func testReadManifestRejectsEmptyPages() throws { + let (storage, rootURL) = makeStorage() + defer { try? FileManager.default.removeItem(at: rootURL) } + + try storage.ensureRootDirectory() + let folderURL = storage.folderURL(relativePath: "123 - Empty") + try FileManager.default.createDirectory(at: folderURL, withIntermediateDirectories: true) + try storage.writeManifest(sampleManifest(pageCount: 0), folderURL: folderURL) + + #expect( + throws: AppError.fileOperationFailed( + String(localized: .RLocalizable.downloadStoreManifestCorrupted) + ) + ) { + try storage.readManifest(folderURL: folderURL) + } + } + + @Test + func testReadManifestRejectsNonContiguousPages() throws { + let (storage, rootURL) = makeStorage() + defer { try? FileManager.default.removeItem(at: rootURL) } + + try storage.ensureRootDirectory() + let folderURL = storage.folderURL(relativePath: "123 - Sparse") + try FileManager.default.createDirectory(at: folderURL, withIntermediateDirectories: true) + try storage.writeManifest(sampleManifest(pageHashes: [1: "", 3: ""]), folderURL: folderURL) + + #expect( + throws: AppError.fileOperationFailed( + String(localized: .RLocalizable.downloadStoreManifestCorrupted) + ) + ) { + try storage.readManifest(folderURL: folderURL) + } + } + + @Test + func testEnsureRootDirectoryMarksDownloadsFolderExcludedFromBackup() throws { + let (storage, rootURL) = makeStorage() + defer { try? FileManager.default.removeItem(at: rootURL) } + + try storage.ensureRootDirectory() + + let resourceValues = try rootURL.resourceValues(forKeys: [.isExcludedFromBackupKey]) + #expect(resourceValues.isExcludedFromBackup == true) + } + + @Test + func testValidateReportsMissingPageFiles() throws { + let (storage, rootURL) = makeStorage() + defer { try? FileManager.default.removeItem(at: rootURL) } + + try storage.ensureRootDirectory() + let folderURL = storage.folderURL(relativePath: "123 - Sample") + let download = sampleDownload(folderURL: folderURL) + try FileManager.default.createDirectory(at: folderURL, withIntermediateDirectories: true) + try Data([0xFF, 0xD8, 0xFF]).write( + to: folderURL.appendingPathComponent("123_token_cover.jpg"), + options: .atomic + ) + let page1URL = folderURL.appendingPathComponent("123_token_1.jpg") + try Data([0x01]).write(to: page1URL, options: .atomic) + try storage.writeManifest( + sampleManifest(pageHashes: [ + 1: try storage.fileHash(at: page1URL), + 2: "sha256:missing" + ]), + folderURL: folderURL + ) + + #expect( + storage.validate(download: download, verifiesContentHashes: true) + == .missingFiles(.RLocalizable.downloadStorePageMissing(page: 2)) + ) + } + + @Test + func testValidateRemovesZeroBytePageFilesAndRequiresRepair() throws { + let (storage, rootURL) = makeStorage() + defer { try? FileManager.default.removeItem(at: rootURL) } + + try storage.ensureRootDirectory() + let folderURL = storage.folderURL(relativePath: "123 - Sample") + let download = sampleDownload(folderURL: folderURL) + try FileManager.default.createDirectory(at: folderURL, withIntermediateDirectories: true) + try Data([0xFF, 0xD8, 0xFF]).write( + to: folderURL.appendingPathComponent("123_token_cover.jpg"), + options: .atomic + ) + try Data().write( + to: folderURL.appendingPathComponent("123_token_1.jpg"), + options: .atomic + ) + try Data([0x02]).write( + to: folderURL.appendingPathComponent("123_token_2.jpg"), + options: .atomic + ) + let page2URL = folderURL.appendingPathComponent("123_token_2.jpg") + try storage.writeManifest( + sampleManifest(pageHashes: [ + 1: "sha256:missing", + 2: try storage.fileHash(at: page2URL) + ]), + folderURL: folderURL + ) + + #expect( + storage.validate(download: download, verifiesContentHashes: true) + == .missingFiles(.RLocalizable.downloadStorePageMissing(page: 1)) + ) + #expect( + FileManager.default.fileExists( + atPath: folderURL.appendingPathComponent("123_token_1.jpg").path + ) == false + ) + } + + @Test + func testExistingPageRelativePathsDetectsFinalAssetFiles() throws { + let (storage, rootURL) = makeStorage() + defer { try? FileManager.default.removeItem(at: rootURL) } + + try storage.ensureRootDirectory() + let folderURL = storage.folderURL(relativePath: "Folder/[123_token] Sample") + try FileManager.default.createDirectory(at: folderURL, withIntermediateDirectories: true) + try Data([0x01]).write(to: folderURL.appendingPathComponent("123_token_1.webp"), options: .atomic) + try Data([0x02]).write(to: folderURL.appendingPathComponent("123_token_2.jpg"), options: .atomic) + try Data([0x03]).write(to: folderURL.appendingPathComponent("123_token_27.jpg"), options: .atomic) + try Data([0x04]).write(to: folderURL.appendingPathComponent("123_token_cover.jpg"), options: .atomic) + let manifest = sampleManifest(pageCount: 2) + + #expect( + storage.existingPageRelativePaths(folderURL: folderURL, manifest: manifest) == [ + 1: "123_token_1.webp", + 2: "123_token_2.jpg" + ] + ) + } + + @Test + func testExistingPageRelativePathsMatchesExactUnpaddedPageIndices() throws { + let (storage, rootURL) = makeStorage() + defer { try? FileManager.default.removeItem(at: rootURL) } + + try storage.ensureRootDirectory() + let folderURL = storage.folderURL(relativePath: "Folder/[123_token] Sample") + try FileManager.default.createDirectory(at: folderURL, withIntermediateDirectories: true) + try Data([0x01]).write(to: folderURL.appendingPathComponent("123_token_1.webp"), options: .atomic) + try Data([0x02]).write(to: folderURL.appendingPathComponent("123_token_10.jpg"), options: .atomic) + try Data([0x03]).write(to: folderURL.appendingPathComponent("123_token_01.jpg"), options: .atomic) + try Data([0x04]).write(to: folderURL.appendingPathComponent("123_token_11.jpg"), options: .atomic) + let manifest = sampleManifest(pageCount: 10) + + #expect( + storage.existingPageRelativePaths(folderURL: folderURL, manifest: manifest) == [ + 1: "123_token_1.webp", + 10: "123_token_10.jpg" + ] + ) + } + + @Test + func testExistingPageRelativePathsIgnoresLegacyPagesFolder() throws { + let (storage, rootURL) = makeStorage() + defer { try? FileManager.default.removeItem(at: rootURL) } + + try storage.ensureRootDirectory() + let folderURL = storage.folderURL(relativePath: "Folder/[123_token] Sample") + let pagesFolderURL = folderURL.appendingPathComponent("pages", isDirectory: true) + try FileManager.default.createDirectory(at: pagesFolderURL, withIntermediateDirectories: true) + try Data([0x01]).write(to: pagesFolderURL.appendingPathComponent("0001.jpg"), options: .atomic) + let manifest = sampleManifest(pageCount: 1) + + #expect(storage.existingPageRelativePaths(folderURL: folderURL, manifest: manifest) == [:]) + #expect(FileManager.default.fileExists(atPath: pagesFolderURL.path)) + } + + @Test + func testExistingPageRelativePathsRemovesZeroByteFinalAssetFiles() throws { + let (storage, rootURL) = makeStorage() + defer { try? FileManager.default.removeItem(at: rootURL) } + + try storage.ensureRootDirectory() + let folderURL = storage.folderURL(relativePath: "Folder/[123_token] Sample") + try FileManager.default.createDirectory(at: folderURL, withIntermediateDirectories: true) + let emptyPageURL = folderURL.appendingPathComponent("123_token_1.jpg") + try Data().write(to: emptyPageURL, options: .atomic) + try Data([0x02]).write(to: folderURL.appendingPathComponent("123_token_2.png"), options: .atomic) + let manifest = sampleManifest(pageCount: 2) + + #expect( + storage.existingPageRelativePaths(folderURL: folderURL, manifest: manifest) == [ + 2: "123_token_2.png" + ] + ) + #expect(FileManager.default.fileExists(atPath: emptyPageURL.path) == false) + } + + @Test + func testExistingPageRelativePathsIgnoresZeroByteLegacyFiles() throws { + let (storage, rootURL) = makeStorage() + defer { try? FileManager.default.removeItem(at: rootURL) } + + try storage.ensureRootDirectory() + let folderURL = storage.folderURL(relativePath: "Folder/[123_token] Sample") + let pagesURL = folderURL.appendingPathComponent("pages", isDirectory: true) + try FileManager.default.createDirectory(at: pagesURL, withIntermediateDirectories: true) + let emptyPageURL = pagesURL.appendingPathComponent("0001.jpg") + try Data().write(to: emptyPageURL, options: .atomic) + try Data([0x02]).write(to: pagesURL.appendingPathComponent("0002.png"), options: .atomic) + let manifest = sampleManifest(pageCount: 2) + + #expect(storage.existingPageRelativePaths(folderURL: folderURL, manifest: manifest) == [:]) + #expect(FileManager.default.fileExists(atPath: emptyPageURL.path)) + } + + @Test + func testExistingCoverRelativePathDetectsFinalAssetFile() throws { + let (storage, rootURL) = makeStorage() + defer { try? FileManager.default.removeItem(at: rootURL) } + + try storage.ensureRootDirectory() + let folderURL = storage.folderURL(relativePath: "Folder/[123_token] Sample") + try FileManager.default.createDirectory(at: folderURL, withIntermediateDirectories: true) + try Data([0x01]).write(to: folderURL.appendingPathComponent("other_token_cover.jpg"), options: .atomic) + try Data([0x02]).write(to: folderURL.appendingPathComponent("123_token_cover.jpg"), options: .atomic) + let manifest = sampleManifest(pageCount: 1) + + #expect( + storage.existingCoverRelativePath(folderURL: folderURL, manifest: manifest) + == "123_token_cover.jpg" + ) + } + + @Test + func testIsReadableAssetFileDoesNotDeleteFileWhenAttributesLookupFails() throws { + let rootURL = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + defer { try? FileManager.default.removeItem(at: rootURL) } + + try FileManager.default.createDirectory(at: rootURL, withIntermediateDirectories: true) + let fileURL = rootURL.appendingPathComponent("123_token_cover.jpg") + try Data([0xFF, 0xD8, 0xFF]).write(to: fileURL, options: .atomic) + let storage = DownloadStore( + rootURL: rootURL, + fileManager: ThrowingAttributesFileManager(failingPath: fileURL.path) + ) + + #expect(storage.isReadableAssetFile(at: fileURL)) + #expect(FileManager.default.fileExists(atPath: fileURL.path)) + } + + @Test + func testMakeFolderRelativePathSanitizesSeparatorsWhitespaceAndLength() throws { + let (storage, rootURL) = makeStorage() + defer { try? FileManager.default.removeItem(at: rootURL) } + + let unsafeTitle = " /Alpha\\\\Beta:\n\tGamma Delta \(String(repeating: "X", count: 200)). " + let relativePath = storage.makeFolderRelativePath(gid: "123", token: "token", title: unsafeTitle) + + #expect(relativePath.hasPrefix("[123_token] ")) + #expect(relativePath.contains("/") == false) + #expect(relativePath.contains("\\") == false) + #expect(relativePath.contains(":") == false) + #expect(relativePath.contains("\n") == false) + #expect(relativePath.hasSuffix(" ") == false) + #expect(relativePath.hasSuffix(".") == false) + #expect(relativePath.utf8.count <= 255) + + let cjkRelativePath = storage.makeFolderRelativePath( + gid: "123", + token: "token", + title: String(repeating: "語", count: 120) + ) + #expect(cjkRelativePath.utf8.count <= 255) + try FileManager.default.createDirectory( + at: storage.folderURL(relativePath: "Folder/\(cjkRelativePath)"), + withIntermediateDirectories: true + ) + } + + @Test + func testFinalFolderRelativePathUsesIdentityPrefixAndSanitizedTitle() { + let (storage, rootURL) = makeStorage() + defer { try? FileManager.default.removeItem(at: rootURL) } + + let unsafeTitle = " /Alpha\\\\Beta:\n\tGamma Delta. " + let relativePath = storage.makeFolderRelativePath(gid: "123", token: "tok/en", title: unsafeTitle) + + #expect(relativePath == "[123_tok_en] Alpha Beta Gamma Delta") + } + + @Test + func testFinalAssetRelativePathsUseIdentityAndUnpaddedPageIndex() { + let (storage, rootURL) = makeStorage() + defer { try? FileManager.default.removeItem(at: rootURL) } + + #expect( + storage.makePageRelativePath(gid: "123", token: "token", index: 7, fileExtension: "JPG") + == "123_token_7.jpg" + ) + #expect( + storage.makeCoverRelativePath(gid: "123", token: "token", fileExtension: "PNG") + == "123_token_cover.png" + ) + } + + @Test + func testScanDownloadFoldersReadsOnlyFoldersWithManifests() throws { + let (storage, rootURL) = makeStorage() + defer { try? FileManager.default.removeItem(at: rootURL) } + + try storage.ensureRootDirectory() + let downloadFolderURL = storage.folderURL(relativePath: "Folder/[123_token] Sample") + let ignoredFolderURL = storage.folderURL(relativePath: "Folder/[456_token] Missing manifest") + let hiddenFolderURL = storage.folderURL(relativePath: "Folder/[789_token] Missing manifest") + try FileManager.default.createDirectory(at: downloadFolderURL, withIntermediateDirectories: true) + try FileManager.default.createDirectory(at: ignoredFolderURL, withIntermediateDirectories: true) + try FileManager.default.createDirectory(at: hiddenFolderURL, withIntermediateDirectories: true) + try storage.writeManifest(sampleManifest(pageCount: 2), folderURL: downloadFolderURL) + + let records = try storage.scanDownloadFolders() + + #expect(records.map(\.relativePath) == ["Folder/[123_token] Sample"]) + #expect(records.first?.manifest.gid == "123") + #expect(records.first?.folderURL == downloadFolderURL) + #expect(records.first?.parentFolderName == "Folder") + } + + @Test + func testScanDownloadsIgnoresRootGalleryFoldersAndListsEmptyUserFolders() throws { + let (storage, rootURL) = makeStorage() + defer { try? FileManager.default.removeItem(at: rootURL) } + + try storage.ensureRootDirectory() + // A gallery folder dropped directly at the root stays invisible. + let rootGalleryURL = storage.folderURL(relativePath: "[123_token] Sample") + try FileManager.default.createDirectory(at: rootGalleryURL, withIntermediateDirectories: true) + try storage.writeManifest(sampleManifest(pageCount: 2), folderURL: rootGalleryURL) + // A broken gallery-like folder without a manifest is not a user folder. + let brokenGalleryURL = storage.folderURL(relativePath: "[456_token] Broken") + try FileManager.default.createDirectory(at: brokenGalleryURL, withIntermediateDirectories: true) + // User folders are listed even when empty. + let emptyFolderURL = storage.userFolderURL(name: "Empty Folder") + try FileManager.default.createDirectory(at: emptyFolderURL, withIntermediateDirectories: true) + // A populated user folder yields records carrying its name. + let galleryFolderURL = storage.folderURL(relativePath: "Library/[789_token] Inside") + try FileManager.default.createDirectory(at: galleryFolderURL, withIntermediateDirectories: true) + try storage.writeManifest( + DownloadManifest( + gid: "789", + host: .ehentai, + token: "token", + title: "Inside", + jpnTitle: nil, + category: .doujinshi, + language: .japanese, + remoteCoverURL: nil, + uploader: "Uploader", + tags: [], + postedDate: .now, + rating: 4, + pages: [1: "", 2: ""] + ), + folderURL: galleryFolderURL + ) + + let scanResult = try storage.scanDownloads() + + #expect(scanResult.userFolders == ["Empty Folder", "Library"]) + #expect(scanResult.records.map(\.relativePath) == ["Library/[789_token] Inside"]) + #expect(scanResult.records.first?.parentFolderName == "Library") + } + + @Test + func testUserFolderNameNormalizationRejectsInvalidNames() throws { + let (storage, rootURL) = makeStorage() + defer { try? FileManager.default.removeItem(at: rootURL) } + + #expect(storage.normalizedUserFolderName(" My Folder ") == "My Folder") + #expect(storage.normalizedUserFolderName("a/b:c") == "a b c") + #expect(storage.normalizedUserFolderName("...") == nil) + #expect(storage.normalizedUserFolderName(" ") == nil) + #expect(storage.normalizedUserFolderName("") == nil) + #expect(storage.normalizedUserFolderName(".hidden") == "hidden") + #expect(storage.normalizedUserFolderName("[123_token] Sample") == nil) + + let cjkName = try #require( + storage.normalizedUserFolderName(String(repeating: "語", count: 120)) + ) + #expect(cjkName.utf8.count <= 255) + try FileManager.default.createDirectory( + at: storage.userFolderURL(name: cjkName), + withIntermediateDirectories: true + ) + } + + @Test + func testExistingFinalAssetFileURLsUseIdentityPrefix() throws { + let (storage, rootURL) = makeStorage() + defer { try? FileManager.default.removeItem(at: rootURL) } + + try storage.ensureRootDirectory() + let folderURL = storage.folderURL(relativePath: "Folder/[123_token] Sample") + try FileManager.default.createDirectory(at: folderURL, withIntermediateDirectories: true) + let pageURL = folderURL.appendingPathComponent("123_token_2.webp") + let coverURL = folderURL.appendingPathComponent("123_token_cover.jpg") + try Data([0x01]).write(to: pageURL, options: .atomic) + try Data([0x02]).write(to: coverURL, options: .atomic) + + #expect(storage.existingPageFileURL(folderURL: folderURL, gid: "123", token: "token", index: 2) == pageURL) + #expect(storage.existingCoverFileURL(folderURL: folderURL, gid: "123", token: "token") == coverURL) + } +} + +private final class ThrowingAttributesFileManager: FileManager { + let failingPath: String + + init(failingPath: String) { + self.failingPath = failingPath + super.init() + } + + override func attributesOfItem(atPath path: String) throws -> [FileAttributeKey: Any] { + if path == failingPath { + throw NSError(domain: NSCocoaErrorDomain, code: NSFileReadUnknownError) + } + return try super.attributesOfItem(atPath: path) + } +} + +private extension DownloadStoreTests { + func makeStorage() -> (DownloadStore, URL) { + let rootURL = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + return ( + DownloadStore(rootURL: rootURL, fileManager: .default), + rootURL + ) + } + + func sampleDownload( + displayStatus: DownloadDisplayStatus = .completed, + folderURL: URL + ) -> DownloadedGallery { + DownloadedGallery( + gid: "123", + host: .ehentai, + token: "token", + title: "Sample", + jpnTitle: nil, + uploader: "Uploader", + category: .doujinshi, + tags: [], + pageCount: 2, + postedDate: .now, + rating: 4, + onlineCoverURL: URL(string: "https://example.com/cover.jpg"), + folderURL: folderURL, + displayStatus: displayStatus, + completedPageCount: displayStatus == .completed ? 2 : 0, + lastDownloadedDate: .now, + lastError: nil + ) + } + + func sampleManifest(pageCount: Int) -> DownloadManifest { + sampleManifest( + pageHashes: pageCount > 0 + ? Dictionary(uniqueKeysWithValues: (1...pageCount).map { ($0, "") }) + : [:] + ) + } + + func sampleManifest(pageHashes: [Int: String]) -> DownloadManifest { + DownloadManifest( + gid: "123", + host: .ehentai, + token: "token", + title: "Sample", + jpnTitle: nil, + category: .doujinshi, + language: .japanese, + remoteCoverURL: URL(string: "https://example.com/cover.jpg"), + uploader: "Uploader", + tags: [], + postedDate: .now, + rating: 4, + pages: pageHashes + ) + } +} diff --git a/AppPackage/Tests/DownloadsFeatureTests/DownloadVersionSignatureTests.swift b/AppPackage/Tests/DownloadsFeatureTests/DownloadVersionSignatureTests.swift new file mode 100644 index 000000000..7a7fd3ba9 --- /dev/null +++ b/AppPackage/Tests/DownloadsFeatureTests/DownloadVersionSignatureTests.swift @@ -0,0 +1,128 @@ +import Foundation +import AppModels +import Testing +import DownloadClient +@testable import AppFeature + +@Suite(.serialized) +struct DownloadVersionSignatureTests: DownloadFeatureTestCase { + @Test + func testDownloadCoordinatorReconcilePreservesIndexedFinalFolder() async throws { + let gid = String(Int(Date().timeIntervalSince1970 * 1000) + 31) + let rootURL = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + defer { try? FileManager.default.removeItem(at: rootURL) } + + let storage = DownloadStore(rootURL: rootURL, fileManager: .default) + let manager = DownloadCoordinator(storage: storage, urlSession: .shared) + try storage.ensureRootDirectory() + let folderURL = storage.folderURL(relativePath: "Folder/[\(gid)_token] Indexed") + try FileManager.default.createDirectory( + at: folderURL, + withIntermediateDirectories: true + ) + try storage.writeManifest( + sampleManifest(gid: gid, title: "Indexed", pageCount: 2), + folderURL: folderURL + ) + + try FileManager.default.createDirectory( + at: folderURL, + withIntermediateDirectories: true + ) + let pageURL = folderURL.appendingPathComponent("\(gid)_token_1.jpg") + try Data([0x01]).write( + to: pageURL, + options: .atomic + ) + + await manager.reconcileDownloads() + + let stored = await manager.fetchDownload(gid: gid) + let localPages = try await manager.loadLocalPageURLs(gid: gid).get() + + #expect(stored?.displayStatus == .inactive) + #expect(stored?.completedPageCount == 0) + #expect(FileManager.default.fileExists(atPath: folderURL.path)) + #expect(localPages[1] == pageURL) + } + + @MainActor + @Test + func testUpdateRemoteVersionUsesIndexedSessionFlag() async throws { + let gid = String(Int(Date().timeIntervalSince1970 * 1000) + 104) + let rootURL = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + defer { try? FileManager.default.removeItem(at: rootURL) } + + let storage = DownloadStore(rootURL: rootURL, fileManager: .default) + let manager = DownloadCoordinator( + storage: storage, + urlSession: .shared + ) + try storage.ensureRootDirectory() + let folderURL = storage.folderURL(relativePath: "Folder/[\(gid)_token] Indexed") + try FileManager.default.createDirectory( + at: folderURL, + withIntermediateDirectories: true + ) + try storage.writeManifest( + DownloadManifest( + gid: gid, + host: .ehentai, + token: "token", + title: "Indexed", + jpnTitle: nil, + category: .doujinshi, + language: .japanese, + remoteCoverURL: URL(string: "https://example.com/cover.jpg"), + uploader: "Uploader", + tags: [], + postedDate: .now, + rating: 4, + pages: [1: "sha256:done"] + ), + folderURL: folderURL + ) + await manager.reloadDownloadIndex() + + let updateResult = await manager.updateRemoteVersion( + gid: gid, + metadata: DownloadVersionMetadata( + gid: gid, + token: "token", + currentGID: gid, + currentKey: "new-token", + parentGID: gid, + parentKey: "token", + firstGID: gid, + firstKey: "token" + ) + ) + let updatedDownload = await manager.fetchDownload(gid: gid) + + #expect(updateResult?.displayStatus == .updateAvailable) + #expect(updatedDownload?.displayStatus == .updateAvailable) + #expect(updatedDownload?.displayStatus == .updateAvailable) + + let currentResult = await manager.updateRemoteVersion( + gid: gid, + metadata: DownloadVersionMetadata( + gid: gid, + token: "token", + currentGID: gid, + currentKey: "token", + parentGID: gid, + parentKey: "token", + firstGID: gid, + firstKey: "token" + ) + ) + let currentDownload = await manager.fetchDownload(gid: gid) + + #expect(currentResult?.displayStatus == .completed) + #expect(currentDownload?.displayStatus == .completed) + #expect(currentDownload?.displayStatus == .completed) + } + +} diff --git a/AppPackage/Tests/DownloadsFeatureTests/DownloadedGalleryManifestModelTests.swift b/AppPackage/Tests/DownloadsFeatureTests/DownloadedGalleryManifestModelTests.swift new file mode 100644 index 000000000..cae65744a --- /dev/null +++ b/AppPackage/Tests/DownloadsFeatureTests/DownloadedGalleryManifestModelTests.swift @@ -0,0 +1,78 @@ +import Foundation +import AppModels +import Testing +@testable import AppFeature + +struct DownloadedGalleryManifestModelTests { + @Test + func testManifestCompletedPageCountDerivesFromNonEmptyHashes() throws { + let manifest = try sampleManifest(pageHashes: [1: "sha256:a", 2: "", 3: nil]) + + #expect(manifest.completedPageCount == 1) + #expect(manifest.isComplete == false) + } + + @Test + func testManifestDerivedFieldsAreNotEncoded() throws { + let manifest = try sampleManifest(pageHashes: [1: "sha256:a", 2: "sha256:b"]) + let encoded = try JSONEncoder().encode(manifest) + let object = try #require( + JSONSerialization.jsonObject(with: encoded) as? [String: Any] + ) + + #expect(manifest.pageCount == 2) + #expect(manifest.galleryURL == URL(string: "https://e-hentai.org/g/123/token")) + #expect(object["pageCount"] == nil) + #expect(object["galleryURL"] == nil) + #expect(object["coverFileHash"] == nil) + #expect(object["coverRelativePath"] == nil) + #expect(object["downloadOptions"] == nil) + #expect(object["downloadedAt"] == nil) + } + + @Test + func testDownloadedGalleryViewModelUsesManifestAndRuntimeStatus() throws { + let modificationDate = Date(timeIntervalSince1970: 1_234) + let manifest = try sampleManifest(pageHashes: [1: "sha256:a", 2: "sha256:b"]) + + let download = DownloadedGallery( + manifest: manifest, + folderURL: URL(fileURLWithPath: "/tmp/Folder/[123_token] Sample", isDirectory: true), + folderName: "Folder", + localCoverURL: nil, + localPageURLs: [:], + modificationDate: modificationDate, + displayStatus: .queued + ) + + #expect(download.gid == "123") + #expect(download.folderURL.lastPathComponent == "[123_token] Sample") + #expect(download.displayStatus == .queued) + #expect(download.onlineCoverURL == manifest.remoteCoverURL) + #expect(download.completedPageCount == 2) + #expect(download.lastDownloadedDate == modificationDate) + } +} + +private extension DownloadedGalleryManifestModelTests { + func sampleManifest(pageHashes: [Int: String?]) throws -> DownloadManifest { + DownloadManifest( + gid: "123", + host: .ehentai, + token: "token", + title: "Sample", + jpnTitle: "サンプル", + category: .doujinshi, + language: .japanese, + remoteCoverURL: URL(string: "https://example.com/cover.jpg"), + uploader: "Uploader", + tags: [], + postedDate: Date(timeIntervalSince1970: 1_000), + rating: 4, + pages: Dictionary( + uniqueKeysWithValues: + pageHashes.map { index, hash in (index, hash ?? "") } + ) + ) + } +} diff --git a/AppPackage/Tests/DownloadsFeatureTests/DownloadsReducerActionTests.swift b/AppPackage/Tests/DownloadsFeatureTests/DownloadsReducerActionTests.swift new file mode 100644 index 000000000..1be8db91e --- /dev/null +++ b/AppPackage/Tests/DownloadsFeatureTests/DownloadsReducerActionTests.swift @@ -0,0 +1,373 @@ +import Foundation +import AppModels +import ComposableArchitecture +import Testing +import DeviceClient +import DownloadClient +@testable import DownloadsFeature +@testable import AppFeature + +@Suite(.serialized) +struct DownloadsReducerActionTests: DownloadFeatureTestCase { + @MainActor + @Test + func testDownloadsReducerKeepsIdleStateForEmptyLibrary() async { + let store = TestStore(initialState: DownloadsReducer.State(), reducer: DownloadsReducer.init) + + await store.send(.fetchDownloadsDone([])) { + $0.loadingState = .idle + } + + #expect(store.state.downloads == []) + } + + @MainActor + @Test + func testDownloadsReducerSeedsOnlineDetailStateFromDownload() async { + let download = sampleDownload( + gid: "123456", + title: "Completed Gallery", + status: .completed + ) + var initialState = DownloadsReducer.State() + initialState.downloads = [download] + + let store = TestStore( + initialState: initialState, + reducer: DownloadsReducer.init, + withDependencies: { $0.deviceClient = .noop } + ) + store.exhaustivity = .off + + await store.send(.galleryTapped(download.gid)) + await store.receive(\.pushGalleryDetail) + + #expect(store.state.path.count == 1) + guard let element = store.state.path.first, case .detail(let detailState) = element else { + Issue.record("Expected a pushed detail element") + return + } + #expect(detailState.gid == download.gid) + #expect(detailState.gallery.id == download.gid) + #expect(detailState.downloadBadge?.status == .completed) + #expect(detailState.shouldCheckForRemoteUpdates == true) + } + + @MainActor + @Test + func testDownloadsReducerDoubleTapPushesDetailOnce() async { + let download = sampleDownload( + gid: "123456", + title: "Completed Gallery", + status: .completed + ) + var initialState = DownloadsReducer.State() + initialState.downloads = [download] + + let store = TestStore( + initialState: initialState, + reducer: DownloadsReducer.init, + withDependencies: { $0.deviceClient = .noop } + ) + store.exhaustivity = .off + + await store.send(.galleryTapped(download.gid)) + await store.receive(\.pushGalleryDetail) + #expect(store.state.path.count == 1) + + // A rapid second activation must not push a duplicate adjacent detail. + await store.send(.galleryTapped(download.gid)) + await store.receive(\.pushGalleryDetail) + #expect(store.state.path.count == 1) + } + + @MainActor + @Test + func testDownloadsReducerDelegatesModalDetailOnPad() async { + let download = sampleDownload( + gid: "123456", + title: "Completed Gallery", + status: .completed + ) + var initialState = DownloadsReducer.State() + initialState.downloads = [download] + + let store = TestStore( + initialState: initialState, + reducer: DownloadsReducer.init, + withDependencies: { + $0.deviceClient = DeviceClient( + isPad: { true }, + absWindowW: { .zero }, + absWindowH: { .zero }, + touchPoint: { nil } + ) + } + ) + store.exhaustivity = .off + + await store.send(.galleryTapped(download.gid)) + await store.receive(\.delegate) + + // The host must not push inline on iPad; AppReducer presents the modal instead. + #expect(store.state.path.isEmpty) + } + + @MainActor + @Test + func testDownloadsReducerFolderFilterNarrowsDownloads() async { + let libraryDownload = sampleDownload( + gid: "111", title: "Library Archive", status: .completed, folderName: "Library" + ) + let otherDownload = sampleDownload( + gid: "222", title: "Other Archive", status: .completed, folderName: "Other" + ) + var state = DownloadsReducer.State() + state.downloads = [libraryDownload, otherDownload] + + state.folderFilter = .all + #expect(state.filteredDownloads == [libraryDownload, otherDownload]) + + state.folderFilter = .folder("Library") + #expect(state.filteredDownloads == [libraryDownload]) + + state.folderFilter = .folder("Vanished") + #expect(state.filteredDownloads.isEmpty) + } + + @MainActor + @Test + func testDownloadsReducerPrunesStaleFolderFilterAfterFetch() async { + var initialState = DownloadsReducer.State() + initialState.folderFilter = .folder("Vanished") + + let store = TestStore(initialState: initialState, reducer: DownloadsReducer.init) + + await store.send(.fetchFoldersDone(["Library"])) { + $0.folders = ["Library"] + $0.folderFilter = .all + } + + await store.send(.fetchFoldersDone(["Library", "Other"])) { + $0.folders = ["Library", "Other"] + } + } + + @MainActor + @Test + func testDownloadsReducerMoveActionUsesDownloadClientMove() async { + let moved = UncheckedBox<(String, String)?>(nil) + let store = TestStore( + initialState: DownloadsReducer.State(), + reducer: DownloadsReducer.init, + withDependencies: { + $0.downloadClient = DownloadClient() + $0.downloadClient.observeDownloads = { + AsyncStream { continuation in + continuation.finish() + } + } + $0.downloadClient.fetchDownloads = { [] } + $0.downloadClient.fetchDownload = { _ in nil } + $0.downloadClient.refreshDownloads = {} + $0.downloadClient.enqueue = { _ in } + $0.downloadClient.togglePause = { _ in } + $0.downloadClient.retry = { _, _ in } + $0.downloadClient.delete = { _ in } + $0.downloadClient.loadManifest = { _ in throw AppError.notFound } + $0.downloadClient.fetchFolders = { ["Library"] } + $0.downloadClient.moveDownload = { gid, folder in + moved.value = (gid, folder) + } + } + ) + store.exhaustivity = .off + + await store.send(.moveDownload("123456", "Library")) + await store.receive(\.moveDownloadDone) + await store.receive(\.fetchFoldersDone) { + $0.folders = ["Library"] + } + + #expect(moved.value?.0 == "123456") + #expect(moved.value?.1 == "Library") + } + + @MainActor + @Test + func testDownloadsReducerUpdateActionUsesDownloadClientRetry() async { + let retried = UncheckedBox<[String]>([]) + let download = sampleDownload( + gid: "123456", + title: "Completed Gallery", + status: .updateAvailable + ) + var initialState = DownloadsReducer.State() + initialState.downloads = [download] + + let store = TestStore( + initialState: initialState, + reducer: DownloadsReducer.init, + withDependencies: { + $0.downloadClient = DownloadClient() + $0.downloadClient.observeDownloads = { + AsyncStream { continuation in + continuation.finish() + } + } + $0.downloadClient.fetchDownloads = { [] } + $0.downloadClient.fetchDownload = { _ in nil } + $0.downloadClient.refreshDownloads = {} + $0.downloadClient.enqueue = { _ in } + $0.downloadClient.togglePause = { _ in } + $0.downloadClient.retry = { gid, mode in + if mode == .update { + retried.value.append(gid) + } + } + $0.downloadClient.delete = { _ in } + $0.downloadClient.loadManifest = { _ in throw AppError.notFound } + } + ) + store.exhaustivity = .off + + await store.send(.updateDownload(download.gid)) + await store.receive(\.updateDownloadDone) + + #expect(retried.value == [download.gid]) + } + + @MainActor + @Test + func testDownloadsReducerDeleteActionUsesDownloadClientDelete() async { + let deleted = UncheckedBox<[String]>([]) + let download = sampleDownload( + gid: "654321", + title: "Completed Gallery", + status: .completed + ) + var initialState = DownloadsReducer.State() + initialState.downloads = [download] + + let store = TestStore( + initialState: initialState, + reducer: DownloadsReducer.init, + withDependencies: { + $0.downloadClient = DownloadClient() + $0.downloadClient.observeDownloads = { + AsyncStream { continuation in + continuation.finish() + } + } + $0.downloadClient.fetchDownloads = { [] } + $0.downloadClient.fetchDownload = { _ in nil } + $0.downloadClient.refreshDownloads = {} + $0.downloadClient.enqueue = { _ in } + $0.downloadClient.togglePause = { _ in } + $0.downloadClient.retry = { _, _ in } + $0.downloadClient.delete = { gid in + deleted.value.append(gid) + } + $0.downloadClient.loadManifest = { _ in throw AppError.notFound } + } + ) + store.exhaustivity = .off + + await store.send(.deleteDownload(download.gid)) + await store.receive(\.deleteDownloadDone) + + #expect(deleted.value == [download.gid]) + } + + @MainActor + @Test + func testDownloadsReducerOpenReadingLoadsManifestAndRoutesToReader() async throws { + let download = sampleDownload( + gid: "135790", + title: "Readable Gallery", + status: .completed, + pageCount: 2 + ) + let manifest = try sampleManifest( + gid: download.gid, + title: download.title, + pageCount: 2 + ) + var initialState = DownloadsReducer.State() + initialState.downloads = [download] + + let store = TestStore( + initialState: initialState, + reducer: DownloadsReducer.init, + withDependencies: { + $0.downloadClient = DownloadClient() + $0.downloadClient.observeDownloads = { + AsyncStream { continuation in + continuation.finish() + } + } + $0.downloadClient.fetchDownloads = { [] } + $0.downloadClient.fetchDownload = { _ in nil } + $0.downloadClient.refreshDownloads = {} + $0.downloadClient.enqueue = { _ in } + $0.downloadClient.togglePause = { _ in } + $0.downloadClient.retry = { _, _ in } + $0.downloadClient.delete = { _ in } + $0.downloadClient.loadManifest = { gid in + guard gid == download.gid else { throw AppError.notFound } + return (download, manifest) + } + } + ) + store.exhaustivity = .off + + await store.send(.openReading(download.gid)) + await store.receive(\.openReadingDone) + + #expect(store.state.destination?.reading?.contentSource == .local(download, manifest)) + } + + @MainActor + @Test + func testDownloadsReducerTogglePauseActionUsesDownloadClientPause() async { + let toggled = UncheckedBox<[String]>([]) + let download = sampleDownload( + gid: "987654", + title: "Downloading Gallery", + status: .downloading, + completedPageCount: 9 + ) + var initialState = DownloadsReducer.State() + initialState.downloads = [download] + + let store = TestStore( + initialState: initialState, + reducer: DownloadsReducer.init, + withDependencies: { + $0.downloadClient = DownloadClient() + $0.downloadClient.observeDownloads = { + AsyncStream { continuation in + continuation.finish() + } + } + $0.downloadClient.fetchDownloads = { [] } + $0.downloadClient.fetchDownload = { _ in nil } + $0.downloadClient.refreshDownloads = {} + $0.downloadClient.enqueue = { _ in } + $0.downloadClient.togglePause = { gid in + toggled.value.append(gid) + } + $0.downloadClient.retry = { _, _ in } + $0.downloadClient.delete = { _ in } + $0.downloadClient.loadManifest = { _ in throw AppError.notFound } + } + ) + store.exhaustivity = .off + + await store.send(.toggleDownloadPause(download.gid)) + await store.receive(\.toggleDownloadPauseDone) + + #expect(toggled.value == [download.gid]) + } + +} diff --git a/AppPackage/Tests/DownloadsFeatureTests/DownloadsReducerReadingDismissTests.swift b/AppPackage/Tests/DownloadsFeatureTests/DownloadsReducerReadingDismissTests.swift new file mode 100644 index 000000000..e4acbca7e --- /dev/null +++ b/AppPackage/Tests/DownloadsFeatureTests/DownloadsReducerReadingDismissTests.swift @@ -0,0 +1,32 @@ +import ComposableArchitecture +import Testing +import HapticsClient +import DeviceClient +import AppDelegateClient +@testable import DownloadsFeature +@testable import AppFeature + +struct DownloadsReducerReadingDismissTests { + @MainActor + @Test + func readingDismissClearsDestination() async { + var initialState = DownloadsReducer.State() + initialState.destination = .reading(.init(gallery: .preview, contentSource: .remote)) + + let store = TestStore( + initialState: initialState, + reducer: DownloadsReducer.init, + withDependencies: { + $0.appDelegateClient = .noop + $0.deviceClient = .noop + $0.hapticsClient = .noop + } + ) + store.exhaustivity = .off + + await store.send(.destination(.presented(.reading(.onPerformDismiss)))) + await store.receive(\.destination.dismiss) + + #expect(store.state.destination == nil) + } +} diff --git a/AppPackage/Tests/DownloadsFeatureTests/DownloadsReducerRefreshTests.swift b/AppPackage/Tests/DownloadsFeatureTests/DownloadsReducerRefreshTests.swift new file mode 100644 index 000000000..6f34796a0 --- /dev/null +++ b/AppPackage/Tests/DownloadsFeatureTests/DownloadsReducerRefreshTests.swift @@ -0,0 +1,150 @@ +import Foundation +import AppModels +import ComposableArchitecture +import Testing +import DownloadClient +@testable import DownloadsFeature +@testable import AppFeature + +@Suite(.serialized) +struct DownloadsReducerRefreshTests: DownloadFeatureTestCase { + @MainActor + @Test + func testDownloadsReducerDoesNotReconcileAfterPauseFailure() async { + let download = sampleDownload( + gid: "987655", + title: "Queued Gallery", + status: .queued, + completedPageCount: 3 + ) + let reconcileCount = UncheckedBox(0) + var initialState = DownloadsReducer.State() + initialState.downloads = [download] + + let store = TestStore( + initialState: initialState, + reducer: DownloadsReducer.init, + withDependencies: { + $0.downloadClient = DownloadClient() + $0.downloadClient.observeDownloads = { + AsyncStream { continuation in + continuation.finish() + } + } + $0.downloadClient.fetchDownloads = { [download] } + $0.downloadClient.fetchDownload = { _ in nil } + $0.downloadClient.reconcileDownloads = { + reconcileCount.value += 1 + } + $0.downloadClient.refreshDownloads = {} + $0.downloadClient.enqueue = { _ in } + $0.downloadClient.togglePause = { _ in throw AppError.networkingFailed } + $0.downloadClient.retry = { _, _ in } + $0.downloadClient.delete = { _ in } + $0.downloadClient.loadManifest = { _ in throw AppError.notFound } + } + ) + + await store.send(.toggleDownloadPause(download.gid)) + await store.receive(\.toggleDownloadPauseDone) + await store.finish() + + #expect(reconcileCount.value == 0) + } + + @MainActor + @Test + func testDownloadsReducerRefreshDownloadsUsesClientRefresh() async { + let refreshCount = UncheckedBox(0) + let reconcileCount = UncheckedBox(0) + + let store = TestStore( + initialState: DownloadsReducer.State(), + reducer: DownloadsReducer.init, + withDependencies: { + $0.downloadClient = DownloadClient() + $0.downloadClient.observeDownloads = { + AsyncStream { continuation in + continuation.finish() + } + } + $0.downloadClient.fetchDownloads = { [] } + $0.downloadClient.fetchDownload = { _ in nil } + $0.downloadClient.reconcileDownloads = { + reconcileCount.value += 1 + } + $0.downloadClient.refreshDownloads = { + refreshCount.value += 1 + } + $0.downloadClient.enqueue = { _ in } + $0.downloadClient.togglePause = { _ in } + $0.downloadClient.retry = { _, _ in } + $0.downloadClient.delete = { _ in } + $0.downloadClient.loadManifest = { _ in throw AppError.notFound } + $0.downloadClient.fetchFolders = { [] } + } + ) + + await store.send(.refreshDownloads) + await store.receive(\.refreshDownloadsDone) + await store.receive(\.fetchFolders) + await store.receive(\.fetchFoldersDone) + + #expect(refreshCount.value == 1) + #expect(reconcileCount.value == 0) + } + + @MainActor + @Test + func testDownloadsReducerOnAppearUsesCachedIndexWithoutRefresh() async { + let fetchCount = UncheckedBox(0) + let folderFetchCount = UncheckedBox(0) + let refreshCount = UncheckedBox(0) + + let store = TestStore( + initialState: DownloadsReducer.State(), + reducer: DownloadsReducer.init, + withDependencies: { + $0.downloadClient = DownloadClient() + $0.downloadClient.observeDownloads = { + AsyncStream { continuation in + continuation.finish() + } + } + $0.downloadClient.fetchDownloads = { + fetchCount.value += 1 + return [] + } + $0.downloadClient.fetchDownload = { _ in nil } + $0.downloadClient.refreshDownloads = { + refreshCount.value += 1 + } + $0.downloadClient.enqueue = { _ in } + $0.downloadClient.togglePause = { _ in } + $0.downloadClient.retry = { _, _ in } + $0.downloadClient.delete = { _ in } + $0.downloadClient.loadManifest = { _ in throw AppError.notFound } + $0.downloadClient.fetchFolders = { + folderFetchCount.value += 1 + return [] + } + } + ) + + await store.send(.onAppear) { + $0.hasLoadedInitialDownloads = true + } + await store.receive(\.fetchDownloads) + await store.receive(\.observeDownloads) + await store.receive(\.fetchFolders) + await store.receive(\.fetchDownloadsDone) { + $0.loadingState = .idle + } + await store.receive(\.fetchFoldersDone) + + #expect(fetchCount.value == 1) + #expect(folderFetchCount.value == 1) + #expect(refreshCount.value == 0) + } + +} diff --git a/AppPackage/Tests/DownloadsFeatureTests/FolderManagerReducerTests.swift b/AppPackage/Tests/DownloadsFeatureTests/FolderManagerReducerTests.swift new file mode 100644 index 000000000..80447ef70 --- /dev/null +++ b/AppPackage/Tests/DownloadsFeatureTests/FolderManagerReducerTests.swift @@ -0,0 +1,343 @@ +import Foundation +import AppModels +import ComposableArchitecture +import Testing +import DownloadClient +@testable import DetailFeature +@testable import AppFeature + +@Suite(.serialized) +@MainActor +struct FolderManagerReducerTests: DownloadFeatureTestCase { + @MainActor + @Test + func testFetchFoldersPopulatesState() async { + let store = makeStore(folders: { ["Alpha", "Beta"] }) + + await store.send(.fetchFolders) { + $0.loadingState = .loading + } + await store.receive(\.fetchFoldersDone, ["Alpha", "Beta"]) { + $0.loadingState = .idle + $0.folders = ["Alpha", "Beta"] + } + } + + @MainActor + @Test + func testCreateFolderForwardsNormalizedEditingNameAndRefetches() async { + let createdName = UncheckedBox(nil) + let store = makeStore( + folders: { createdName.value.map { [$0] } ?? [] }, + createFolder: { name in + createdName.value = name + } + ) + store.exhaustivity = .off + + await store.send(.binding(.set(\.editingFolderName, " Favorites/2026 "))) + await store.send(.createFolder) { + $0.loadingState = .loading + } + await store.receive(\.createFolderDone) + await store.receive(\.fetchFolders) + await store.receive(\.fetchFoldersDone) { + $0.loadingState = .idle + $0.folders = ["Favorites 2026"] + } + + #expect(createdName.value == "Favorites 2026") + } + + @MainActor + @Test + func testRenameFolderForwardsOriginalAndNormalizedEditedNames() async { + let renamedPair = UncheckedBox<(String, String)?>(nil) + let store = makeStore( + folders: { ["New Name"] }, + renameFolder: { oldName, newName in + renamedPair.value = (oldName, newName) + } + ) + store.exhaustivity = .off + + await store.send(.binding(.set(\.editingFolderName, " New/Name "))) + await store.send(.renameFolder("Old Name")) { + $0.loadingState = .loading + } + await store.receive(\.renameFolderDone) + await store.receive(\.fetchFolders) + await store.receive(\.fetchFoldersDone) { + $0.loadingState = .idle + $0.folders = ["New Name"] + } + + #expect(renamedPair.value?.0 == "Old Name") + #expect(renamedPair.value?.1 == "New Name") + } + + @MainActor + @Test + func testDeleteFolderForwardsNameAndRefetches() async { + let deletedName = UncheckedBox(nil) + let store = makeStore( + folders: { deletedName.value == nil ? ["Doomed"] : [] }, + deleteFolder: { name in + deletedName.value = name + } + ) + store.exhaustivity = .off + + await store.send(.deleteFolder("Doomed")) { + $0.loadingState = .loading + } + await store.receive(\.deleteFolderDone) + await store.receive(\.fetchFolders) + await store.receive(\.fetchFoldersDone) { + $0.loadingState = .idle + $0.folders = [] + } + + #expect(deletedName.value == "Doomed") + } + + @MainActor + @Test + func testSetEditingFieldPrefillsAndClearsEditingName() async { + let store = makeStore(folders: { [] }) + + await store.send(.setEditingField(.renameFolder("Old Name"))) { + $0.editingField = .renameFolder("Old Name") + $0.editingFolderName = "Old Name" + } + await store.send(.setEditingField(.newFolder)) { + $0.editingField = .newFolder + $0.editingFolderName = "" + } + await store.send(.binding(.set(\.editingFolderName, "Drafted"))) { + $0.editingFolderName = "Drafted" + } + await store.send(.setEditingField(nil)) { + $0.editingField = nil + $0.editingFolderName = "" + } + } + + @MainActor + @Test + func testSubmitEditingFieldCreatesFolderWhenNameIsValid() async { + let createdName = UncheckedBox(nil) + let store = makeStore( + folders: { createdName.value.map { [$0] } ?? [] }, + createFolder: { name in + createdName.value = name + } + ) + store.exhaustivity = .off + + await store.send(.setEditingField(.newFolder)) { + $0.editingField = .newFolder + } + await store.send(.binding(.set(\.editingFolderName, "Favorites"))) + await store.send(.submitEditingField) { + $0.editingField = nil + } + await store.receive(\.createFolder) { + $0.loadingState = .loading + } + await store.receive(\.createFolderDone) + await store.receive(\.fetchFolders) + await store.receive(\.fetchFoldersDone) { + $0.loadingState = .idle + $0.folders = ["Favorites"] + } + + #expect(createdName.value == "Favorites") + } + + @MainActor + @Test + func testSubmitEditingFieldRenamesFolderWithOriginalName() async { + let renamedPair = UncheckedBox<(String, String)?>(nil) + let store = makeStore( + folders: { renamedPair.value == nil ? ["Old Name"] : ["New Name"] }, + renameFolder: { oldName, newName in + renamedPair.value = (oldName, newName) + } + ) + store.exhaustivity = .off + + await store.send(.setEditingField(.renameFolder("Old Name"))) { + $0.editingField = .renameFolder("Old Name") + $0.editingFolderName = "Old Name" + } + await store.send(.binding(.set(\.editingFolderName, "New Name"))) + await store.send(.submitEditingField) { + $0.editingField = nil + } + await store.receive(\.renameFolder) { + $0.loadingState = .loading + } + await store.receive(\.renameFolderDone) + await store.receive(\.fetchFolders) + await store.receive(\.fetchFoldersDone) { + $0.loadingState = .idle + $0.folders = ["New Name"] + } + + #expect(renamedPair.value?.0 == "Old Name") + #expect(renamedPair.value?.1 == "New Name") + } + + @MainActor + @Test + func testSubmitEditingFieldWithInvalidNameOnlyDismissesField() async { + let store = makeStore(folders: { [] }) + + await store.send(.setEditingField(.newFolder)) { + $0.editingField = .newFolder + } + await store.send(.submitEditingField) { + $0.editingField = nil + } + } + + @MainActor + @Test + func testEditingNameValidationRejectsBlankAndNormalizedDuplicateNames() { + var state = FolderManagerReducer.State() + state.folders = ["Existing", "a b c"] + + state.editingFolderName = " " + #expect(state.isEditingNameValid == false) + + state.editingFolderName = "Existing" + #expect(state.isEditingNameValid == false) + + state.editingFolderName = "a/b:c" + #expect(state.isEditingNameValid == false) + + state.editingFolderName = "Fresh" + #expect(state.isEditingNameValid) + } + + @MainActor + @Test + func testRenameValidationAllowsNormalizedCurrentFolderName() { + var state = FolderManagerReducer.State() + state.editingField = .renameFolder("a b c") + state.folders = ["a b c"] + state.editingFolderName = "a/b:c" + + #expect(state.isEditingNameValid) + } + + @MainActor + @Test + func testCreateFolderFailureSetsFailedStateWithoutRefetching() async { + let fetchCount = UncheckedBox(0) + let error = AppError.fileOperationFailed("disk full") + let store = makeStore( + folders: { + fetchCount.value += 1 + return [] + }, + createFolder: { _ in throw error } + ) + + await store.send(.binding(.set(\.editingFolderName, "Favorites"))) { + $0.editingFolderName = "Favorites" + } + await store.send(.createFolder) { + $0.loadingState = .loading + } + await store.receive(\.createFolderDone) { + $0.loadingState = .failed(error) + } + #expect(fetchCount.value == 0) + } + + @MainActor + @Test + func testRenameFolderFailureSetsFailedStateWithoutRefetching() async { + let fetchCount = UncheckedBox(0) + let error = AppError.fileOperationFailed("folder busy") + let store = makeStore( + folders: { + fetchCount.value += 1 + return [] + }, + renameFolder: { _, _ in throw error } + ) + + await store.send(.binding(.set(\.editingFolderName, "New Name"))) { + $0.editingFolderName = "New Name" + } + await store.send(.renameFolder("Old Name")) { + $0.loadingState = .loading + } + await store.receive(\.renameFolderDone) { + $0.loadingState = .failed(error) + } + #expect(fetchCount.value == 0) + } + + @MainActor + @Test + func testDeleteFolderFailureSetsFailedStateWithoutRefetching() async { + let fetchCount = UncheckedBox(0) + let error = AppError.fileOperationFailed("permission denied") + let store = makeStore( + folders: { + fetchCount.value += 1 + return [] + }, + deleteFolder: { _ in throw error } + ) + + await store.send(.deleteFolder("Doomed")) { + $0.loadingState = .loading + } + await store.receive(\.deleteFolderDone) { + $0.loadingState = .failed(error) + } + #expect(fetchCount.value == 0) + } +} + +// MARK: - Store Factory Helpers + +private extension FolderManagerReducerTests { + func makeStore( + folders: @escaping @Sendable () -> [String], + createFolder: @escaping @Sendable (String) async throws -> Void + = { _ in }, + renameFolder: @escaping @Sendable (String, String) async throws -> Void + = { _, _ in }, + deleteFolder: @escaping @Sendable (String) async throws -> Void + = { _ in } + ) -> TestStoreOf { + TestStore( + initialState: FolderManagerReducer.State(), + reducer: FolderManagerReducer.init, + withDependencies: { + $0.downloadClient = DownloadClient() + $0.downloadClient.observeDownloads = { + AsyncStream { continuation in continuation.finish() } + } + $0.downloadClient.fetchDownloads = { [] } + $0.downloadClient.fetchDownload = { _ in nil } + $0.downloadClient.refreshDownloads = {} + $0.downloadClient.enqueue = { _ in } + $0.downloadClient.togglePause = { _ in } + $0.downloadClient.retry = { _, _ in } + $0.downloadClient.delete = { _ in } + $0.downloadClient.loadManifest = { _ in throw AppError.notFound } + $0.downloadClient.fetchFolders = { folders() } + $0.downloadClient.createFolder = createFolder + $0.downloadClient.renameFolder = renameFolder + $0.downloadClient.deleteFolder = deleteFolder + } + ) + } +} diff --git a/AppPackage/Tests/DownloadsFeatureTests/PreviewsReducerDownloadTests.swift b/AppPackage/Tests/DownloadsFeatureTests/PreviewsReducerDownloadTests.swift new file mode 100644 index 000000000..c0a96dc19 --- /dev/null +++ b/AppPackage/Tests/DownloadsFeatureTests/PreviewsReducerDownloadTests.swift @@ -0,0 +1,148 @@ +import Foundation +import AppModels +import ComposableArchitecture +import Testing +import HapticsClient +import DownloadClient +@testable import DetailFeature +@testable import AppFeature + +@Suite(.serialized) +@MainActor +struct PreviewsReducerDownloadTests: DownloadFeatureTestCase { + @MainActor + @Test + func testPreviewsReducerOpenReadingUsesLocalManifestWhenAvailable() async throws { + let download = sampleDownload( + gid: "991", title: "Preview Download", status: .completed, pageCount: 2, completedPageCount: 2 + ) + let manifest = try sampleManifest(gid: download.gid, title: download.title) + + let store = makePreviewsManifestStore(download: download, manifest: manifest) + + await store.send(.openReading(1)) + await store.skipReceivedActions(strict: false) + + if case .local(let actualDownload, let actualManifest) = store.state.destination?.reading?.contentSource { + #expect(actualDownload == download) + #expect(actualManifest == manifest) + } else { + Issue.record("Expected previews to open local reading content.") + } + if case .reading = store.state.destination { + } else { + Issue.record("Expected reading destination to be active.") + } + } + + @MainActor + @Test + func testPreviewsReducerClearsLocalPreviewURLsWhenObservedDownloadDisappears() async { + let gallery = sampleGallery() + let localURL = URL(fileURLWithPath: "/tmp/\(UUID().uuidString).jpg") + var initialState = PreviewsReducer.State(gallery: gallery) + initialState.localPreviewURLs = [1: localURL] + + let store = makePreviewsNoManifestStore(initialState: initialState, withLoadLocalPageURLs: true) + + await store.send(.observeDownloadsDone([])) + await store.receive(\.loadLocalPreviewURLs) + let requestID = store.state.localPreviewRequestID + await store.receive(\.loadLocalPreviewURLsDone) { + $0.localPreviewURLs = [:] + } + #expect(store.state.localPreviewRequestID == requestID) + } + + @MainActor + @Test + func testPreviewsReducerRemoteFallbackKeepsExistingLocalPreviewPages() async { + let gallery = sampleGallery() + let localURL = URL(fileURLWithPath: "/tmp/\(UUID().uuidString).jpg") + var initialState = PreviewsReducer.State(gallery: gallery) + initialState.localPreviewURLs = [1: localURL] + + let store = makePreviewsNoManifestStore(initialState: initialState, withLoadLocalPageURLs: false) + + await store.send(.openReading(1)) + await store.receive(\.openReadingDone) + guard case .reading = store.state.destination else { + Issue.record("Expected previews destination to enter reading") + return + } + #expect(store.state.destination?.reading?.contentSource == .remote) + #expect(store.state.destination?.reading?.localPageURLs == [1: localURL]) + } + +} + +// MARK: - Store Factory Helpers + +private extension PreviewsReducerDownloadTests { + func makePreviewsManifestStore( + download: DownloadedGallery, + manifest: DownloadManifest + ) -> TestStoreOf { + let initialState = PreviewsReducer.State(gallery: download.gallery) + let store = TestStore( + initialState: initialState, + reducer: PreviewsReducer.init, + withDependencies: { + $0.downloadClient = DownloadClient() + $0.downloadClient.observeDownloads = { AsyncStream { continuation in continuation.finish() } } + $0.downloadClient.fetchDownloads = { [download] } + $0.downloadClient.fetchDownload = { gid in gid == download.gid ? download : nil } + $0.downloadClient.refreshDownloads = {} + $0.downloadClient.enqueue = { _ in } + $0.downloadClient.togglePause = { _ in } + $0.downloadClient.retry = { _, _ in } + $0.downloadClient.delete = { _ in } + $0.downloadClient.loadManifest = { gid in + guard gid == download.gid else { throw AppError.notFound } + return (download, manifest) + } + $0.hapticsClient = .noop + } + ) + store.exhaustivity = .off + return store + } + + func makePreviewsNoManifestClient(loadLocalPageURLs: Bool) -> DownloadClient { + let loadLocalPageURLsResult: @Sendable (String) async -> [Int: URL]? + if loadLocalPageURLs { + loadLocalPageURLsResult = { _ in [:] } + } else { + loadLocalPageURLsResult = { _ in nil } + } + var client = DownloadClient() + client.observeDownloads = { AsyncStream { continuation in continuation.finish() } } + client.fetchDownloads = { [] } + client.fetchDownload = { _ in nil } + client.refreshDownloads = {} + client.enqueue = { _ in } + client.togglePause = { _ in } + client.retry = { _, _ in } + client.delete = { _ in } + client.loadManifest = { _ in throw AppError.notFound } + client.loadLocalPageURLs = loadLocalPageURLsResult + return client + } + + func makePreviewsNoManifestStore( + initialState: PreviewsReducer.State, + withLoadLocalPageURLs: Bool + ) -> TestStoreOf { + let downloadClient = makePreviewsNoManifestClient(loadLocalPageURLs: withLoadLocalPageURLs) + let store = TestStore( + initialState: initialState, + reducer: PreviewsReducer.init, + withDependencies: { + $0.downloadClient = downloadClient + $0.hapticsClient = .noop + } + ) + store.exhaustivity = .off + return store + } +} diff --git a/AppPackage/Tests/DownloadsFeatureTests/ReaderImageDataTests.swift b/AppPackage/Tests/DownloadsFeatureTests/ReaderImageDataTests.swift new file mode 100644 index 000000000..2625acafd --- /dev/null +++ b/AppPackage/Tests/DownloadsFeatureTests/ReaderImageDataTests.swift @@ -0,0 +1,341 @@ +import TestingSupport +import Foundation +import AppModels +import Testing +import UIKit +import AppTools +import ImageClient +@testable import AppFeature + +@Suite(.serialized) +struct ReaderImageDataTests { + @Test + func testFetchesAndStoresOnCacheMiss() async throws { + let (cache, rootURL) = makeIsolatedDataCache() + defer { try? FileManager.default.removeItem(at: rootURL) } + let url = try #require(URL(string: "https://example.com/reader/fetch.png")) + let imageData = try makePNGData() + let requestCount = UncheckedBox(0) + let (session, sessionID) = makeStubbedSession() + defer { SharedSessionStubURLProtocol.removeHandler(for: sessionID) } + SharedSessionStubURLProtocol.setHandler(for: sessionID) { _ in + requestCount.value += 1 + return (try makeHTTPResponse(url: url, statusCode: 200), imageData) + } + + let data = try await ImageClient.readerImageData( + url: url, dataCache: cache, urlSession: session + ) + + #expect(data == imageData) + #expect(requestCount.value == 1) + let cached = await cache.data( + forKeys: url.imageCacheKeys + ) + #expect(cached == imageData) + } + + @Test + func testStoresUnderPrimaryKeyOnly() async throws { + let (cache, rootURL) = makeIsolatedDataCache() + defer { try? FileManager.default.removeItem(at: rootURL) } + let url = try #require(URL(string: "https://ehgt.org/h/abc/primary-key-page/1.jpg")) + let stableKey = try #require(url.stableImageCacheKey) + let imageData = try makePNGData() + let (session, sessionID) = makeStubbedSession() + defer { SharedSessionStubURLProtocol.removeHandler(for: sessionID) } + SharedSessionStubURLProtocol.setHandler(for: sessionID) { _ in + (try makeHTTPResponse(url: url, statusCode: 200), imageData) + } + + _ = try await ImageClient.readerImageData( + url: url, dataCache: cache, urlSession: session + ) + + // The bytes are written under the stable primary key only; the absolute-URL + // alias stays a read fallback, never a second stored copy. + #expect(await cache.data(forKeys: [stableKey]) == imageData) + #expect(await cache.data(forKeys: [url.absoluteString]) == nil) + } + + @Test + func testReturnsCachedBytesWithoutNetwork() async throws { + let (cache, rootURL) = makeIsolatedDataCache() + defer { try? FileManager.default.removeItem(at: rootURL) } + let url = try #require(URL(string: "https://example.com/reader/cached.png")) + let imageData = try makePNGData() + try await cache.store( + imageData, forKeys: url.imageCacheKeys + ) + let requestCount = UncheckedBox(0) + let (session, sessionID) = makeStubbedSession() + defer { SharedSessionStubURLProtocol.removeHandler(for: sessionID) } + SharedSessionStubURLProtocol.setHandler(for: sessionID) { _ in + requestCount.value += 1 + return (try makeHTTPResponse(url: url, statusCode: 200), imageData) + } + + let data = try await ImageClient.readerImageData( + url: url, dataCache: cache, urlSession: session + ) + + #expect(data == imageData) + #expect(requestCount.value == 0) + } + + @Test + func testThrowsAndSkipsCacheOnHTTPError() async throws { + let (cache, rootURL) = makeIsolatedDataCache() + defer { try? FileManager.default.removeItem(at: rootURL) } + let url = try #require(URL(string: "https://example.com/reader/error.png")) + let imageData = try makePNGData() + let (session, sessionID) = makeStubbedSession() + defer { SharedSessionStubURLProtocol.removeHandler(for: sessionID) } + SharedSessionStubURLProtocol.setHandler(for: sessionID) { _ in + (try makeHTTPResponse(url: url, statusCode: 503), imageData) + } + + do { + _ = try await ImageClient.readerImageData( + url: url, dataCache: cache, urlSession: session + ) + Issue.record("Expected readerImageData to throw on an HTTP error") + } catch let error as AppError { + #expect(error == .networkingFailed) + } catch { + Issue.record("Unexpected error: \(error)") + } + let cached = await cache.data( + forKeys: url.imageCacheKeys + ) + #expect(cached == nil) + } + + @Test + func testRejectsAndSkipsCacheForNonDecodableBody() async throws { + let (cache, rootURL) = makeIsolatedDataCache() + defer { try? FileManager.default.removeItem(at: rootURL) } + let url = try #require(URL(string: "https://example.com/reader/notimage.png")) + let htmlData = Data("Your IP has been temporarily banned".utf8) + let (session, sessionID) = makeStubbedSession() + defer { SharedSessionStubURLProtocol.removeHandler(for: sessionID) } + SharedSessionStubURLProtocol.setHandler(for: sessionID) { _ in + (try makeHTTPResponse(url: url, statusCode: 200), htmlData) + } + + do { + _ = try await ImageClient.readerImageData( + url: url, dataCache: cache, urlSession: session + ) + Issue.record("Expected readerImageData to reject a non-decodable body") + } catch let error as AppError { + #expect(error == .parseFailed) + } catch { + Issue.record("Unexpected error: \(error)") + } + let cached = await cache.data( + forKeys: url.imageCacheKeys + ) + #expect(cached == nil) + } + + @Test + func testRejectsAndSkipsCacheForQuotaPlaceholderFromNetwork() async throws { + let (cache, rootURL) = makeIsolatedDataCache() + defer { try? FileManager.default.removeItem(at: rootURL) } + let url = try #require(URL(string: "https://ehgt.org/g/509.gif")) + let placeholderData = try fixtureData(resource: "BandwidthExceeded", pathExtension: "html") + let (session, sessionID) = makeStubbedSession() + defer { SharedSessionStubURLProtocol.removeHandler(for: sessionID) } + SharedSessionStubURLProtocol.setHandler(for: sessionID) { _ in + (try makeHTTPResponse(url: url, statusCode: 200), placeholderData) + } + + // The H@H `509` notice is a valid 200 GIF; the owned fetch must surface it as + // `.quotaExceeded` and never cache it, or it would poison the key until expiry. + do { + _ = try await ImageClient.readerImageData( + url: url, dataCache: cache, urlSession: session + ) + Issue.record("Expected readerImageData to reject the quota placeholder") + } catch let error as AppError { + #expect(error == .quotaExceeded) + } catch { + Issue.record("Unexpected error: \(error)") + } + let cached = await cache.data( + forKeys: url.imageCacheKeys + ) + #expect(cached == nil) + } + + @Test + func testRejectsAndSkipsCacheForAuthPlaceholderFromNetwork() async throws { + let (cache, rootURL) = makeIsolatedDataCache() + defer { try? FileManager.default.removeItem(at: rootURL) } + let url = try #require(URL(string: "https://exhentai.org/img/kokomade.jpg")) + let placeholderData = try fixtureData(resource: "Kokomade", pathExtension: "jpg") + let (session, sessionID) = makeStubbedSession() + defer { SharedSessionStubURLProtocol.removeHandler(for: sessionID) } + SharedSessionStubURLProtocol.setHandler(for: sessionID) { _ in + (try makeHTTPResponse(url: url, statusCode: 200), placeholderData) + } + + do { + _ = try await ImageClient.readerImageData( + url: url, dataCache: cache, urlSession: session + ) + Issue.record("Expected readerImageData to reject the auth placeholder") + } catch let error as AppError { + #expect(error == .authenticationRequired) + } catch { + Issue.record("Unexpected error: \(error)") + } + let cached = await cache.data( + forKeys: url.imageCacheKeys + ) + #expect(cached == nil) + } + + @Test + func testPurgesCachedPlaceholderAndRefetches() async throws { + let (cache, rootURL) = makeIsolatedDataCache() + defer { try? FileManager.default.removeItem(at: rootURL) } + let url = try #require(URL(string: "https://ehgt.org/g/509.gif")) + let cacheKeys = url.imageCacheKeys + let placeholderData = try fixtureData(resource: "BandwidthExceeded", pathExtension: "html") + try await cache.store(placeholderData, forKeys: cacheKeys) + let realImageData = try makePNGData() + let requestCount = UncheckedBox(0) + let (session, sessionID) = makeStubbedSession() + defer { SharedSessionStubURLProtocol.removeHandler(for: sessionID) } + SharedSessionStubURLProtocol.setHandler(for: sessionID) { _ in + requestCount.value += 1 + return (try makeHTTPResponse(url: url, statusCode: 200), realImageData) + } + + // A placeholder cached before the guard existed must be purged and re-fetched, + // so a lifted limit recovers without the user clearing the cache by hand. + let data = try await ImageClient.readerImageData( + url: url, dataCache: cache, urlSession: session + ) + + #expect(data == realImageData) + #expect(requestCount.value == 1) + let cached = await cache.data(forKeys: cacheKeys) + #expect(cached == realImageData) + } + + @Test + func testFetchImageAssetServesOwnedCacheAndRoutesByBytes() async throws { + let (cache, rootURL) = makeIsolatedDataCache() + defer { try? FileManager.default.removeItem(at: rootURL) } + let url = try #require(URL(string: "https://example.com/reader/export.png")) + let imageData = try makePNGData() + try await cache.store( + imageData, forKeys: url.imageCacheKeys + ) + let requestCount = UncheckedBox(0) + let (session, sessionID) = makeStubbedSession() + defer { SharedSessionStubURLProtocol.removeHandler(for: sessionID) } + SharedSessionStubURLProtocol.setHandler(for: sessionID) { _ in + requestCount.value += 1 + return (try makeHTTPResponse(url: url, statusCode: 200), imageData) + } + var client = ImageClient.live + client.dataCache = cache + client.urlSession = session + + let asset = try await client.fetchImageAsset(url: url).get() + + #expect(asset.data == imageData) + #expect(asset.isAnimated == false) + #expect(requestCount.value == 0) + } + + @Test + func testCancellationStopsTheOwnedFetch() async throws { + let (cache, rootURL) = makeIsolatedDataCache() + defer { try? FileManager.default.removeItem(at: rootURL) } + let url = try #require(URL(string: "https://example.com/reader/hang.png")) + let configuration = URLSessionConfiguration.ephemeral + configuration.protocolClasses = [HangingURLProtocol.self] + let session = URLSession(configuration: configuration) + + // Dismissing the reader mid-fetch cancels the Task; the owned URLSession + // fetch must honor that and stop instead of completing (BUG-24). + let task = Task { + try await ImageClient.readerImageData(url: url, dataCache: cache, urlSession: session) + } + task.cancel() + + do { + _ = try await task.value + Issue.record("Expected the cancelled owned fetch to throw") + } catch is CancellationError { + } catch let error as URLError where error.code == .cancelled { + } catch { + Issue.record("Unexpected error: \(error)") + } + } + + @Test + func testCancelledReaderImageAssetFetchReturnsNil() async throws { + let (cache, rootURL) = makeIsolatedDataCache() + defer { try? FileManager.default.removeItem(at: rootURL) } + let url = try #require(URL(string: "https://example.com/reader/hang-asset.png")) + let configuration = URLSessionConfiguration.ephemeral + configuration.protocolClasses = [HangingURLProtocol.self] + var client = ImageClient.noop + client.dataCache = cache + client.urlSession = URLSession(configuration: configuration) + + // A cancelled fetch must surface as nil (not a thrown failure), so the reader + // can distinguish "scrolled away" from a real load failure via Task.isCancelled. + let task = Task { await client.fetchReaderImageAsset(url: url) } + task.cancel() + let asset = await task.value + + #expect(asset == nil) + } + + private func fixtureData(resource: String, pathExtension: String) throws -> Data { + let fixtureURL = try #require( + TestFixtures.url(forResource: resource, withExtension: pathExtension) + ) + return try Data(contentsOf: fixtureURL) + } + + private func makeIsolatedDataCache() -> (cache: DataCache, rootURL: URL) { + let rootURL = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + return (DataCache(configuration: .init(rootURL: rootURL)), rootURL) + } + + private func makeStubbedSession() -> (session: URLSession, sessionID: String) { + let sessionID = UUID().uuidString + let configuration = URLSessionConfiguration.ephemeral + configuration.protocolClasses = [SharedSessionStubURLProtocol.self] + configuration.httpAdditionalHeaders = [ + SharedSessionStubURLProtocol.headerKey: sessionID + ] + return (URLSession(configuration: configuration), sessionID) + } + + private func makePNGData() throws -> Data { + let image = UIGraphicsImageRenderer(size: .init(width: 2, height: 2)).image { context in + UIColor.red.setFill() + context.fill(CGRect(x: 0, y: 0, width: 2, height: 2)) + } + return try #require(image.pngData()) + } +} + +private func makeHTTPResponse(url: URL, statusCode: Int) throws -> HTTPURLResponse { + guard let response = HTTPURLResponse( + url: url, statusCode: statusCode, httpVersion: nil, headerFields: nil + ) else { + throw AppError.unknown + } + return response +} diff --git a/AppPackage/Tests/DownloadsFeatureTests/ReadingReducerDownloadTests.swift b/AppPackage/Tests/DownloadsFeatureTests/ReadingReducerDownloadTests.swift new file mode 100644 index 000000000..1d59f173f --- /dev/null +++ b/AppPackage/Tests/DownloadsFeatureTests/ReadingReducerDownloadTests.swift @@ -0,0 +1,194 @@ +import Foundation +import AppModels +import ComposableArchitecture +import Testing +import URLClient +import HapticsClient +import ImageClient +import DownloadClient +import ClipboardClient +import CookieClient +import DeviceClient +import AppDelegateClient +@testable import ReadingFeature +@testable import DetailFeature +@testable import AppFeature + +@Suite(.serialized) +@MainActor +struct ReadingReducerDownloadTests: DownloadFeatureTestCase { + @MainActor + @Test + func testDetailReducerDownloadedContextStoresVersionMetadataResult() async { + let download = sampleDownload( + gid: "889", title: "Offline Archive", status: .completed, pageCount: 2 + ) + let detail = sampleGalleryDetail(gid: download.gid, title: download.title) + var initialState = DetailReducer.State(gallery: download.gallery) + initialState.galleryDetail = detail + let metadata = DownloadVersionMetadata( + gid: detail.gid, token: download.token, + currentGID: "990", currentKey: "chain-key", + parentGID: download.gid, parentKey: download.token, + firstGID: download.gid, firstKey: download.token + ) + + let store = TestStore(initialState: initialState, reducer: DetailReducer.init) + await store.send(.fetchVersionMetadataDone(.success(metadata))) { + $0.galleryVersionMetadata = metadata + } + } + + @MainActor + @Test + func testReadingReducerRemoteSourceLoadsLocalPagesAndSkipsRemoteFetchForDownloadedPage() async throws { + let gallery = sampleGallery() + let localPageURL = URL(fileURLWithPath: "/tmp/\(UUID().uuidString).jpg") + let remotePageURL = try #require(URL(string: "https://example.com/pages/0001.jpg")) + var initialState = ReadingReducer.State(gallery: gallery, contentSource: .remote) + initialState.imageURLs = [1: remotePageURL] + + let store = makeLocalPageLoadStore( + initialState: initialState, gallery: gallery, localPageURL: localPageURL + ) + + await store.send(.loadLocalPageURLs(gallery.gid)) + let requestID = store.state.localPageRequestID + await store.receive(\.loadLocalPageURLsDone) { $0.localPageURLs = [1: localPageURL] } + #expect(store.state.localPageRequestID == requestID) + #expect(store.state.localPageURLs[1] == localPageURL) + + await store.send(.fetchImageURLs(1)) { $0.imageURLLoadingStates[1] = .idle } + } + + @MainActor + @Test + func testReadingReducerLocalPageLoadClearsStaleRemoteImageFailure() async throws { + let gallery = sampleGallery() + let localPageURL = URL(fileURLWithPath: "/tmp/\(UUID().uuidString).jpg") + var initialState = ReadingReducer.State(gallery: gallery, contentSource: .remote) + initialState.imageURLLoadingStates[1] = .failed(.webImageFailed) + initialState.previewLoadingStates[1] = .failed(.webImageFailed) + + let store = makeLocalPageLoadStore( + initialState: initialState, gallery: gallery, localPageURL: localPageURL + ) + + await store.send(.loadLocalPageURLs(gallery.gid)) + let requestID = store.state.localPageRequestID + await store.receive(\.loadLocalPageURLsDone) { + $0.localPageURLs = [1: localPageURL] + $0.imageURLLoadingStates[1] = .idle + $0.previewLoadingStates[1] = .idle + } + #expect(store.state.localPageRequestID == requestID) + } + + @MainActor + @Test + func testReadingReducerOnWebImageSucceededCapturesCachedPageIntoDownloadProgress() async throws { + let capturedCalls = UncheckedBox([CapturedPageCall]()) + let gallery = sampleGallery() + let remotePageURL = try #require(URL(string: "https://example.com/pages/0001.jpg")) + var initialState = ReadingReducer.State(gallery: gallery, contentSource: .remote) + initialState.imageURLs = [1: remotePageURL] + + let store = makeCapturePageStore( + initialState: initialState, capturedCalls: capturedCalls + ) + + await store.send(.onWebImageSucceeded(1)) { + $0.imageURLLoadingStates[1] = .idle + $0.webImageLoadSuccessIndices.insert(1) + } + await store.receive(\.captureCachedPage) + + #expect(capturedCalls.value.count == 1) + #expect(capturedCalls.value.first?.gid == gallery.gid) + #expect(capturedCalls.value.first?.index == 1) + #expect(capturedCalls.value.first?.imageURL == remotePageURL) + } + +} + +// MARK: - Captured Page Call + +private struct CapturedPageCall { + let gid: String + let index: Int + let imageURL: URL? +} + +// MARK: - Store Factory Helpers + +private extension ReadingReducerDownloadTests { + func makeLocalPageLoadStore( + initialState: ReadingReducer.State, + gallery: Gallery, + localPageURL: URL + ) -> TestStoreOf { + let store = TestStore( + initialState: initialState, + reducer: ReadingReducer.init, + withDependencies: { + $0.appDelegateClient = .noop + $0.clipboardClient = .noop + $0.cookieClient = .noop + $0.deviceClient = .noop + $0.downloadClient = DownloadClient() + $0.downloadClient.observeDownloads = { AsyncStream { $0.yield([]); $0.finish() } } + $0.downloadClient.fetchDownloads = { [] } + $0.downloadClient.fetchDownload = { _ in nil } + $0.downloadClient.refreshDownloads = {} + $0.downloadClient.enqueue = { _ in } + $0.downloadClient.togglePause = { _ in } + $0.downloadClient.retry = { _, _ in } + $0.downloadClient.delete = { _ in } + $0.downloadClient.loadManifest = { _ in throw AppError.notFound } + $0.downloadClient.loadLocalPageURLs = { gid in + guard gid == gallery.gid else { return nil } + return [1: localPageURL] + } + $0.hapticsClient = .noop + $0.imageClient = .noop + $0.urlClient = .noop + } + ) + store.exhaustivity = .off + return store + } + + func makeCapturePageStore( + initialState: ReadingReducer.State, + capturedCalls: UncheckedBox<[CapturedPageCall]> + ) -> TestStoreOf { + let store = TestStore( + initialState: initialState, + reducer: ReadingReducer.init, + withDependencies: { + $0.appDelegateClient = .noop + $0.clipboardClient = .noop + $0.cookieClient = .noop + $0.deviceClient = .noop + $0.downloadClient = DownloadClient() + $0.downloadClient.observeDownloads = { AsyncStream { $0.finish() } } + $0.downloadClient.fetchDownloads = { [] } + $0.downloadClient.fetchDownload = { _ in nil } + $0.downloadClient.refreshDownloads = {} + $0.downloadClient.enqueue = { _ in } + $0.downloadClient.togglePause = { _ in } + $0.downloadClient.retry = { _, _ in } + $0.downloadClient.delete = { _ in } + $0.downloadClient.loadManifest = { _ in throw AppError.notFound } + $0.downloadClient.captureCachedPage = { gid, index, imageURL in + capturedCalls.value.append(CapturedPageCall(gid: gid, index: index, imageURL: imageURL)) + } + $0.hapticsClient = .noop + $0.imageClient = .noop + $0.urlClient = .noop + } + ) + store.exhaustivity = .off + return store + } +} diff --git a/AppPackage/Tests/DownloadsFeatureTests/ReadingReducerFlushTests.swift b/AppPackage/Tests/DownloadsFeatureTests/ReadingReducerFlushTests.swift new file mode 100644 index 000000000..67a7547f9 --- /dev/null +++ b/AppPackage/Tests/DownloadsFeatureTests/ReadingReducerFlushTests.swift @@ -0,0 +1,102 @@ +import Foundation +import Sharing +import AppModels +import ComposableArchitecture +import Testing +import HapticsClient +@testable import ReadingFeature + +// V-1 regression guard. Closing the reader must persist the current page *synchronously* on +// `.onPerformDismiss` — that runs in this child reducer before the parent nils the presentation and +// cancels the pending debounce, so a deferred send (the old `onDisappear` approach) would be dropped. +// And a flush that fires before the first page turn must rewrite the *restored* resume position, never +// clobber it with a stale `.zero`. +@Suite(.serialized) +struct ReadingReducerFlushTests: DownloadFeatureTestCase { + private let now = Date(timeIntervalSince1970: 1_000) + + /// Reads `@Shared(.galleryHistory)` from an isolated `UserDefaults` so the test never touches (or + /// depends on) the real app defaults. + private func persistedProgress(_ defaults: UserDefaults, gid: String) -> Int? { + withDependencies { + $0.defaultAppStorage = defaults + } operation: { + @Shared(.galleryHistory) var history + return history.first { $0.gid == gid }?.readingProgress + } + } + + @MainActor + @Test + func dismissFlushesTheLastSyncedPageBeforeTheDebounceFires() async throws { + let suiteName = "reading-flush-\(UUID().uuidString)" + let defaults = try #require(UserDefaults(suiteName: suiteName)) + defer { defaults.removePersistentDomain(forName: suiteName) } + let gallery = sampleGallery() + let clock = TestClock() + + let store = TestStore( + initialState: ReadingReducer.State(gallery: gallery, contentSource: .remote), + reducer: ReadingReducer.init, + withDependencies: { + $0.defaultAppStorage = defaults + $0.continuousClock = clock + $0.date = .constant(now) + $0.hapticsClient = .noop + } + ) + store.exhaustivity = .off + + await store.send(.syncReadingProgress(7)) // pending = 7, starts the 1s debounce (not yet fired) + await store.send(.onPerformDismiss) // must persist 7 inline, before the debounce fires + + #expect(persistedProgress(defaults, gid: gallery.id) == 7) + + await clock.advance(by: .seconds(1)) // drain the still-pending debounce so the store finishes + await store.finish() + } + + @MainActor + @Test + func dismissBeforeAnyPageTurnDoesNotClobberTheRestoredResumePosition() async throws { + let suiteName = "reading-flush-\(UUID().uuidString)" + let defaults = try #require(UserDefaults(suiteName: suiteName)) + defer { defaults.removePersistentDomain(forName: suiteName) } + let gallery = sampleGallery() + + // Pre-seed a saved resume position of 5. + withDependencies { + $0.defaultAppStorage = defaults + } operation: { + @Shared(.galleryHistory) var history + $history.withLock { + $0.recordGalleryOpen(gid: gallery.id, token: gallery.token, date: now) + $0.updateReadingProgress(gid: gallery.id, token: gallery.token, progress: 5, date: now) + } + } + + // State.init reads the saved resume position (5) from history, so build it inside the + // `defaults` scope that holds the pre-seeded history. + let initialState = withDependencies { + $0.defaultAppStorage = defaults + } operation: { + ReadingReducer.State(gallery: gallery, contentSource: .remote) + } + let store = TestStore( + initialState: initialState, + reducer: ReadingReducer.init, + withDependencies: { + $0.defaultAppStorage = defaults + $0.continuousClock = TestClock() + $0.date = .constant(now) + $0.hapticsClient = .noop + } + ) + store.exhaustivity = .off + + await store.send(.onPerformDismiss) // flushes the restored 5, must not overwrite with 0 + + #expect(persistedProgress(defaults, gid: gallery.id) == 5) + await store.finish() + } +} diff --git a/AppPackage/Tests/DownloadsFeatureTests/ReadingReducerLocalTests.swift b/AppPackage/Tests/DownloadsFeatureTests/ReadingReducerLocalTests.swift new file mode 100644 index 000000000..058dfa30f --- /dev/null +++ b/AppPackage/Tests/DownloadsFeatureTests/ReadingReducerLocalTests.swift @@ -0,0 +1,154 @@ +import Foundation +import AppModels +import ComposableArchitecture +import Testing +import AppTools +import URLClient +import HapticsClient +import ImageClient +import DownloadClient +import ClipboardClient +import CookieClient +import DeviceClient +import AppDelegateClient +@testable import ReadingFeature +@testable import AppFeature + +@Suite(.serialized) +struct ReadingReducerLocalTests: DownloadFeatureTestCase { + @Test + func testContainerDataSourceHandlesZeroPageGallery() { + var gallery = sampleGallery() + gallery.pageCount = 0 + var state = ReadingReducer.State(gallery: gallery) + + var dualPageSetting = Setting() + dualPageSetting.enablesDualPageMode = true + dualPageSetting.readingDirection = .leftToRight + dualPageSetting.exceptCover = true + + #expect(state.containerDataSource(setting: Setting(), isLandscape: false) == []) + #expect(state.containerDataSource(setting: dualPageSetting, isLandscape: true) == []) + } + + // V2-A / #5: `ReadingView.init` seeds the pager from the resume page (`max(readingProgress, 1)` + // mapped through `mapToPager`), because no post-subscribe change event repositions it anymore. + // This pins that mapping so a saved page opens at its index and no history opens at the first page. + @MainActor + @Test + func testResumePageMapsToPagerIndex() { + let handler = PageHandler() + #expect(handler.mapToPager(index: 5, setting: Setting(), isLandscape: false) == 4) + #expect(handler.mapToPager(index: max(0, 1), setting: Setting(), isLandscape: false) == 0) + } + + @MainActor + @Test + func testReadingReducerOnWebImageSucceededDoesNotCaptureAlreadyLocalPage() async { + let capturedCalls = UncheckedBox([(String, Int, URL?)]()) + let gallery = sampleGallery() + let localPageURL = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString) + .appendingPathComponent("0001.jpg") + var initialState = ReadingReducer.State(gallery: gallery, contentSource: .remote) + initialState.localPageURLs = [1: localPageURL] + + let store = TestStore( + initialState: initialState, + reducer: ReadingReducer.init, + withDependencies: { + $0.appDelegateClient = .noop + $0.clipboardClient = .noop + $0.cookieClient = .noop + $0.deviceClient = .noop + $0.downloadClient = DownloadClient() + $0.downloadClient.observeDownloads = { + AsyncStream { continuation in + continuation.finish() + } + } + $0.downloadClient.fetchDownloads = { [] } + $0.downloadClient.fetchDownload = { _ in nil } + $0.downloadClient.refreshDownloads = {} + $0.downloadClient.enqueue = { _ in } + $0.downloadClient.togglePause = { _ in } + $0.downloadClient.retry = { _, _ in } + $0.downloadClient.delete = { _ in } + $0.downloadClient.loadManifest = { _ in throw AppError.notFound } + $0.downloadClient.captureCachedPage = { gid, index, imageURL in + capturedCalls.value.append((gid, index, imageURL)) + } + $0.hapticsClient = .noop + $0.imageClient = .noop + $0.urlClient = .noop + } + ) + store.exhaustivity = .off + + await store.send(.onWebImageSucceeded(1)) { + $0.imageURLLoadingStates[1] = .idle + $0.webImageLoadSuccessIndices.insert(1) + } + await store.finish() + + #expect(capturedCalls.value.isEmpty) + } + + @MainActor + @Test + func testReadingReducerLocalSourceLoadsOfflineImagesWithoutNetwork() async throws { + let gid = "777" + let title = "Offline Archive" + let folderURL = FileUtil.downloadsDirectoryURL + .appendingPathComponent("[\(gid)_token] \(title)", isDirectory: true) + let localPageURLs = [ + 1: folderURL.appendingPathComponent("123_token_1.jpg"), + 2: folderURL.appendingPathComponent("123_token_2.jpg") + ] + let download = sampleDownload( + gid: gid, + title: title, + status: .completed, + pageCount: 2, + folderURL: folderURL, + localPageURLs: localPageURLs + ) + let manifest = try sampleManifest(gid: download.gid, title: download.title) + _ = try prepareLocalDownloadFiles(download: download, manifest: manifest) + defer { try? FileManager.default.removeItem(at: folderURL) } + + let store = TestStore( + initialState: ReadingReducer.State(gallery: .preview, contentSource: .local(download, manifest)) + ) { + ReadingReducer() + } withDependencies: { + $0.appDelegateClient = .noop + $0.clipboardClient = .noop + $0.cookieClient = .noop + $0.deviceClient = .noop + $0.hapticsClient = .noop + $0.imageClient = .noop + $0.urlClient = .noop + } + store.exhaustivity = .off + + // State.init applies the local source at construction, so no separate restore step is needed. + #expect(store.state.gallery.id == download.gid) + #expect(store.state.localPageURLs[1] == folderURL.appendingPathComponent("123_token_1.jpg")) + #expect(store.state.localPageURLs[2] == folderURL.appendingPathComponent("123_token_2.jpg")) + #expect(store.state.imageURLs.isEmpty) + #expect(store.state.previewURLs.isEmpty) + #expect(store.state.thumbnailURLs.isEmpty) + #expect(store.state.originalImageURLs.isEmpty) + + await store.send(.fetchImageURLs(1)) { + $0.imageURLLoadingStates[1] = .idle + } + await store.send(.reloadAllWebImages) + + #expect(store.state.localPageURLs[1] == folderURL.appendingPathComponent("123_token_1.jpg")) + #expect(store.state.localPageURLs[2] == folderURL.appendingPathComponent("123_token_2.jpg")) + #expect(store.state.imageURLs.isEmpty) + } + +} diff --git a/AppPackage/Tests/FeatureTests.xctestplan b/AppPackage/Tests/FeatureTests.xctestplan new file mode 100644 index 000000000..45c561e0c --- /dev/null +++ b/AppPackage/Tests/FeatureTests.xctestplan @@ -0,0 +1,66 @@ +{ + "configurations" : [ + { + "id" : "C0DEC0DE-FEED-4A11-BEEF-FEA7011E5751", + "name" : "Configuration 1", + "options" : { + + } + } + ], + "defaultOptions" : { + "testTimeoutsEnabled" : true + }, + "testTargets" : [ + { + "target" : { + "containerPath" : "container:AppPackage", + "identifier" : "AppModelsTests", + "name" : "AppModelsTests" + } + }, + { + "target" : { + "containerPath" : "container:AppPackage", + "identifier" : "DetailFeatureTests", + "name" : "DetailFeatureTests" + } + }, + { + "target" : { + "containerPath" : "container:AppPackage", + "identifier" : "DownloadsFeatureTests", + "name" : "DownloadsFeatureTests" + } + }, + { + "target" : { + "containerPath" : "container:AppPackage", + "identifier" : "FileClientTests", + "name" : "FileClientTests" + } + }, + { + "target" : { + "containerPath" : "container:AppPackage", + "identifier" : "NetworkingFeatureTests", + "name" : "NetworkingFeatureTests" + } + }, + { + "target" : { + "containerPath" : "container:AppPackage", + "identifier" : "ParserFeatureTests", + "name" : "ParserFeatureTests" + } + }, + { + "target" : { + "containerPath" : "container:AppPackage", + "identifier" : "SettingFeatureTests", + "name" : "SettingFeatureTests" + } + } + ], + "version" : 1 +} diff --git a/AppPackage/Tests/FileClientTests/.swiftlint.yml b/AppPackage/Tests/FileClientTests/.swiftlint.yml new file mode 100644 index 000000000..1242ffcaa --- /dev/null +++ b/AppPackage/Tests/FileClientTests/.swiftlint.yml @@ -0,0 +1 @@ +parent_config: ../../../.swiftlint.yml diff --git a/AppPackage/Tests/FileClientTests/FileClientTests.swift b/AppPackage/Tests/FileClientTests/FileClientTests.swift new file mode 100644 index 000000000..3d16f0dc0 --- /dev/null +++ b/AppPackage/Tests/FileClientTests/FileClientTests.swift @@ -0,0 +1,117 @@ +import Testing +import Foundation +import AppModels +import FileClient + +// Exercises the live importer's coordinated, security-scoped read (REV-1) against local files; +// the iCloud download that coordination triggers is system behavior, smoke-tested manually. +// Serialized: the tag-translation cache/import endpoints write fixed paths in the real Caches and +// Application Support directories, so parallel cases would race on the same files. +@Suite(.serialized) +struct FileClientTests { + private func writeTemporaryFile(_ data: Data) throws -> URL { + let url = FileManager.default.temporaryDirectory + .appending(path: "tags-\(UUID().uuidString).json") + try data.write(to: url) + return url + } + + private func sampleResponseData() throws -> Data { + let response = EhTagTranslationDatabaseResponse( + data: [.init(namespace: "female", data: ["tag": .init(name: "translated")])] + ) + return try JSONEncoder().encode(response) + } + + private var customTranslationsURL: URL { + .applicationSupportDirectory.appending(component: "tagTranslations-custom.json") + } + + @Test + func importsValidTranslationFileViaCoordinatedRead() async throws { + let url = try writeTemporaryFile(try sampleResponseData()) + defer { + try? FileManager.default.removeItem(at: url) + try? FileManager.default.removeItem(at: customTranslationsURL) + } + + let translator = try await FileClient.live.importTagTranslator(url).get() + #expect(translator.hasCustomTranslations) + #expect(translator.translations.count == 1) + } + + @Test + func undecodableFileFailsWithParseFailed() async throws { + let url = try writeTemporaryFile(Data("not json".utf8)) + defer { try? FileManager.default.removeItem(at: url) } + + let result = await FileClient.live.importTagTranslator(url) + #expect(result == .failure(.parseFailed)) + } + + @Test + func cachesRemoteTableAndRebuildsItFromMetadata() throws { + let language = TranslatableLanguage.english + let cacheURL = URL.cachesDirectory.appending(component: language.cachedTranslationsFilename) + defer { try? FileManager.default.removeItem(at: cacheURL) } + + let built = try #require( + FileClient.live.cacheAndBuildRemoteTagTranslator(try sampleResponseData(), language, .distantPast) + ) + #expect(built.language == language) + #expect(built.translations.count == 1) + #expect(FileManager.default.fileExists(atPath: cacheURL.path)) + + // A launch-time rebuild restores the same table from the cached file the metadata points at. + let rebuilt = try #require(FileClient.live.loadCachedTagTranslator(TagTranslatorInfo(language: language))) + #expect(rebuilt.language == language) + #expect(rebuilt.translations.count == 1) + } + + @Test + func rebuildsCustomTableFromApplicationSupport() async throws { + let url = try writeTemporaryFile(try sampleResponseData()) + defer { + try? FileManager.default.removeItem(at: url) + try? FileManager.default.removeItem(at: customTranslationsURL) + } + + _ = try await FileClient.live.importTagTranslator(url).get() + + let rebuilt = try #require( + FileClient.live.loadCachedTagTranslator(TagTranslatorInfo(hasCustomTranslations: true)) + ) + #expect(rebuilt.hasCustomTranslations) + #expect(rebuilt.translations.count == 1) + } + + @Test + func loadCachedTagTranslatorReturnsNilWhenCacheMissing() throws { + let language = TranslatableLanguage.japanese + try? FileManager.default.removeItem( + at: URL.cachesDirectory.appending(component: language.cachedTranslationsFilename) + ) + #expect(FileClient.live.loadCachedTagTranslator(TagTranslatorInfo(language: language)) == nil) + } + + // REV-14: removing custom translations must delete the imported file from Application Support so it + // doesn't linger in non-purgeable storage forever. + @Test + func removeCustomTranslationsDeletesTheImportedFile() async throws { + let url = try writeTemporaryFile(try sampleResponseData()) + defer { + try? FileManager.default.removeItem(at: url) + try? FileManager.default.removeItem(at: customTranslationsURL) + } + + _ = try await FileClient.live.importTagTranslator(url).get() + #expect(FileManager.default.fileExists(atPath: customTranslationsURL.path)) + + FileClient.live.removeCustomTranslations() + + #expect(!FileManager.default.fileExists(atPath: customTranslationsURL.path)) + #expect( + FileClient.live.loadCachedTagTranslator(TagTranslatorInfo(hasCustomTranslations: true)) == nil + ) + } +} diff --git a/AppPackage/Tests/NetworkingFeatureTests/.swiftlint.yml b/AppPackage/Tests/NetworkingFeatureTests/.swiftlint.yml new file mode 100644 index 000000000..1242ffcaa --- /dev/null +++ b/AppPackage/Tests/NetworkingFeatureTests/.swiftlint.yml @@ -0,0 +1 @@ +parent_config: ../../../.swiftlint.yml diff --git a/AppPackage/Tests/NetworkingFeatureTests/GalleriesMetadataDecodeTests.swift b/AppPackage/Tests/NetworkingFeatureTests/GalleriesMetadataDecodeTests.swift new file mode 100644 index 000000000..36ef2af56 --- /dev/null +++ b/AppPackage/Tests/NetworkingFeatureTests/GalleriesMetadataDecodeTests.swift @@ -0,0 +1,160 @@ +import Foundation +import Testing +import AppModels +@testable import NetworkingFeature + +// REV-2: the `gdata` API returns bare `{ gid, error }` objects for expunged/removed gids (and can omit +// `token`). Decoding the whole `[GalleryMetadata]` array must tolerate those per-entry — one bad gid +// must never fail the batch and blank the entire History screen. A *resolved* entry missing a required +// display field is dropped rather than defaulted, matching the HTML list parser's row policy. +@Suite +struct GalleriesMetadataDecodeTests { + @Test + func mixedPayloadDropsErrorEntriesAndKeepsResolvableGalleries() throws { + let json = """ + { + "gmetadata": [ + { + "gid": 100, "token": "aaa", "title": "First & Title", + "title_jpn": "最初のタイトル", "expunged": false, + "category": "Doujinshi", "thumb": "https://example.com/1.jpg", + "uploader": "u1", "posted": "1600000000", "filecount": "20", + "rating": "4.5", "tags": ["language:japanese", "artist:someone"] + }, + { "gid": 999, "error": "Key missing, or incorrect key." }, + { + "gid": 200, "token": "bbb", "title": "Second Title", + "category": "Manga", "thumb": "https://example.com/2.jpg", + "uploader": "u2", "posted": "1600000100", "filecount": "30", + "rating": "3.0", "tags": ["language:english"] + } + ] + } + """ + + let galleries = try GalleriesMetadataRequest.galleries(fromResponseData: Data(json.utf8)) + + #expect(galleries.count == 2) + #expect(galleries.map(\.id) == ["100", "200"]) + #expect(galleries.first?.token == "aaa") + #expect(galleries.first?.title == "First & Title") + #expect(galleries.first?.titleJpn == "最初のタイトル") + #expect(galleries.first?.isExpunged == false) + } + + @Test + func titleJpnAndExpungedAreParsed() throws { + let json = """ + { + "gmetadata": [ + { + "gid": 100, "token": "aaa", "title": "First Title", + "title_jpn": "最初のタイトル", + "category": "Doujinshi", "thumb": "https://example.com/1.jpg", + "uploader": "u1", "posted": "1600000000", "filecount": "20", + "rating": "4.5", "tags": ["language:japanese"], "expunged": true + } + ] + } + """ + + let galleries = try GalleriesMetadataRequest.galleries(fromResponseData: Data(json.utf8)) + + #expect(galleries.count == 1) + #expect(galleries.first?.titleJpn == "最初のタイトル") + #expect(galleries.first?.isExpunged == true) + } + + // REV-16: when `title_jpn` is present but empty, it must collapse to `nil` so that + // `titleJpn ?? title` falls back to the regular title instead of rendering a blank. + @Test + func emptyTitleJpnCollapsesToNil() throws { + let json = """ + { + "gmetadata": [ + { + "gid": 100, "token": "aaa", "title": "Fallback Title", + "title_jpn": "", + "category": "Doujinshi", "thumb": "https://example.com/1.jpg", + "uploader": "u1", "posted": "1600000000", "filecount": "20", + "rating": "4.5", "tags": ["language:japanese"] + } + ] + } + """ + let galleries = try GalleriesMetadataRequest.galleries(fromResponseData: Data(json.utf8)) + + #expect(galleries.count == 1) + #expect(galleries.first?.title == "Fallback Title") + #expect(galleries.first?.titleJpn == nil) + } + + // REV-15: entities in the title decode in a single left-to-right pass. `&lt;` is an escaped + // literal `<`, so it must decode once to `<` — not be re-read into `<`. Numeric, hex and + // named references all resolve in the same pass. + @Test + func titleEntitiesDecodeInASinglePass() throws { + let json = """ + { + "gmetadata": [ + { + "gid": 100, "token": "aaa", + "title": "&lt; A & <tag>", + "category": "Doujinshi", "thumb": "https://example.com/1.jpg", + "posted": "1600000000", "filecount": "20", "rating": "4.5" + } + ] + } + """ + let galleries = try GalleriesMetadataRequest.galleries(fromResponseData: Data(json.utf8)) + // &lt; -> "<" (not "<"), A -> "A", & -> "&", <tag> -> "". + #expect(galleries.first?.title == "< A & ") + } + + // A tokenless (but error-free) entry is unresolvable — its gallery URL can't be built — so it is + // dropped rather than decoded with an empty token. + @Test + func tokenlessEntryIsDropped() throws { + let json = """ + { "gmetadata": [ { "gid": 300, "title": "No Token", "posted": "1600000000" } ] } + """ + let galleries = try GalleriesMetadataRequest.galleries(fromResponseData: Data(json.utf8)) + #expect(galleries.isEmpty) + } + + // A *resolved* entry (error-free, has token) that is nonetheless missing a required display field + // is dropped rather than defaulted — matching the HTML list parser, which drops an incomplete row + // instead of rendering it with `.misc`/`0`/no cover. Here `category`, `rating`, `thumb` and + // `filecount` are each absent in turn. + @Test + func resolvedEntryMissingRequiredFieldIsDropped() throws { + let json = """ + { + "gmetadata": [ + { + "gid": 400, "token": "ddd", "title": "No Category", + "thumb": "https://example.com/4.jpg", "posted": "1600000000", + "filecount": "10", "rating": "4.0" + }, + { + "gid": 500, "token": "eee", "title": "No Rating", + "category": "Manga", "thumb": "https://example.com/5.jpg", + "posted": "1600000000", "filecount": "10" + }, + { + "gid": 600, "token": "fff", "title": "No Cover", + "category": "Manga", "posted": "1600000000", + "filecount": "10", "rating": "4.0" + }, + { + "gid": 700, "token": "ggg", "title": "No Pagecount", + "category": "Manga", "thumb": "https://example.com/7.jpg", + "posted": "1600000000", "rating": "4.0" + } + ] + } + """ + let galleries = try GalleriesMetadataRequest.galleries(fromResponseData: Data(json.utf8)) + #expect(galleries.isEmpty) + } +} diff --git a/AppPackage/Tests/ParserFeatureTests/.swiftlint.yml b/AppPackage/Tests/ParserFeatureTests/.swiftlint.yml new file mode 100644 index 000000000..1242ffcaa --- /dev/null +++ b/AppPackage/Tests/ParserFeatureTests/.swiftlint.yml @@ -0,0 +1 @@ +parent_config: ../../../.swiftlint.yml diff --git a/AppPackage/Tests/ParserFeatureTests/Gallery/GalleryDetailParserTests.swift b/AppPackage/Tests/ParserFeatureTests/Gallery/GalleryDetailParserTests.swift new file mode 100644 index 000000000..ec034c07a --- /dev/null +++ b/AppPackage/Tests/ParserFeatureTests/Gallery/GalleryDetailParserTests.swift @@ -0,0 +1,39 @@ +import TestingSupport +import Kanna +import Testing +import ParserFeature +@testable import AppFeature + +struct GalleryDetailParserTests: TestHelper { + @Test + func testExample() throws { + let document = try htmlDocument(filename: .galleryDetail) + let (detail, state) = try Parser.parseGalleryDetail(doc: document, gid: "2725078") + #expect(detail.gid == "2725078") + #expect(detail.title == "[Artist] mks") + #expect(detail.jpnTitle == "[アーティスト] mks") + #expect(detail.isFavorited == false) + #expect(detail.visibility == .yes) + #expect(detail.rating == 4.5) + #expect(detail.userRating == 0) + #expect(detail.ratingCount == 110) + #expect(detail.category == .nonH) + #expect(detail.language == .japanese) + #expect(detail.uploader == "Pokom") + #expect( + detail.coverURL?.absoluteString + == "https://ehgt.org/03/08/0308268821e99628b05a19fa54e2fc0fa9ad8f4b-1705560-1012-1470-png_250.jpg" + ) + #expect(detail.archiveURL?.absoluteString == "https://e-hentai.org/archiver.php?gid=3103480&token=0000000000") + #expect(detail.parentURL?.absoluteString == "https://e-hentai.org/g/2930572/daf4b9880d/") + #expect(detail.favoritedCount == 591) + #expect(detail.pageCount == 156) + #expect(detail.sizeCount == 314.3) + #expect(detail.sizeType == "MiB") + #expect(detail.torrentCount == 1) + #expect(state.tags.count == 1) + #expect(state.previewURLs.count == 40) + #expect(state.previewConfig == .normal(rows: 4)) + #expect(state.comments.count == 10) + } +} diff --git a/AppPackage/Tests/ParserFeatureTests/Gallery/GalleryImageURLParserTests.swift b/AppPackage/Tests/ParserFeatureTests/Gallery/GalleryImageURLParserTests.swift new file mode 100644 index 000000000..13499a94e --- /dev/null +++ b/AppPackage/Tests/ParserFeatureTests/Gallery/GalleryImageURLParserTests.swift @@ -0,0 +1,34 @@ +import TestingSupport +import Kanna +import Testing +import ParserFeature +@testable import AppFeature + +struct GalleryImageURLParserTests: TestHelper { + @Test + func testExample() throws { + let document = try htmlDocument(filename: .galleryNormalImageURL) + try testGalleryNormalImageURLParser(doc: document) + try testSkipServerIdentifierParser(doc: document) + } + + func testGalleryNormalImageURLParser(doc: HTMLDocument) throws { + let inputIndex = 1 + let result = try Parser.parseGalleryNormalImageURL(doc: doc, index: inputIndex) + let index = result.index + let imageURL = result.imageURL + let originalImageURL = result.originalImageURL + #expect(index == inputIndex) + let expectedImageURL = + "https://akrtazd.spuqplybaxmf.hath.network:65000/h/" + + "ea42b28bceeae68f1f6adb414da61d186b3d126b-311480-1280-1920-jpg/" + + "keystamp=1694132700-fd778f8260;fileindex=132044713;xres=1280/" + + "87052610_5090394_0.jpg" + #expect(imageURL.absoluteString == expectedImageURL) + #expect(originalImageURL?.absoluteString == "https://e-hentai.org/fullimg.php?gid=0000000&page=1&key=000000000") + } + func testSkipServerIdentifierParser(doc: HTMLDocument) throws { + let identifier = try Parser.parseSkipServerIdentifier(doc: doc) + #expect(identifier == "00000-000000") + } +} diff --git a/AppPackage/Tests/ParserFeatureTests/Gallery/GalleryMPVKeysParserTests.swift b/AppPackage/Tests/ParserFeatureTests/Gallery/GalleryMPVKeysParserTests.swift new file mode 100644 index 000000000..36bc8528b --- /dev/null +++ b/AppPackage/Tests/ParserFeatureTests/Gallery/GalleryMPVKeysParserTests.swift @@ -0,0 +1,15 @@ +import TestingSupport +import Kanna +import Testing +import ParserFeature +@testable import AppFeature + +struct GalleryMPVKeysParserTests: TestHelper { + @Test + func testExample() throws { + let document = try htmlDocument(filename: .galleryMPVKeys) + let (mpvKey, mpvImageKeys) = try Parser.parseMPVKeys(doc: document) + #expect(mpvKey == "00000000000") + #expect(mpvImageKeys.count == 194) + } +} diff --git a/AppPackage/Tests/ParserFeatureTests/List/ListParserTests.swift b/AppPackage/Tests/ParserFeatureTests/List/ListParserTests.swift new file mode 100644 index 000000000..dc3d30e71 --- /dev/null +++ b/AppPackage/Tests/ParserFeatureTests/List/ListParserTests.swift @@ -0,0 +1,120 @@ +import TestingSupport +import AppTools +import Foundation +import AppModels +import Kanna +import Testing +import ParserFeature +@testable import AppFeature + +struct ListParserTests: TestHelper { + @Test + func testExample() throws { + let tuples: [(ListParserTestType, HTMLDocument)] = try ListParserTestType.allCases.compactMap { type in + (type, try htmlDocument(filename: type.filename)) + } + #expect(tuples.count == ListParserTestType.allCases.count) + + try tuples.forEach { type, document in + let galleries = try Parser.parseGalleries(doc: document) + let uploaders = galleries.compactMap(\.uploader).filter { !$0.isEmpty } + #expect(galleries.count == type.assertCount, "\(type)") + if type.hasUploader { + #expect(uploaders.count == type.assertCount, "\(type)") + } + } + } + + @Test + func testDateSeekNavigation() throws { + let document = try htmlDocument(filename: .frontPageMinimalList) + let pageNumber = Parser.parsePageNum(doc: document) + let navigation = try #require(Parser.parseDateSeekNavigation(doc: document, host: Defaults.URL.ehentai)) + + #expect(pageNumber.hasNextPage()) + #expect(pageNumber.lastItemTimestamp == "2668517") + #expect(navigation.newerURL == nil) + #expect(navigation.olderURL?.absoluteString == "https://e-hentai.org/?next=2668517") + #expect(DateSeekNavigation.dateFormatter.string(from: navigation.minimumDate) == "2007-03-20") + #expect(DateSeekNavigation.dateFormatter.string(from: navigation.maximumDate) == "2023-09-08") + } + + @Test + func testDateSeekURL() throws { + let document = try htmlDocument(filename: .frontPageMinimalList) + let navigation = try #require(Parser.parseDateSeekNavigation(doc: document, host: Defaults.URL.ehentai)) + let url = try #require(navigation.seekURL(date: navigation.maximumDate, direction: .older)) + let queryItems = URLComponents(url: url, resolvingAgainstBaseURL: false)?.queryItems + + #expect(queryItems?.first(where: { $0.name == "next" })?.value == "2668517") + #expect(queryItems?.first(where: { $0.name == "seek" })?.value == "2023-09-08") + #expect(navigation.seekURL(date: navigation.maximumDate, direction: .newer) == nil) + } + + @Test + func testDateSeekNavigationNormalizesExHentaiHost() throws { + let document = try Kanna.HTML(html: """ + + + + + + + """, encoding: .utf8) + + let navigation = try #require(Parser.parseDateSeekNavigation(doc: document, host: Defaults.URL.exhentai)) + let newerURL = try #require(navigation.newerURL) + let olderURL = try #require(navigation.olderURL) + + #expect(newerURL.host == "exhentai.org") + #expect(olderURL.host == "exhentai.org") + #expect( + URLComponents(url: newerURL, resolvingAgainstBaseURL: false)? + .queryItems? + .first(where: { $0.name == "page" })? + .value == "1" + ) + #expect( + URLComponents(url: olderURL, resolvingAgainstBaseURL: false)? + .queryItems? + .first(where: { $0.name == "next" })? + .value == "456" + ) + } + + @Test + func testDateSeekNavigationIsPreservedWithNumericPager() throws { + let document = try Kanna.HTML(html: """ + + + + + + + + + +
123
+ + + """, encoding: .utf8) + + let pageNumber = Parser.parsePageNum(doc: document) + let navigation = try #require(Parser.parseDateSeekNavigation(doc: document, host: Defaults.URL.ehentai)) + + #expect(pageNumber.current == 1) + #expect(pageNumber.maximum == 2) + #expect(navigation.newerURL?.absoluteString == "https://e-hentai.org/?prev=123") + #expect(navigation.olderURL?.absoluteString == "https://e-hentai.org/?next=456") + } +} diff --git a/EhPandaTests/Models/ListParserTestType.swift b/AppPackage/Tests/ParserFeatureTests/ListParserTestType.swift similarity index 81% rename from EhPandaTests/Models/ListParserTestType.swift rename to AppPackage/Tests/ParserFeatureTests/ListParserTestType.swift index 657e079a8..de4b22ce1 100644 --- a/EhPandaTests/Models/ListParserTestType.swift +++ b/AppPackage/Tests/ParserFeatureTests/ListParserTestType.swift @@ -1,8 +1,4 @@ -// -// ListParserTestType.swift -// EhPandaTests -// - +import TestingSupport enum ListParserTestType: CaseIterable { // FrontPage case frontPageMinimalList @@ -64,13 +60,17 @@ extension ListParserTestType { } var assertCount: Int { switch self { - case .frontPageMinimalList, .frontPageMinimalPlusList, .frontPageCompactList, .frontPageExtendedList, .frontPageThumbnailList: + case .frontPageMinimalList, .frontPageMinimalPlusList, .frontPageCompactList, + .frontPageExtendedList, .frontPageThumbnailList: return 100 - case .watchedMinimalList, .watchedMinimalPlusList, .watchedCompactList, .watchedExtendedList, .watchedThumbnailList: + case .watchedMinimalList, .watchedMinimalPlusList, .watchedCompactList, + .watchedExtendedList, .watchedThumbnailList: return 100 - case .popularMinimalList, .popularMinimalPlusList, .popularCompactList, .popularExtendedList, .popularThumbnailList: + case .popularMinimalList, .popularMinimalPlusList, .popularCompactList, + .popularExtendedList, .popularThumbnailList: return 100 - case .favoritesMinimalList, .favoritesMinimalPlusList, .favoritesCompactList, .favoritesExtendedList, .favoritesThumbnailList: + case .favoritesMinimalList, .favoritesMinimalPlusList, .favoritesCompactList, + .favoritesExtendedList, .favoritesThumbnailList: return 100 case .toplistsCompactList: return 50 @@ -79,12 +79,12 @@ extension ListParserTestType { var hasUploader: Bool { switch self { case .frontPageMinimalList, .frontPageMinimalPlusList, .frontPageCompactList, .frontPageExtendedList, - .watchedMinimalList, .watchedMinimalPlusList, .watchedCompactList, .watchedExtendedList, - .popularMinimalList, .popularMinimalPlusList, .popularCompactList, .popularExtendedList, - .toplistsCompactList: + .watchedMinimalList, .watchedMinimalPlusList, .watchedCompactList, .watchedExtendedList, + .popularMinimalList, .popularMinimalPlusList, .popularCompactList, .popularExtendedList, + .toplistsCompactList: return true case .frontPageThumbnailList, .watchedThumbnailList, .popularThumbnailList, .favoritesThumbnailList, - .favoritesMinimalList, .favoritesMinimalPlusList, .favoritesCompactList, .favoritesExtendedList: + .favoritesMinimalList, .favoritesMinimalPlusList, .favoritesCompactList, .favoritesExtendedList: return false } } diff --git a/AppPackage/Tests/ParserFeatureTests/Other/AnimatedImageDataTests.swift b/AppPackage/Tests/ParserFeatureTests/Other/AnimatedImageDataTests.swift new file mode 100644 index 000000000..e60abc142 --- /dev/null +++ b/AppPackage/Tests/ParserFeatureTests/Other/AnimatedImageDataTests.swift @@ -0,0 +1,116 @@ +import Foundation +import Testing +import AnimatedImageFeature +@testable import AppFeature + +struct AnimatedImageDataTests { + @Test + func testGIFAnimationRequiresMultipleImageDescriptors() { + #expect(Data(singleFrameGIFBytes).isAnimatedImageData == false) + #expect(Data(animatedGIFBytes).isAnimatedImageData == true) + #expect(Data(singleFrameGIFBytes).animatedImagePasteboardType == nil) + #expect(Data(animatedGIFBytes).animatedImagePasteboardType != nil) + } + + @Test + func testAPNGAnimationControlMustAppearBeforeImageData() { + let animatedAPNG = Data(Self.pngSignature + pngChunk("acTL") + pngChunk("IDAT")) + let staticPNG = Data(Self.pngSignature + pngChunk("IDAT") + pngChunk("acTL")) + + #expect(animatedAPNG.isAnimatedImageData == true) + #expect(staticPNG.isAnimatedImageData == false) + #expect(animatedAPNG.animatedImagePasteboardType != nil) + #expect(staticPNG.animatedImagePasteboardType == nil) + } + + @Test + func testWebPAnimationUsesExtendedAnimationFlag() { + let animatedWebP = Data(webPFile(chunks: [ + webPChunk("VP8X", payload: [0x02] + Array(repeating: 0, count: 9)) + ])) + let staticWebP = Data(webPFile(chunks: [ + webPChunk("VP8X", payload: [0x00] + Array(repeating: 0, count: 9)) + ])) + + #expect(animatedWebP.isAnimatedImageData == true) + #expect(staticWebP.isAnimatedImageData == false) + #expect(animatedWebP.animatedImagePasteboardType != nil) + #expect(staticWebP.animatedImagePasteboardType == nil) + } + + private static let pngSignature: [UInt8] = [ + 0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A + ] + + private var singleFrameGIFBytes: [UInt8] { + Array("GIF89a".utf8) + [ + 0x01, 0x00, 0x01, 0x00, + 0x00, 0x00, 0x00, + 0x2C, + 0x00, 0x00, 0x00, 0x00, + 0x01, 0x00, 0x01, 0x00, + 0x00, + 0x02, + 0x02, 0x4C, 0x01, + 0x00, + 0x3B + ] + } + + private var animatedGIFBytes: [UInt8] { + Array(singleFrameGIFBytes.dropLast()) + [ + 0x2C, + 0x00, 0x00, 0x00, 0x00, + 0x01, 0x00, 0x01, 0x00, + 0x00, + 0x02, + 0x02, 0x4C, 0x01, + 0x00, + 0x3B + ] + } + + private func pngChunk(_ type: String, payload: [UInt8] = []) -> [UInt8] { + return bigEndianUInt32(UInt32(payload.count)) + + Array(type.utf8) + + payload + + [0x00, 0x00, 0x00, 0x00] + } + + private func webPFile(chunks: [[UInt8]]) -> [UInt8] { + let payload = chunks.flatMap { $0 } + return Array("RIFF".utf8) + + littleEndianUInt32(4 + payload.count) + + Array("WEBP".utf8) + + payload + } + + private func webPChunk(_ type: String, payload: [UInt8]) -> [UInt8] { + return Array(type.utf8) + + littleEndianUInt32(UInt32(payload.count)) + + payload + + (payload.count.isMultiple(of: 2) ? [] : [0x00]) + } + + private func bigEndianUInt32(_ value: UInt32) -> [UInt8] { + [ + UInt8((value >> 24) & 0xFF), + UInt8((value >> 16) & 0xFF), + UInt8((value >> 8) & 0xFF), + UInt8(value & 0xFF) + ] + } + + private func littleEndianUInt32(_ value: Int) -> [UInt8] { + littleEndianUInt32(UInt32(value)) + } + + private func littleEndianUInt32(_ value: UInt32) -> [UInt8] { + [ + UInt8(value & 0xFF), + UInt8((value >> 8) & 0xFF), + UInt8((value >> 16) & 0xFF), + UInt8((value >> 24) & 0xFF) + ] + } +} diff --git a/AppPackage/Tests/ParserFeatureTests/Other/BanIntervalParserTests.swift b/AppPackage/Tests/ParserFeatureTests/Other/BanIntervalParserTests.swift new file mode 100644 index 000000000..740f78918 --- /dev/null +++ b/AppPackage/Tests/ParserFeatureTests/Other/BanIntervalParserTests.swift @@ -0,0 +1,14 @@ +import TestingSupport +import Kanna +import Testing +import ParserFeature +@testable import AppFeature + +struct BanIntervalParserTests: TestHelper { + @Test + func testExample() throws { + let document = try htmlDocument(filename: .ipBanned) + let banInterval = Parser.parseBanInterval(doc: document) + #expect(banInterval == .minutes(59, seconds: 48)) + } +} diff --git a/AppPackage/Tests/ParserFeatureTests/Other/DownloadPageErrorParserTests.swift b/AppPackage/Tests/ParserFeatureTests/Other/DownloadPageErrorParserTests.swift new file mode 100644 index 000000000..046855ff5 --- /dev/null +++ b/AppPackage/Tests/ParserFeatureTests/Other/DownloadPageErrorParserTests.swift @@ -0,0 +1,112 @@ +import TestingSupport +import Kanna +import AppModels +import Combine +import Testing +import ParserFeature +import NetworkingFeature +@testable import AppFeature + +struct DownloadPageErrorParserTests: TestHelper { + @Test + func testIPBannedPageMapsToIPBanned() throws { + let document = try htmlDocument(filename: .ipBanned) + + #expect( + Parser.parseResponseError(doc: document) == .ipBanned(.minutes(59, seconds: 48)) + ) + } + + @Test + func testNormalGalleryDetailPageDoesNotMapToDownloadError() throws { + let document = try htmlDocument(filename: .galleryDetail) + + #expect(Parser.parseResponseError(doc: document) == nil) + } + + @Test + func testNormalParserFixturesDoNotMapToResponseError() throws { + for type in ListParserTestType.allCases { + let document = try htmlDocument(filename: type.filename) + #expect(Parser.parseResponseError(doc: document) == nil) + } + + for filename in [ + HTMLFilename.galleryDetail, + .galleryDetailWithGreeting, + .galleryMPVKeys, + .galleryNormalImageURL, + .ehSetting + ] { + let document = try htmlDocument(filename: filename) + #expect(Parser.parseResponseError(doc: document) == nil) + } + } + + @Test + func testAuthenticationRequiredMarkersMapToAuthenticationRequired() throws { + let document = try Kanna.HTML( + html: """ + + + + +

Access to ExHentai.org is restricted.

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

Invalid page

Gallery not found.

+

Key missing.

Keep trying.

+ """, + encoding: .utf8 + ) + + #expect(Parser.parseResponseError(doc: document) == .notFound) + #expect(Parser.parseResponseError(content: "Gallery not found") == .notFound) + #expect(Parser.parseResponseError(content: "Keep trying") == .notFound) + } + + @Test + func testGalleryNotAvailableIsNotHardMappedToDownloadError() { + #expect(Parser.parseResponseError(content: "Gallery Not Available") == nil) + } + + @Test + func testMapAppErrorUsesResponseErrorFromParserFailure() async throws { + let document = try htmlDocument(filename: .ipBanned) + let result = await FailingHTMLRequest(document: document).response() + + switch result { + case .success: + Issue.record("Expected response parser to map the IP ban failure.") + case .failure(let error): + #expect(error == .ipBanned(.minutes(59, seconds: 48))) + } + } +} + +private struct FailingHTMLRequest: Request { + let document: HTMLDocument + + var publisher: AnyPublisher { + Just(document) + .setFailureType(to: AppError.self) + .tryMap { document in + try parseResponse(doc: document) { _ in + throw AppError.parseFailed + } + } + .mapError(mapAppError) + .eraseToAnyPublisher() + } +} diff --git a/AppPackage/Tests/ParserFeatureTests/Other/EhSettingParserTests.swift b/AppPackage/Tests/ParserFeatureTests/Other/EhSettingParserTests.swift new file mode 100644 index 000000000..b5df492b1 --- /dev/null +++ b/AppPackage/Tests/ParserFeatureTests/Other/EhSettingParserTests.swift @@ -0,0 +1,89 @@ +import TestingSupport +import Kanna +import AppModels +import Testing +import ParserFeature +@testable import AppFeature + +struct EhSettingParserTests: TestHelper { + @Test + func testExample() throws { + let document = try htmlDocument(filename: .ehSetting) + let ehSetting = try Parser.parseEhSetting(doc: document) + testEhProfiles(ehSetting.ehProfiles) + testCapability(ehSetting: ehSetting) + testRemainingStuff(ehSetting: ehSetting) + } + + func testEhProfiles(_ profiles: [EhProfile]) { + #expect(profiles.count == 3) + + let ehProfile1 = profiles[0] + #expect(ehProfile1.value == 1) + #expect(ehProfile1.name == "Default Profile") + #expect(ehProfile1.isSelected == true) + #expect(ehProfile1.isDefault == true) + + let ehProfile2 = profiles[1] + #expect(ehProfile2.value == 2) + #expect(ehProfile2.name == "EhPanda") + #expect(ehProfile2.isSelected == false) + #expect(ehProfile2.isDefault == false) + #expect(EhSetting.verifyEhPandaProfileName(with: ehProfile2.name)) + } + + func testCapability(ehSetting: EhSetting) { + #expect(ehSetting.capableLoadThroughHathSetting == .legacyNo) + #expect(ehSetting.capableLoadThroughHathSettings == EhSetting.LoadThroughHathSetting.allCases) + + #expect(ehSetting.capableImageResolution == .x2560) + #expect(ehSetting.capableImageResolutions == EhSetting.ImageResolution.allCases) + + #expect(ehSetting.capableSearchResultCount == .oneHundred) + #expect(ehSetting.capableSearchResultCounts == [.twentyFive, .fifty, .oneHundred]) + + #expect(ehSetting.capableThumbnailConfigSizes == [.auto, .small, .normal]) + + #expect(ehSetting.capableThumbnailConfigRowCount == .forty) + #expect(ehSetting.capableThumbnailConfigRowCounts == EhSetting.ThumbnailRowCount.allCases) + } + + func testRemainingStuff(ehSetting: EhSetting) { + #expect(ehSetting.loadThroughHathSetting == .anyClient) + #expect(ehSetting.browsingCountry == .autoDetect) + #expect(ehSetting.literalBrowsingCountry == "Japan") + #expect(ehSetting.imageResolution == .auto) + #expect(ehSetting.imageSizeWidth == 0) + #expect(ehSetting.imageSizeHeight == 0) + #expect(ehSetting.galleryName == .japanese) + #expect(ehSetting.archiverBehavior == .manualSelectManualStart) + #expect(ehSetting.displayMode == .compact) + #expect(ehSetting.showSearchRangeIndicator == true) + #expect(ehSetting.disabledCategories == .init(repeating: false, count: 10)) + #expect(ehSetting.favoriteCategories == [ + "Favorites 0", "Favorites 1", "Favorites 2", "Favorites 3", "Favorites 4", + "Favorites 5", "Favorites 6", "Favorites 7", "Favorites 8", "Favorites 9" + ]) + #expect(ehSetting.favoritesSortOrder == .favoritedTime) + #expect(ehSetting.ratingsColor == "") + #expect(ehSetting.tagFilteringThreshold == 0) + #expect(ehSetting.tagWatchingThreshold == 0) + #expect(ehSetting.showFilteredRemovalCount == true) + #expect(ehSetting.excludedLanguages == EhSetting.empty.excludedLanguages) + #expect(ehSetting.excludedUploaders == "") + #expect(ehSetting.searchResultCount == .oneHundred) + #expect(ehSetting.thumbnailLoadTiming == .onMouseOver) + #expect(ehSetting.thumbnailConfigSize == .auto) + #expect(ehSetting.thumbnailConfigRows == .four) + #expect(ehSetting.coverScaleFactor == 100) + #expect(ehSetting.viewportVirtualWidth == 0) + #expect(ehSetting.commentsSortOrder == .oldest) + #expect(ehSetting.commentVotesShowTiming == .onHoverOrClick) + #expect(ehSetting.tagsSortOrder == .alphabetical) + #expect(ehSetting.galleryPageNumbering == .none) + #expect(ehSetting.useOriginalImages == false) + #expect(ehSetting.useMultiplePageViewer == true) + #expect(ehSetting.multiplePageViewerStyle == .alignLeftScaleIfOverWidth) + #expect(ehSetting.multiplePageViewerShowThumbnailPane == true) + } +} diff --git a/AppPackage/Tests/ParserFeatureTests/Other/GreetingParserTests.swift b/AppPackage/Tests/ParserFeatureTests/Other/GreetingParserTests.swift new file mode 100644 index 000000000..3292848e5 --- /dev/null +++ b/AppPackage/Tests/ParserFeatureTests/Other/GreetingParserTests.swift @@ -0,0 +1,20 @@ +import TestingSupport +import Kanna +import Testing +import ParserFeature +@testable import AppFeature + +struct GreetingParserTests: TestHelper { + @Test + func testExample() throws { + let document = try htmlDocument(filename: .galleryDetailWithGreeting) + let greeting = try Parser.parseGreeting(doc: document) + #expect(greeting.gainedEXP == 30) + #expect(greeting.gainedCredits == 329) + #expect(greeting.gainedGP == nil) + #expect(greeting.gainedHath == nil) + #expect(greeting.updateTime != nil) + #expect(greeting.gainedNothing == false) + #expect(greeting.gainContent != nil) + } +} diff --git a/AppPackage/Tests/ParserFeatureTests/Other/SettingDownloadTests.swift b/AppPackage/Tests/ParserFeatureTests/Other/SettingDownloadTests.swift new file mode 100644 index 000000000..9b0d46455 --- /dev/null +++ b/AppPackage/Tests/ParserFeatureTests/Other/SettingDownloadTests.swift @@ -0,0 +1,113 @@ +import SwiftUI +import AppModels +import Testing +import AppTools +import URLClient +@testable import AppFeature + +struct SettingDownloadTests { + @Test + func testSettingRoundTripsDownloadFields() throws { + // Strict decode (#2): a persisted Setting is written whole, so its download fields survive an + // encode/decode round-trip intact. (A partial blob no longer decodes — it resets to defaults.) + var setting = Setting() + setting.downloadThreadLimit = 3 + setting.downloadAllowCellular = false + setting.downloadAutoRetryFailedPages = false + + let data = try JSONEncoder().encode(setting) + let decoded = try JSONDecoder().decode(Setting.self, from: data) + + #expect(decoded.downloadThreadLimit == 3) + #expect(!decoded.downloadAllowCellular) + #expect(!decoded.downloadAutoRetryFailedPages) + } + + @Test + func testDownloadRequestOptionsMatchesSettingValues() { + var setting = Setting() + setting.downloadThreadLimit = 4 + setting.downloadAllowCellular = false + setting.downloadAutoRetryFailedPages = false + + #expect( + setting.downloadRequestOptions == DownloadRequestOptions( + threadLimit: 4, + allowCellular: false, + autoRetryFailedPages: false + ) + ) + } + + @Test + func testDownloadRequestOptionsDefaultsMatchSettingDefaults() { + #expect( + Setting().downloadRequestOptions == DownloadRequestOptions() + ) + } + + @Test + func testImageCacheKeysPreferStablePathAlias() throws { + let url = try #require(URL(string: "https://alpha.hath.network/h/123/456/image.webp?download=1")) + + #expect( + url.imageCacheKeys == [ + "download::h/123/456/image.webp", + "https://alpha.hath.network/h/123/456/image.webp?download=1" + ] + ) + } + + @Test + func testStableImageCacheKeyIgnoresHostRotationAndQuery() throws { + let firstURL = try #require(URL(string: "https://alpha.hath.network/h/123/456/image.webp?download=1")) + let secondURL = try #require(URL(string: "https://beta.hath.network/h/123/456/image.webp?source=viewer")) + + #expect(firstURL.stableImageCacheKey == secondURL.stableImageCacheKey) + } + + @Test + func testStableImageCacheKeyKeepsIdentityQueryForFullImageScript() throws { + let firstURL = try #require(URL(string: "https://e-hentai.org/fullimg.php?gid=42&page=7&key=alpha")) + let secondURL = try #require(URL(string: "https://exhentai.org/fullimg.php?page=7&gid=42&key=beta")) + + #expect(firstURL.stableImageCacheKey == "download::fullimg.php?gid=42&key=alpha&page=7") + #expect(secondURL.stableImageCacheKey == "download::fullimg.php?gid=42&key=beta&page=7") + #expect(firstURL.stableImageCacheKey != secondURL.stableImageCacheKey) + } + + @Test + func testStableImageCacheKeyFallbackRetainsNonIgnoredNonPreferredQueries() throws { + let url = try #require(URL(string: "https://example.com/h/123/image.webp?custom=abc&dl=1")) + + #expect(url.stableImageCacheKey == "download::h/123/image.webp?custom=abc") + } + + @Test + func testCombinedPreviewURLCleanupIncludesPlainPreviewURL() throws { + let plainURL = try #require(URL(string: "https://ehgt.org/ab/cd/preview.webp")) + let combinedURL = URLUtil.combinedPreviewURL( + plainURL: plainURL, + width: "200", + height: "300", + offset: "40" + ) + + #expect(combinedURL.previewCacheCleanupURLs() == [combinedURL, plainURL]) + #expect(plainURL.previewCacheCleanupURLs() == [plainURL]) + } + + @Test + func testCheckIfMPVURLHandlesHostOnlyURL() throws { + let url = try #require(URL(string: "https://e-hentai.org")) + + #expect(!URLClient.live.checkIfMPVURL(url)) + } + + @Test + func testCheckIfMPVURLDetectsMPVPath() throws { + let url = try #require(URL(string: "https://e-hentai.org/mpv/123456/token")) + + #expect(URLClient.live.checkIfMPVURL(url)) + } +} diff --git a/AppPackage/Tests/SettingFeatureTests/.swiftlint.yml b/AppPackage/Tests/SettingFeatureTests/.swiftlint.yml new file mode 100644 index 000000000..1242ffcaa --- /dev/null +++ b/AppPackage/Tests/SettingFeatureTests/.swiftlint.yml @@ -0,0 +1 @@ +parent_config: ../../../.swiftlint.yml diff --git a/AppPackage/Tests/SettingFeatureTests/AccountSettingReducerTests.swift b/AppPackage/Tests/SettingFeatureTests/AccountSettingReducerTests.swift new file mode 100644 index 000000000..751e2c2b9 --- /dev/null +++ b/AppPackage/Tests/SettingFeatureTests/AccountSettingReducerTests.swift @@ -0,0 +1,20 @@ +import ComposableArchitecture +import Testing +import AppModels +import UserDefaultsClient +@testable import SettingFeature + +@MainActor +struct AccountSettingReducerTests { + // The gallery-host picker writes into `@Shared(.setting)`; the reducer mirrors the choice into + // UserDefaults (a non-capturable fire-and-forget client), so this pins that the wiring is handled + // and runs to completion. + @Test + func galleryHostChangedRunsToCompletion() async { + let store = TestStore(initialState: .init(), reducer: AccountSettingReducer.init) { + $0.userDefaultsClient = .noop + } + await store.send(.galleryHostChanged(.exhentai)) + await store.finish() + } +} diff --git a/AppPackage/Tests/SettingFeatureTests/AppActivityLogsReducerTests.swift b/AppPackage/Tests/SettingFeatureTests/AppActivityLogsReducerTests.swift new file mode 100644 index 000000000..3b22a4305 --- /dev/null +++ b/AppPackage/Tests/SettingFeatureTests/AppActivityLogsReducerTests.swift @@ -0,0 +1,185 @@ +import OSLog +import Testing +import Sharing +import Foundation +import AppModels +import LogsClient +@testable import SettingFeature +import ComposableArchitecture + +@Suite +struct AppActivityLogsReducerTests { + @MainActor + @Test + func testPumpAppendsNewEntriesToStateAndFile() async { + let entryA = makeLog("first", secondsSince1970: 10) + let entryB = makeLog("second", secondsSince1970: 20) + let fetchCount = LockIsolated(0) + let appended = LockIsolated([[AppActivityLog]]()) + let fileURL = URL(fileURLWithPath: "/tmp/ehpanda-20200101-090000-3.jsonl") + + var client = LogsClient.noop + client.nextRunCount = { _ in 3 } + client.currentRunFileURL = { _, _ in fileURL } + client.fetchNewEntries = { _ in + fetchCount.withValue { $0 += 1 } + return fetchCount.value == 1 ? [entryA, entryB] : [] + } + client.appendToRunFile = { logs, _ in + appended.withValue { $0.append(logs) } + } + + let storage = InMemoryStorage() + await withDependencies { + $0.defaultInMemoryStorage = storage + } operation: { + let store = TestStore( + initialState: AppActivityLogsPumpReducer.State(), + reducer: AppActivityLogsPumpReducer.init + ) { + $0.logsClient = client + $0.continuousClock = TestClock() + $0.date = .constant(.init(timeIntervalSince1970: 0)) + $0.defaultInMemoryStorage = storage + } + store.exhaustivity = .off + + await store.send(.startPump) + await store.receive(\.didReceiveNewEntries) + + #expect(store.state.currentRunLogs == [entryA, entryB]) + #expect(store.state.lastCursorDate == entryB.date) + + await store.send(.pausePump) + await store.finish() + + // The pump appended the batch to the per-run jsonl file exactly once. + #expect(appended.value == [[entryA, entryB]]) + } + } + + @MainActor + @Test + func testSelectingPreviousRunLoadsFileBackedLogs() async { + let fileLog = makeLog("archived", secondsSince1970: 5) + let run = RunLogFile( + url: URL(fileURLWithPath: "/tmp/ehpanda-20200101-090000-2.jsonl"), + date: .init(timeIntervalSince1970: 0), + runCount: 2 + ) + var client = LogsClient.noop + client.readRunFile = { _ in [fileLog] } + + let storage = InMemoryStorage() + await withDependencies { + $0.defaultInMemoryStorage = storage + } operation: { + @Shared(.appActivityLogsCurrentRun) var currentRun: RunLogFile? + @Shared(.appActivityLogsCurrentRunLogs) var currentRunLogs: [AppActivityLog] + $currentRun.withLock { + $0 = RunLogFile( + url: URL(fileURLWithPath: "/tmp/ehpanda-20200101-100000-3.jsonl"), + date: .init(timeIntervalSince1970: 3600), + runCount: 3 + ) + } + $currentRunLogs.withLock { $0 = [makeLog("live", secondsSince1970: 100)] } + + var initialState = AppActivityLogsReducer.State() + initialState.previousRuns = [run] + + let store = TestStore(initialState: initialState, reducer: AppActivityLogsReducer.init) { + $0.logsClient = client + $0.defaultInMemoryStorage = storage + } + + await store.send(.selectRun(run.url)) { + $0.selectedRun = run.url + $0.loadingState = .loading + } + await store.receive(\.runFileResponse) { + $0.loadingState = .idle + $0.selectedRunLogs = [fileLog] + } + #expect(store.state.displayedLogs == [fileLog]) + } + } + + @MainActor + @Test + func testSelectingCurrentRunRestoresLiveLogs() async { + let live = makeLog("live", secondsSince1970: 100) + + let storage = InMemoryStorage() + await withDependencies { + $0.defaultInMemoryStorage = storage + } operation: { + @Shared(.appActivityLogsCurrentRun) var currentRun: RunLogFile? + @Shared(.appActivityLogsCurrentRunLogs) var currentRunLogs: [AppActivityLog] + $currentRun.withLock { + $0 = RunLogFile( + url: URL(fileURLWithPath: "/tmp/ehpanda-20200101-100000-3.jsonl"), + date: .init(timeIntervalSince1970: 3600), + runCount: 3 + ) + } + $currentRunLogs.withLock { $0 = [live] } + + var initialState = AppActivityLogsReducer.State() + initialState.selectedRun = URL(fileURLWithPath: "/tmp/ehpanda-20200101-090000-2.jsonl") + initialState.selectedRunLogs = [makeLog("archived", secondsSince1970: 5)] + + let store = TestStore(initialState: initialState, reducer: AppActivityLogsReducer.init) { + $0.logsClient = .noop + $0.defaultInMemoryStorage = storage + } + + await store.send(.selectRun(nil)) { + $0.selectedRun = nil + $0.selectedRunLogs = [] + } + #expect(store.state.displayedLogs == [live]) + } + } + + @MainActor + @Test + func testQueryLogsFiltersDisplayedLogs() async { + let hello = makeLog("hello world", secondsSince1970: 10) + let goodbye = makeLog("goodbye", secondsSince1970: 20) + + let storage = InMemoryStorage() + await withDependencies { + $0.defaultInMemoryStorage = storage + } operation: { + @Shared(.appActivityLogsCurrentRunLogs) var currentRunLogs: [AppActivityLog] + $currentRunLogs.withLock { $0 = [hello, goodbye] } + + let store = TestStore( + initialState: AppActivityLogsReducer.State(), + reducer: AppActivityLogsReducer.init + ) { + $0.logsClient = .noop + $0.defaultInMemoryStorage = storage + } + + await store.send(.queryLogs("hello")) { + $0.keyword = "hello" + } + #expect(store.state.displayedLogs == [hello]) + } + } + + private func makeLog( + _ message: String, + secondsSince1970: TimeInterval, + level: OSLogEntryLog.Level = .info + ) -> AppActivityLog { + AppActivityLog( + date: .init(timeIntervalSince1970: secondsSince1970), + category: "Test", + level: level, + message: message + ) + } +} diff --git a/AppPackage/Tests/SettingFeatureTests/AppIconReducerTests.swift b/AppPackage/Tests/SettingFeatureTests/AppIconReducerTests.swift new file mode 100644 index 000000000..22b27a98b --- /dev/null +++ b/AppPackage/Tests/SettingFeatureTests/AppIconReducerTests.swift @@ -0,0 +1,31 @@ +import Foundation +import Testing +import AppModels +import Sharing +@testable import SettingFeature +import ComposableArchitecture + +@MainActor +struct AppIconReducerTests { + // The screen writes `appIconType` straight into `@Shared(.setting)`; the reducer then reconciles the + // stored value against whatever icon the system actually reports. This pins that reconciliation by + // feeding the system's icon name directly and asserting it persists through an independent handle, + // with storage isolated to an in-memory suite. + @Test + func syncAppIconTypeDonePersistsMatchedType() async { + let defaults = UserDefaults.inMemory + await withDependencies { + $0.defaultAppStorage = defaults + } operation: { + let store = TestStore(initialState: .init(), reducer: AppIconReducer.init) { + $0.defaultAppStorage = defaults + } + store.exhaustivity = .off + + await store.send(.syncAppIconTypeDone(AppIconType.ukiyoe.filename)) + + @Shared(.setting) var persisted + #expect(persisted.appIconType == .ukiyoe) + } + } +} diff --git a/AppPackage/Tests/SettingFeatureTests/AppearanceSettingReducerTests.swift b/AppPackage/Tests/SettingFeatureTests/AppearanceSettingReducerTests.swift new file mode 100644 index 000000000..552fbaa60 --- /dev/null +++ b/AppPackage/Tests/SettingFeatureTests/AppearanceSettingReducerTests.swift @@ -0,0 +1,19 @@ +import ComposableArchitecture +import Testing +import AppModels +import ApplicationClient +@testable import SettingFeature + +@MainActor +struct AppearanceSettingReducerTests { + // The theme picker writes into `@Shared(.setting)`; the reducer applies the interface style through + // a non-capturable client, so this pins that the change action is handled and runs to completion. + @Test + func preferredColorSchemeChangedRunsToCompletion() async { + let store = TestStore(initialState: .init(), reducer: AppearanceSettingReducer.init) { + $0.applicationClient = .noop + } + await store.send(.preferredColorSchemeChanged(.dark)) + await store.finish() + } +} diff --git a/AppPackage/Tests/SettingFeatureTests/GeneralSettingReducerTests.swift b/AppPackage/Tests/SettingFeatureTests/GeneralSettingReducerTests.swift new file mode 100644 index 000000000..4ab9cee4a --- /dev/null +++ b/AppPackage/Tests/SettingFeatureTests/GeneralSettingReducerTests.swift @@ -0,0 +1,33 @@ +import Testing +import Foundation +@testable import SettingFeature +import ComposableArchitecture + +// Covers the tag-translation file-import flow: the button drives a native `.fileImporter` through a +// `@Presents` destination, so present/dismiss are exhaustively assertable in the reducer. +@Suite +@MainActor +struct GeneralSettingReducerTests { + @Test + func importButtonPresentsFileImporter() async { + let store = TestStore(initialState: .init(), reducer: GeneralSettingReducer.init) + + await store.send(.importCustomTranslationsButtonTapped) { + $0.destination = .importTranslations + } + + // Cancelling or picking flips the `isPresented` binding back to false → dismiss. + await store.send(.destination(.dismiss)) { + $0.destination = nil + } + } + + @Test + func filePickedCausesNoLocalStateChange() async { + let store = TestStore(initialState: .init(), reducer: GeneralSettingReducer.init) + + // The child only relays the URL and mutates no local state; the import itself is handled by + // `SettingReducer` (covered by `generalFilePickedImportsAndStoresTagTranslator`). + await store.send(.onTranslationsFilePicked(URL(filePath: "/tmp/tags.json"))) + } +} diff --git a/AppPackage/Tests/SettingFeatureTests/LaboratorySettingReducerTests.swift b/AppPackage/Tests/SettingFeatureTests/LaboratorySettingReducerTests.swift new file mode 100644 index 000000000..7ac274e41 --- /dev/null +++ b/AppPackage/Tests/SettingFeatureTests/LaboratorySettingReducerTests.swift @@ -0,0 +1,21 @@ +import ComposableArchitecture +import Testing +import HapticsClient +import DFClient +@testable import SettingFeature + +@MainActor +struct LaboratorySettingReducerTests { + // The SNI toggle's side effect (haptic + `DFClient.setActive`) is fire-and-forget, and neither + // client exposes a capturable double, so this pins the wiring: the change action is handled and its + // merged effects run to completion without emitting anything unexpected. + @Test + func bypassesSNIFilteringChangedRunsToCompletion() async { + let store = TestStore(initialState: .init(), reducer: LaboratorySettingReducer.init) { + $0.hapticsClient = .noop + $0.dfClient = .noop + } + await store.send(.bypassesSNIFilteringChanged(true)) + await store.finish() + } +} diff --git a/AppPackage/Tests/SettingFeatureTests/SettingReducerNavigationTests.swift b/AppPackage/Tests/SettingFeatureTests/SettingReducerNavigationTests.swift new file mode 100644 index 000000000..276f55e61 --- /dev/null +++ b/AppPackage/Tests/SettingFeatureTests/SettingReducerNavigationTests.swift @@ -0,0 +1,234 @@ +import Testing +import Foundation +import AppModels +import Sharing +import CookieClient +import FileClient +import HapticsClient +@testable import SettingFeature +import ComposableArchitecture + +// Covers the Setting tab's single flat navigation stack: root-row taps, child `delegate`-driven +// pushes, and the post-login effect cascade that `SettingReducer` runs while the login screen +// self-dismisses. +@Suite +@MainActor +struct SettingReducerNavigationTests { + // MARK: Root menu + + @Test + func settingRowTappedAppendsMatchingScreen() async throws { + let store = TestStore(initialState: .init(), reducer: SettingReducer.init) + + // Each root row appends exactly its mapped `SettingPath` element, in order. + for screen in SettingReducer.RootScreen.allCases { + await store.send(.settingRowTapped(screen)) { + $0.path.append(screen.pathElement) + } + } + + #expect(store.state.path.count == SettingReducer.RootScreen.allCases.count) + } + + @Test + func pushLoginAppendsLoginScreen() async { + let store = TestStore(initialState: .init(), reducer: SettingReducer.init) + + await store.send(.pushLogin) { + $0.path.append(.login(.init())) + } + } + + @Test + func settingRowTappedGuardsAgainstAdjacentDuplicate() async { + let store = TestStore(initialState: .init(), reducer: SettingReducer.init) + store.exhaustivity = .off + + await store.send(.settingRowTapped(.account)) + #expect(store.state.path.count == 1) + + // A rapid second identical tap is skipped — only the adjacent top is compared. + await store.send(.settingRowTapped(.account)) + #expect(store.state.path.count == 1) + + // A different row still appends. + await store.send(.settingRowTapped(.general)) + #expect(store.state.path.count == 2) + } + + // MARK: Child delegate → parent push + + @Test + func accountDelegatePushLoginAppendsLogin() async throws { + let store = TestStore(initialState: .init(), reducer: SettingReducer.init) + + await store.send(.settingRowTapped(.account)) { + $0.path.append(.account(.init())) + } + let id = try #require(store.state.path.ids.last) + await store.send(.path(.element(id: id, action: .account(.delegate(.pushLogin))))) { + $0.path.append(.login(.init())) + } + } + + @Test + func accountDelegatePushEhSettingAppendsEhSetting() async throws { + let store = TestStore(initialState: .init(), reducer: SettingReducer.init) + + await store.send(.settingRowTapped(.account)) { + $0.path.append(.account(.init())) + } + let id = try #require(store.state.path.ids.last) + await store.send(.path(.element(id: id, action: .account(.delegate(.pushEhSetting))))) { + $0.path.append(.ehSetting(.init())) + } + } + + @Test + func appearanceDelegatePushAppIconAppendsAppIcon() async throws { + let store = TestStore(initialState: .init(), reducer: SettingReducer.init) + + await store.send(.settingRowTapped(.appearance)) { + $0.path.append(.appearance(.init())) + } + let id = try #require(store.state.path.ids.last) + await store.send(.path(.element(id: id, action: .appearance(.delegate(.pushAppIcon))))) { + $0.path.append(.appIcon(.init())) + } + } + + @Test + func generalDelegatePushAppActivityLogsAppendsLogs() async throws { + // The logs screen reads in-memory `@SharedReader` keys; isolate them so the read can't see + // another test's pump state. + let store = TestStore(initialState: .init(), reducer: SettingReducer.init) { + $0.defaultInMemoryStorage = InMemoryStorage() + } + store.exhaustivity = .off + + await store.send(.settingRowTapped(.general)) + let id = try #require(store.state.path.ids.last) + await store.send(.path(.element(id: id, action: .general(.delegate(.pushAppActivityLogs))))) + + #expect(store.state.path.count == 2) + guard case .appActivityLogs = store.state.path.last else { + Issue.record("Expected .appActivityLogs on top of the Setting stack") + return + } + } + + // MARK: Child delegate → parent effect + + @Test + func generalEnablesTagsExtensionDelegateRebuildsWhenEnabled() async throws { + let defaults = UserDefaults.inMemory + try await withDependencies { + $0.defaultAppStorage = defaults + } operation: { + @Shared(.setting) var setting + $setting.withLock { $0.enablesTagsExtension = true } + + // A loading table makes the rebuild's follow-on `fetchTagTranslator` guard-return (no network). + var initialState = SettingReducer.State() + initialState.tagTranslatorLoadingState = .loading + + let store = TestStore(initialState: initialState, reducer: SettingReducer.init) { + $0.defaultAppStorage = defaults + $0.fileClient.loadCachedTagTranslator = { _ in nil } + } + store.exhaustivity = .off + + await store.send(.settingRowTapped(.general)) + let id = try #require(store.state.path.ids.last) + await store.send(.path(.element(id: id, action: .general(.delegate(.enablesTagsExtensionChanged))))) + await store.receive(\.rebuildTagTranslator) + await store.finish() + } + } + + @Test + func generalEnablesTagsExtensionDelegateSkipsRebuildWhenDisabled() async throws { + // `enablesTagsExtension` defaults to false, so the delegate must emit no rebuild — the exhaustive + // store fails if any effect is left unhandled. + let store = TestStore(initialState: .init(), reducer: SettingReducer.init) { + $0.defaultAppStorage = UserDefaults.inMemory + } + + await store.send(.settingRowTapped(.general)) { + $0.path.append(SettingReducer.RootScreen.general.pathElement) + } + let id = try #require(store.state.path.ids.last) + await store.send(.path(.element(id: id, action: .general(.delegate(.enablesTagsExtensionChanged))))) + } + + // MARK: Child intercepts + + @Test + func generalFilePickedImportsAndStoresTagTranslator() async throws { + let imported = TagTranslator(hasCustomTranslations: true) + let store = TestStore(initialState: .init(), reducer: SettingReducer.init) { + $0.fileClient.importTagTranslator = { _ in .success(imported) } + } + + await store.send(.settingRowTapped(.general)) { + $0.path.append(SettingReducer.RootScreen.general.pathElement) + } + let id = try #require(store.state.path.ids.last) + let url = URL(filePath: "/tmp/tags.json") + await store.send(.path(.element(id: id, action: .general(.onTranslationsFilePicked(url))))) + + // The parent intercept runs `fileClient.importTagTranslator`, stores the (in-memory) table + // and records the custom-import flag in the persisted `tagTranslatorInfo`. + await store.receive(\.fetchTagTranslatorDone) { + $0.$tagTranslator.withLock { $0 = imported } + $0.$tagTranslatorInfo.withLock { $0 = TagTranslatorInfo(hasCustomTranslations: true) } + } + } + + // MARK: Post-login cascade + + @Test + func loginDoneRunsPostLoginFetchCascade() async throws { + let store = TestStore(initialState: .init(), reducer: SettingReducer.init) { + $0.cookieClient = .noop + $0.hapticsClient = .noop + } + store.exhaustivity = .off + + await store.send(.pushLogin) + let id = try #require(store.state.path.ids.last) + + // Finishing login fans out to the four signed-in fetches; each guards on `didLogin` (false + // under the noop cookie client) so no network effects run. + await store.send(.path(.element(id: id, action: .login(.loginDone(.success(nil)))))) + await store.receive(\.fetchIgneous) + await store.receive(\.fetchUserInfo) + await store.receive(\.fetchFavoriteCategories) + await store.receive(\.fetchEhProfileIndex) + } + + // MARK: Igneous refresh signalling + + @Test + func fetchIgneousDoneSuccessSignalsRefreshed() async throws { + let store = TestStore(initialState: .init(), reducer: SettingReducer.init) { + $0.cookieClient = .noop + } + store.exhaustivity = .off + + let response = try #require( + HTTPURLResponse(url: .mock, statusCode: 200, httpVersion: nil, headerFields: nil) + ) + await store.send(.fetchIgneousDone(.success(response))) + await store.receive(\.igneousRefreshed) + } + + @Test + func fetchIgneousDoneFailureStillSignalsRefreshed() async { + let store = TestStore(initialState: .init(), reducer: SettingReducer.init) + store.exhaustivity = .off + + await store.send(.fetchIgneousDone(.failure(.notFound))) + await store.receive(\.igneousRefreshed) + } +} diff --git a/AppPackage/Tests/SettingFeatureTests/SettingWriteThroughTests.swift b/AppPackage/Tests/SettingFeatureTests/SettingWriteThroughTests.swift new file mode 100644 index 000000000..7f17f31f5 --- /dev/null +++ b/AppPackage/Tests/SettingFeatureTests/SettingWriteThroughTests.swift @@ -0,0 +1,36 @@ +import Foundation +import Testing +import AppModels +import Sharing +@testable import SettingFeature +import ComposableArchitecture + +// REV-8 / V-2: `setting` is stored directly in `@Shared(.setting)`, so a non-binding write path like +// `syncAppIconTypeDone` (fired at launch) must persist atomically. Previously it mutated a working copy +// and returned `.none`, leaving the persisted value silently diverged until an unrelated binding synced +// it. This pins the fix by reading an INDEPENDENT `@Shared(.setting)` handle rather than `store.state` +// — the working-copy bug updated `state.setting` too, so a `store.state` assertion passed pre-fix and +// wasn't discriminating. Storage is isolated to an in-memory suite so the test never touches real +// UserDefaults. +@Suite +@MainActor +struct SettingWriteThroughTests { + @Test + func syncAppIconTypeDonePersistsIconTypeToSharedSetting() async { + let defaults = UserDefaults.inMemory + await withDependencies { + $0.defaultAppStorage = defaults + } operation: { + let store = TestStore(initialState: .init(), reducer: SettingReducer.init) { + $0.defaultAppStorage = defaults + } + store.exhaustivity = .off + + // The alternate-icon name maps to `.ukiyoe`; the derived type is written through `$setting`. + await store.send(.syncAppIconTypeDone(AppIconType.ukiyoe.filename)) + + @Shared(.setting) var persisted + #expect(persisted.appIconType == .ukiyoe) + } + } +} diff --git a/CLAUDE.md b/CLAUDE.md new file mode 120000 index 000000000..55bf822df --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1 @@ +./AGENTS.md \ No newline at end of file diff --git a/EhPanda.xcodeproj/project.pbxproj b/EhPanda.xcodeproj/project.pbxproj index 15c18f3b5..ecd6c69b2 100644 --- a/EhPanda.xcodeproj/project.pbxproj +++ b/EhPanda.xcodeproj/project.pbxproj @@ -3,286 +3,18 @@ archiveVersion = 1; classes = { }; - objectVersion = 56; + objectVersion = 100; objects = { /* Begin PBXBuildFile section */ - AB0929B6277F043D00F107CA /* AccountSettingReducer.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB0929B5277F043D00F107CA /* AccountSettingReducer.swift */; }; - AB0929BE2780032400F107CA /* EhSettingReducer.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB0929BD2780032400F107CA /* EhSettingReducer.swift */; }; - AB0929C027805A8200F107CA /* LoginReducer.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB0929BF27805A8200F107CA /* LoginReducer.swift */; }; - AB0929C6278160AE00F107CA /* LibraryClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB0929C5278160AE00F107CA /* LibraryClient.swift */; }; - AB0929C82781938A00F107CA /* DFClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB0929C72781938A00F107CA /* DFClient.swift */; }; - AB0929CA278196ED00F107CA /* CookieClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB0929C9278196ED00F107CA /* CookieClient.swift */; }; - AB0929CC2781A0B000F107CA /* HapticsClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB0929CB2781A0B000F107CA /* HapticsClient.swift */; }; - AB0929CE2781AADA00F107CA /* DatabaseClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB0929CD2781AADA00F107CA /* DatabaseClient.swift */; }; - AB0929D02781E1CC00F107CA /* UIApplicationClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB0929CF2781E1CC00F107CA /* UIApplicationClient.swift */; }; - AB0929D22781E7D500F107CA /* LoggerClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB0929D12781E7D500F107CA /* LoggerClient.swift */; }; - AB0929D42781EDDC00F107CA /* UserDefaultsClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB0929D32781EDDC00F107CA /* UserDefaultsClient.swift */; }; - AB0929D62782A65F00F107CA /* GeneralSettingReducer.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB0929D52782A65F00F107CA /* GeneralSettingReducer.swift */; }; - AB0929D82782A83A00F107CA /* AuthorizationClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB0929D72782A83A00F107CA /* AuthorizationClient.swift */; }; - AB0ABCB526C5406400AD970F /* LoginView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB0ABCB426C5406400AD970F /* LoginView.swift */; }; - AB0ABCB726C541A400AD970F /* WaveForm.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB0ABCB626C541A400AD970F /* WaveForm.swift */; }; - AB0CFB7427BAB9D0004BD372 /* AppIcon_Default@3x.png in Resources */ = {isa = PBXBuildFile; fileRef = AB0CFB6527BAB9CF004BD372 /* AppIcon_Default@3x.png */; }; - AB0CFB7527BAB9D0004BD372 /* AppIcon_Default_iPad@2x.png in Resources */ = {isa = PBXBuildFile; fileRef = AB0CFB6627BAB9CF004BD372 /* AppIcon_Default_iPad@2x.png */; }; - AB0CFB7827BAB9D0004BD372 /* AppIcon_Default_iPad_Pro@2x.png in Resources */ = {isa = PBXBuildFile; fileRef = AB0CFB6927BAB9CF004BD372 /* AppIcon_Default_iPad_Pro@2x.png */; }; - AB0CFB7A27BAB9D0004BD372 /* AppIcon_Default@2x.png in Resources */ = {isa = PBXBuildFile; fileRef = AB0CFB6B27BAB9CF004BD372 /* AppIcon_Default@2x.png */; }; - AB0CFB7B27BAB9D0004BD372 /* AppIcon_Default_iPad.png in Resources */ = {isa = PBXBuildFile; fileRef = AB0CFB6C27BAB9CF004BD372 /* AppIcon_Default_iPad.png */; }; - AB0CFB8027BBBFA0004BD372 /* EhSetting.html in Resources */ = {isa = PBXBuildFile; fileRef = AB0CFB7F27BBBFA0004BD372 /* EhSetting.html */; }; - AB0CFB8227BBBFCE004BD372 /* EhSettingParserTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB0CFB8127BBBFCE004BD372 /* EhSettingParserTests.swift */; }; - AB0CFB8827BBD2D7004BD372 /* AppIcon_Developer_iPad@2x.png in Resources */ = {isa = PBXBuildFile; fileRef = AB0CFB8327BBD2D7004BD372 /* AppIcon_Developer_iPad@2x.png */; }; - AB0CFB8927BBD2D7004BD372 /* AppIcon_Developer@2x.png in Resources */ = {isa = PBXBuildFile; fileRef = AB0CFB8427BBD2D7004BD372 /* AppIcon_Developer@2x.png */; }; - AB0CFB8A27BBD2D7004BD372 /* AppIcon_Developer_iPad.png in Resources */ = {isa = PBXBuildFile; fileRef = AB0CFB8527BBD2D7004BD372 /* AppIcon_Developer_iPad.png */; }; - AB0CFB8B27BBD2D7004BD372 /* AppIcon_Developer_iPad_Pro@2x.png in Resources */ = {isa = PBXBuildFile; fileRef = AB0CFB8627BBD2D7004BD372 /* AppIcon_Developer_iPad_Pro@2x.png */; }; - AB0CFB8C27BBD2D7004BD372 /* AppIcon_Developer@3x.png in Resources */ = {isa = PBXBuildFile; fileRef = AB0CFB8727BBD2D7004BD372 /* AppIcon_Developer@3x.png */; }; - AB0CFB9227BBD323004BD372 /* AppIcon_Ukiyoe_iPad@2x.png in Resources */ = {isa = PBXBuildFile; fileRef = AB0CFB8D27BBD323004BD372 /* AppIcon_Ukiyoe_iPad@2x.png */; }; - AB0CFB9327BBD323004BD372 /* AppIcon_Ukiyoe@3x.png in Resources */ = {isa = PBXBuildFile; fileRef = AB0CFB8E27BBD323004BD372 /* AppIcon_Ukiyoe@3x.png */; }; - AB0CFB9427BBD323004BD372 /* AppIcon_Ukiyoe_iPad_Pro@2x.png in Resources */ = {isa = PBXBuildFile; fileRef = AB0CFB8F27BBD323004BD372 /* AppIcon_Ukiyoe_iPad_Pro@2x.png */; }; - AB0CFB9527BBD323004BD372 /* AppIcon_Ukiyoe_iPad.png in Resources */ = {isa = PBXBuildFile; fileRef = AB0CFB9027BBD323004BD372 /* AppIcon_Ukiyoe_iPad.png */; }; - AB0CFB9627BBD323004BD372 /* AppIcon_Ukiyoe@2x.png in Resources */ = {isa = PBXBuildFile; fileRef = AB0CFB9127BBD323004BD372 /* AppIcon_Ukiyoe@2x.png */; }; - AB0CFBC927C07F95004BD372 /* TagSuggestionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB0CFBC827C07F95004BD372 /* TagSuggestionView.swift */; }; - AB0CFBCB27C0B07F004BD372 /* TagSuggestion.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB0CFBCA27C0B07F004BD372 /* TagSuggestion.swift */; }; - AB0CFBCD27C1CC67004BD372 /* EhTagTranslationDatabaseModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB0CFBCC27C1CC67004BD372 /* EhTagTranslationDatabaseModel.swift */; }; - AB0CFBD527C24B3B004BD372 /* MarkdownUtil.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB0CFBD427C24B3B004BD372 /* MarkdownUtil.swift */; }; - AB0CFBD727C3B2D0004BD372 /* TagDetailView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB0CFBD627C3B2D0004BD372 /* TagDetailView.swift */; }; - AB10117E26986B7D00C2C1A9 /* GalleryStateMO+CoreDataProperties.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB10117D26986B7D00C2C1A9 /* GalleryStateMO+CoreDataProperties.swift */; }; - AB10118026986C1100C2C1A9 /* GalleryStateMO+CoreDataClass.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB10117F26986C1100C2C1A9 /* GalleryStateMO+CoreDataClass.swift */; }; - AB17573D27675B1E00FD64E2 /* Colorful in Frameworks */ = {isa = PBXBuildFile; productRef = AB17573C27675B1E00FD64E2 /* Colorful */; }; - AB17574027678B3400FD64E2 /* UIImageColors in Frameworks */ = {isa = PBXBuildFile; productRef = AB17573F27678B3400FD64E2 /* UIImageColors */; }; - AB1EF25427AFA19200F507D6 /* Heap.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB1EF25327AFA19200F507D6 /* Heap.swift */; }; - AB1FA8FC27C5E0E50063EF55 /* TagDetail.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB1FA8FB27C5E0E50063EF55 /* TagDetail.swift */; }; - AB1FA94927C62BC80063EF55 /* CommonMark in Frameworks */ = {isa = PBXBuildFile; productRef = AB1FA94827C62BC80063EF55 /* CommonMark */; }; - AB1FA94D27CA1F140063EF55 /* TagTranslation.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB1FA94C27CA1F140063EF55 /* TagTranslation.swift */; }; - AB24C55A27674EDF0085C33A /* FavoritesView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB24C55927674EDF0085C33A /* FavoritesView.swift */; }; - AB24C55C2767565A0085C33A /* HomeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB24C55B2767565A0085C33A /* HomeView.swift */; }; - AB24C566276758E30085C33A /* GalleryCardCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB24C565276758E30085C33A /* GalleryCardCell.swift */; }; - AB26F59027ABF21000AB3468 /* Model5toModel6.xcmappingmodel in Sources */ = {isa = PBXBuildFile; fileRef = AB26F58F27ABF21000AB3468 /* Model5toModel6.xcmappingmodel */; }; - AB26F59427ACC6CD00AB3468 /* TagTranslator.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB26F59327ACC6CD00AB3468 /* TagTranslator.swift */; }; - AB26F59627ACCA1800AB3468 /* AppEnv.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB26F59527ACCA1800AB3468 /* AppEnv.swift */; }; - AB26F59927ACDB4200AB3468 /* FilePicker in Frameworks */ = {isa = PBXBuildFile; productRef = AB26F59827ACDB4200AB3468 /* FilePicker */; }; - AB2CED64268AB6AE003130F7 /* GalleryMO+CoreDataProperties.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB2CED63268AB6AE003130F7 /* GalleryMO+CoreDataProperties.swift */; }; - AB2EB99F280251D600011A8A /* TTProgressHUD in Frameworks */ = {isa = PBXBuildFile; productRef = AB2EB99E280251D600011A8A /* TTProgressHUD */; }; - AB2EB9A2280251F600011A8A /* AlertKit in Frameworks */ = {isa = PBXBuildFile; productRef = AB2EB9A1280251F600011A8A /* AlertKit */; }; - AB2EB9A52802521700011A8A /* DeprecatedAPI in Frameworks */ = {isa = PBXBuildFile; productRef = AB2EB9A42802521700011A8A /* DeprecatedAPI */; }; - AB3072D2276D734800EFF242 /* SubSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB3072D1276D734800EFF242 /* SubSection.swift */; }; - AB3072D4276E19AA00EFF242 /* FrontpageView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB3072D3276E19AA00EFF242 /* FrontpageView.swift */; }; - AB31CD3027B666E200F40E0A /* TestError.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB31CD2F27B666E200F40E0A /* TestError.swift */; }; - AB31CD3227B6671400F40E0A /* BanIntervalParserTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB31CD3127B6671400F40E0A /* BanIntervalParserTests.swift */; }; - AB31CD3727B6695800F40E0A /* HTMLFilename.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB31CD3627B6695800F40E0A /* HTMLFilename.swift */; }; - AB31CD3B27B66E0300F40E0A /* ListParserTestType.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB31CD3A27B66E0300F40E0A /* ListParserTestType.swift */; }; - AB31CD3D27B66F7D00F40E0A /* GalleryImageURLParserTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB31CD3C27B66F7D00F40E0A /* GalleryImageURLParserTests.swift */; }; - AB31CD3F27B670FD00F40E0A /* GalleryNormalImageURL.html in Resources */ = {isa = PBXBuildFile; fileRef = AB31CD3E27B670FD00F40E0A /* GalleryNormalImageURL.html */; }; - AB31CD4127B6769F00F40E0A /* GalleryMPVKeys.html in Resources */ = {isa = PBXBuildFile; fileRef = AB31CD4027B6769F00F40E0A /* GalleryMPVKeys.html */; }; - AB31CD4327B676C300F40E0A /* GalleryMPVKeysParserTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB31CD4227B676C300F40E0A /* GalleryMPVKeysParserTests.swift */; }; - AB358311269D7B63009466A5 /* DFURLProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB358310269D7B63009466A5 /* DFURLProtocol.swift */; }; - AB358313269D7E89009466A5 /* DFRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB358312269D7E89009466A5 /* DFRequest.swift */; }; - AB358315269D821D009466A5 /* DFExtensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB358314269D821D009466A5 /* DFExtensions.swift */; }; - AB358317269D826B009466A5 /* DFStreamHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB358316269D826B009466A5 /* DFStreamHandler.swift */; }; - AB358319269D9996009466A5 /* DomainResolver.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB358318269D9996009466A5 /* DomainResolver.swift */; }; - AB38A0CB25CA993D00764D64 /* ColorCodable.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB38A0CA25CA993D00764D64 /* ColorCodable.swift */; }; - AB3E9E6E26D210B1008FE518 /* GalleryDetail.html in Resources */ = {isa = PBXBuildFile; fileRef = AB3E9E6526D210B1008FE518 /* GalleryDetail.html */; }; - AB3E9E7426D210B1008FE518 /* TestHelper.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB3E9E6D26D210B1008FE518 /* TestHelper.swift */; }; - AB41DB3D27B760D700DD3604 /* WatchedCompactList.html in Resources */ = {isa = PBXBuildFile; fileRef = AB41DB2827B760D600DD3604 /* WatchedCompactList.html */; }; - AB41DB3E27B760D700DD3604 /* PopularThumbnailList.html in Resources */ = {isa = PBXBuildFile; fileRef = AB41DB2927B760D600DD3604 /* PopularThumbnailList.html */; }; - AB41DB3F27B760D700DD3604 /* FrontPageExtendedList.html in Resources */ = {isa = PBXBuildFile; fileRef = AB41DB2A27B760D600DD3604 /* FrontPageExtendedList.html */; }; - AB41DB4027B760D700DD3604 /* FrontPageMinimalPlusList.html in Resources */ = {isa = PBXBuildFile; fileRef = AB41DB2B27B760D600DD3604 /* FrontPageMinimalPlusList.html */; }; - AB41DB4127B760D700DD3604 /* PopularExtendedList.html in Resources */ = {isa = PBXBuildFile; fileRef = AB41DB2C27B760D600DD3604 /* PopularExtendedList.html */; }; - AB41DB4227B760D700DD3604 /* FavoritesThumbnailList.html in Resources */ = {isa = PBXBuildFile; fileRef = AB41DB2D27B760D700DD3604 /* FavoritesThumbnailList.html */; }; - AB41DB4327B760D700DD3604 /* ToplistsCompactList.html in Resources */ = {isa = PBXBuildFile; fileRef = AB41DB2E27B760D700DD3604 /* ToplistsCompactList.html */; }; - AB41DB4427B760D700DD3604 /* FavoritesExtendedList.html in Resources */ = {isa = PBXBuildFile; fileRef = AB41DB2F27B760D700DD3604 /* FavoritesExtendedList.html */; }; - AB41DB4527B760D700DD3604 /* FrontPageMinimalList.html in Resources */ = {isa = PBXBuildFile; fileRef = AB41DB3027B760D700DD3604 /* FrontPageMinimalList.html */; }; - AB41DB4627B760D700DD3604 /* FrontPageCompactList.html in Resources */ = {isa = PBXBuildFile; fileRef = AB41DB3127B760D700DD3604 /* FrontPageCompactList.html */; }; - AB41DB4727B760D700DD3604 /* PopularMinimalPlusList.html in Resources */ = {isa = PBXBuildFile; fileRef = AB41DB3227B760D700DD3604 /* PopularMinimalPlusList.html */; }; - AB41DB4827B760D700DD3604 /* WatchedExtendedList.html in Resources */ = {isa = PBXBuildFile; fileRef = AB41DB3327B760D700DD3604 /* WatchedExtendedList.html */; }; - AB41DB4927B760D700DD3604 /* FavoritesMinimalPlusList.html in Resources */ = {isa = PBXBuildFile; fileRef = AB41DB3427B760D700DD3604 /* FavoritesMinimalPlusList.html */; }; - AB41DB4A27B760D700DD3604 /* WatchedThumbnailList.html in Resources */ = {isa = PBXBuildFile; fileRef = AB41DB3527B760D700DD3604 /* WatchedThumbnailList.html */; }; - AB41DB4B27B760D700DD3604 /* FrontPageThumbnailList.html in Resources */ = {isa = PBXBuildFile; fileRef = AB41DB3627B760D700DD3604 /* FrontPageThumbnailList.html */; }; - AB41DB4C27B760D700DD3604 /* FavoritesCompactList.html in Resources */ = {isa = PBXBuildFile; fileRef = AB41DB3727B760D700DD3604 /* FavoritesCompactList.html */; }; - AB41DB4D27B760D700DD3604 /* FavoritesMinimalList.html in Resources */ = {isa = PBXBuildFile; fileRef = AB41DB3827B760D700DD3604 /* FavoritesMinimalList.html */; }; - AB41DB4E27B760D700DD3604 /* WatchedMinimalPlusList.html in Resources */ = {isa = PBXBuildFile; fileRef = AB41DB3927B760D700DD3604 /* WatchedMinimalPlusList.html */; }; - AB41DB4F27B760D700DD3604 /* WatchedMinimalList.html in Resources */ = {isa = PBXBuildFile; fileRef = AB41DB3A27B760D700DD3604 /* WatchedMinimalList.html */; }; - AB41DB5027B760D700DD3604 /* PopularCompactList.html in Resources */ = {isa = PBXBuildFile; fileRef = AB41DB3B27B760D700DD3604 /* PopularCompactList.html */; }; - AB41DB5127B760D700DD3604 /* PopularMinimalList.html in Resources */ = {isa = PBXBuildFile; fileRef = AB41DB3C27B760D700DD3604 /* PopularMinimalList.html */; }; - AB4FD2C1268AB83300A95968 /* GalleryDetailMO+CoreDataProperties.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB4FD2C0268AB83300A95968 /* GalleryDetailMO+CoreDataProperties.swift */; }; - AB58A5AC2776B2BC00C0D285 /* AppDelegateReducer.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB58A5AB2776B2BC00C0D285 /* AppDelegateReducer.swift */; }; - AB58A5B22776B99000C0D285 /* AppReducer.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB58A5B12776B99000C0D285 /* AppReducer.swift */; }; - AB5BE67926B95FDD007D4A55 /* ShareViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB5BE67826B95FDD007D4A55 /* ShareViewController.swift */; }; + A0F00000000000000000F001 /* AppFeature in Frameworks */ = {isa = PBXBuildFile; productRef = A0F00000000000000000F002 /* AppFeature */; }; AB5BE68026B95FDD007D4A55 /* ShareExtension.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = AB5BE67626B95FDD007D4A55 /* ShareExtension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; - AB60D0E9274C7ECE00F899AB /* WaterfallGrid in Frameworks */ = {isa = PBXBuildFile; productRef = AB60D0E8274C7ECE00F899AB /* WaterfallGrid */; }; - AB63EADB2699AC8200090535 /* AppEnvMO+CoreDataProperties.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB63EADA2699AC8200090535 /* AppEnvMO+CoreDataProperties.swift */; }; - AB63EADD2699AC9100090535 /* AppEnvMO+CoreDataClass.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB63EADC2699AC9100090535 /* AppEnvMO+CoreDataClass.swift */; }; - AB6505A026B0027800F91E9D /* SwiftUIPager in Frameworks */ = {isa = PBXBuildFile; productRef = AB65059F26B0027800F91E9D /* SwiftUIPager */; }; - AB69CB8026B3DABC00699359 /* AdvancedList.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB69CB7F26B3DABC00699359 /* AdvancedList.swift */; }; - AB69CB8226B3DAF400699359 /* ControlPanel.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB69CB8126B3DAF400699359 /* ControlPanel.swift */; }; - AB6DE897268822390087C579 /* LogsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB6DE896268822390087C579 /* LogsView.swift */; }; - AB706F7927890A6C0025A48A /* AppRouteReducer.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB706F7827890A6C0025A48A /* AppRouteReducer.swift */; }; - AB706F7B278937500025A48A /* FrontpageReducer.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB706F7A278937500025A48A /* FrontpageReducer.swift */; }; - AB706F80278981370025A48A /* AlertKit_Extension.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB706F7F278981370025A48A /* AlertKit_Extension.swift */; }; - AB706F82278986120025A48A /* ToolbarItems.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB706F81278986120025A48A /* ToolbarItems.swift */; }; - AB706F842789AD2D0025A48A /* ToplistsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB706F832789AD2D0025A48A /* ToplistsView.swift */; }; - AB706F862789AD490025A48A /* ToplistsReducer.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB706F852789AD490025A48A /* ToplistsReducer.swift */; }; - AB706F88278A4C8A0025A48A /* PopularView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB706F87278A4C8A0025A48A /* PopularView.swift */; }; - AB706F8A278A4CC50025A48A /* PopularReducer.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB706F89278A4CC50025A48A /* PopularReducer.swift */; }; - AB706F8C278A4F6C0025A48A /* WatchedView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB706F8B278A4F6C0025A48A /* WatchedView.swift */; }; - AB706F8E278A5DCF0025A48A /* DeviceClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB706F8D278A5DCF0025A48A /* DeviceClient.swift */; }; - AB706F90278A5F680025A48A /* AppDelegateClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB706F8F278A5F680025A48A /* AppDelegateClient.swift */; }; - AB706F92278A6E8C0025A48A /* WatchedReducer.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB706F91278A6E8C0025A48A /* WatchedReducer.swift */; }; - AB706F95278A75D30025A48A /* HistoryView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB706F94278A75D30025A48A /* HistoryView.swift */; }; - AB706F97278A77E20025A48A /* HistoryReducer.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB706F96278A77E20025A48A /* HistoryReducer.swift */; }; - AB706F99278A820C0025A48A /* FiltersReducer.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB706F98278A820C0025A48A /* FiltersReducer.swift */; }; - AB706F9B278AC5A30025A48A /* SearchRootView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB706F9A278AC5A30025A48A /* SearchRootView.swift */; }; - AB706F9D278ACCA20025A48A /* SearchRootReducer.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB706F9C278ACCA20025A48A /* SearchRootReducer.swift */; }; - AB706F9F278AD4800025A48A /* GalleryHistoryCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB706F9E278AD4800025A48A /* GalleryHistoryCell.swift */; }; - AB706FA1278BCEC60025A48A /* DetailView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB706FA0278BCEC60025A48A /* DetailView.swift */; }; - AB706FA3278BCF2F0025A48A /* DetailReducer.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB706FA2278BCF2F0025A48A /* DetailReducer.swift */; }; - AB706FA5278C3DDE0025A48A /* PreviewsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB706FA4278C3DDE0025A48A /* PreviewsView.swift */; }; - AB7B29F226AC471E00EE1F14 /* Model5toModel6MigrationPolicy.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB7B29F126AC471E00EE1F14 /* Model5toModel6MigrationPolicy.swift */; }; - AB7B29F626AC741600EE1F14 /* GenericList.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB7B29F526AC741600EE1F14 /* GenericList.swift */; }; - AB7BF2A927A63C89001865A3 /* Language.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB7BF2A827A63C89001865A3 /* Language.swift */; }; - AB7BF2AB27A642FB001865A3 /* BrowsingCountry.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB7BF2AA27A642FB001865A3 /* BrowsingCountry.swift */; }; - AB7BF2B727A9652F001865A3 /* Greeting.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB7BF2B627A9652F001865A3 /* Greeting.swift */; }; - AB7BF2BA27A96562001865A3 /* Gallery.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB7BF2B927A96562001865A3 /* Gallery.swift */; }; - AB7BF2BC27A965DA001865A3 /* Category.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB7BF2BB27A965DA001865A3 /* Category.swift */; }; - AB7BF2C027A9669A001865A3 /* TagNamespace.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB7BF2BF27A9669A001865A3 /* TagNamespace.swift */; }; - AB7BF2C227A96760001865A3 /* GalleryDetail.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB7BF2C127A96760001865A3 /* GalleryDetail.swift */; }; - AB7BF2C427A9683F001865A3 /* GalleryArchive.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB7BF2C327A9683F001865A3 /* GalleryArchive.swift */; }; - AB7BF2C627A968AB001865A3 /* TranslatableLanguage.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB7BF2C527A968AB001865A3 /* TranslatableLanguage.swift */; }; - AB7BF2C827A968F7001865A3 /* GalleryComment.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB7BF2C727A968F7001865A3 /* GalleryComment.swift */; }; - AB7BF2CA27A969F4001865A3 /* GalleryState.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB7BF2C927A969F4001865A3 /* GalleryState.swift */; }; - AB7BF2CC27A96A3C001865A3 /* GalleryTorrent.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB7BF2CB27A96A3C001865A3 /* GalleryTorrent.swift */; }; - AB7BF2CE27AA3E58001865A3 /* AppUtil.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB7BF2CD27AA3E58001865A3 /* AppUtil.swift */; }; - AB7BF2D027AA3E75001865A3 /* DeviceUtil.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB7BF2CF27AA3E75001865A3 /* DeviceUtil.swift */; }; - AB7BF2D227AA3EDC001865A3 /* HapticsUtil.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB7BF2D127AA3EDC001865A3 /* HapticsUtil.swift */; }; - AB7BF2D427AA3F12001865A3 /* CookieUtil.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB7BF2D327AA3F12001865A3 /* CookieUtil.swift */; }; - AB7BF2D627AA3F4C001865A3 /* FileUtil.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB7BF2D527AA3F4C001865A3 /* FileUtil.swift */; }; - AB7BF2D827AA3F61001865A3 /* UserDefaultsUtil.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB7BF2D727AA3F61001865A3 /* UserDefaultsUtil.swift */; }; - AB7BF2DA27AA78CF001865A3 /* Reducer_Extension.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB7BF2D927AA78CF001865A3 /* Reducer_Extension.swift */; }; - AB7BF2FB27ABCA3A001865A3 /* MigrationView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB7BF2FA27ABCA3A001865A3 /* MigrationView.swift */; }; - AB7BF2FD27ABCAD4001865A3 /* MigrationReducer.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB7BF2FC27ABCAD4001865A3 /* MigrationReducer.swift */; }; - AB7BF30727ABDFF1001865A3 /* CoreDataMigrator.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB7BF2FE27ABDFF1001865A3 /* CoreDataMigrator.swift */; }; - AB7BF30A27ABDFF1001865A3 /* CoreDataMigrationStep.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB7BF30327ABDFF1001865A3 /* CoreDataMigrationStep.swift */; }; - AB7BF30D27ABDFF1001865A3 /* CoreDataMigrationVersion.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB7BF30627ABDFF1001865A3 /* CoreDataMigrationVersion.swift */; }; - AB7BF31B27ABE028001865A3 /* NSManagedObjectModel+Resource.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB7BF31327ABE028001865A3 /* NSManagedObjectModel+Resource.swift */; }; - AB7BF31C27ABE028001865A3 /* NSManagedObjectModel+Compatible.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB7BF31427ABE028001865A3 /* NSManagedObjectModel+Compatible.swift */; }; - AB7BF31D27ABE028001865A3 /* FileManager+ApplicationSupport.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB7BF31627ABE028001865A3 /* FileManager+ApplicationSupport.swift */; }; - AB7BF31E27ABE028001865A3 /* NSPersistentStoreCoordinator+SQLite.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB7BF31827ABE028001865A3 /* NSPersistentStoreCoordinator+SQLite.swift */; }; - AB7E6B3025D24FE00035CC68 /* InfoPlist.strings in Resources */ = {isa = PBXBuildFile; fileRef = AB7E6B3225D24FE00035CC68 /* InfoPlist.strings */; }; - AB86ABF52782DAB300E61E6A /* LogsReducer.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB86ABF42782DAB300E61E6A /* LogsReducer.swift */; }; - AB86ABF72782DDE600E61E6A /* FileClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB86ABF62782DDE600E61E6A /* FileClient.swift */; }; - AB86ABF92782EC0D00E61E6A /* AboutView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB86ABF82782EC0D00E61E6A /* AboutView.swift */; }; - AB86AC0A2782FAFA00E61E6A /* AppearanceSettingReducer.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB86AC092782FAFA00E61E6A /* AppearanceSettingReducer.swift */; }; - AB86AC1027831AD100E61E6A /* ComposableArchitecture in Frameworks */ = {isa = PBXBuildFile; productRef = AB86AC0F27831AD100E61E6A /* ComposableArchitecture */; }; - AB86AC1327856F2700E61E6A /* AppLockReducer.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB86AC1227856F2700E61E6A /* AppLockReducer.swift */; }; - AB86AC1A2785C2B300E61E6A /* HomeReducer.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB86AC192785C2B300E61E6A /* HomeReducer.swift */; }; - AB8C821926BF801700E8C5E6 /* EhSetting.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB8C821826BF801700E8C5E6 /* EhSetting.swift */; }; - AB90276B291F548700697256 /* AppIcon_NotMyPresident@3x.png in Resources */ = {isa = PBXBuildFile; fileRef = AB902766291F548600697256 /* AppIcon_NotMyPresident@3x.png */; }; - AB90276C291F548700697256 /* AppIcon_NotMyPresident_iPad@2x.png in Resources */ = {isa = PBXBuildFile; fileRef = AB902767291F548600697256 /* AppIcon_NotMyPresident_iPad@2x.png */; }; - AB90276D291F548700697256 /* AppIcon_NotMyPresident_iPad_Pro@2x.png in Resources */ = {isa = PBXBuildFile; fileRef = AB902768291F548700697256 /* AppIcon_NotMyPresident_iPad_Pro@2x.png */; }; - AB90276E291F548700697256 /* AppIcon_NotMyPresident_iPad.png in Resources */ = {isa = PBXBuildFile; fileRef = AB902769291F548700697256 /* AppIcon_NotMyPresident_iPad.png */; }; - AB90276F291F548700697256 /* AppIcon_NotMyPresident@2x.png in Resources */ = {isa = PBXBuildFile; fileRef = AB90276A291F548700697256 /* AppIcon_NotMyPresident@2x.png */; }; - ABA732D925A8018A00B3D9AB /* Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = ABA732D825A8018A00B3D9AB /* Extensions.swift */; }; - ABA732DF25A852D800B3D9AB /* Filter.swift in Sources */ = {isa = PBXBuildFile; fileRef = ABA732DE25A852D800B3D9AB /* Filter.swift */; }; - ABA9A6BC28EC786100EE28DE /* swiftgen.yml in Resources */ = {isa = PBXBuildFile; fileRef = ABA9A6BB28EC786100EE28DE /* swiftgen.yml */; }; - ABA9A6C228EC7BD000EE28DE /* Strings.swift in Sources */ = {isa = PBXBuildFile; fileRef = ABA9A6C128EC7BD000EE28DE /* Strings.swift */; }; - ABAB5B9527EF023300198597 /* Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = ABAB5B9427EF023300198597 /* Extensions.swift */; }; - ABAC82FE26BC4A96009F5026 /* OpenCC in Frameworks */ = {isa = PBXBuildFile; productRef = ABAC82FD26BC4A96009F5026 /* OpenCC */; }; - ABBB2631278E6EF3007B6149 /* SearchView.swift in Sources */ = {isa = PBXBuildFile; fileRef = ABBB2630278E6EF3007B6149 /* SearchView.swift */; }; - ABBB2636278FB888007B6149 /* SwiftUINavigation in Frameworks */ = {isa = PBXBuildFile; productRef = ABBB2635278FB888007B6149 /* SwiftUINavigation */; }; - ABBB2638278FBD2F007B6149 /* SwiftUINavigation_Extension.swift in Sources */ = {isa = PBXBuildFile; fileRef = ABBB2637278FBD2F007B6149 /* SwiftUINavigation_Extension.swift */; }; - ABBB263A2792588F007B6149 /* TTProgressHUD_Extension.swift in Sources */ = {isa = PBXBuildFile; fileRef = ABBB26392792588F007B6149 /* TTProgressHUD_Extension.swift */; }; - ABBB263E2793C648007B6149 /* PreviewsReducer.swift in Sources */ = {isa = PBXBuildFile; fileRef = ABBB263D2793C648007B6149 /* PreviewsReducer.swift */; }; - ABBB2640279417EC007B6149 /* CommentsReducer.swift in Sources */ = {isa = PBXBuildFile; fileRef = ABBB263F279417EC007B6149 /* CommentsReducer.swift */; }; - ABBB264227942B74007B6149 /* URLClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = ABBB264127942B74007B6149 /* URLClient.swift */; }; - ABBB266627977C2A007B6149 /* ArchivesReducer.swift in Sources */ = {isa = PBXBuildFile; fileRef = ABBB266527977C2A007B6149 /* ArchivesReducer.swift */; }; - ABBB26682797BFAA007B6149 /* ActivityView.swift in Sources */ = {isa = PBXBuildFile; fileRef = ABBB26672797BFAA007B6149 /* ActivityView.swift */; }; - ABBB266A2797C61F007B6149 /* TorrentsReducer.swift in Sources */ = {isa = PBXBuildFile; fileRef = ABBB26692797C61F007B6149 /* TorrentsReducer.swift */; }; - ABBB266C2797E882007B6149 /* ClipboardClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = ABBB266B2797E882007B6149 /* ClipboardClient.swift */; }; - ABBB266E27998479007B6149 /* QuickSearchReducer.swift in Sources */ = {isa = PBXBuildFile; fileRef = ABBB266D27998479007B6149 /* QuickSearchReducer.swift */; }; - ABBB2671279AFA61007B6149 /* EnvironmentKeys.swift in Sources */ = {isa = PBXBuildFile; fileRef = ABBB2670279AFA61007B6149 /* EnvironmentKeys.swift */; }; - ABBB2673279B9332007B6149 /* ReadingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = ABBB2672279B9332007B6149 /* ReadingView.swift */; }; - ABBB2675279B933D007B6149 /* ReadingReducer.swift in Sources */ = {isa = PBXBuildFile; fileRef = ABBB2674279B933D007B6149 /* ReadingReducer.swift */; }; - ABBB2677279CDBB0007B6149 /* ImageClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = ABBB2676279CDBB0007B6149 /* ImageClient.swift */; }; - ABBB2679279D454C007B6149 /* GalleryInfosReducer.swift in Sources */ = {isa = PBXBuildFile; fileRef = ABBB2678279D454C007B6149 /* GalleryInfosReducer.swift */; }; - ABBC332826BE31AE0084A331 /* EhSettingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = ABBC332726BE31AE0084A331 /* EhSettingView.swift */; }; - ABBC332A26BE7C940084A331 /* SettingTextField.swift in Sources */ = {isa = PBXBuildFile; fileRef = ABBC332926BE7C940084A331 /* SettingTextField.swift */; }; - ABBCCC9026C95F6E007D8A36 /* GalleryInfosView.swift in Sources */ = {isa = PBXBuildFile; fileRef = ABBCCC8F26C95F6E007D8A36 /* GalleryInfosView.swift */; }; - ABBD2B602768D7AD0072AED2 /* GalleryRankingCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = ABBD2B5F2768D7AD0072AED2 /* GalleryRankingCell.swift */; }; - ABC0A8D126F7037F008EC24C /* IPBanned.html in Resources */ = {isa = PBXBuildFile; fileRef = ABC0A8D026F7037F008EC24C /* IPBanned.html */; }; - ABC1FAB82642C37D00A9F352 /* NewDawnView.swift in Sources */ = {isa = PBXBuildFile; fileRef = ABC1FAB72642C37D00A9F352 /* NewDawnView.swift */; }; - ABC3C7852593699B00E0C11B /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = ABC3C7692593699A00E0C11B /* Assets.xcassets */; }; - ABC3C7872593699B00E0C11B /* EhPandaApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = ABC3C76B2593699A00E0C11B /* EhPandaApp.swift */; }; - ABC3C7892593699B00E0C11B /* Defaults.swift in Sources */ = {isa = PBXBuildFile; fileRef = ABC3C76D2593699A00E0C11B /* Defaults.swift */; }; - ABC3C78F2593699B00E0C11B /* ViewModifiers.swift in Sources */ = {isa = PBXBuildFile; fileRef = ABC3C7762593699A00E0C11B /* ViewModifiers.swift */; }; - ABC4A0792751B40E00968A4F /* Kingfisher in Frameworks */ = {isa = PBXBuildFile; productRef = ABC4A0782751B40E00968A4F /* Kingfisher */; }; - ABC681F326898D46007BBD69 /* Model.xcdatamodeld in Sources */ = {isa = PBXBuildFile; fileRef = ABC681F126898D46007BBD69 /* Model.xcdatamodeld */; }; - ABC732C527B9024500D47DA9 /* LiveText.swift in Sources */ = {isa = PBXBuildFile; fileRef = ABC732C427B9024500D47DA9 /* LiveText.swift */; }; - ABC732C727B90F0900D47DA9 /* LiveTextView.swift in Sources */ = {isa = PBXBuildFile; fileRef = ABC732C627B90F0900D47DA9 /* LiveTextView.swift */; }; - ABC8355D27B118330091DCDB /* DetailSearchView.swift in Sources */ = {isa = PBXBuildFile; fileRef = ABC8355C27B118330091DCDB /* DetailSearchView.swift */; }; - ABC8355F27B118370091DCDB /* DetailSearchReducer.swift in Sources */ = {isa = PBXBuildFile; fileRef = ABC8355E27B118370091DCDB /* DetailSearchReducer.swift */; }; - ABCA93BE26918DE100A98BC6 /* Persistence.swift in Sources */ = {isa = PBXBuildFile; fileRef = ABCA93BD26918DE100A98BC6 /* Persistence.swift */; }; - ABCA93C02691925900A98BC6 /* GalleryMO+CoreDataClass.swift in Sources */ = {isa = PBXBuildFile; fileRef = ABCA93BF2691925900A98BC6 /* GalleryMO+CoreDataClass.swift */; }; - ABCA93C22691929D00A98BC6 /* GalleryDetailMO+CoreDataClass.swift in Sources */ = {isa = PBXBuildFile; fileRef = ABCA93C12691929D00A98BC6 /* GalleryDetailMO+CoreDataClass.swift */; }; - ABCD2F0A259763FC008E5A20 /* Request.swift in Sources */ = {isa = PBXBuildFile; fileRef = ABCD2F09259763FC008E5A20 /* Request.swift */; }; - ABCD2F0E25976B95008E5A20 /* Parser.swift in Sources */ = {isa = PBXBuildFile; fileRef = ABCD2F0D25976B95008E5A20 /* Parser.swift */; }; - ABD4032626B78E5A00001B8C /* GalleryThumbnailCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = ABD4032526B78E5A00001B8C /* GalleryThumbnailCell.swift */; }; - ABD4032826B7967F00001B8C /* CategoryView.swift in Sources */ = {isa = PBXBuildFile; fileRef = ABD4032726B7967F00001B8C /* CategoryView.swift */; }; - ABD49D5A277C5356003D1A07 /* FavoritesReducer.swift in Sources */ = {isa = PBXBuildFile; fileRef = ABD49D59277C5356003D1A07 /* FavoritesReducer.swift */; }; - ABD49D5D277C6C9D003D1A07 /* SFSafeSymbols in Frameworks */ = {isa = PBXBuildFile; productRef = ABD49D5C277C6C9D003D1A07 /* SFSafeSymbols */; }; - ABD49D60277C7722003D1A07 /* TabBarView.swift in Sources */ = {isa = PBXBuildFile; fileRef = ABD49D5F277C7722003D1A07 /* TabBarView.swift */; }; - ABD49D64277C7AD5003D1A07 /* TabBarReducer.swift in Sources */ = {isa = PBXBuildFile; fileRef = ABD49D63277C7AD5003D1A07 /* TabBarReducer.swift */; }; - ABD49D67277EAC90003D1A07 /* URLUtil.swift in Sources */ = {isa = PBXBuildFile; fileRef = ABD49D66277EAC90003D1A07 /* URLUtil.swift */; }; - ABD5FDD4263D05110021A4C6 /* .swiftlint.yml in Resources */ = {isa = PBXBuildFile; fileRef = ABD5FDD3263D05110021A4C6 /* .swiftlint.yml */; }; - ABD7005926B1C31500DC59C9 /* Kanna in Frameworks */ = {isa = PBXBuildFile; productRef = ABD7005826B1C31500DC59C9 /* Kanna */; }; - ABD9770E27B65A7300983DE7 /* ListParserTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = ABD9770D27B65A7300983DE7 /* ListParserTests.swift */; }; - ABD9771027B65E3400983DE7 /* GalleryDetailParserTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = ABD9770F27B65E3400983DE7 /* GalleryDetailParserTests.swift */; }; - ABD9771327B6612400983DE7 /* GreetingParserTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = ABD9771227B6612400983DE7 /* GreetingParserTests.swift */; }; - ABE1867826A1733000689FDC /* LaboratorySettingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = ABE1867726A1733000689FDC /* LaboratorySettingView.swift */; }; - ABE9012227F722D100F3651D /* AppIcon_StandWithUkraine2022@2x.png in Resources */ = {isa = PBXBuildFile; fileRef = ABE9011D27F722D000F3651D /* AppIcon_StandWithUkraine2022@2x.png */; }; - ABE9012327F722D100F3651D /* AppIcon_StandWithUkraine2022_iPad.png in Resources */ = {isa = PBXBuildFile; fileRef = ABE9011E27F722D100F3651D /* AppIcon_StandWithUkraine2022_iPad.png */; }; - ABE9012427F722D100F3651D /* AppIcon_StandWithUkraine2022@3x.png in Resources */ = {isa = PBXBuildFile; fileRef = ABE9011F27F722D100F3651D /* AppIcon_StandWithUkraine2022@3x.png */; }; - ABE9012527F722D100F3651D /* AppIcon_StandWithUkraine2022_iPad_Pro@2x.png in Resources */ = {isa = PBXBuildFile; fileRef = ABE9012027F722D100F3651D /* AppIcon_StandWithUkraine2022_iPad_Pro@2x.png */; }; - ABE9012627F722D100F3651D /* AppIcon_StandWithUkraine2022_iPad@2x.png in Resources */ = {isa = PBXBuildFile; fileRef = ABE9012127F722D100F3651D /* AppIcon_StandWithUkraine2022_iPad@2x.png */; }; - ABE9401526FF158D0085E158 /* QuickSearchView.swift in Sources */ = {isa = PBXBuildFile; fileRef = ABE9401426FF158D0085E158 /* QuickSearchView.swift */; }; - ABEA1FE625A9B40B002966B9 /* Setting.swift in Sources */ = {isa = PBXBuildFile; fileRef = ABEA1FE525A9B40B002966B9 /* Setting.swift */; }; - ABEE0AFA2595C6F800C997AE /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = ABEE0AFC2595C6F800C997AE /* Localizable.strings */; }; - ABF313A525B1AB6600D47A2F /* Misc.swift in Sources */ = {isa = PBXBuildFile; fileRef = ABF313A425B1AB6600D47A2F /* Misc.swift */; }; - ABF45ABB25F3312F00ECB568 /* AppError.swift in Sources */ = {isa = PBXBuildFile; fileRef = ABF45AB625F3312F00ECB568 /* AppError.swift */; }; - ABF45ADF25F3313D00ECB568 /* FiltersView.swift in Sources */ = {isa = PBXBuildFile; fileRef = ABF45AC125F3313D00ECB568 /* FiltersView.swift */; }; - ABF45AE425F3313D00ECB568 /* TagCloudView.swift in Sources */ = {isa = PBXBuildFile; fileRef = ABF45AC725F3313D00ECB568 /* TagCloudView.swift */; }; - ABF45AE525F3313D00ECB568 /* PostCommentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = ABF45AC825F3313D00ECB568 /* PostCommentView.swift */; }; - ABF45AE725F3313D00ECB568 /* RatingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = ABF45ACA25F3313D00ECB568 /* RatingView.swift */; }; - ABF45AE825F3313D00ECB568 /* LinkedText.swift in Sources */ = {isa = PBXBuildFile; fileRef = ABF45ACB25F3313D00ECB568 /* LinkedText.swift */; }; - ABF45AE925F3313D00ECB568 /* AlertView.swift in Sources */ = {isa = PBXBuildFile; fileRef = ABF45ACC25F3313D00ECB568 /* AlertView.swift */; }; - ABF45AEA25F3313D00ECB568 /* Placeholder.swift in Sources */ = {isa = PBXBuildFile; fileRef = ABF45ACD25F3313D00ECB568 /* Placeholder.swift */; }; - ABF45AEB25F3313D00ECB568 /* GalleryDetailCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = ABF45ACE25F3313D00ECB568 /* GalleryDetailCell.swift */; }; - ABF45AEE25F3313D00ECB568 /* ArchivesView.swift in Sources */ = {isa = PBXBuildFile; fileRef = ABF45AD325F3313D00ECB568 /* ArchivesView.swift */; }; - ABF45AEF25F3313D00ECB568 /* TorrentsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = ABF45AD425F3313D00ECB568 /* TorrentsView.swift */; }; - ABF45AF025F3313D00ECB568 /* CommentsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = ABF45AD525F3313D00ECB568 /* CommentsView.swift */; }; - ABF45AF225F3313D00ECB568 /* GeneralSettingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = ABF45AD825F3313D00ECB568 /* GeneralSettingView.swift */; }; - ABF45AF325F3313D00ECB568 /* AccountSettingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = ABF45AD925F3313D00ECB568 /* AccountSettingView.swift */; }; - ABF45AF425F3313D00ECB568 /* WebView.swift in Sources */ = {isa = PBXBuildFile; fileRef = ABF45ADA25F3313D00ECB568 /* WebView.swift */; }; - ABF45AF525F3313D00ECB568 /* ReadingSettingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = ABF45ADB25F3313D00ECB568 /* ReadingSettingView.swift */; }; - ABF45AF625F3313D00ECB568 /* AppearanceSettingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = ABF45ADC25F3313D00ECB568 /* AppearanceSettingView.swift */; }; - ABF45AF725F3313D00ECB568 /* SettingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = ABF45ADD25F3313D00ECB568 /* SettingView.swift */; }; - ABF75F3F25A19CD200544D29 /* User.swift in Sources */ = {isa = PBXBuildFile; fileRef = ABF75F3E25A19CD200544D29 /* User.swift */; }; - ABF9720A26DE6E1300118887 /* GalleryDetailWithGreeting.html in Resources */ = {isa = PBXBuildFile; fileRef = ABF9720926DE6E1300118887 /* GalleryDetailWithGreeting.html */; }; - EA0BBD472E37CCB700DC8143 /* CODEOWNERS in Resources */ = {isa = PBXBuildFile; fileRef = EA0BBD462E37CCB700DC8143 /* CODEOWNERS */; }; - EA0C92452C3EB42300D211F6 /* ISSUE_TEMPLATE in Resources */ = {isa = PBXBuildFile; fileRef = EA0C92432C3EB42300D211F6 /* ISSUE_TEMPLATE */; }; - EA0C92462C3EB42300D211F6 /* workflows in Resources */ = {isa = PBXBuildFile; fileRef = EA0C92442C3EB42300D211F6 /* workflows */; }; EA0C92592C3EB49500D211F6 /* README.cht.md in Resources */ = {isa = PBXBuildFile; fileRef = EA0C92532C3EB49500D211F6 /* README.cht.md */; }; EA0C925A2C3EB49500D211F6 /* README.ko.md in Resources */ = {isa = PBXBuildFile; fileRef = EA0C92542C3EB49500D211F6 /* README.ko.md */; }; EA0C925B2C3EB49500D211F6 /* README.md in Resources */ = {isa = PBXBuildFile; fileRef = EA0C92552C3EB49500D211F6 /* README.md */; }; EA0C925C2C3EB49500D211F6 /* README.de.md in Resources */ = {isa = PBXBuildFile; fileRef = EA0C92562C3EB49500D211F6 /* README.de.md */; }; EA0C925D2C3EB49500D211F6 /* README.chs.md in Resources */ = {isa = PBXBuildFile; fileRef = EA0C92572C3EB49500D211F6 /* README.chs.md */; }; EA0C925E2C3EB49500D211F6 /* README.jpn.md in Resources */ = {isa = PBXBuildFile; fileRef = EA0C92582C3EB49500D211F6 /* README.jpn.md */; }; - EA2E2E7F2A1F7E500038A261 /* SettingReducer.swift in Sources */ = {isa = PBXBuildFile; fileRef = EA2E2E7E2A1F7E500038A261 /* SettingReducer.swift */; }; - EA2E2E822A1FA1060038A261 /* SearchReducer.swift in Sources */ = {isa = PBXBuildFile; fileRef = EA2E2E812A1FA1050038A261 /* SearchReducer.swift */; }; - EA5AA4A72EA9149E00BC2B5C /* PageHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = EA5AA4A62EA9149E00BC2B5C /* PageHandler.swift */; }; - EA5AA4A82EA9149E00BC2B5C /* LiveTextHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = EA5AA4A52EA9149E00BC2B5C /* LiveTextHandler.swift */; }; - EA5AA4A92EA9149E00BC2B5C /* GestureHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = EA5AA4A42EA9149E00BC2B5C /* GestureHandler.swift */; }; - EA5AA4AA2EA9149E00BC2B5C /* AutoPlayHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = EA5AA4A32EA9149E00BC2B5C /* AutoPlayHandler.swift */; }; - EA698C032CCDD2FB0058BC19 /* EquatableVoid.swift in Sources */ = {isa = PBXBuildFile; fileRef = EA698C022CCDD2FB0058BC19 /* EquatableVoid.swift */; }; - EA698C092CCDE7090058BC19 /* IdentifiableBox.swift in Sources */ = {isa = PBXBuildFile; fileRef = EA698C082CCDE7050058BC19 /* IdentifiableBox.swift */; }; - EAE63E2129E2A6330048C601 /* SwiftyBeaver in Frameworks */ = {isa = PBXBuildFile; productRef = EAE63E2029E2A6330048C601 /* SwiftyBeaver */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -293,304 +25,25 @@ remoteGlobalIDString = AB5BE67526B95FDD007D4A55; remoteInfo = ShareExtension; }; - ABF294D026D20F82004DD03A /* PBXContainerItemProxy */ = { - isa = PBXContainerItemProxy; - containerPortal = ABC3C74C2593696C00E0C11B /* Project object */; - proxyType = 1; - remoteGlobalIDString = ABC3C7532593696C00E0C11B; - remoteInfo = EhPanda; - }; /* End PBXContainerItemProxy section */ /* Begin PBXCopyFilesBuildPhase section */ AB5BE68126B95FDD007D4A55 /* Embed Foundation Extensions */ = { isa = PBXCopyFilesBuildPhase; - buildActionMask = 2147483647; dstPath = ""; - dstSubfolderSpec = 13; + dstSubfolder = PlugIns; files = ( AB5BE68026B95FDD007D4A55 /* ShareExtension.appex in Embed Foundation Extensions */, ); name = "Embed Foundation Extensions"; - runOnlyForDeploymentPostprocessing = 0; }; /* End PBXCopyFilesBuildPhase section */ /* Begin PBXFileReference section */ - AB0929B5277F043D00F107CA /* AccountSettingReducer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccountSettingReducer.swift; sourceTree = ""; }; - AB0929BD2780032400F107CA /* EhSettingReducer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EhSettingReducer.swift; sourceTree = ""; }; - AB0929BF27805A8200F107CA /* LoginReducer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoginReducer.swift; sourceTree = ""; }; - AB0929C5278160AE00F107CA /* LibraryClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LibraryClient.swift; sourceTree = ""; }; - AB0929C72781938A00F107CA /* DFClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DFClient.swift; sourceTree = ""; }; - AB0929C9278196ED00F107CA /* CookieClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CookieClient.swift; sourceTree = ""; }; - AB0929CB2781A0B000F107CA /* HapticsClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HapticsClient.swift; sourceTree = ""; }; - AB0929CD2781AADA00F107CA /* DatabaseClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DatabaseClient.swift; sourceTree = ""; }; - AB0929CF2781E1CC00F107CA /* UIApplicationClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UIApplicationClient.swift; sourceTree = ""; }; - AB0929D12781E7D500F107CA /* LoggerClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoggerClient.swift; sourceTree = ""; }; - AB0929D32781EDDC00F107CA /* UserDefaultsClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserDefaultsClient.swift; sourceTree = ""; }; - AB0929D52782A65F00F107CA /* GeneralSettingReducer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GeneralSettingReducer.swift; sourceTree = ""; }; - AB0929D72782A83A00F107CA /* AuthorizationClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AuthorizationClient.swift; sourceTree = ""; }; - AB0ABCB426C5406400AD970F /* LoginView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoginView.swift; sourceTree = ""; }; - AB0ABCB626C541A400AD970F /* WaveForm.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WaveForm.swift; sourceTree = ""; }; - AB0CFB6527BAB9CF004BD372 /* AppIcon_Default@3x.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = "AppIcon_Default@3x.png"; sourceTree = ""; }; - AB0CFB6627BAB9CF004BD372 /* AppIcon_Default_iPad@2x.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = "AppIcon_Default_iPad@2x.png"; sourceTree = ""; }; - AB0CFB6927BAB9CF004BD372 /* AppIcon_Default_iPad_Pro@2x.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = "AppIcon_Default_iPad_Pro@2x.png"; sourceTree = ""; }; - AB0CFB6B27BAB9CF004BD372 /* AppIcon_Default@2x.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = "AppIcon_Default@2x.png"; sourceTree = ""; }; - AB0CFB6C27BAB9CF004BD372 /* AppIcon_Default_iPad.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = AppIcon_Default_iPad.png; sourceTree = ""; }; - AB0CFB7F27BBBFA0004BD372 /* EhSetting.html */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.html; path = EhSetting.html; sourceTree = ""; }; - AB0CFB8127BBBFCE004BD372 /* EhSettingParserTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EhSettingParserTests.swift; sourceTree = ""; }; - AB0CFB8327BBD2D7004BD372 /* AppIcon_Developer_iPad@2x.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = "AppIcon_Developer_iPad@2x.png"; sourceTree = ""; }; - AB0CFB8427BBD2D7004BD372 /* AppIcon_Developer@2x.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = "AppIcon_Developer@2x.png"; sourceTree = ""; }; - AB0CFB8527BBD2D7004BD372 /* AppIcon_Developer_iPad.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = AppIcon_Developer_iPad.png; sourceTree = ""; }; - AB0CFB8627BBD2D7004BD372 /* AppIcon_Developer_iPad_Pro@2x.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = "AppIcon_Developer_iPad_Pro@2x.png"; sourceTree = ""; }; - AB0CFB8727BBD2D7004BD372 /* AppIcon_Developer@3x.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = "AppIcon_Developer@3x.png"; sourceTree = ""; }; - AB0CFB8D27BBD323004BD372 /* AppIcon_Ukiyoe_iPad@2x.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = "AppIcon_Ukiyoe_iPad@2x.png"; sourceTree = ""; }; - AB0CFB8E27BBD323004BD372 /* AppIcon_Ukiyoe@3x.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = "AppIcon_Ukiyoe@3x.png"; sourceTree = ""; }; - AB0CFB8F27BBD323004BD372 /* AppIcon_Ukiyoe_iPad_Pro@2x.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = "AppIcon_Ukiyoe_iPad_Pro@2x.png"; sourceTree = ""; }; - AB0CFB9027BBD323004BD372 /* AppIcon_Ukiyoe_iPad.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = AppIcon_Ukiyoe_iPad.png; sourceTree = ""; }; - AB0CFB9127BBD323004BD372 /* AppIcon_Ukiyoe@2x.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = "AppIcon_Ukiyoe@2x.png"; sourceTree = ""; }; - AB0CFBC827C07F95004BD372 /* TagSuggestionView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = TagSuggestionView.swift; sourceTree = ""; }; - AB0CFBCA27C0B07F004BD372 /* TagSuggestion.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TagSuggestion.swift; sourceTree = ""; }; - AB0CFBCC27C1CC67004BD372 /* EhTagTranslationDatabaseModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EhTagTranslationDatabaseModel.swift; sourceTree = ""; }; - AB0CFBD427C24B3B004BD372 /* MarkdownUtil.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MarkdownUtil.swift; sourceTree = ""; }; - AB0CFBD627C3B2D0004BD372 /* TagDetailView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TagDetailView.swift; sourceTree = ""; }; - AB10117D26986B7D00C2C1A9 /* GalleryStateMO+CoreDataProperties.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "GalleryStateMO+CoreDataProperties.swift"; sourceTree = ""; }; - AB10117F26986C1100C2C1A9 /* GalleryStateMO+CoreDataClass.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "GalleryStateMO+CoreDataClass.swift"; sourceTree = ""; }; - AB1EF25327AFA19200F507D6 /* Heap.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Heap.swift; sourceTree = ""; }; - AB1FA8FB27C5E0E50063EF55 /* TagDetail.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TagDetail.swift; sourceTree = ""; }; - AB1FA94C27CA1F140063EF55 /* TagTranslation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TagTranslation.swift; sourceTree = ""; }; - AB24C55927674EDF0085C33A /* FavoritesView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FavoritesView.swift; sourceTree = ""; }; - AB24C55B2767565A0085C33A /* HomeView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HomeView.swift; sourceTree = ""; }; - AB24C565276758E30085C33A /* GalleryCardCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GalleryCardCell.swift; sourceTree = ""; }; - AB253B4726AB08B500F95275 /* de */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = de; path = de.lproj/Localizable.strings; sourceTree = ""; }; - AB253B4826AB08B500F95275 /* de */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = de; path = de.lproj/InfoPlist.strings; sourceTree = ""; }; - AB26F58F27ABF21000AB3468 /* Model5toModel6.xcmappingmodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcmappingmodel; path = Model5toModel6.xcmappingmodel; sourceTree = ""; }; - AB26F59327ACC6CD00AB3468 /* TagTranslator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TagTranslator.swift; sourceTree = ""; }; - AB26F59527ACCA1800AB3468 /* AppEnv.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppEnv.swift; sourceTree = ""; }; - AB2CED63268AB6AE003130F7 /* GalleryMO+CoreDataProperties.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "GalleryMO+CoreDataProperties.swift"; sourceTree = ""; }; - AB3072D1276D734800EFF242 /* SubSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SubSection.swift; sourceTree = ""; }; - AB3072D3276E19AA00EFF242 /* FrontpageView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FrontpageView.swift; sourceTree = ""; }; - AB31CD2F27B666E200F40E0A /* TestError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TestError.swift; sourceTree = ""; }; - AB31CD3127B6671400F40E0A /* BanIntervalParserTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BanIntervalParserTests.swift; sourceTree = ""; }; - AB31CD3627B6695800F40E0A /* HTMLFilename.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HTMLFilename.swift; sourceTree = ""; }; - AB31CD3A27B66E0300F40E0A /* ListParserTestType.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ListParserTestType.swift; sourceTree = ""; }; - AB31CD3C27B66F7D00F40E0A /* GalleryImageURLParserTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GalleryImageURLParserTests.swift; sourceTree = ""; }; - AB31CD3E27B670FD00F40E0A /* GalleryNormalImageURL.html */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.html; path = GalleryNormalImageURL.html; sourceTree = ""; }; - AB31CD4027B6769F00F40E0A /* GalleryMPVKeys.html */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.html; path = GalleryMPVKeys.html; sourceTree = ""; }; - AB31CD4227B676C300F40E0A /* GalleryMPVKeysParserTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GalleryMPVKeysParserTests.swift; sourceTree = ""; }; - AB358310269D7B63009466A5 /* DFURLProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DFURLProtocol.swift; sourceTree = ""; }; - AB358312269D7E89009466A5 /* DFRequest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DFRequest.swift; sourceTree = ""; }; - AB358314269D821D009466A5 /* DFExtensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DFExtensions.swift; sourceTree = ""; }; - AB358316269D826B009466A5 /* DFStreamHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DFStreamHandler.swift; sourceTree = ""; }; - AB358318269D9996009466A5 /* DomainResolver.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DomainResolver.swift; sourceTree = ""; }; - AB38A0CA25CA993D00764D64 /* ColorCodable.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ColorCodable.swift; sourceTree = ""; }; - AB3E9E6526D210B1008FE518 /* GalleryDetail.html */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.html; path = GalleryDetail.html; sourceTree = ""; }; - AB3E9E6D26D210B1008FE518 /* TestHelper.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = TestHelper.swift; sourceTree = ""; }; - AB41DB2827B760D600DD3604 /* WatchedCompactList.html */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.html; path = WatchedCompactList.html; sourceTree = ""; }; - AB41DB2927B760D600DD3604 /* PopularThumbnailList.html */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.html; path = PopularThumbnailList.html; sourceTree = ""; }; - AB41DB2A27B760D600DD3604 /* FrontPageExtendedList.html */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.html; path = FrontPageExtendedList.html; sourceTree = ""; }; - AB41DB2B27B760D600DD3604 /* FrontPageMinimalPlusList.html */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.html; path = FrontPageMinimalPlusList.html; sourceTree = ""; }; - AB41DB2C27B760D600DD3604 /* PopularExtendedList.html */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.html; path = PopularExtendedList.html; sourceTree = ""; }; - AB41DB2D27B760D700DD3604 /* FavoritesThumbnailList.html */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.html; path = FavoritesThumbnailList.html; sourceTree = ""; }; - AB41DB2E27B760D700DD3604 /* ToplistsCompactList.html */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.html; path = ToplistsCompactList.html; sourceTree = ""; }; - AB41DB2F27B760D700DD3604 /* FavoritesExtendedList.html */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.html; path = FavoritesExtendedList.html; sourceTree = ""; }; - AB41DB3027B760D700DD3604 /* FrontPageMinimalList.html */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.html; path = FrontPageMinimalList.html; sourceTree = ""; }; - AB41DB3127B760D700DD3604 /* FrontPageCompactList.html */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.html; path = FrontPageCompactList.html; sourceTree = ""; }; - AB41DB3227B760D700DD3604 /* PopularMinimalPlusList.html */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.html; path = PopularMinimalPlusList.html; sourceTree = ""; }; - AB41DB3327B760D700DD3604 /* WatchedExtendedList.html */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.html; path = WatchedExtendedList.html; sourceTree = ""; }; - AB41DB3427B760D700DD3604 /* FavoritesMinimalPlusList.html */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.html; path = FavoritesMinimalPlusList.html; sourceTree = ""; }; - AB41DB3527B760D700DD3604 /* WatchedThumbnailList.html */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.html; path = WatchedThumbnailList.html; sourceTree = ""; }; - AB41DB3627B760D700DD3604 /* FrontPageThumbnailList.html */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.html; path = FrontPageThumbnailList.html; sourceTree = ""; }; - AB41DB3727B760D700DD3604 /* FavoritesCompactList.html */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.html; path = FavoritesCompactList.html; sourceTree = ""; }; - AB41DB3827B760D700DD3604 /* FavoritesMinimalList.html */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.html; path = FavoritesMinimalList.html; sourceTree = ""; }; - AB41DB3927B760D700DD3604 /* WatchedMinimalPlusList.html */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.html; path = WatchedMinimalPlusList.html; sourceTree = ""; }; - AB41DB3A27B760D700DD3604 /* WatchedMinimalList.html */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.html; path = WatchedMinimalList.html; sourceTree = ""; }; - AB41DB3B27B760D700DD3604 /* PopularCompactList.html */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.html; path = PopularCompactList.html; sourceTree = ""; }; - AB41DB3C27B760D700DD3604 /* PopularMinimalList.html */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.html; path = PopularMinimalList.html; sourceTree = ""; }; - AB41DB5227B7EC5500DD3604 /* Model 7.xcdatamodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcdatamodel; path = "Model 7.xcdatamodel"; sourceTree = ""; }; - AB48BCF626D2539B0021A06C /* Model 2.xcdatamodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcdatamodel; path = "Model 2.xcdatamodel"; sourceTree = ""; }; - AB4FD2C0268AB83300A95968 /* GalleryDetailMO+CoreDataProperties.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "GalleryDetailMO+CoreDataProperties.swift"; sourceTree = ""; }; - AB543FF126DB7FD9009344C0 /* Model 3.xcdatamodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcdatamodel; path = "Model 3.xcdatamodel"; sourceTree = ""; }; - AB58A5AB2776B2BC00C0D285 /* AppDelegateReducer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegateReducer.swift; sourceTree = ""; }; - AB58A5B12776B99000C0D285 /* AppReducer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppReducer.swift; sourceTree = ""; }; + A0F00000000000000000F004 /* AppPackage */ = {isa = PBXFileReference; lastKnownFileType = wrapper; path = AppPackage; sourceTree = ""; }; AB5BE67626B95FDD007D4A55 /* ShareExtension.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = ShareExtension.appex; sourceTree = BUILT_PRODUCTS_DIR; }; - AB5BE67826B95FDD007D4A55 /* ShareViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShareViewController.swift; sourceTree = ""; }; - AB5BE67D26B95FDD007D4A55 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; - AB63EADA2699AC8200090535 /* AppEnvMO+CoreDataProperties.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "AppEnvMO+CoreDataProperties.swift"; sourceTree = ""; }; - AB63EADC2699AC9100090535 /* AppEnvMO+CoreDataClass.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "AppEnvMO+CoreDataClass.swift"; sourceTree = ""; }; - AB69CB7F26B3DABC00699359 /* AdvancedList.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AdvancedList.swift; sourceTree = ""; }; - AB69CB8126B3DAF400699359 /* ControlPanel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ControlPanel.swift; sourceTree = ""; }; - AB6DE896268822390087C579 /* LogsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LogsView.swift; sourceTree = ""; }; - AB706F7827890A6C0025A48A /* AppRouteReducer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppRouteReducer.swift; sourceTree = ""; }; - AB706F7A278937500025A48A /* FrontpageReducer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FrontpageReducer.swift; sourceTree = ""; }; - AB706F7F278981370025A48A /* AlertKit_Extension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AlertKit_Extension.swift; sourceTree = ""; }; - AB706F81278986120025A48A /* ToolbarItems.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ToolbarItems.swift; sourceTree = ""; }; - AB706F832789AD2D0025A48A /* ToplistsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ToplistsView.swift; sourceTree = ""; }; - AB706F852789AD490025A48A /* ToplistsReducer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ToplistsReducer.swift; sourceTree = ""; }; - AB706F87278A4C8A0025A48A /* PopularView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PopularView.swift; sourceTree = ""; }; - AB706F89278A4CC50025A48A /* PopularReducer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PopularReducer.swift; sourceTree = ""; }; - AB706F8B278A4F6C0025A48A /* WatchedView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WatchedView.swift; sourceTree = ""; }; - AB706F8D278A5DCF0025A48A /* DeviceClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeviceClient.swift; sourceTree = ""; }; - AB706F8F278A5F680025A48A /* AppDelegateClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegateClient.swift; sourceTree = ""; }; - AB706F91278A6E8C0025A48A /* WatchedReducer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WatchedReducer.swift; sourceTree = ""; }; - AB706F93278A6F2B0025A48A /* Model 6.xcdatamodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcdatamodel; path = "Model 6.xcdatamodel"; sourceTree = ""; }; - AB706F94278A75D30025A48A /* HistoryView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HistoryView.swift; sourceTree = ""; }; - AB706F96278A77E20025A48A /* HistoryReducer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HistoryReducer.swift; sourceTree = ""; }; - AB706F98278A820C0025A48A /* FiltersReducer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FiltersReducer.swift; sourceTree = ""; }; - AB706F9A278AC5A30025A48A /* SearchRootView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchRootView.swift; sourceTree = ""; }; - AB706F9C278ACCA20025A48A /* SearchRootReducer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchRootReducer.swift; sourceTree = ""; }; - AB706F9E278AD4800025A48A /* GalleryHistoryCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GalleryHistoryCell.swift; sourceTree = ""; }; - AB706FA0278BCEC60025A48A /* DetailView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DetailView.swift; sourceTree = ""; }; - AB706FA2278BCF2F0025A48A /* DetailReducer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DetailReducer.swift; sourceTree = ""; }; - AB706FA4278C3DDE0025A48A /* PreviewsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PreviewsView.swift; sourceTree = ""; }; - AB7B29F126AC471E00EE1F14 /* Model5toModel6MigrationPolicy.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Model5toModel6MigrationPolicy.swift; sourceTree = ""; }; - AB7B29F526AC741600EE1F14 /* GenericList.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GenericList.swift; sourceTree = ""; }; - AB7BF2A827A63C89001865A3 /* Language.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Language.swift; sourceTree = ""; }; - AB7BF2AA27A642FB001865A3 /* BrowsingCountry.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BrowsingCountry.swift; sourceTree = ""; }; - AB7BF2B627A9652F001865A3 /* Greeting.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Greeting.swift; sourceTree = ""; }; - AB7BF2B927A96562001865A3 /* Gallery.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Gallery.swift; sourceTree = ""; }; - AB7BF2BB27A965DA001865A3 /* Category.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Category.swift; sourceTree = ""; }; - AB7BF2BF27A9669A001865A3 /* TagNamespace.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TagNamespace.swift; sourceTree = ""; }; - AB7BF2C127A96760001865A3 /* GalleryDetail.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GalleryDetail.swift; sourceTree = ""; }; - AB7BF2C327A9683F001865A3 /* GalleryArchive.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GalleryArchive.swift; sourceTree = ""; }; - AB7BF2C527A968AB001865A3 /* TranslatableLanguage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TranslatableLanguage.swift; sourceTree = ""; }; - AB7BF2C727A968F7001865A3 /* GalleryComment.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GalleryComment.swift; sourceTree = ""; }; - AB7BF2C927A969F4001865A3 /* GalleryState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GalleryState.swift; sourceTree = ""; }; - AB7BF2CB27A96A3C001865A3 /* GalleryTorrent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GalleryTorrent.swift; sourceTree = ""; }; - AB7BF2CD27AA3E58001865A3 /* AppUtil.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppUtil.swift; sourceTree = ""; }; - AB7BF2CF27AA3E75001865A3 /* DeviceUtil.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeviceUtil.swift; sourceTree = ""; }; - AB7BF2D127AA3EDC001865A3 /* HapticsUtil.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HapticsUtil.swift; sourceTree = ""; }; - AB7BF2D327AA3F12001865A3 /* CookieUtil.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CookieUtil.swift; sourceTree = ""; }; - AB7BF2D527AA3F4C001865A3 /* FileUtil.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileUtil.swift; sourceTree = ""; }; - AB7BF2D727AA3F61001865A3 /* UserDefaultsUtil.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserDefaultsUtil.swift; sourceTree = ""; }; - AB7BF2D927AA78CF001865A3 /* Reducer_Extension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Reducer_Extension.swift; sourceTree = ""; }; - AB7BF2FA27ABCA3A001865A3 /* MigrationView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MigrationView.swift; sourceTree = ""; }; - AB7BF2FC27ABCAD4001865A3 /* MigrationReducer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MigrationReducer.swift; sourceTree = ""; }; - AB7BF2FE27ABDFF1001865A3 /* CoreDataMigrator.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CoreDataMigrator.swift; sourceTree = ""; }; - AB7BF30327ABDFF1001865A3 /* CoreDataMigrationStep.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CoreDataMigrationStep.swift; sourceTree = ""; }; - AB7BF30627ABDFF1001865A3 /* CoreDataMigrationVersion.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CoreDataMigrationVersion.swift; sourceTree = ""; }; - AB7BF31327ABE028001865A3 /* NSManagedObjectModel+Resource.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "NSManagedObjectModel+Resource.swift"; sourceTree = ""; }; - AB7BF31427ABE028001865A3 /* NSManagedObjectModel+Compatible.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "NSManagedObjectModel+Compatible.swift"; sourceTree = ""; }; - AB7BF31627ABE028001865A3 /* FileManager+ApplicationSupport.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "FileManager+ApplicationSupport.swift"; sourceTree = ""; }; - AB7BF31827ABE028001865A3 /* NSPersistentStoreCoordinator+SQLite.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "NSPersistentStoreCoordinator+SQLite.swift"; sourceTree = ""; }; - AB7E6B3125D24FE00035CC68 /* ja */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ja; path = ja.lproj/InfoPlist.strings; sourceTree = ""; }; - AB7E6B3425D24FE40035CC68 /* zh-Hans */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "zh-Hans"; path = "zh-Hans.lproj/InfoPlist.strings"; sourceTree = ""; }; - AB7E6B3525D24FE50035CC68 /* en */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = en; path = en.lproj/InfoPlist.strings; sourceTree = ""; }; - AB86ABF42782DAB300E61E6A /* LogsReducer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LogsReducer.swift; sourceTree = ""; }; - AB86ABF62782DDE600E61E6A /* FileClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileClient.swift; sourceTree = ""; }; - AB86ABF82782EC0D00E61E6A /* AboutView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AboutView.swift; sourceTree = ""; }; - AB86AC092782FAFA00E61E6A /* AppearanceSettingReducer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppearanceSettingReducer.swift; sourceTree = ""; }; - AB86AC1227856F2700E61E6A /* AppLockReducer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppLockReducer.swift; sourceTree = ""; }; - AB86AC192785C2B300E61E6A /* HomeReducer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HomeReducer.swift; sourceTree = ""; }; - AB8C821826BF801700E8C5E6 /* EhSetting.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EhSetting.swift; sourceTree = ""; }; - AB902766291F548600697256 /* AppIcon_NotMyPresident@3x.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = "AppIcon_NotMyPresident@3x.png"; sourceTree = ""; }; - AB902767291F548600697256 /* AppIcon_NotMyPresident_iPad@2x.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = "AppIcon_NotMyPresident_iPad@2x.png"; sourceTree = ""; }; - AB902768291F548700697256 /* AppIcon_NotMyPresident_iPad_Pro@2x.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = "AppIcon_NotMyPresident_iPad_Pro@2x.png"; sourceTree = ""; }; - AB902769291F548700697256 /* AppIcon_NotMyPresident_iPad.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = AppIcon_NotMyPresident_iPad.png; sourceTree = ""; }; - AB90276A291F548700697256 /* AppIcon_NotMyPresident@2x.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = "AppIcon_NotMyPresident@2x.png"; sourceTree = ""; }; - AB994DBB25986F7A00E9A367 /* ja */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ja; path = ja.lproj/Localizable.strings; sourceTree = ""; }; - ABA732D825A8018A00B3D9AB /* Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Extensions.swift; sourceTree = ""; }; - ABA732DE25A852D800B3D9AB /* Filter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Filter.swift; sourceTree = ""; }; - ABA9A6BB28EC786100EE28DE /* swiftgen.yml */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.yaml; path = swiftgen.yml; sourceTree = SOURCE_ROOT; }; - ABA9A6BE28EC7BA200EE28DE /* en */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = en; path = en.lproj/Constant.strings; sourceTree = ""; }; - ABA9A6C128EC7BD000EE28DE /* Strings.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Strings.swift; sourceTree = ""; }; - ABAB5B9427EF023300198597 /* Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Extensions.swift; sourceTree = ""; }; - ABB5013026A41EBA00B542D9 /* ko */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ko; path = ko.lproj/Localizable.strings; sourceTree = ""; }; - ABB5013126A41EBA00B542D9 /* ko */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ko; path = ko.lproj/InfoPlist.strings; sourceTree = ""; }; - ABBB2630278E6EF3007B6149 /* SearchView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchView.swift; sourceTree = ""; }; - ABBB2637278FBD2F007B6149 /* SwiftUINavigation_Extension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SwiftUINavigation_Extension.swift; sourceTree = ""; }; - ABBB26392792588F007B6149 /* TTProgressHUD_Extension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TTProgressHUD_Extension.swift; sourceTree = ""; }; - ABBB263D2793C648007B6149 /* PreviewsReducer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PreviewsReducer.swift; sourceTree = ""; }; - ABBB263F279417EC007B6149 /* CommentsReducer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CommentsReducer.swift; sourceTree = ""; }; - ABBB264127942B74007B6149 /* URLClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = URLClient.swift; sourceTree = ""; }; - ABBB266527977C2A007B6149 /* ArchivesReducer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ArchivesReducer.swift; sourceTree = ""; }; - ABBB26672797BFAA007B6149 /* ActivityView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ActivityView.swift; sourceTree = ""; }; - ABBB26692797C61F007B6149 /* TorrentsReducer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TorrentsReducer.swift; sourceTree = ""; }; - ABBB266B2797E882007B6149 /* ClipboardClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ClipboardClient.swift; sourceTree = ""; }; - ABBB266D27998479007B6149 /* QuickSearchReducer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = QuickSearchReducer.swift; sourceTree = ""; }; - ABBB2670279AFA61007B6149 /* EnvironmentKeys.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EnvironmentKeys.swift; sourceTree = ""; }; - ABBB2672279B9332007B6149 /* ReadingView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReadingView.swift; sourceTree = ""; }; - ABBB2674279B933D007B6149 /* ReadingReducer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReadingReducer.swift; sourceTree = ""; }; - ABBB2676279CDBB0007B6149 /* ImageClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImageClient.swift; sourceTree = ""; }; - ABBB2678279D454C007B6149 /* GalleryInfosReducer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GalleryInfosReducer.swift; sourceTree = ""; }; - ABBC332726BE31AE0084A331 /* EhSettingView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EhSettingView.swift; sourceTree = ""; }; - ABBC332926BE7C940084A331 /* SettingTextField.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingTextField.swift; sourceTree = ""; }; - ABBCCC8F26C95F6E007D8A36 /* GalleryInfosView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GalleryInfosView.swift; sourceTree = ""; }; - ABBD2B5F2768D7AD0072AED2 /* GalleryRankingCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GalleryRankingCell.swift; sourceTree = ""; }; - ABC0A8D026F7037F008EC24C /* IPBanned.html */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.html; path = IPBanned.html; sourceTree = ""; }; - ABC1FAB72642C37D00A9F352 /* NewDawnView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NewDawnView.swift; sourceTree = ""; }; ABC3C7542593696C00E0C11B /* EhPanda.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = EhPanda.app; sourceTree = BUILT_PRODUCTS_DIR; }; - ABC3C7692593699A00E0C11B /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; - ABC3C76B2593699A00E0C11B /* EhPandaApp.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = EhPandaApp.swift; sourceTree = ""; }; - ABC3C76D2593699A00E0C11B /* Defaults.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Defaults.swift; sourceTree = ""; }; - ABC3C76E2593699A00E0C11B /* Info.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; - ABC3C7762593699A00E0C11B /* ViewModifiers.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ViewModifiers.swift; sourceTree = ""; }; - ABC4A07A2753084100968A4F /* Model 5.xcdatamodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcdatamodel; path = "Model 5.xcdatamodel"; sourceTree = ""; }; - ABC681F226898D46007BBD69 /* Model.xcdatamodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcdatamodel; path = Model.xcdatamodel; sourceTree = ""; }; - ABC732C427B9024500D47DA9 /* LiveText.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LiveText.swift; sourceTree = ""; }; - ABC732C627B90F0900D47DA9 /* LiveTextView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LiveTextView.swift; sourceTree = ""; }; - ABC8355C27B118330091DCDB /* DetailSearchView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = DetailSearchView.swift; sourceTree = ""; }; - ABC8355E27B118370091DCDB /* DetailSearchReducer.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = DetailSearchReducer.swift; sourceTree = ""; }; - ABCA93BD26918DE100A98BC6 /* Persistence.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Persistence.swift; sourceTree = ""; }; - ABCA93BF2691925900A98BC6 /* GalleryMO+CoreDataClass.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "GalleryMO+CoreDataClass.swift"; sourceTree = ""; }; - ABCA93C12691929D00A98BC6 /* GalleryDetailMO+CoreDataClass.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "GalleryDetailMO+CoreDataClass.swift"; sourceTree = ""; }; - ABCD2F09259763FC008E5A20 /* Request.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Request.swift; sourceTree = ""; }; - ABCD2F0D25976B95008E5A20 /* Parser.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Parser.swift; sourceTree = ""; }; - ABD4032526B78E5A00001B8C /* GalleryThumbnailCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GalleryThumbnailCell.swift; sourceTree = ""; }; - ABD4032726B7967F00001B8C /* CategoryView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CategoryView.swift; sourceTree = ""; }; - ABD49D59277C5356003D1A07 /* FavoritesReducer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FavoritesReducer.swift; sourceTree = ""; }; - ABD49D5F277C7722003D1A07 /* TabBarView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TabBarView.swift; sourceTree = ""; }; - ABD49D63277C7AD5003D1A07 /* TabBarReducer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TabBarReducer.swift; sourceTree = ""; }; - ABD49D66277EAC90003D1A07 /* URLUtil.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = URLUtil.swift; sourceTree = ""; }; - ABD5FDD3263D05110021A4C6 /* .swiftlint.yml */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.yaml; path = .swiftlint.yml; sourceTree = SOURCE_ROOT; }; - ABD9770D27B65A7300983DE7 /* ListParserTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ListParserTests.swift; sourceTree = ""; }; - ABD9770F27B65E3400983DE7 /* GalleryDetailParserTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GalleryDetailParserTests.swift; sourceTree = ""; }; - ABD9771227B6612400983DE7 /* GreetingParserTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GreetingParserTests.swift; sourceTree = ""; }; - ABDD3E872930E73E009B3C2D /* zh-Hant-TW */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "zh-Hant-TW"; path = "zh-Hant-TW.lproj/InfoPlist.strings"; sourceTree = ""; }; - ABDD3E882930E73E009B3C2D /* zh-Hant-TW */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "zh-Hant-TW"; path = "zh-Hant-TW.lproj/Localizable.strings"; sourceTree = ""; }; - ABDD3E8B2930E797009B3C2D /* zh-Hant-HK */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "zh-Hant-HK"; path = "zh-Hant-HK.lproj/InfoPlist.strings"; sourceTree = ""; }; - ABDD3E8C2930E797009B3C2D /* zh-Hant-HK */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "zh-Hant-HK"; path = "zh-Hant-HK.lproj/Localizable.strings"; sourceTree = ""; }; - ABDD3E8D2930E879009B3C2D /* zh-Hant */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "zh-Hant"; path = "zh-Hant.lproj/InfoPlist.strings"; sourceTree = ""; }; - ABDD3E8E2930E879009B3C2D /* zh-Hant */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "zh-Hant"; path = "zh-Hant.lproj/Localizable.strings"; sourceTree = ""; }; - ABE1867726A1733000689FDC /* LaboratorySettingView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LaboratorySettingView.swift; sourceTree = ""; }; - ABE9011D27F722D000F3651D /* AppIcon_StandWithUkraine2022@2x.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = "AppIcon_StandWithUkraine2022@2x.png"; sourceTree = ""; }; - ABE9011E27F722D100F3651D /* AppIcon_StandWithUkraine2022_iPad.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = AppIcon_StandWithUkraine2022_iPad.png; sourceTree = ""; }; - ABE9011F27F722D100F3651D /* AppIcon_StandWithUkraine2022@3x.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = "AppIcon_StandWithUkraine2022@3x.png"; sourceTree = ""; }; - ABE9012027F722D100F3651D /* AppIcon_StandWithUkraine2022_iPad_Pro@2x.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = "AppIcon_StandWithUkraine2022_iPad_Pro@2x.png"; sourceTree = ""; }; - ABE9012127F722D100F3651D /* AppIcon_StandWithUkraine2022_iPad@2x.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = "AppIcon_StandWithUkraine2022_iPad@2x.png"; sourceTree = ""; }; - ABE9401426FF158D0085E158 /* QuickSearchView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = QuickSearchView.swift; sourceTree = ""; }; - ABE9401626FF2E610085E158 /* Model 4.xcdatamodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcdatamodel; path = "Model 4.xcdatamodel"; sourceTree = ""; }; - ABEA1FE525A9B40B002966B9 /* Setting.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Setting.swift; sourceTree = ""; }; - ABEE0AFB2595C6F800C997AE /* en */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = en; path = en.lproj/Localizable.strings; sourceTree = ""; }; - ABEE0AFE2595C73D00C997AE /* zh-Hans */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "zh-Hans"; path = "zh-Hans.lproj/Localizable.strings"; sourceTree = ""; }; - ABF294CC26D20F82004DD03A /* EhPandaTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = EhPandaTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; - ABF313A425B1AB6600D47A2F /* Misc.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Misc.swift; sourceTree = ""; }; - ABF45AB625F3312F00ECB568 /* AppError.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppError.swift; sourceTree = ""; }; - ABF45AC125F3313D00ECB568 /* FiltersView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = FiltersView.swift; sourceTree = ""; }; - ABF45AC725F3313D00ECB568 /* TagCloudView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = TagCloudView.swift; sourceTree = ""; }; - ABF45AC825F3313D00ECB568 /* PostCommentView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = PostCommentView.swift; sourceTree = ""; }; - ABF45ACA25F3313D00ECB568 /* RatingView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = RatingView.swift; sourceTree = ""; }; - ABF45ACB25F3313D00ECB568 /* LinkedText.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = LinkedText.swift; sourceTree = ""; }; - ABF45ACC25F3313D00ECB568 /* AlertView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AlertView.swift; sourceTree = ""; }; - ABF45ACD25F3313D00ECB568 /* Placeholder.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Placeholder.swift; sourceTree = ""; }; - ABF45ACE25F3313D00ECB568 /* GalleryDetailCell.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = GalleryDetailCell.swift; sourceTree = ""; }; - ABF45AD325F3313D00ECB568 /* ArchivesView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ArchivesView.swift; sourceTree = ""; }; - ABF45AD425F3313D00ECB568 /* TorrentsView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = TorrentsView.swift; sourceTree = ""; }; - ABF45AD525F3313D00ECB568 /* CommentsView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CommentsView.swift; sourceTree = ""; }; - ABF45AD825F3313D00ECB568 /* GeneralSettingView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = GeneralSettingView.swift; sourceTree = ""; }; - ABF45AD925F3313D00ECB568 /* AccountSettingView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AccountSettingView.swift; sourceTree = ""; }; - ABF45ADA25F3313D00ECB568 /* WebView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = WebView.swift; sourceTree = ""; }; - ABF45ADB25F3313D00ECB568 /* ReadingSettingView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ReadingSettingView.swift; sourceTree = ""; }; - ABF45ADC25F3313D00ECB568 /* AppearanceSettingView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppearanceSettingView.swift; sourceTree = ""; }; - ABF45ADD25F3313D00ECB568 /* SettingView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SettingView.swift; sourceTree = ""; }; - ABF53F4725A306D200AB5918 /* EhPanda.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = EhPanda.entitlements; sourceTree = ""; }; - ABF75F3E25A19CD200544D29 /* User.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = User.swift; sourceTree = ""; }; - ABF9720926DE6E1300118887 /* GalleryDetailWithGreeting.html */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.html; path = GalleryDetailWithGreeting.html; sourceTree = ""; }; - EA0BBD462E37CCB700DC8143 /* CODEOWNERS */ = {isa = PBXFileReference; lastKnownFileType = text; name = CODEOWNERS; path = .github/CODEOWNERS; sourceTree = ""; }; - EA0C92432C3EB42300D211F6 /* ISSUE_TEMPLATE */ = {isa = PBXFileReference; lastKnownFileType = folder; name = ISSUE_TEMPLATE; path = .github/ISSUE_TEMPLATE; sourceTree = ""; }; - EA0C92442C3EB42300D211F6 /* workflows */ = {isa = PBXFileReference; lastKnownFileType = folder; name = workflows; path = .github/workflows; sourceTree = ""; }; EA0C92482C3EB45E00D211F6 /* AltStore.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = AltStore.json; sourceTree = ""; }; - EA0C92492C3EB45E00D211F6 /* swiftgen.yml */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.yaml; path = swiftgen.yml; sourceTree = ""; }; EA0C924A2C3EB45E00D211F6 /* .gitattributes */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text; path = .gitattributes; sourceTree = ""; }; EA0C924B2C3EB45E00D211F6 /* .swiftlint.yml */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.yaml; path = .swiftlint.yml; sourceTree = ""; }; EA0C924C2C3EB45E00D211F6 /* .gitignore */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text; path = .gitignore; sourceTree = ""; }; @@ -600,940 +53,124 @@ EA0C92562C3EB49500D211F6 /* README.de.md */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = net.daringfireball.markdown; name = README.de.md; path = READMEs/README.de.md; sourceTree = ""; }; EA0C92572C3EB49500D211F6 /* README.chs.md */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = net.daringfireball.markdown; name = README.chs.md; path = READMEs/README.chs.md; sourceTree = ""; }; EA0C92582C3EB49500D211F6 /* README.jpn.md */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = net.daringfireball.markdown; name = README.jpn.md; path = READMEs/README.jpn.md; sourceTree = ""; }; - EA2E2E7E2A1F7E500038A261 /* SettingReducer.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SettingReducer.swift; sourceTree = ""; }; - EA2E2E812A1FA1050038A261 /* SearchReducer.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SearchReducer.swift; sourceTree = ""; }; - EA5AA4A32EA9149E00BC2B5C /* AutoPlayHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AutoPlayHandler.swift; sourceTree = ""; }; - EA5AA4A42EA9149E00BC2B5C /* GestureHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GestureHandler.swift; sourceTree = ""; }; - EA5AA4A52EA9149E00BC2B5C /* LiveTextHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LiveTextHandler.swift; sourceTree = ""; }; - EA5AA4A62EA9149E00BC2B5C /* PageHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PageHandler.swift; sourceTree = ""; }; - EA698C022CCDD2FB0058BC19 /* EquatableVoid.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EquatableVoid.swift; sourceTree = ""; }; - EA698C082CCDE7050058BC19 /* IdentifiableBox.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IdentifiableBox.swift; sourceTree = ""; }; /* End PBXFileReference section */ - -/* Begin PBXFrameworksBuildPhase section */ - AB5BE67326B95FDD007D4A55 /* Frameworks */ = { - isa = PBXFrameworksBuildPhase; - buildActionMask = 2147483647; - files = ( - ); - runOnlyForDeploymentPostprocessing = 0; - }; - ABC3C7512593696C00E0C11B /* Frameworks */ = { - isa = PBXFrameworksBuildPhase; - buildActionMask = 2147483647; - files = ( - AB2EB99F280251D600011A8A /* TTProgressHUD in Frameworks */, - AB2EB9A52802521700011A8A /* DeprecatedAPI in Frameworks */, - AB17574027678B3400FD64E2 /* UIImageColors in Frameworks */, - AB2EB9A2280251F600011A8A /* AlertKit in Frameworks */, - ABD7005926B1C31500DC59C9 /* Kanna in Frameworks */, - AB60D0E9274C7ECE00F899AB /* WaterfallGrid in Frameworks */, - ABC4A0792751B40E00968A4F /* Kingfisher in Frameworks */, - EAE63E2129E2A6330048C601 /* SwiftyBeaver in Frameworks */, - AB26F59927ACDB4200AB3468 /* FilePicker in Frameworks */, - AB6505A026B0027800F91E9D /* SwiftUIPager in Frameworks */, - ABD49D5D277C6C9D003D1A07 /* SFSafeSymbols in Frameworks */, - ABAC82FE26BC4A96009F5026 /* OpenCC in Frameworks */, - AB86AC1027831AD100E61E6A /* ComposableArchitecture in Frameworks */, - ABBB2636278FB888007B6149 /* SwiftUINavigation in Frameworks */, - AB1FA94927C62BC80063EF55 /* CommonMark in Frameworks */, - AB17573D27675B1E00FD64E2 /* Colorful in Frameworks */, - ); - runOnlyForDeploymentPostprocessing = 0; - }; - ABF294C926D20F82004DD03A /* Frameworks */ = { - isa = PBXFrameworksBuildPhase; - buildActionMask = 2147483647; - files = ( - ); - runOnlyForDeploymentPostprocessing = 0; - }; -/* End PBXFrameworksBuildPhase section */ - -/* Begin PBXGroup section */ - AB0929C12781589000F107CA /* Clients */ = { - isa = PBXGroup; - children = ( - AB0929C72781938A00F107CA /* DFClient.swift */, - AB86ABF62782DDE600E61E6A /* FileClient.swift */, - ABBB264127942B74007B6149 /* URLClient.swift */, - ABBB2676279CDBB0007B6149 /* ImageClient.swift */, - AB706F8D278A5DCF0025A48A /* DeviceClient.swift */, - AB0929D12781E7D500F107CA /* LoggerClient.swift */, - AB0929CB2781A0B000F107CA /* HapticsClient.swift */, - AB0929C5278160AE00F107CA /* LibraryClient.swift */, - AB0929C9278196ED00F107CA /* CookieClient.swift */, - AB0929CD2781AADA00F107CA /* DatabaseClient.swift */, - ABBB266B2797E882007B6149 /* ClipboardClient.swift */, - AB706F8F278A5F680025A48A /* AppDelegateClient.swift */, - AB0929D32781EDDC00F107CA /* UserDefaultsClient.swift */, - AB0929CF2781E1CC00F107CA /* UIApplicationClient.swift */, - AB0929D72782A83A00F107CA /* AuthorizationClient.swift */, - ); - path = Clients; - sourceTree = ""; - }; - AB1FA8FA27C5DE800063EF55 /* Tags */ = { - isa = PBXGroup; - children = ( - AB1FA8FB27C5E0E50063EF55 /* TagDetail.swift */, - AB26F59327ACC6CD00AB3468 /* TagTranslator.swift */, - AB1FA94C27CA1F140063EF55 /* TagTranslation.swift */, - AB7BF2BF27A9669A001865A3 /* TagNamespace.swift */, - AB0CFBCA27C0B07F004BD372 /* TagSuggestion.swift */, - AB7BF2C527A968AB001865A3 /* TranslatableLanguage.swift */, - AB0CFBCC27C1CC67004BD372 /* EhTagTranslationDatabaseModel.swift */, - ); - path = Tags; - sourceTree = ""; - }; - AB24C55D276756A40085C33A /* Support */ = { - isa = PBXGroup; - children = ( - ABF45AC125F3313D00ECB568 /* FiltersView.swift */, - AB706F98278A820C0025A48A /* FiltersReducer.swift */, - ABC1FAB72642C37D00A9F352 /* NewDawnView.swift */, - ABF45AC625F3313D00ECB568 /* Components */, - ); - path = Support; - sourceTree = ""; - }; - AB24C55F276757240085C33A /* Favorites */ = { - isa = PBXGroup; - children = ( - AB24C55927674EDF0085C33A /* FavoritesView.swift */, - ABD49D59277C5356003D1A07 /* FavoritesReducer.swift */, - ); - path = Favorites; - sourceTree = ""; - }; - AB24C561276757A30085C33A /* Support */ = { - isa = PBXGroup; - children = ( - EA5AA4A32EA9149E00BC2B5C /* AutoPlayHandler.swift */, - EA5AA4A42EA9149E00BC2B5C /* GestureHandler.swift */, - EA5AA4A52EA9149E00BC2B5C /* LiveTextHandler.swift */, - EA5AA4A62EA9149E00BC2B5C /* PageHandler.swift */, - ABC732C627B90F0900D47DA9 /* LiveTextView.swift */, - AB69CB8126B3DAF400699359 /* ControlPanel.swift */, - AB69CB7F26B3DABC00699359 /* AdvancedList.swift */, - ); - path = Support; - sourceTree = ""; - }; - AB24C562276757B00085C33A /* Components */ = { - isa = PBXGroup; - children = ( - ABF45ACB25F3313D00ECB568 /* LinkedText.swift */, - ABF45ACA25F3313D00ECB568 /* RatingView.swift */, - AB0CFBD627C3B2D0004BD372 /* TagDetailView.swift */, - ABF45AC825F3313D00ECB568 /* PostCommentView.swift */, - ); - path = Components; - sourceTree = ""; - }; - AB24C563276757C30085C33A /* Support */ = { - isa = PBXGroup; - children = ( - ABE9401426FF158D0085E158 /* QuickSearchView.swift */, - ABBB266D27998479007B6149 /* QuickSearchReducer.swift */, - ); - path = Support; - sourceTree = ""; - }; - AB24C564276758D00085C33A /* Cells */ = { - isa = PBXGroup; - children = ( - ABF45ACE25F3313D00ECB568 /* GalleryDetailCell.swift */, - ABD4032526B78E5A00001B8C /* GalleryThumbnailCell.swift */, - AB24C565276758E30085C33A /* GalleryCardCell.swift */, - ABBD2B5F2768D7AD0072AED2 /* GalleryRankingCell.swift */, - AB706F9E278AD4800025A48A /* GalleryHistoryCell.swift */, - ); - path = Cells; - sourceTree = ""; - }; - AB31CD2E27B666D500F40E0A /* Models */ = { - isa = PBXGroup; - children = ( - AB31CD2F27B666E200F40E0A /* TestError.swift */, - AB31CD3627B6695800F40E0A /* HTMLFilename.swift */, - AB31CD3A27B66E0300F40E0A /* ListParserTestType.swift */, - ); - path = Models; - sourceTree = ""; - }; - AB31CD3327B6674B00F40E0A /* List */ = { - isa = PBXGroup; - children = ( - ABD9770D27B65A7300983DE7 /* ListParserTests.swift */, - ); - path = List; - sourceTree = ""; - }; - AB31CD3427B6675100F40E0A /* Gallery */ = { - isa = PBXGroup; - children = ( - ABD9770F27B65E3400983DE7 /* GalleryDetailParserTests.swift */, - AB31CD4227B676C300F40E0A /* GalleryMPVKeysParserTests.swift */, - AB31CD3C27B66F7D00F40E0A /* GalleryImageURLParserTests.swift */, - ); - path = Gallery; - sourceTree = ""; - }; - AB31CD3527B6675D00F40E0A /* Other */ = { - isa = PBXGroup; - children = ( - ABD9771227B6612400983DE7 /* GreetingParserTests.swift */, - AB0CFB8127BBBFCE004BD372 /* EhSettingParserTests.swift */, - AB31CD3127B6671400F40E0A /* BanIntervalParserTests.swift */, - ); - path = Other; - sourceTree = ""; - }; - AB3E9E6126D210B1008FE518 /* EhPandaTests */ = { - isa = PBXGroup; - children = ( - ABA12F2E27D49AD10021922D /* Tests */, - AB31CD2E27B666D500F40E0A /* Models */, - AB3E9E6226D210B1008FE518 /* Resources */, - ); - path = EhPandaTests; - sourceTree = ""; - }; - AB3E9E6226D210B1008FE518 /* Resources */ = { - isa = PBXGroup; - children = ( - AB3E9E6C26D210B1008FE518 /* Utility */, - AB3E9E6326D210B1008FE518 /* Parser */, - ); - path = Resources; - sourceTree = ""; - }; - AB3E9E6326D210B1008FE518 /* Parser */ = { - isa = PBXGroup; - children = ( - AB3E9E6626D210B1008FE518 /* List */, - AB3E9E6426D210B1008FE518 /* Gallery */, - ABD9771127B65F9D00983DE7 /* Other */, - ); - path = Parser; - sourceTree = ""; - }; - AB3E9E6426D210B1008FE518 /* Gallery */ = { - isa = PBXGroup; - children = ( - AB3E9E6526D210B1008FE518 /* GalleryDetail.html */, - AB31CD4027B6769F00F40E0A /* GalleryMPVKeys.html */, - AB31CD3E27B670FD00F40E0A /* GalleryNormalImageURL.html */, - ); - path = Gallery; - sourceTree = ""; - }; - AB3E9E6626D210B1008FE518 /* List */ = { - isa = PBXGroup; - children = ( - AB41DB3727B760D700DD3604 /* FavoritesCompactList.html */, - AB41DB2F27B760D700DD3604 /* FavoritesExtendedList.html */, - AB41DB3827B760D700DD3604 /* FavoritesMinimalList.html */, - AB41DB3427B760D700DD3604 /* FavoritesMinimalPlusList.html */, - AB41DB2D27B760D700DD3604 /* FavoritesThumbnailList.html */, - AB41DB3127B760D700DD3604 /* FrontPageCompactList.html */, - AB41DB2A27B760D600DD3604 /* FrontPageExtendedList.html */, - AB41DB3027B760D700DD3604 /* FrontPageMinimalList.html */, - AB41DB2B27B760D600DD3604 /* FrontPageMinimalPlusList.html */, - AB41DB3627B760D700DD3604 /* FrontPageThumbnailList.html */, - AB41DB3B27B760D700DD3604 /* PopularCompactList.html */, - AB41DB2C27B760D600DD3604 /* PopularExtendedList.html */, - AB41DB3C27B760D700DD3604 /* PopularMinimalList.html */, - AB41DB3227B760D700DD3604 /* PopularMinimalPlusList.html */, - AB41DB2927B760D600DD3604 /* PopularThumbnailList.html */, - AB41DB2E27B760D700DD3604 /* ToplistsCompactList.html */, - AB41DB2827B760D600DD3604 /* WatchedCompactList.html */, - AB41DB3327B760D700DD3604 /* WatchedExtendedList.html */, - AB41DB3A27B760D700DD3604 /* WatchedMinimalList.html */, - AB41DB3927B760D700DD3604 /* WatchedMinimalPlusList.html */, - AB41DB3527B760D700DD3604 /* WatchedThumbnailList.html */, - ); - path = List; - sourceTree = ""; - }; - AB3E9E6C26D210B1008FE518 /* Utility */ = { - isa = PBXGroup; - children = ( - AB3E9E6D26D210B1008FE518 /* TestHelper.swift */, - ABAB5B9427EF023300198597 /* Extensions.swift */, - ); - path = Utility; - sourceTree = ""; - }; - AB40CFE52598423E00D1DC9A /* Tools */ = { - isa = PBXGroup; - children = ( - AB706F7E278981210025A48A /* Extensions */, - AB0929C12781589000F107CA /* Clients */, - ABD49D65277EAC7E003D1A07 /* Utilities */, - ABCD2F0D25976B95008E5A20 /* Parser.swift */, - ABC3C76D2593699A00E0C11B /* Defaults.swift */, - AB38A0CA25CA993D00764D64 /* ColorCodable.swift */, - EA698C022CCDD2FB0058BC19 /* EquatableVoid.swift */, - EA698C082CCDE7050058BC19 /* IdentifiableBox.swift */, - ABBB2670279AFA61007B6149 /* EnvironmentKeys.swift */, - ); - path = Tools; - sourceTree = ""; - }; - AB47FDA625BC823F0007765D /* Icons */ = { - isa = PBXGroup; - children = ( - AB0CFB6927BAB9CF004BD372 /* AppIcon_Default_iPad_Pro@2x.png */, - AB0CFB6C27BAB9CF004BD372 /* AppIcon_Default_iPad.png */, - AB0CFB6627BAB9CF004BD372 /* AppIcon_Default_iPad@2x.png */, - AB0CFB6B27BAB9CF004BD372 /* AppIcon_Default@2x.png */, - AB0CFB6527BAB9CF004BD372 /* AppIcon_Default@3x.png */, - AB0CFB8627BBD2D7004BD372 /* AppIcon_Developer_iPad_Pro@2x.png */, - AB0CFB8527BBD2D7004BD372 /* AppIcon_Developer_iPad.png */, - AB0CFB8327BBD2D7004BD372 /* AppIcon_Developer_iPad@2x.png */, - AB0CFB8427BBD2D7004BD372 /* AppIcon_Developer@2x.png */, - AB902768291F548700697256 /* AppIcon_NotMyPresident_iPad_Pro@2x.png */, - AB902769291F548700697256 /* AppIcon_NotMyPresident_iPad.png */, - AB902767291F548600697256 /* AppIcon_NotMyPresident_iPad@2x.png */, - AB90276A291F548700697256 /* AppIcon_NotMyPresident@2x.png */, - AB902766291F548600697256 /* AppIcon_NotMyPresident@3x.png */, - ABE9012027F722D100F3651D /* AppIcon_StandWithUkraine2022_iPad_Pro@2x.png */, - ABE9011E27F722D100F3651D /* AppIcon_StandWithUkraine2022_iPad.png */, - ABE9012127F722D100F3651D /* AppIcon_StandWithUkraine2022_iPad@2x.png */, - ABE9011D27F722D000F3651D /* AppIcon_StandWithUkraine2022@2x.png */, - ABE9011F27F722D100F3651D /* AppIcon_StandWithUkraine2022@3x.png */, - AB0CFB8727BBD2D7004BD372 /* AppIcon_Developer@3x.png */, - AB0CFB8F27BBD323004BD372 /* AppIcon_Ukiyoe_iPad_Pro@2x.png */, - AB0CFB9027BBD323004BD372 /* AppIcon_Ukiyoe_iPad.png */, - AB0CFB8D27BBD323004BD372 /* AppIcon_Ukiyoe_iPad@2x.png */, - AB0CFB9127BBD323004BD372 /* AppIcon_Ukiyoe@2x.png */, - AB0CFB8E27BBD323004BD372 /* AppIcon_Ukiyoe@3x.png */, - ); - path = Icons; - sourceTree = ""; - }; - AB5BE67726B95FDD007D4A55 /* ShareExtension */ = { - isa = PBXGroup; - children = ( - AB5BE67826B95FDD007D4A55 /* ShareViewController.swift */, - AB5BE67D26B95FDD007D4A55 /* Info.plist */, - ); - path = ShareExtension; - sourceTree = ""; - }; - AB706F7E278981210025A48A /* Extensions */ = { - isa = PBXGroup; - children = ( - ABA732D825A8018A00B3D9AB /* Extensions.swift */, - ABC3C7762593699A00E0C11B /* ViewModifiers.swift */, - AB706F7F278981370025A48A /* AlertKit_Extension.swift */, - AB7BF2D927AA78CF001865A3 /* Reducer_Extension.swift */, - ABBB26392792588F007B6149 /* TTProgressHUD_Extension.swift */, - ABBB2637278FBD2F007B6149 /* SwiftUINavigation_Extension.swift */, - ); - path = Extensions; - sourceTree = ""; - }; - AB7B29F326AC472B00EE1F14 /* MODefinition */ = { - isa = PBXGroup; - children = ( - AB63EADC2699AC9100090535 /* AppEnvMO+CoreDataClass.swift */, - AB63EADA2699AC8200090535 /* AppEnvMO+CoreDataProperties.swift */, - ABCA93BF2691925900A98BC6 /* GalleryMO+CoreDataClass.swift */, - AB2CED63268AB6AE003130F7 /* GalleryMO+CoreDataProperties.swift */, - ABCA93C12691929D00A98BC6 /* GalleryDetailMO+CoreDataClass.swift */, - AB4FD2C0268AB83300A95968 /* GalleryDetailMO+CoreDataProperties.swift */, - AB10117F26986C1100C2C1A9 /* GalleryStateMO+CoreDataClass.swift */, - AB10117D26986B7D00C2C1A9 /* GalleryStateMO+CoreDataProperties.swift */, - ); - path = MODefinition; - sourceTree = ""; - }; - AB7B29F426AC475300EE1F14 /* Migration */ = { - isa = PBXGroup; - children = ( - AB7BF30327ABDFF1001865A3 /* CoreDataMigrationStep.swift */, - AB7BF30627ABDFF1001865A3 /* CoreDataMigrationVersion.swift */, - AB7BF2FE27ABDFF1001865A3 /* CoreDataMigrator.swift */, - AB7BF2FF27ABDFF1001865A3 /* Mappings */, - AB7BF30127ABDFF1001865A3 /* Policies */, - ); - path = Migration; - sourceTree = ""; - }; - AB7BF2B827A96559001865A3 /* Gallery */ = { - isa = PBXGroup; - children = ( - AB7BF2B927A96562001865A3 /* Gallery.swift */, - AB7BF2C127A96760001865A3 /* GalleryDetail.swift */, - AB7BF2C927A969F4001865A3 /* GalleryState.swift */, - AB7BF2C327A9683F001865A3 /* GalleryArchive.swift */, - AB7BF2CB27A96A3C001865A3 /* GalleryTorrent.swift */, - AB7BF2C727A968F7001865A3 /* GalleryComment.swift */, - AB7BF2BB27A965DA001865A3 /* Category.swift */, - AB7BF2A827A63C89001865A3 /* Language.swift */, - ); - path = Gallery; - sourceTree = ""; - }; - AB7BF2BD27A9663E001865A3 /* Persistent */ = { - isa = PBXGroup; - children = ( - ABF75F3E25A19CD200544D29 /* User.swift */, - ABA732DE25A852D800B3D9AB /* Filter.swift */, - ABEA1FE525A9B40B002966B9 /* Setting.swift */, - AB26F59527ACCA1800AB3468 /* AppEnv.swift */, - AB7BF2B627A9652F001865A3 /* Greeting.swift */, - ); - path = Persistent; - sourceTree = ""; - }; - AB7BF2BE27A96674001865A3 /* Support */ = { - isa = PBXGroup; - children = ( - ABF313A425B1AB6600D47A2F /* Misc.swift */, - ABC732C427B9024500D47DA9 /* LiveText.swift */, - ABF45AB625F3312F00ECB568 /* AppError.swift */, - AB8C821826BF801700E8C5E6 /* EhSetting.swift */, - AB7BF2AA27A642FB001865A3 /* BrowsingCountry.swift */, - ); - path = Support; - sourceTree = ""; - }; - AB7BF2F927ABCA20001865A3 /* Migration */ = { - isa = PBXGroup; - children = ( - AB7BF2FA27ABCA3A001865A3 /* MigrationView.swift */, - AB7BF2FC27ABCAD4001865A3 /* MigrationReducer.swift */, - ); - path = Migration; - sourceTree = ""; - }; - AB7BF2FF27ABDFF1001865A3 /* Mappings */ = { - isa = PBXGroup; - children = ( - AB26F58F27ABF21000AB3468 /* Model5toModel6.xcmappingmodel */, - ); - path = Mappings; - sourceTree = ""; - }; - AB7BF30127ABDFF1001865A3 /* Policies */ = { - isa = PBXGroup; - children = ( - AB7B29F126AC471E00EE1F14 /* Model5toModel6MigrationPolicy.swift */, - ); - path = Policies; - sourceTree = ""; - }; - AB7BF30E27ABE028001865A3 /* Extensions */ = { - isa = PBXGroup; - children = ( - AB7BF31227ABE028001865A3 /* NSManagedObjectModel */, - AB7BF31527ABE028001865A3 /* FileManager */, - AB7BF31727ABE028001865A3 /* NSPersistentStoreCoordinator */, - ); - path = Extensions; - sourceTree = ""; - }; - AB7BF31227ABE028001865A3 /* NSManagedObjectModel */ = { - isa = PBXGroup; - children = ( - AB7BF31327ABE028001865A3 /* NSManagedObjectModel+Resource.swift */, - AB7BF31427ABE028001865A3 /* NSManagedObjectModel+Compatible.swift */, - ); - path = NSManagedObjectModel; - sourceTree = ""; - }; - AB7BF31527ABE028001865A3 /* FileManager */ = { - isa = PBXGroup; - children = ( - AB7BF31627ABE028001865A3 /* FileManager+ApplicationSupport.swift */, - ); - path = FileManager; - sourceTree = ""; - }; - AB7BF31727ABE028001865A3 /* NSPersistentStoreCoordinator */ = { - isa = PBXGroup; - children = ( - AB7BF31827ABE028001865A3 /* NSPersistentStoreCoordinator+SQLite.swift */, - ); - path = NSPersistentStoreCoordinator; - sourceTree = ""; - }; - AB821BEF268A09AC009B2381 /* Database */ = { - isa = PBXGroup; - children = ( - ABC681F126898D46007BBD69 /* Model.xcdatamodeld */, - AB7B29F426AC475300EE1F14 /* Migration */, - AB7BF30E27ABE028001865A3 /* Extensions */, - AB7B29F326AC472B00EE1F14 /* MODefinition */, - ABCA93BD26918DE100A98BC6 /* Persistence.swift */, - ); - path = Database; - sourceTree = ""; - }; - AB82819926B1C39B00A80CFA /* Frameworks */ = { - isa = PBXGroup; - children = ( - ); - name = Frameworks; - sourceTree = ""; - }; - AB86AC112783226100E61E6A /* Search */ = { - isa = PBXGroup; - children = ( - AB706F9A278AC5A30025A48A /* SearchRootView.swift */, - AB706F9C278ACCA20025A48A /* SearchRootReducer.swift */, - ABBB2630278E6EF3007B6149 /* SearchView.swift */, - EA2E2E812A1FA1050038A261 /* SearchReducer.swift */, - AB24C563276757C30085C33A /* Support */, - ); - path = Search; - sourceTree = ""; - }; - ABA12F2E27D49AD10021922D /* Tests */ = { - isa = PBXGroup; - children = ( - ABD9770C27B65A5300983DE7 /* Parser */, - ); - path = Tests; - sourceTree = ""; - }; - ABA9A6C028EC7BD000EE28DE /* Generated */ = { - isa = PBXGroup; - children = ( - ABA9A6C128EC7BD000EE28DE /* Strings.swift */, - ); - path = Generated; - sourceTree = ""; - }; - ABC3C74B2593696C00E0C11B = { - isa = PBXGroup; - children = ( - ABC3C7562593696C00E0C11B /* EhPanda */, - AB5BE67726B95FDD007D4A55 /* ShareExtension */, - AB3E9E6126D210B1008FE518 /* EhPandaTests */, - EA0C92472C3EB44300D211F6 /* Config */, - EA0C92422C3EB40100D211F6 /* GitHub */, - EA0C92522C3EB47F00D211F6 /* READMEs */, - ABC3C7552593696C00E0C11B /* Products */, - AB82819926B1C39B00A80CFA /* Frameworks */, - ); - sourceTree = ""; - }; - ABC3C7552593696C00E0C11B /* Products */ = { - isa = PBXGroup; - children = ( - ABC3C7542593696C00E0C11B /* EhPanda.app */, - AB5BE67626B95FDD007D4A55 /* ShareExtension.appex */, - ABF294CC26D20F82004DD03A /* EhPandaTests.xctest */, - ); - name = Products; - sourceTree = ""; - }; - ABC3C7562593696C00E0C11B /* EhPanda */ = { - isa = PBXGroup; - children = ( - ABC3C7682593699A00E0C11B /* App */, - ABF45AB325F3312F00ECB568 /* DataFlow */, - ABC3C77B2593699A00E0C11B /* Network */, - ABC3C77F2593699A00E0C11B /* Models */, - AB821BEF268A09AC009B2381 /* Database */, - ABF45ABF25F3313D00ECB568 /* View */, - ABD5FDD3263D05110021A4C6 /* .swiftlint.yml */, - ABA9A6BB28EC786100EE28DE /* swiftgen.yml */, - ABF53F4725A306D200AB5918 /* EhPanda.entitlements */, - ); - path = EhPanda; - sourceTree = ""; - }; - ABC3C7682593699A00E0C11B /* App */ = { - isa = PBXGroup; - children = ( - ABC3C76B2593699A00E0C11B /* EhPandaApp.swift */, - AB40CFE52598423E00D1DC9A /* Tools */, - AB47FDA625BC823F0007765D /* Icons */, - ABC3C7692593699A00E0C11B /* Assets.xcassets */, - ABC3C76E2593699A00E0C11B /* Info.plist */, - AB7E6B3225D24FE00035CC68 /* InfoPlist.strings */, - ABA9A6BD28EC7BA200EE28DE /* Constant.strings */, - ABEE0AFC2595C6F800C997AE /* Localizable.strings */, - ABA9A6C028EC7BD000EE28DE /* Generated */, - ); - path = App; - sourceTree = ""; - }; - ABC3C77B2593699A00E0C11B /* Network */ = { - isa = PBXGroup; - children = ( - ABCD2F09259763FC008E5A20 /* Request.swift */, - AB358318269D9996009466A5 /* DomainResolver.swift */, - AB358312269D7E89009466A5 /* DFRequest.swift */, - AB358316269D826B009466A5 /* DFStreamHandler.swift */, - AB358314269D821D009466A5 /* DFExtensions.swift */, - AB358310269D7B63009466A5 /* DFURLProtocol.swift */, - ); - path = Network; - sourceTree = ""; - }; - ABC3C77F2593699A00E0C11B /* Models */ = { - isa = PBXGroup; - children = ( - AB7BF2B827A96559001865A3 /* Gallery */, - AB7BF2BD27A9663E001865A3 /* Persistent */, - AB1FA8FA27C5DE800063EF55 /* Tags */, - AB7BF2BE27A96674001865A3 /* Support */, - ); - path = Models; - sourceTree = ""; - }; - ABD49D5E277C7715003D1A07 /* TabBar */ = { - isa = PBXGroup; - children = ( - ABD49D5F277C7722003D1A07 /* TabBarView.swift */, - ABD49D63277C7AD5003D1A07 /* TabBarReducer.swift */, - ); - path = TabBar; - sourceTree = ""; - }; - ABD49D65277EAC7E003D1A07 /* Utilities */ = { - isa = PBXGroup; - children = ( - ABD49D66277EAC90003D1A07 /* URLUtil.swift */, - AB7BF2CD27AA3E58001865A3 /* AppUtil.swift */, - AB7BF2D527AA3F4C001865A3 /* FileUtil.swift */, - AB7BF2CF27AA3E75001865A3 /* DeviceUtil.swift */, - AB7BF2D127AA3EDC001865A3 /* HapticsUtil.swift */, - AB7BF2D327AA3F12001865A3 /* CookieUtil.swift */, - AB0CFBD427C24B3B004BD372 /* MarkdownUtil.swift */, - AB7BF2D727AA3F61001865A3 /* UserDefaultsUtil.swift */, - ); - path = Utilities; - sourceTree = ""; - }; - ABD9770C27B65A5300983DE7 /* Parser */ = { - isa = PBXGroup; - children = ( - AB31CD3327B6674B00F40E0A /* List */, - AB31CD3427B6675100F40E0A /* Gallery */, - AB31CD3527B6675D00F40E0A /* Other */, - ); - path = Parser; - sourceTree = ""; - }; - ABD9771127B65F9D00983DE7 /* Other */ = { - isa = PBXGroup; - children = ( - AB0CFB7F27BBBFA0004BD372 /* EhSetting.html */, - ABC0A8D026F7037F008EC24C /* IPBanned.html */, - ABF9720926DE6E1300118887 /* GalleryDetailWithGreeting.html */, - ); - path = Other; - sourceTree = ""; - }; - ABF45AB325F3312F00ECB568 /* DataFlow */ = { - isa = PBXGroup; - children = ( - AB1EF25327AFA19200F507D6 /* Heap.swift */, - AB58A5B12776B99000C0D285 /* AppReducer.swift */, - AB86AC1227856F2700E61E6A /* AppLockReducer.swift */, - AB706F7827890A6C0025A48A /* AppRouteReducer.swift */, - AB58A5AB2776B2BC00C0D285 /* AppDelegateReducer.swift */, - ); - path = DataFlow; - sourceTree = ""; - }; - ABF45ABF25F3313D00ECB568 /* View */ = { - isa = PBXGroup; - children = ( - AB7BF2F927ABCA20001865A3 /* Migration */, - ABD49D5E277C7715003D1A07 /* TabBar */, - ABF45AC025F3313D00ECB568 /* Home */, - AB24C55F276757240085C33A /* Favorites */, - AB86AC112783226100E61E6A /* Search */, - ABF45AD125F3313D00ECB568 /* Detail */, - ABF45ACF25F3313D00ECB568 /* Reading */, - ABF45AD725F3313D00ECB568 /* Setting */, - AB24C55D276756A40085C33A /* Support */, - ); - path = View; - sourceTree = ""; - }; - ABF45AC025F3313D00ECB568 /* Home */ = { - isa = PBXGroup; - children = ( - EA7E47EC2A210C4300971697 /* History */, - EA7E47EA2A2103FE00971697 /* Popular */, - EA7E47E92A2102BA00971697 /* Toplists */, - EA7E47EB2A2107CF00971697 /* Watched */, - EA7E47E82A21015400971697 /* Frontpage */, - AB24C55B2767565A0085C33A /* HomeView.swift */, - AB86AC192785C2B300E61E6A /* HomeReducer.swift */, - ); - path = Home; - sourceTree = ""; - }; - ABF45AC625F3313D00ECB568 /* Components */ = { - isa = PBXGroup; - children = ( - AB24C564276758D00085C33A /* Cells */, - AB7B29F526AC741600EE1F14 /* GenericList.swift */, - ABD4032726B7967F00001B8C /* CategoryView.swift */, - ABF45ACD25F3313D00ECB568 /* Placeholder.swift */, - ABF45AC725F3313D00ECB568 /* TagCloudView.swift */, - ABBC332926BE7C940084A331 /* SettingTextField.swift */, - ABF45ACC25F3313D00ECB568 /* AlertView.swift */, - AB0ABCB626C541A400AD970F /* WaveForm.swift */, - AB3072D1276D734800EFF242 /* SubSection.swift */, - AB706F81278986120025A48A /* ToolbarItems.swift */, - ABBB26672797BFAA007B6149 /* ActivityView.swift */, - AB0CFBC827C07F95004BD372 /* TagSuggestionView.swift */, - ); - path = Components; - sourceTree = ""; - }; - ABF45ACF25F3313D00ECB568 /* Reading */ = { - isa = PBXGroup; - children = ( - ABBB2672279B9332007B6149 /* ReadingView.swift */, - ABBB2674279B933D007B6149 /* ReadingReducer.swift */, - AB24C561276757A30085C33A /* Support */, - ); - path = Reading; - sourceTree = ""; - }; - ABF45AD125F3313D00ECB568 /* Detail */ = { - isa = PBXGroup; - children = ( - EA2E2E852A20E40B0038A261 /* Torrents */, - EA2E2E842A20E1840038A261 /* Archives */, - EA2E2E862A20E52C0038A261 /* Previews */, - EA2E2E872A20E6C90038A261 /* Comments */, - EA2E2E882A20E9C50038A261 /* GalleryInfos */, - EA2E2E892A20EA460038A261 /* DetailSearch */, - AB24C562276757B00085C33A /* Components */, - AB706FA0278BCEC60025A48A /* DetailView.swift */, - AB706FA2278BCF2F0025A48A /* DetailReducer.swift */, - ); - path = Detail; - sourceTree = ""; - }; - ABF45AD725F3313D00ECB568 /* Setting */ = { - isa = PBXGroup; - children = ( - EA2E2E792A1F78980038A261 /* Logs */, - EA2B9B062A0A8A7C00E7BA07 /* Login */, - EAEC870B2A1F74D500E1A97A /* EhSetting */, - EA2E2E7B2A1F7AEF0038A261 /* GeneralSetting */, - EA2B9B042A0A89C900E7BA07 /* AccountSetting */, - EA2E2E7D2A1F7D390038A261 /* AppearanceSetting */, - EA2E2E802A1F7F2A0038A261 /* Components */, - ABF45ADD25F3313D00ECB568 /* SettingView.swift */, - EA2E2E7E2A1F7E500038A261 /* SettingReducer.swift */, - ); - path = Setting; - sourceTree = ""; - }; - EA0C92422C3EB40100D211F6 /* GitHub */ = { - isa = PBXGroup; - children = ( - EA0BBD462E37CCB700DC8143 /* CODEOWNERS */, - EA0C92432C3EB42300D211F6 /* ISSUE_TEMPLATE */, - EA0C92442C3EB42300D211F6 /* workflows */, - ); - name = GitHub; - sourceTree = ""; - }; - EA0C92472C3EB44300D211F6 /* Config */ = { - isa = PBXGroup; - children = ( - EA0C924A2C3EB45E00D211F6 /* .gitattributes */, - EA0C924C2C3EB45E00D211F6 /* .gitignore */, - EA0C924B2C3EB45E00D211F6 /* .swiftlint.yml */, - EA0C92482C3EB45E00D211F6 /* AltStore.json */, - EA0C92492C3EB45E00D211F6 /* swiftgen.yml */, - ); - name = Config; - sourceTree = ""; - }; - EA0C92522C3EB47F00D211F6 /* READMEs */ = { - isa = PBXGroup; - children = ( - EA0C92572C3EB49500D211F6 /* README.chs.md */, - EA0C92532C3EB49500D211F6 /* README.cht.md */, - EA0C92562C3EB49500D211F6 /* README.de.md */, - EA0C92582C3EB49500D211F6 /* README.jpn.md */, - EA0C92542C3EB49500D211F6 /* README.ko.md */, - EA0C92552C3EB49500D211F6 /* README.md */, - ); - name = READMEs; - sourceTree = ""; - }; - EA2B9B042A0A89C900E7BA07 /* AccountSetting */ = { - isa = PBXGroup; - children = ( - ABF45AD925F3313D00ECB568 /* AccountSettingView.swift */, - AB0929B5277F043D00F107CA /* AccountSettingReducer.swift */, - ); - path = AccountSetting; - sourceTree = ""; - }; - EA2B9B062A0A8A7C00E7BA07 /* Login */ = { - isa = PBXGroup; - children = ( - AB0ABCB426C5406400AD970F /* LoginView.swift */, - AB0929BF27805A8200F107CA /* LoginReducer.swift */, - ); - path = Login; - sourceTree = ""; - }; - EA2E2E792A1F78980038A261 /* Logs */ = { - isa = PBXGroup; - children = ( - AB6DE896268822390087C579 /* LogsView.swift */, - AB86ABF42782DAB300E61E6A /* LogsReducer.swift */, - ); - path = Logs; - sourceTree = ""; - }; - EA2E2E7B2A1F7AEF0038A261 /* GeneralSetting */ = { - isa = PBXGroup; - children = ( - ABF45AD825F3313D00ECB568 /* GeneralSettingView.swift */, - AB0929D52782A65F00F107CA /* GeneralSettingReducer.swift */, - ); - path = GeneralSetting; - sourceTree = ""; - }; - EA2E2E7D2A1F7D390038A261 /* AppearanceSetting */ = { - isa = PBXGroup; - children = ( - ABF45ADC25F3313D00ECB568 /* AppearanceSettingView.swift */, - AB86AC092782FAFA00E61E6A /* AppearanceSettingReducer.swift */, - ); - path = AppearanceSetting; - sourceTree = ""; - }; - EA2E2E802A1F7F2A0038A261 /* Components */ = { - isa = PBXGroup; - children = ( - ABF45ADB25F3313D00ECB568 /* ReadingSettingView.swift */, - ABE1867726A1733000689FDC /* LaboratorySettingView.swift */, - AB86ABF82782EC0D00E61E6A /* AboutView.swift */, - ABF45ADA25F3313D00ECB568 /* WebView.swift */, - ); - path = Components; - sourceTree = ""; - }; - EA2E2E842A20E1840038A261 /* Archives */ = { - isa = PBXGroup; - children = ( - ABF45AD325F3313D00ECB568 /* ArchivesView.swift */, - ABBB266527977C2A007B6149 /* ArchivesReducer.swift */, + +/* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */ + A6844C242F780C8700BBF6E5 /* Exceptions for "App" folder in "EhPanda" target */ = { + isa = PBXFileSystemSynchronizedBuildFileExceptionSet; + membershipExceptions = ( + Info.plist, ); - path = Archives; - sourceTree = ""; + target = ABC3C7532593696C00E0C11B /* EhPanda */; }; - EA2E2E852A20E40B0038A261 /* Torrents */ = { - isa = PBXGroup; - children = ( - ABF45AD425F3313D00ECB568 /* TorrentsView.swift */, - ABBB26692797C61F007B6149 /* TorrentsReducer.swift */, + A6844C292F780C8D00BBF6E5 /* Exceptions for "ShareExtension" folder in "ShareExtension" target */ = { + isa = PBXFileSystemSynchronizedBuildFileExceptionSet; + membershipExceptions = ( + ShareViewController.swift, ); - path = Torrents; + target = AB5BE67526B95FDD007D4A55 /* ShareExtension */; + }; +/* End PBXFileSystemSynchronizedBuildFileExceptionSet section */ + +/* Begin PBXFileSystemSynchronizedRootGroup section */ + A62261E62FC559F00055B5C0 /* .github */ = { + isa = PBXFileSystemSynchronizedRootGroup; + path = .github; sourceTree = ""; }; - EA2E2E862A20E52C0038A261 /* Previews */ = { - isa = PBXGroup; - children = ( - AB706FA4278C3DDE0025A48A /* PreviewsView.swift */, - ABBB263D2793C648007B6149 /* PreviewsReducer.swift */, + A6844B3D2F780C8600BBF6E5 /* App */ = { + isa = PBXFileSystemSynchronizedRootGroup; + exceptions = ( + A6844C242F780C8700BBF6E5 /* Exceptions for "App" folder in "EhPanda" target */, ); - path = Previews; + path = App; sourceTree = ""; }; - EA2E2E872A20E6C90038A261 /* Comments */ = { - isa = PBXGroup; - children = ( - ABF45AD525F3313D00ECB568 /* CommentsView.swift */, - ABBB263F279417EC007B6149 /* CommentsReducer.swift */, + A6844C272F780C8B00BBF6E5 /* ShareExtension */ = { + isa = PBXFileSystemSynchronizedRootGroup; + exceptions = ( + A6844C292F780C8D00BBF6E5 /* Exceptions for "ShareExtension" folder in "ShareExtension" target */, ); - path = Comments; + path = ShareExtension; sourceTree = ""; }; - EA2E2E882A20E9C50038A261 /* GalleryInfos */ = { - isa = PBXGroup; - children = ( - ABBCCC8F26C95F6E007D8A36 /* GalleryInfosView.swift */, - ABBB2678279D454C007B6149 /* GalleryInfosReducer.swift */, +/* End PBXFileSystemSynchronizedRootGroup section */ + +/* Begin PBXFrameworksBuildPhase section */ + AB5BE67326B95FDD007D4A55 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + files = ( ); - path = GalleryInfos; - sourceTree = ""; }; - EA2E2E892A20EA460038A261 /* DetailSearch */ = { - isa = PBXGroup; - children = ( - ABC8355C27B118330091DCDB /* DetailSearchView.swift */, - ABC8355E27B118370091DCDB /* DetailSearchReducer.swift */, + ABC3C7512593696C00E0C11B /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + files = ( + A0F00000000000000000F001 /* AppFeature in Frameworks */, ); - path = DetailSearch; - sourceTree = ""; }; - EA7E47E82A21015400971697 /* Frontpage */ = { +/* End PBXFrameworksBuildPhase section */ + +/* Begin PBXGroup section */ + AB82819926B1C39B00A80CFA /* Frameworks */ = { isa = PBXGroup; children = ( - AB3072D3276E19AA00EFF242 /* FrontpageView.swift */, - AB706F7A278937500025A48A /* FrontpageReducer.swift */, ); - path = Frontpage; + name = Frameworks; sourceTree = ""; }; - EA7E47E92A2102BA00971697 /* Toplists */ = { + ABC3C74B2593696C00E0C11B = { isa = PBXGroup; children = ( - AB706F832789AD2D0025A48A /* ToplistsView.swift */, - AB706F852789AD490025A48A /* ToplistsReducer.swift */, + A0F00000000000000000F004 /* AppPackage */, + A6844B3D2F780C8600BBF6E5 /* App */, + A6844C272F780C8B00BBF6E5 /* ShareExtension */, + EA0C92472C3EB44300D211F6 /* Config */, + EA0C92422C3EB40100D211F6 /* GitHub */, + EA0C92522C3EB47F00D211F6 /* READMEs */, + ABC3C7552593696C00E0C11B /* Products */, + AB82819926B1C39B00A80CFA /* Frameworks */, ); - path = Toplists; sourceTree = ""; }; - EA7E47EA2A2103FE00971697 /* Popular */ = { + ABC3C7552593696C00E0C11B /* Products */ = { isa = PBXGroup; children = ( - AB706F87278A4C8A0025A48A /* PopularView.swift */, - AB706F89278A4CC50025A48A /* PopularReducer.swift */, + ABC3C7542593696C00E0C11B /* EhPanda.app */, + AB5BE67626B95FDD007D4A55 /* ShareExtension.appex */, ); - path = Popular; + name = Products; sourceTree = ""; }; - EA7E47EB2A2107CF00971697 /* Watched */ = { + EA0C92422C3EB40100D211F6 /* GitHub */ = { isa = PBXGroup; children = ( - AB706F8B278A4F6C0025A48A /* WatchedView.swift */, - AB706F91278A6E8C0025A48A /* WatchedReducer.swift */, + A62261E62FC559F00055B5C0 /* .github */, ); - path = Watched; + name = GitHub; sourceTree = ""; }; - EA7E47EC2A210C4300971697 /* History */ = { + EA0C92472C3EB44300D211F6 /* Config */ = { isa = PBXGroup; children = ( - AB706F94278A75D30025A48A /* HistoryView.swift */, - AB706F96278A77E20025A48A /* HistoryReducer.swift */, + EA0C924A2C3EB45E00D211F6 /* .gitattributes */, + EA0C924C2C3EB45E00D211F6 /* .gitignore */, + EA0C924B2C3EB45E00D211F6 /* .swiftlint.yml */, + EA0C92482C3EB45E00D211F6 /* AltStore.json */, ); - path = History; + name = Config; sourceTree = ""; }; - EAEC870B2A1F74D500E1A97A /* EhSetting */ = { + EA0C92522C3EB47F00D211F6 /* READMEs */ = { isa = PBXGroup; children = ( - ABBC332726BE31AE0084A331 /* EhSettingView.swift */, - AB0929BD2780032400F107CA /* EhSettingReducer.swift */, + EA0C92572C3EB49500D211F6 /* README.chs.md */, + EA0C92532C3EB49500D211F6 /* README.cht.md */, + EA0C92562C3EB49500D211F6 /* README.de.md */, + EA0C92582C3EB49500D211F6 /* README.jpn.md */, + EA0C92542C3EB49500D211F6 /* README.ko.md */, + EA0C92552C3EB49500D211F6 /* README.md */, ); - path = EhSetting; + name = READMEs; sourceTree = ""; }; /* End PBXGroup section */ @@ -1550,6 +187,7 @@ buildRules = ( ); dependencies = ( + A66A766F2F77C89100FC07B8 /* PBXTargetDependency */, ); name = ShareExtension; productName = ShareExtension; @@ -1560,8 +198,6 @@ isa = PBXNativeTarget; buildConfigurationList = ABC3C7632593696E00E0C11B /* Build configuration list for PBXNativeTarget "EhPanda" */; buildPhases = ( - AB2E936227A24E0A00EA99F1 /* SwiftGen */, - AB69FE41263C328400716FBD /* SwiftLint */, ABC3C7502593696C00E0C11B /* Sources */, ABC3C7512593696C00E0C11B /* Frameworks */, ABC3C7522593696C00E0C11B /* Resources */, @@ -1570,49 +206,20 @@ buildRules = ( ); dependencies = ( + A66A766D2F77C88A00FC07B8 /* PBXTargetDependency */, AB5BE67F26B95FDD007D4A55 /* PBXTargetDependency */, ); + fileSystemSynchronizedGroups = ( + A6844B3D2F780C8600BBF6E5 /* App */, + ); name = EhPanda; packageProductDependencies = ( - AB65059F26B0027800F91E9D /* SwiftUIPager */, - ABD7005826B1C31500DC59C9 /* Kanna */, - ABAC82FD26BC4A96009F5026 /* OpenCC */, - AB60D0E8274C7ECE00F899AB /* WaterfallGrid */, - ABC4A0782751B40E00968A4F /* Kingfisher */, - AB17573C27675B1E00FD64E2 /* Colorful */, - AB17573F27678B3400FD64E2 /* UIImageColors */, - ABD49D5C277C6C9D003D1A07 /* SFSafeSymbols */, - AB86AC0F27831AD100E61E6A /* ComposableArchitecture */, - ABBB2635278FB888007B6149 /* SwiftUINavigation */, - AB26F59827ACDB4200AB3468 /* FilePicker */, - AB1FA94827C62BC80063EF55 /* CommonMark */, - AB2EB99E280251D600011A8A /* TTProgressHUD */, - AB2EB9A1280251F600011A8A /* AlertKit */, - AB2EB9A42802521700011A8A /* DeprecatedAPI */, - EAE63E2029E2A6330048C601 /* SwiftyBeaver */, + A0F00000000000000000F002 /* AppFeature */, ); productName = EhPanda; productReference = ABC3C7542593696C00E0C11B /* EhPanda.app */; productType = "com.apple.product-type.application"; }; - ABF294CB26D20F82004DD03A /* EhPandaTests */ = { - isa = PBXNativeTarget; - buildConfigurationList = ABF294D426D20F82004DD03A /* Build configuration list for PBXNativeTarget "EhPandaTests" */; - buildPhases = ( - ABF294C826D20F82004DD03A /* Sources */, - ABF294C926D20F82004DD03A /* Frameworks */, - ABF294CA26D20F82004DD03A /* Resources */, - ); - buildRules = ( - ); - dependencies = ( - ABF294D126D20F82004DD03A /* PBXTargetDependency */, - ); - name = EhPandaTests; - productName = EhPandaTests; - productReference = ABF294CC26D20F82004DD03A /* EhPandaTests.xctest */; - productType = "com.apple.product-type.bundle.unit-test"; - }; /* End PBXNativeTarget section */ /* Begin PBXProject section */ @@ -1621,7 +228,7 @@ attributes = { BuildIndependentTargetsInParallel = YES; LastSwiftUpdateCheck = 1300; - LastUpgradeCheck = 1430; + LastUpgradeCheck = 2660; TargetAttributes = { AB5BE67526B95FDD007D4A55 = { CreatedOnToolsVersion = 13.0; @@ -1629,14 +236,9 @@ ABC3C7532593696C00E0C11B = { CreatedOnToolsVersion = 12.2; }; - ABF294CB26D20F82004DD03A = { - CreatedOnToolsVersion = 13.0; - TestTargetID = ABC3C7532593696C00E0C11B; - }; }; }; buildConfigurationList = ABC3C74F2593696C00E0C11B /* Build configuration list for PBXProject "EhPanda" */; - compatibilityVersion = "Xcode 14.0"; developmentRegion = en; hasScannedForEncodings = 0; knownRegions = ( @@ -1646,36 +248,19 @@ "zh-Hans", ko, de, - "zh-Hant-TW", - "zh-Hant-HK", "zh-Hant", ); mainGroup = ABC3C74B2593696C00E0C11B; packageReferences = ( - AB65059E26B0027800F91E9D /* XCRemoteSwiftPackageReference "SwiftUIPager" */, - ABD7005726B1C31500DC59C9 /* XCRemoteSwiftPackageReference "Kanna" */, - ABAC82FC26BC4866009F5026 /* XCRemoteSwiftPackageReference "SwiftyOpenCC" */, - AB60D0E7274C7ECE00F899AB /* XCRemoteSwiftPackageReference "WaterfallGrid" */, - ABC4A0772751B40E00968A4F /* XCRemoteSwiftPackageReference "Kingfisher" */, - AB17573B27675B1E00FD64E2 /* XCRemoteSwiftPackageReference "Colorful" */, - AB17573E27678B3400FD64E2 /* XCRemoteSwiftPackageReference "UIImageColors" */, - ABD49D5B277C6C9D003D1A07 /* XCRemoteSwiftPackageReference "SFSafeSymbols" */, - AB86AC0E27831AD100E61E6A /* XCRemoteSwiftPackageReference "swift-composable-architecture" */, - ABBB2634278FB888007B6149 /* XCRemoteSwiftPackageReference "swift-navigation" */, - AB26F59727ACDB4200AB3468 /* XCRemoteSwiftPackageReference "FilePicker" */, - AB1FA94727C62BC80063EF55 /* XCRemoteSwiftPackageReference "SwiftCommonMark" */, - AB2EB99D280251D600011A8A /* XCRemoteSwiftPackageReference "TTProgressHUD" */, - AB2EB9A0280251F600011A8A /* XCRemoteSwiftPackageReference "AlertKit" */, - AB2EB9A32802521700011A8A /* XCRemoteSwiftPackageReference "DeprecatedAPI" */, - EAE63E1F29E2A6330048C601 /* XCRemoteSwiftPackageReference "SwiftyBeaver" */, + A66A766B2F77C87400FC07B8 /* XCRemoteSwiftPackageReference "SwiftLintPlugins" */, ); + preferredProjectObjectVersion = 100; productRefGroup = ABC3C7552593696C00E0C11B /* Products */; projectDirPath = ""; projectRoot = ""; targets = ( ABC3C7532593696C00E0C11B /* EhPanda */, AB5BE67526B95FDD007D4A55 /* ShareExtension */, - ABF294CB26D20F82004DD03A /* EhPandaTests */, ); }; /* End PBXProject section */ @@ -1683,415 +268,59 @@ /* Begin PBXResourcesBuildPhase section */ AB5BE67426B95FDD007D4A55 /* Resources */ = { isa = PBXResourcesBuildPhase; - buildActionMask = 2147483647; files = ( ); - runOnlyForDeploymentPostprocessing = 0; }; ABC3C7522593696C00E0C11B /* Resources */ = { isa = PBXResourcesBuildPhase; - buildActionMask = 2147483647; files = ( - AB0CFB8C27BBD2D7004BD372 /* AppIcon_Developer@3x.png in Resources */, - AB0CFB9627BBD323004BD372 /* AppIcon_Ukiyoe@2x.png in Resources */, - AB90276E291F548700697256 /* AppIcon_NotMyPresident_iPad.png in Resources */, - AB90276D291F548700697256 /* AppIcon_NotMyPresident_iPad_Pro@2x.png in Resources */, - AB0CFB7A27BAB9D0004BD372 /* AppIcon_Default@2x.png in Resources */, EA0C92592C3EB49500D211F6 /* README.cht.md in Resources */, - AB0CFB7527BAB9D0004BD372 /* AppIcon_Default_iPad@2x.png in Resources */, - AB0CFB9227BBD323004BD372 /* AppIcon_Ukiyoe_iPad@2x.png in Resources */, - ABC3C7852593699B00E0C11B /* Assets.xcassets in Resources */, - AB0CFB7B27BAB9D0004BD372 /* AppIcon_Default_iPad.png in Resources */, - AB90276C291F548700697256 /* AppIcon_NotMyPresident_iPad@2x.png in Resources */, EA0C925D2C3EB49500D211F6 /* README.chs.md in Resources */, - AB0CFB9527BBD323004BD372 /* AppIcon_Ukiyoe_iPad.png in Resources */, EA0C925C2C3EB49500D211F6 /* README.de.md in Resources */, - ABE9012427F722D100F3651D /* AppIcon_StandWithUkraine2022@3x.png in Resources */, - AB90276B291F548700697256 /* AppIcon_NotMyPresident@3x.png in Resources */, - EA0C92452C3EB42300D211F6 /* ISSUE_TEMPLATE in Resources */, - AB0CFB8827BBD2D7004BD372 /* AppIcon_Developer_iPad@2x.png in Resources */, - ABE9012227F722D100F3651D /* AppIcon_StandWithUkraine2022@2x.png in Resources */, EA0C925B2C3EB49500D211F6 /* README.md in Resources */, - AB0CFB7427BAB9D0004BD372 /* AppIcon_Default@3x.png in Resources */, - AB7E6B3025D24FE00035CC68 /* InfoPlist.strings in Resources */, - ABA9A6BC28EC786100EE28DE /* swiftgen.yml in Resources */, - AB0CFB9327BBD323004BD372 /* AppIcon_Ukiyoe@3x.png in Resources */, - AB0CFB8927BBD2D7004BD372 /* AppIcon_Developer@2x.png in Resources */, - EA0BBD472E37CCB700DC8143 /* CODEOWNERS in Resources */, - ABEE0AFA2595C6F800C997AE /* Localizable.strings in Resources */, - AB90276F291F548700697256 /* AppIcon_NotMyPresident@2x.png in Resources */, - EA0C92462C3EB42300D211F6 /* workflows in Resources */, EA0C925A2C3EB49500D211F6 /* README.ko.md in Resources */, - ABD5FDD4263D05110021A4C6 /* .swiftlint.yml in Resources */, - ABE9012527F722D100F3651D /* AppIcon_StandWithUkraine2022_iPad_Pro@2x.png in Resources */, - ABE9012327F722D100F3651D /* AppIcon_StandWithUkraine2022_iPad.png in Resources */, - AB0CFB7827BAB9D0004BD372 /* AppIcon_Default_iPad_Pro@2x.png in Resources */, - AB0CFB8B27BBD2D7004BD372 /* AppIcon_Developer_iPad_Pro@2x.png in Resources */, EA0C925E2C3EB49500D211F6 /* README.jpn.md in Resources */, - AB0CFB8A27BBD2D7004BD372 /* AppIcon_Developer_iPad.png in Resources */, - ABE9012627F722D100F3651D /* AppIcon_StandWithUkraine2022_iPad@2x.png in Resources */, - AB0CFB9427BBD323004BD372 /* AppIcon_Ukiyoe_iPad_Pro@2x.png in Resources */, - ); - runOnlyForDeploymentPostprocessing = 0; - }; - ABF294CA26D20F82004DD03A /* Resources */ = { - isa = PBXResourcesBuildPhase; - buildActionMask = 2147483647; - files = ( - AB41DB3F27B760D700DD3604 /* FrontPageExtendedList.html in Resources */, - AB41DB4C27B760D700DD3604 /* FavoritesCompactList.html in Resources */, - AB31CD4127B6769F00F40E0A /* GalleryMPVKeys.html in Resources */, - ABC0A8D126F7037F008EC24C /* IPBanned.html in Resources */, - ABF9720A26DE6E1300118887 /* GalleryDetailWithGreeting.html in Resources */, - AB41DB4927B760D700DD3604 /* FavoritesMinimalPlusList.html in Resources */, - AB41DB4E27B760D700DD3604 /* WatchedMinimalPlusList.html in Resources */, - AB41DB3D27B760D700DD3604 /* WatchedCompactList.html in Resources */, - AB41DB4427B760D700DD3604 /* FavoritesExtendedList.html in Resources */, - AB3E9E6E26D210B1008FE518 /* GalleryDetail.html in Resources */, - AB41DB3E27B760D700DD3604 /* PopularThumbnailList.html in Resources */, - AB41DB4727B760D700DD3604 /* PopularMinimalPlusList.html in Resources */, - AB41DB4827B760D700DD3604 /* WatchedExtendedList.html in Resources */, - AB41DB4F27B760D700DD3604 /* WatchedMinimalList.html in Resources */, - AB41DB4227B760D700DD3604 /* FavoritesThumbnailList.html in Resources */, - AB41DB5027B760D700DD3604 /* PopularCompactList.html in Resources */, - AB41DB4B27B760D700DD3604 /* FrontPageThumbnailList.html in Resources */, - AB41DB4327B760D700DD3604 /* ToplistsCompactList.html in Resources */, - AB0CFB8027BBBFA0004BD372 /* EhSetting.html in Resources */, - AB41DB5127B760D700DD3604 /* PopularMinimalList.html in Resources */, - AB41DB4627B760D700DD3604 /* FrontPageCompactList.html in Resources */, - AB41DB4127B760D700DD3604 /* PopularExtendedList.html in Resources */, - AB41DB4527B760D700DD3604 /* FrontPageMinimalList.html in Resources */, - AB41DB4D27B760D700DD3604 /* FavoritesMinimalList.html in Resources */, - AB41DB4027B760D700DD3604 /* FrontPageMinimalPlusList.html in Resources */, - AB41DB4A27B760D700DD3604 /* WatchedThumbnailList.html in Resources */, - AB31CD3F27B670FD00F40E0A /* GalleryNormalImageURL.html in Resources */, ); - runOnlyForDeploymentPostprocessing = 0; }; /* End PBXResourcesBuildPhase section */ -/* Begin PBXShellScriptBuildPhase section */ - AB2E936227A24E0A00EA99F1 /* SwiftGen */ = { - isa = PBXShellScriptBuildPhase; - alwaysOutOfDate = 1; - buildActionMask = 2147483647; - files = ( - ); - inputFileListPaths = ( - ); - inputPaths = ( - ); - name = SwiftGen; - outputFileListPaths = ( - ); - outputPaths = ( - $SRCROOT/EhPanda/App/Generated/Strings.swift, - ); - runOnlyForDeploymentPostprocessing = 0; - shellPath = /bin/sh; - shellScript = "if test -d \"/opt/homebrew/bin/\"; then\n PATH=\"/opt/homebrew/bin/:${PATH}\"\nfi\n\nexport PATH\n\nif which swiftgen >/dev/null; then\n swiftgen\nelse\n echo \"warning: SwiftGen not installed, download from https://github.com/SwiftGen/SwiftGen\"\nfi\n"; - }; - AB69FE41263C328400716FBD /* SwiftLint */ = { - isa = PBXShellScriptBuildPhase; - alwaysOutOfDate = 1; - buildActionMask = 2147483647; - files = ( - ); - inputFileListPaths = ( - ); - inputPaths = ( - ); - name = SwiftLint; - outputFileListPaths = ( - ); - outputPaths = ( - ); - runOnlyForDeploymentPostprocessing = 0; - shellPath = /bin/sh; - shellScript = "if test -d \"/opt/homebrew/bin/\"; then\n PATH=\"/opt/homebrew/bin/:${PATH}\"\nfi\n\nexport PATH\n\nif which swiftlint >/dev/null; then\n swiftlint\nelse\n echo \"warning: SwiftLint not installed, download from https://github.com/realm/SwiftLint\"\nfi\n"; - }; -/* End PBXShellScriptBuildPhase section */ - /* Begin PBXSourcesBuildPhase section */ AB5BE67226B95FDD007D4A55 /* Sources */ = { isa = PBXSourcesBuildPhase; - buildActionMask = 2147483647; files = ( - AB5BE67926B95FDD007D4A55 /* ShareViewController.swift in Sources */, ); - runOnlyForDeploymentPostprocessing = 0; }; ABC3C7502593696C00E0C11B /* Sources */ = { isa = PBXSourcesBuildPhase; - buildActionMask = 2147483647; - files = ( - ABA732D925A8018A00B3D9AB /* Extensions.swift in Sources */, - AB0929D02781E1CC00F107CA /* UIApplicationClient.swift in Sources */, - ABF45AF325F3313D00ECB568 /* AccountSettingView.swift in Sources */, - AB706F842789AD2D0025A48A /* ToplistsView.swift in Sources */, - ABCD2F0A259763FC008E5A20 /* Request.swift in Sources */, - ABF45AE925F3313D00ECB568 /* AlertView.swift in Sources */, - ABF45ABB25F3312F00ECB568 /* AppError.swift in Sources */, - AB10118026986C1100C2C1A9 /* GalleryStateMO+CoreDataClass.swift in Sources */, - AB63EADB2699AC8200090535 /* AppEnvMO+CoreDataProperties.swift in Sources */, - AB7BF2FD27ABCAD4001865A3 /* MigrationReducer.swift in Sources */, - AB7BF2D827AA3F61001865A3 /* UserDefaultsUtil.swift in Sources */, - EA2E2E822A1FA1060038A261 /* SearchReducer.swift in Sources */, - AB0929CA278196ED00F107CA /* CookieClient.swift in Sources */, - AB7BF2FB27ABCA3A001865A3 /* MigrationView.swift in Sources */, - AB7BF31C27ABE028001865A3 /* NSManagedObjectModel+Compatible.swift in Sources */, - EA2E2E7F2A1F7E500038A261 /* SettingReducer.swift in Sources */, - AB7BF31B27ABE028001865A3 /* NSManagedObjectModel+Resource.swift in Sources */, - ABBC332826BE31AE0084A331 /* EhSettingView.swift in Sources */, - AB7BF2C827A968F7001865A3 /* GalleryComment.swift in Sources */, - AB706F97278A77E20025A48A /* HistoryReducer.swift in Sources */, - AB0929CC2781A0B000F107CA /* HapticsClient.swift in Sources */, - ABD4032626B78E5A00001B8C /* GalleryThumbnailCell.swift in Sources */, - AB0CFBCD27C1CC67004BD372 /* EhTagTranslationDatabaseModel.swift in Sources */, - AB358319269D9996009466A5 /* DomainResolver.swift in Sources */, - AB63EADD2699AC9100090535 /* AppEnvMO+CoreDataClass.swift in Sources */, - AB7BF2C027A9669A001865A3 /* TagNamespace.swift in Sources */, - ABCA93BE26918DE100A98BC6 /* Persistence.swift in Sources */, - AB86ABF92782EC0D00E61E6A /* AboutView.swift in Sources */, - AB7BF2BA27A96562001865A3 /* Gallery.swift in Sources */, - AB0929BE2780032400F107CA /* EhSettingReducer.swift in Sources */, - AB0929D42781EDDC00F107CA /* UserDefaultsClient.swift in Sources */, - AB0929D82782A83A00F107CA /* AuthorizationClient.swift in Sources */, - ABF45AEF25F3313D00ECB568 /* TorrentsView.swift in Sources */, - AB706F99278A820C0025A48A /* FiltersReducer.swift in Sources */, - AB3072D4276E19AA00EFF242 /* FrontpageView.swift in Sources */, - AB3072D2276D734800EFF242 /* SubSection.swift in Sources */, - ABBB26682797BFAA007B6149 /* ActivityView.swift in Sources */, - AB1FA94D27CA1F140063EF55 /* TagTranslation.swift in Sources */, - ABC8355D27B118330091DCDB /* DetailSearchView.swift in Sources */, - ABBB264227942B74007B6149 /* URLClient.swift in Sources */, - AB0CFBD527C24B3B004BD372 /* MarkdownUtil.swift in Sources */, - ABF45AF625F3313D00ECB568 /* AppearanceSettingView.swift in Sources */, - AB7BF2CE27AA3E58001865A3 /* AppUtil.swift in Sources */, - AB86AC1A2785C2B300E61E6A /* HomeReducer.swift in Sources */, - EA698C032CCDD2FB0058BC19 /* EquatableVoid.swift in Sources */, - AB7BF2D427AA3F12001865A3 /* CookieUtil.swift in Sources */, - AB7BF30A27ABDFF1001865A3 /* CoreDataMigrationStep.swift in Sources */, - AB69CB8226B3DAF400699359 /* ControlPanel.swift in Sources */, - AB7BF2D227AA3EDC001865A3 /* HapticsUtil.swift in Sources */, - ABD49D5A277C5356003D1A07 /* FavoritesReducer.swift in Sources */, - AB1EF25427AFA19200F507D6 /* Heap.swift in Sources */, - AB7BF2C227A96760001865A3 /* GalleryDetail.swift in Sources */, - ABE1867826A1733000689FDC /* LaboratorySettingView.swift in Sources */, - ABF45AEB25F3313D00ECB568 /* GalleryDetailCell.swift in Sources */, - AB69CB8026B3DABC00699359 /* AdvancedList.swift in Sources */, - ABC3C7892593699B00E0C11B /* Defaults.swift in Sources */, - AB8C821926BF801700E8C5E6 /* EhSetting.swift in Sources */, - AB86AC1327856F2700E61E6A /* AppLockReducer.swift in Sources */, - AB58A5AC2776B2BC00C0D285 /* AppDelegateReducer.swift in Sources */, - ABBB263E2793C648007B6149 /* PreviewsReducer.swift in Sources */, - ABBC332A26BE7C940084A331 /* SettingTextField.swift in Sources */, - AB358317269D826B009466A5 /* DFStreamHandler.swift in Sources */, - AB7BF2CA27A969F4001865A3 /* GalleryState.swift in Sources */, - AB0929C6278160AE00F107CA /* LibraryClient.swift in Sources */, - ABF45ADF25F3313D00ECB568 /* FiltersView.swift in Sources */, - AB706F9F278AD4800025A48A /* GalleryHistoryCell.swift in Sources */, - AB706F8E278A5DCF0025A48A /* DeviceClient.swift in Sources */, - AB0CFBCB27C0B07F004BD372 /* TagSuggestion.swift in Sources */, - AB7BF30727ABDFF1001865A3 /* CoreDataMigrator.swift in Sources */, - ABC3C78F2593699B00E0C11B /* ViewModifiers.swift in Sources */, - AB86ABF52782DAB300E61E6A /* LogsReducer.swift in Sources */, - AB7BF2AB27A642FB001865A3 /* BrowsingCountry.swift in Sources */, - ABD49D60277C7722003D1A07 /* TabBarView.swift in Sources */, - AB26F59027ABF21000AB3468 /* Model5toModel6.xcmappingmodel in Sources */, - AB706FA5278C3DDE0025A48A /* PreviewsView.swift in Sources */, - ABF45AE725F3313D00ECB568 /* RatingView.swift in Sources */, - AB2CED64268AB6AE003130F7 /* GalleryMO+CoreDataProperties.swift in Sources */, - ABCD2F0E25976B95008E5A20 /* Parser.swift in Sources */, - ABF45AF725F3313D00ECB568 /* SettingView.swift in Sources */, - AB1FA8FC27C5E0E50063EF55 /* TagDetail.swift in Sources */, - AB706F862789AD490025A48A /* ToplistsReducer.swift in Sources */, - AB7BF2CC27A96A3C001865A3 /* GalleryTorrent.swift in Sources */, - ABF45AEA25F3313D00ECB568 /* Placeholder.swift in Sources */, - ABD4032826B7967F00001B8C /* CategoryView.swift in Sources */, - ABC681F326898D46007BBD69 /* Model.xcdatamodeld in Sources */, - ABBB266627977C2A007B6149 /* ArchivesReducer.swift in Sources */, - ABBB2640279417EC007B6149 /* CommentsReducer.swift in Sources */, - AB0929C027805A8200F107CA /* LoginReducer.swift in Sources */, - ABBB2631278E6EF3007B6149 /* SearchView.swift in Sources */, - AB706F92278A6E8C0025A48A /* WatchedReducer.swift in Sources */, - AB706F80278981370025A48A /* AlertKit_Extension.swift in Sources */, - ABA9A6C228EC7BD000EE28DE /* Strings.swift in Sources */, - AB58A5B22776B99000C0D285 /* AppReducer.swift in Sources */, - AB24C566276758E30085C33A /* GalleryCardCell.swift in Sources */, - ABBB2679279D454C007B6149 /* GalleryInfosReducer.swift in Sources */, - AB7BF2B727A9652F001865A3 /* Greeting.swift in Sources */, - ABC8355F27B118370091DCDB /* DetailSearchReducer.swift in Sources */, - AB4FD2C1268AB83300A95968 /* GalleryDetailMO+CoreDataProperties.swift in Sources */, - AB26F59427ACC6CD00AB3468 /* TagTranslator.swift in Sources */, - AB0929CE2781AADA00F107CA /* DatabaseClient.swift in Sources */, - AB6DE897268822390087C579 /* LogsView.swift in Sources */, - AB7BF31D27ABE028001865A3 /* FileManager+ApplicationSupport.swift in Sources */, - AB706F7B278937500025A48A /* FrontpageReducer.swift in Sources */, - AB86ABF72782DDE600E61E6A /* FileClient.swift in Sources */, - AB7B29F226AC471E00EE1F14 /* Model5toModel6MigrationPolicy.swift in Sources */, - AB706F7927890A6C0025A48A /* AppRouteReducer.swift in Sources */, - AB706F88278A4C8A0025A48A /* PopularView.swift in Sources */, - AB706FA1278BCEC60025A48A /* DetailView.swift in Sources */, - ABE9401526FF158D0085E158 /* QuickSearchView.swift in Sources */, - AB706F9B278AC5A30025A48A /* SearchRootView.swift in Sources */, - AB706F8A278A4CC50025A48A /* PopularReducer.swift in Sources */, - ABD49D64277C7AD5003D1A07 /* TabBarReducer.swift in Sources */, - ABF45AF025F3313D00ECB568 /* CommentsView.swift in Sources */, - ABBB2671279AFA61007B6149 /* EnvironmentKeys.swift in Sources */, - AB7BF2DA27AA78CF001865A3 /* Reducer_Extension.swift in Sources */, - ABBD2B602768D7AD0072AED2 /* GalleryRankingCell.swift in Sources */, - ABBB263A2792588F007B6149 /* TTProgressHUD_Extension.swift in Sources */, - AB7BF2D627AA3F4C001865A3 /* FileUtil.swift in Sources */, - AB0ABCB726C541A400AD970F /* WaveForm.swift in Sources */, - AB0929D62782A65F00F107CA /* GeneralSettingReducer.swift in Sources */, - AB706FA3278BCF2F0025A48A /* DetailReducer.swift in Sources */, - ABBCCC9026C95F6E007D8A36 /* GalleryInfosView.swift in Sources */, - AB7BF2A927A63C89001865A3 /* Language.swift in Sources */, - AB86AC0A2782FAFA00E61E6A /* AppearanceSettingReducer.swift in Sources */, - AB7BF2C427A9683F001865A3 /* GalleryArchive.swift in Sources */, - ABF45AF525F3313D00ECB568 /* ReadingSettingView.swift in Sources */, - EA698C092CCDE7090058BC19 /* IdentifiableBox.swift in Sources */, - AB0CFBD727C3B2D0004BD372 /* TagDetailView.swift in Sources */, - AB38A0CB25CA993D00764D64 /* ColorCodable.swift in Sources */, - ABBB2675279B933D007B6149 /* ReadingReducer.swift in Sources */, - ABF45AF425F3313D00ECB568 /* WebView.swift in Sources */, - AB7B29F626AC741600EE1F14 /* GenericList.swift in Sources */, - AB0CFBC927C07F95004BD372 /* TagSuggestionView.swift in Sources */, - AB706F82278986120025A48A /* ToolbarItems.swift in Sources */, - ABBB266E27998479007B6149 /* QuickSearchReducer.swift in Sources */, - AB0ABCB526C5406400AD970F /* LoginView.swift in Sources */, - AB24C55C2767565A0085C33A /* HomeView.swift in Sources */, - ABF45AF225F3313D00ECB568 /* GeneralSettingView.swift in Sources */, - AB358311269D7B63009466A5 /* DFURLProtocol.swift in Sources */, - ABBB266C2797E882007B6149 /* ClipboardClient.swift in Sources */, - AB24C55A27674EDF0085C33A /* FavoritesView.swift in Sources */, - AB7BF2BC27A965DA001865A3 /* Category.swift in Sources */, - ABBB2673279B9332007B6149 /* ReadingView.swift in Sources */, - AB7BF2D027AA3E75001865A3 /* DeviceUtil.swift in Sources */, - ABF45AE425F3313D00ECB568 /* TagCloudView.swift in Sources */, - ABCA93C22691929D00A98BC6 /* GalleryDetailMO+CoreDataClass.swift in Sources */, - AB7BF2C627A968AB001865A3 /* TranslatableLanguage.swift in Sources */, - ABF45AEE25F3313D00ECB568 /* ArchivesView.swift in Sources */, - ABBB2677279CDBB0007B6149 /* ImageClient.swift in Sources */, - AB706F95278A75D30025A48A /* HistoryView.swift in Sources */, - ABEA1FE625A9B40B002966B9 /* Setting.swift in Sources */, - ABCA93C02691925900A98BC6 /* GalleryMO+CoreDataClass.swift in Sources */, - AB7BF30D27ABDFF1001865A3 /* CoreDataMigrationVersion.swift in Sources */, - AB706F8C278A4F6C0025A48A /* WatchedView.swift in Sources */, - AB0929D22781E7D500F107CA /* LoggerClient.swift in Sources */, - AB358315269D821D009466A5 /* DFExtensions.swift in Sources */, - ABC3C7872593699B00E0C11B /* EhPandaApp.swift in Sources */, - AB0929C82781938A00F107CA /* DFClient.swift in Sources */, - AB706F9D278ACCA20025A48A /* SearchRootReducer.swift in Sources */, - ABF313A525B1AB6600D47A2F /* Misc.swift in Sources */, - ABA732DF25A852D800B3D9AB /* Filter.swift in Sources */, - AB7BF31E27ABE028001865A3 /* NSPersistentStoreCoordinator+SQLite.swift in Sources */, - ABC1FAB82642C37D00A9F352 /* NewDawnView.swift in Sources */, - ABC732C527B9024500D47DA9 /* LiveText.swift in Sources */, - ABF45AE825F3313D00ECB568 /* LinkedText.swift in Sources */, - ABC732C727B90F0900D47DA9 /* LiveTextView.swift in Sources */, - AB0929B6277F043D00F107CA /* AccountSettingReducer.swift in Sources */, - ABD49D67277EAC90003D1A07 /* URLUtil.swift in Sources */, - ABBB2638278FBD2F007B6149 /* SwiftUINavigation_Extension.swift in Sources */, - AB10117E26986B7D00C2C1A9 /* GalleryStateMO+CoreDataProperties.swift in Sources */, - AB26F59627ACCA1800AB3468 /* AppEnv.swift in Sources */, - EA5AA4A72EA9149E00BC2B5C /* PageHandler.swift in Sources */, - EA5AA4A82EA9149E00BC2B5C /* LiveTextHandler.swift in Sources */, - EA5AA4A92EA9149E00BC2B5C /* GestureHandler.swift in Sources */, - EA5AA4AA2EA9149E00BC2B5C /* AutoPlayHandler.swift in Sources */, - ABF45AE525F3313D00ECB568 /* PostCommentView.swift in Sources */, - AB358313269D7E89009466A5 /* DFRequest.swift in Sources */, - AB706F90278A5F680025A48A /* AppDelegateClient.swift in Sources */, - ABBB266A2797C61F007B6149 /* TorrentsReducer.swift in Sources */, - ABF75F3F25A19CD200544D29 /* User.swift in Sources */, - ); - runOnlyForDeploymentPostprocessing = 0; - }; - ABF294C826D20F82004DD03A /* Sources */ = { - isa = PBXSourcesBuildPhase; - buildActionMask = 2147483647; files = ( - AB31CD3D27B66F7D00F40E0A /* GalleryImageURLParserTests.swift in Sources */, - AB0CFB8227BBBFCE004BD372 /* EhSettingParserTests.swift in Sources */, - AB31CD4327B676C300F40E0A /* GalleryMPVKeysParserTests.swift in Sources */, - AB31CD3027B666E200F40E0A /* TestError.swift in Sources */, - ABD9771027B65E3400983DE7 /* GalleryDetailParserTests.swift in Sources */, - AB31CD3227B6671400F40E0A /* BanIntervalParserTests.swift in Sources */, - ABD9771327B6612400983DE7 /* GreetingParserTests.swift in Sources */, - AB31CD3727B6695800F40E0A /* HTMLFilename.swift in Sources */, - AB3E9E7426D210B1008FE518 /* TestHelper.swift in Sources */, - AB31CD3B27B66E0300F40E0A /* ListParserTestType.swift in Sources */, - ABD9770E27B65A7300983DE7 /* ListParserTests.swift in Sources */, - ABAB5B9527EF023300198597 /* Extensions.swift in Sources */, ); - runOnlyForDeploymentPostprocessing = 0; }; /* End PBXSourcesBuildPhase section */ /* Begin PBXTargetDependency section */ + A66A766D2F77C88A00FC07B8 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + productRef = A66A766C2F77C88A00FC07B8 /* SwiftLintBuildToolPlugin */; + }; + A66A766F2F77C89100FC07B8 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + productRef = A66A766E2F77C89100FC07B8 /* SwiftLintBuildToolPlugin */; + }; AB5BE67F26B95FDD007D4A55 /* PBXTargetDependency */ = { isa = PBXTargetDependency; target = AB5BE67526B95FDD007D4A55 /* ShareExtension */; targetProxy = AB5BE67E26B95FDD007D4A55 /* PBXContainerItemProxy */; }; - ABF294D126D20F82004DD03A /* PBXTargetDependency */ = { - isa = PBXTargetDependency; - target = ABC3C7532593696C00E0C11B /* EhPanda */; - targetProxy = ABF294D026D20F82004DD03A /* PBXContainerItemProxy */; - }; /* End PBXTargetDependency section */ -/* Begin PBXVariantGroup section */ - AB7E6B3225D24FE00035CC68 /* InfoPlist.strings */ = { - isa = PBXVariantGroup; - children = ( - AB7E6B3125D24FE00035CC68 /* ja */, - AB7E6B3425D24FE40035CC68 /* zh-Hans */, - AB7E6B3525D24FE50035CC68 /* en */, - ABB5013126A41EBA00B542D9 /* ko */, - AB253B4826AB08B500F95275 /* de */, - ABDD3E872930E73E009B3C2D /* zh-Hant-TW */, - ABDD3E8B2930E797009B3C2D /* zh-Hant-HK */, - ABDD3E8D2930E879009B3C2D /* zh-Hant */, - ); - name = InfoPlist.strings; - sourceTree = ""; - }; - ABA9A6BD28EC7BA200EE28DE /* Constant.strings */ = { - isa = PBXVariantGroup; - children = ( - ABA9A6BE28EC7BA200EE28DE /* en */, - ); - name = Constant.strings; - sourceTree = ""; - }; - ABEE0AFC2595C6F800C997AE /* Localizable.strings */ = { - isa = PBXVariantGroup; - children = ( - ABEE0AFB2595C6F800C997AE /* en */, - ABEE0AFE2595C73D00C997AE /* zh-Hans */, - AB994DBB25986F7A00E9A367 /* ja */, - ABB5013026A41EBA00B542D9 /* ko */, - AB253B4726AB08B500F95275 /* de */, - ABDD3E882930E73E009B3C2D /* zh-Hant-TW */, - ABDD3E8C2930E797009B3C2D /* zh-Hant-HK */, - ABDD3E8E2930E879009B3C2D /* zh-Hant */, - ); - name = Localizable.strings; - sourceTree = ""; - }; -/* End PBXVariantGroup section */ - /* Begin XCBuildConfiguration section */ - AB5BE68226B95FDD007D4A55 /* Debug */ = { + AB5BE68226B95FDD007D4A55 /* Debug configuration for PBXNativeTarget "ShareExtension" */ = { isa = XCBuildConfiguration; buildSettings = { CLANG_CXX_LANGUAGE_STANDARD = "gnu++17"; - CODE_SIGN_IDENTITY = "iPhone Developer"; - CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 157; - DEVELOPMENT_TEAM = 9SKQ7QTZ74; + CODE_SIGN_IDENTITY = "Apple Development"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 158; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = ShareExtension/Info.plist; INFOPLIST_KEY_CFBundleDisplayName = ShareExtension; @@ -2102,24 +331,26 @@ "@executable_path/Frameworks", "@executable_path/../../Frameworks", ); + MARKETING_VERSION = 3.0.0; PRODUCT_BUNDLE_IDENTIFIER = app.ehpanda.shareExtension; PRODUCT_NAME = "$(TARGET_NAME)"; - PROVISIONING_PROFILE_SPECIFIER = ShareExtension_Dev; + PROVISIONING_PROFILE_SPECIFIER = ""; SKIP_INSTALL = YES; + SWIFT_APPROACHABLE_CONCURRENCY = YES; SWIFT_EMIT_LOC_STRINGS = YES; - SWIFT_VERSION = 5.0; + SWIFT_STRICT_CONCURRENCY = complete; + SWIFT_VERSION = 6.0; TARGETED_DEVICE_FAMILY = "1,2"; }; name = Debug; }; - AB5BE68326B95FDD007D4A55 /* Release */ = { + AB5BE68326B95FDD007D4A55 /* Release configuration for PBXNativeTarget "ShareExtension" */ = { isa = XCBuildConfiguration; buildSettings = { CLANG_CXX_LANGUAGE_STANDARD = "gnu++17"; - CODE_SIGN_IDENTITY = "iPhone Developer"; - CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 157; - DEVELOPMENT_TEAM = 9SKQ7QTZ74; + CODE_SIGN_IDENTITY = "Apple Development"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 158; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = ShareExtension/Info.plist; INFOPLIST_KEY_CFBundleDisplayName = ShareExtension; @@ -2130,20 +361,24 @@ "@executable_path/Frameworks", "@executable_path/../../Frameworks", ); + MARKETING_VERSION = 3.0.0; PRODUCT_BUNDLE_IDENTIFIER = app.ehpanda.shareExtension; PRODUCT_NAME = "$(TARGET_NAME)"; - PROVISIONING_PROFILE_SPECIFIER = ShareExtension_Dev; + PROVISIONING_PROFILE_SPECIFIER = ""; SKIP_INSTALL = YES; + SWIFT_APPROACHABLE_CONCURRENCY = YES; SWIFT_EMIT_LOC_STRINGS = YES; - SWIFT_VERSION = 5.0; + SWIFT_STRICT_CONCURRENCY = complete; + SWIFT_VERSION = 6.0; TARGETED_DEVICE_FAMILY = "1,2"; }; name = Release; }; - ABC3C7612593696E00E0C11B /* Debug */ = { + ABC3C7612593696E00E0C11B /* Debug configuration for PBXProject "EhPanda" */ = { isa = XCBuildConfiguration; buildSettings = { ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; CLANG_ANALYZER_LOCALIZABILITY_NONLOCALIZED = YES; CLANG_ANALYZER_NONNULL = YES; CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; @@ -2178,6 +413,7 @@ DEBUG_INFORMATION_FORMAT = dwarf; ENABLE_STRICT_OBJC_MSGSEND = YES; ENABLE_TESTABILITY = YES; + ENABLE_USER_SCRIPT_SANDBOXING = YES; GCC_C_LANGUAGE_STANDARD = gnu11; GCC_DYNAMIC_NO_PIC = NO; GCC_NO_COMMON_BLOCKS = YES; @@ -2193,20 +429,23 @@ GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; IPHONEOS_DEPLOYMENT_TARGET = 16.0; + LOCALIZATION_PREFERS_STRING_CATALOGS = YES; MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; MTL_FAST_MATH = YES; ONLY_ACTIVE_ARCH = YES; OTHER_LDFLAGS = ""; SDKROOT = iphoneos; + STRING_CATALOG_GENERATE_SYMBOLS = YES; SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; SWIFT_OPTIMIZATION_LEVEL = "-Onone"; }; name = Debug; }; - ABC3C7622593696E00E0C11B /* Release */ = { + ABC3C7622593696E00E0C11B /* Release configuration for PBXProject "EhPanda" */ = { isa = XCBuildConfiguration; buildSettings = { ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; CLANG_ANALYZER_LOCALIZABILITY_NONLOCALIZED = YES; CLANG_ANALYZER_NONNULL = YES; CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; @@ -2241,6 +480,7 @@ DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; ENABLE_NS_ASSERTIONS = NO; ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_USER_SCRIPT_SANDBOXING = YES; GCC_C_LANGUAGE_STANDARD = gnu11; GCC_NO_COMMON_BLOCKS = YES; GCC_WARN_64_TO_32_BIT_CONVERSION = YES; @@ -2250,125 +490,75 @@ GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; IPHONEOS_DEPLOYMENT_TARGET = 16.0; + LOCALIZATION_PREFERS_STRING_CATALOGS = YES; MTL_ENABLE_DEBUG_INFO = NO; MTL_FAST_MATH = YES; OTHER_LDFLAGS = ""; SDKROOT = iphoneos; + STRING_CATALOG_GENERATE_SYMBOLS = YES; SWIFT_COMPILATION_MODE = wholemodule; SWIFT_OPTIMIZATION_LEVEL = "-O"; VALIDATE_PRODUCT = YES; }; name = Release; }; - ABC3C7642593696E00E0C11B /* Debug */ = { + ABC3C7642593696E00E0C11B /* Debug configuration for PBXNativeTarget "EhPanda" */ = { isa = XCBuildConfiguration; buildSettings = { - ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = NO; ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; - CODE_SIGN_ENTITLEMENTS = EhPanda/EhPanda.entitlements; - CODE_SIGN_IDENTITY = "iPhone Developer"; - CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 157; + CODE_SIGN_ENTITLEMENTS = App/EhPanda.entitlements; + CODE_SIGN_IDENTITY = "Apple Development"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 158; DEVELOPMENT_ASSET_PATHS = ""; - DEVELOPMENT_TEAM = 9SKQ7QTZ74; ENABLE_PREVIEWS = YES; - INFOPLIST_FILE = EhPanda/App/Info.plist; + INFOPLIST_FILE = App/Info.plist; IPHONEOS_DEPLOYMENT_TARGET = 26.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", ); + MARKETING_VERSION = 3.0.0; OTHER_LDFLAGS = ""; PRODUCT_BUNDLE_IDENTIFIER = app.ehpanda; PRODUCT_NAME = "$(TARGET_NAME)"; - PROVISIONING_PROFILE_SPECIFIER = App_Dev; + PROVISIONING_PROFILE_SPECIFIER = ""; + SWIFT_APPROACHABLE_CONCURRENCY = YES; SWIFT_OPTIMIZATION_LEVEL = "-Onone"; - SWIFT_VERSION = 5.0; + SWIFT_STRICT_CONCURRENCY = complete; + SWIFT_VERSION = 6.0; TARGETED_DEVICE_FAMILY = "1,2"; }; name = Debug; }; - ABC3C7652593696E00E0C11B /* Release */ = { + ABC3C7652593696E00E0C11B /* Release configuration for PBXNativeTarget "EhPanda" */ = { isa = XCBuildConfiguration; buildSettings = { - ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = NO; ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; - CODE_SIGN_ENTITLEMENTS = EhPanda/EhPanda.entitlements; - CODE_SIGN_IDENTITY = "iPhone Developer"; - CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 157; + CODE_SIGN_ENTITLEMENTS = App/EhPanda.entitlements; + CODE_SIGN_IDENTITY = "Apple Development"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 158; DEVELOPMENT_ASSET_PATHS = ""; - DEVELOPMENT_TEAM = 9SKQ7QTZ74; ENABLE_PREVIEWS = YES; - INFOPLIST_FILE = EhPanda/App/Info.plist; + INFOPLIST_FILE = App/Info.plist; IPHONEOS_DEPLOYMENT_TARGET = 26.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", ); + MARKETING_VERSION = 3.0.0; OTHER_LDFLAGS = ""; PRODUCT_BUNDLE_IDENTIFIER = app.ehpanda; PRODUCT_NAME = "$(TARGET_NAME)"; - PROVISIONING_PROFILE_SPECIFIER = App_Dev; - SWIFT_OPTIMIZATION_LEVEL = "-O"; - SWIFT_VERSION = 5.0; - TARGETED_DEVICE_FAMILY = "1,2"; - }; - name = Release; - }; - ABF294D226D20F82004DD03A /* Debug */ = { - isa = XCBuildConfiguration; - buildSettings = { - BUNDLE_LOADER = "$(TEST_HOST)"; - CLANG_CXX_LANGUAGE_STANDARD = "gnu++17"; - CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 157; - DEVELOPMENT_TEAM = 9SKQ7QTZ74; - GENERATE_INFOPLIST_FILE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 26.0; - LD_RUNPATH_SEARCH_PATHS = ( - "$(inherited)", - "@executable_path/Frameworks", - "@loader_path/Frameworks", - ); - MARKETING_VERSION = 1.0; - PRODUCT_BUNDLE_IDENTIFIER = app.ehpanda.tests; - PRODUCT_NAME = "$(TARGET_NAME)"; - PROVISIONING_PROFILE_SPECIFIER = ""; - "PROVISIONING_PROFILE_SPECIFIER[sdk=macosx*]" = ""; - SWIFT_EMIT_LOC_STRINGS = NO; - SWIFT_VERSION = 5.0; - TARGETED_DEVICE_FAMILY = "1,2"; - TEST_HOST = "$(BUILT_PRODUCTS_DIR)/EhPanda.app/EhPanda"; - }; - name = Debug; - }; - ABF294D326D20F82004DD03A /* Release */ = { - isa = XCBuildConfiguration; - buildSettings = { - BUNDLE_LOADER = "$(TEST_HOST)"; - CLANG_CXX_LANGUAGE_STANDARD = "gnu++17"; - CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 157; - DEVELOPMENT_TEAM = 9SKQ7QTZ74; - GENERATE_INFOPLIST_FILE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 26.0; - LD_RUNPATH_SEARCH_PATHS = ( - "$(inherited)", - "@executable_path/Frameworks", - "@loader_path/Frameworks", - ); - MARKETING_VERSION = 1.0; - PRODUCT_BUNDLE_IDENTIFIER = app.ehpanda.tests; - PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; - "PROVISIONING_PROFILE_SPECIFIER[sdk=macosx*]" = ""; - SWIFT_EMIT_LOC_STRINGS = NO; - SWIFT_VERSION = 5.0; + SWIFT_APPROACHABLE_CONCURRENCY = YES; + SWIFT_OPTIMIZATION_LEVEL = "-O"; + SWIFT_STRICT_CONCURRENCY = complete; + SWIFT_VERSION = 6.0; TARGETED_DEVICE_FAMILY = "1,2"; - TEST_HOST = "$(BUILT_PRODUCTS_DIR)/EhPanda.app/EhPanda"; }; name = Release; }; @@ -2378,273 +568,64 @@ AB5BE68426B95FDD007D4A55 /* Build configuration list for PBXNativeTarget "ShareExtension" */ = { isa = XCConfigurationList; buildConfigurations = ( - AB5BE68226B95FDD007D4A55 /* Debug */, - AB5BE68326B95FDD007D4A55 /* Release */, + AB5BE68226B95FDD007D4A55 /* Debug configuration for PBXNativeTarget "ShareExtension" */, + AB5BE68326B95FDD007D4A55 /* Release configuration for PBXNativeTarget "ShareExtension" */, ); - defaultConfigurationIsVisible = 0; defaultConfigurationName = Release; }; ABC3C74F2593696C00E0C11B /* Build configuration list for PBXProject "EhPanda" */ = { isa = XCConfigurationList; buildConfigurations = ( - ABC3C7612593696E00E0C11B /* Debug */, - ABC3C7622593696E00E0C11B /* Release */, + ABC3C7612593696E00E0C11B /* Debug configuration for PBXProject "EhPanda" */, + ABC3C7622593696E00E0C11B /* Release configuration for PBXProject "EhPanda" */, ); - defaultConfigurationIsVisible = 0; defaultConfigurationName = Release; }; ABC3C7632593696E00E0C11B /* Build configuration list for PBXNativeTarget "EhPanda" */ = { isa = XCConfigurationList; buildConfigurations = ( - ABC3C7642593696E00E0C11B /* Debug */, - ABC3C7652593696E00E0C11B /* Release */, - ); - defaultConfigurationIsVisible = 0; - defaultConfigurationName = Release; - }; - ABF294D426D20F82004DD03A /* Build configuration list for PBXNativeTarget "EhPandaTests" */ = { - isa = XCConfigurationList; - buildConfigurations = ( - ABF294D226D20F82004DD03A /* Debug */, - ABF294D326D20F82004DD03A /* Release */, + ABC3C7642593696E00E0C11B /* Debug configuration for PBXNativeTarget "EhPanda" */, + ABC3C7652593696E00E0C11B /* Release configuration for PBXNativeTarget "EhPanda" */, ); - defaultConfigurationIsVisible = 0; defaultConfigurationName = Release; }; /* End XCConfigurationList section */ -/* Begin XCRemoteSwiftPackageReference section */ - AB17573B27675B1E00FD64E2 /* XCRemoteSwiftPackageReference "Colorful" */ = { - isa = XCRemoteSwiftPackageReference; - repositoryURL = "https://github.com/Co2333/Colorful"; - requirement = { - kind = exactVersion; - version = 1.0.1; - }; - }; - AB17573E27678B3400FD64E2 /* XCRemoteSwiftPackageReference "UIImageColors" */ = { - isa = XCRemoteSwiftPackageReference; - repositoryURL = "https://github.com/jathu/UIImageColors"; - requirement = { - kind = upToNextMajorVersion; - minimumVersion = 2.0.0; - }; - }; - AB1FA94727C62BC80063EF55 /* XCRemoteSwiftPackageReference "SwiftCommonMark" */ = { - isa = XCRemoteSwiftPackageReference; - repositoryURL = "https://github.com/gonzalezreal/SwiftCommonMark"; - requirement = { - kind = upToNextMajorVersion; - minimumVersion = 1.0.0; - }; - }; - AB26F59727ACDB4200AB3468 /* XCRemoteSwiftPackageReference "FilePicker" */ = { - isa = XCRemoteSwiftPackageReference; - repositoryURL = "https://github.com/markrenaud/FilePicker"; - requirement = { - kind = upToNextMajorVersion; - minimumVersion = 1.0.0; - }; - }; - AB2EB99D280251D600011A8A /* XCRemoteSwiftPackageReference "TTProgressHUD" */ = { - isa = XCRemoteSwiftPackageReference; - repositoryURL = "https://github.com/EhPanda-Team/TTProgressHUD"; - requirement = { - branch = custom; - kind = branch; - }; - }; - AB2EB9A0280251F600011A8A /* XCRemoteSwiftPackageReference "AlertKit" */ = { - isa = XCRemoteSwiftPackageReference; - repositoryURL = "https://github.com/EhPanda-Team/AlertKit"; - requirement = { - branch = custom; - kind = branch; - }; - }; - AB2EB9A32802521700011A8A /* XCRemoteSwiftPackageReference "DeprecatedAPI" */ = { - isa = XCRemoteSwiftPackageReference; - repositoryURL = "https://github.com/EhPanda-Team/DeprecatedAPI"; - requirement = { - branch = main; - kind = branch; - }; - }; - AB60D0E7274C7ECE00F899AB /* XCRemoteSwiftPackageReference "WaterfallGrid" */ = { - isa = XCRemoteSwiftPackageReference; - repositoryURL = "https://github.com/paololeonardi/WaterfallGrid"; - requirement = { - kind = upToNextMajorVersion; - minimumVersion = 1.0.0; - }; - }; - AB65059E26B0027800F91E9D /* XCRemoteSwiftPackageReference "SwiftUIPager" */ = { - isa = XCRemoteSwiftPackageReference; - repositoryURL = "https://github.com/fermoya/SwiftUIPager"; - requirement = { - kind = upToNextMajorVersion; - minimumVersion = 2.0.0; - }; - }; - AB86AC0E27831AD100E61E6A /* XCRemoteSwiftPackageReference "swift-composable-architecture" */ = { - isa = XCRemoteSwiftPackageReference; - repositoryURL = "https://github.com/pointfreeco/swift-composable-architecture"; - requirement = { - kind = upToNextMajorVersion; - minimumVersion = 1.17.0; - }; - }; - ABAC82FC26BC4866009F5026 /* XCRemoteSwiftPackageReference "SwiftyOpenCC" */ = { - isa = XCRemoteSwiftPackageReference; - repositoryURL = "https://github.com/ddddxxx/SwiftyOpenCC"; - requirement = { - kind = upToNextMajorVersion; - minimumVersion = "2.0.0-beta"; - }; - }; - ABBB2634278FB888007B6149 /* XCRemoteSwiftPackageReference "swift-navigation" */ = { - isa = XCRemoteSwiftPackageReference; - repositoryURL = "https://github.com/pointfreeco/swift-navigation"; - requirement = { - kind = upToNextMajorVersion; - minimumVersion = 2.0.0; - }; +/* Begin XCLocalSwiftPackageReference section */ + A0F00000000000000000F003 /* XCLocalSwiftPackageReference "AppPackage" */ = { + isa = XCLocalSwiftPackageReference; + relativePath = AppPackage; }; - ABC4A0772751B40E00968A4F /* XCRemoteSwiftPackageReference "Kingfisher" */ = { - isa = XCRemoteSwiftPackageReference; - repositoryURL = "https://github.com/onevcat/Kingfisher"; - requirement = { - kind = upToNextMajorVersion; - minimumVersion = 7.0.0; - }; - }; - ABD49D5B277C6C9D003D1A07 /* XCRemoteSwiftPackageReference "SFSafeSymbols" */ = { - isa = XCRemoteSwiftPackageReference; - repositoryURL = "https://github.com/SFSafeSymbols/SFSafeSymbols"; - requirement = { - kind = upToNextMajorVersion; - minimumVersion = 4.0.0; - }; - }; - ABD7005726B1C31500DC59C9 /* XCRemoteSwiftPackageReference "Kanna" */ = { - isa = XCRemoteSwiftPackageReference; - repositoryURL = "https://github.com/tid-kijyun/Kanna"; - requirement = { - kind = upToNextMajorVersion; - minimumVersion = 5.0.0; - }; - }; - EAE63E1F29E2A6330048C601 /* XCRemoteSwiftPackageReference "SwiftyBeaver" */ = { +/* End XCLocalSwiftPackageReference section */ + +/* Begin XCRemoteSwiftPackageReference section */ + A66A766B2F77C87400FC07B8 /* XCRemoteSwiftPackageReference "SwiftLintPlugins" */ = { isa = XCRemoteSwiftPackageReference; - repositoryURL = "https://github.com/SwiftyBeaver/SwiftyBeaver"; + repositoryURL = "https://github.com/SimplyDanny/SwiftLintPlugins"; requirement = { kind = upToNextMajorVersion; - minimumVersion = 2.0.0; + minimumVersion = 0.0.0; }; }; /* End XCRemoteSwiftPackageReference section */ /* Begin XCSwiftPackageProductDependency section */ - AB17573C27675B1E00FD64E2 /* Colorful */ = { - isa = XCSwiftPackageProductDependency; - package = AB17573B27675B1E00FD64E2 /* XCRemoteSwiftPackageReference "Colorful" */; - productName = Colorful; - }; - AB17573F27678B3400FD64E2 /* UIImageColors */ = { - isa = XCSwiftPackageProductDependency; - package = AB17573E27678B3400FD64E2 /* XCRemoteSwiftPackageReference "UIImageColors" */; - productName = UIImageColors; - }; - AB1FA94827C62BC80063EF55 /* CommonMark */ = { - isa = XCSwiftPackageProductDependency; - package = AB1FA94727C62BC80063EF55 /* XCRemoteSwiftPackageReference "SwiftCommonMark" */; - productName = CommonMark; - }; - AB26F59827ACDB4200AB3468 /* FilePicker */ = { - isa = XCSwiftPackageProductDependency; - package = AB26F59727ACDB4200AB3468 /* XCRemoteSwiftPackageReference "FilePicker" */; - productName = FilePicker; - }; - AB2EB99E280251D600011A8A /* TTProgressHUD */ = { - isa = XCSwiftPackageProductDependency; - package = AB2EB99D280251D600011A8A /* XCRemoteSwiftPackageReference "TTProgressHUD" */; - productName = TTProgressHUD; - }; - AB2EB9A1280251F600011A8A /* AlertKit */ = { - isa = XCSwiftPackageProductDependency; - package = AB2EB9A0280251F600011A8A /* XCRemoteSwiftPackageReference "AlertKit" */; - productName = AlertKit; - }; - AB2EB9A42802521700011A8A /* DeprecatedAPI */ = { - isa = XCSwiftPackageProductDependency; - package = AB2EB9A32802521700011A8A /* XCRemoteSwiftPackageReference "DeprecatedAPI" */; - productName = DeprecatedAPI; - }; - AB60D0E8274C7ECE00F899AB /* WaterfallGrid */ = { - isa = XCSwiftPackageProductDependency; - package = AB60D0E7274C7ECE00F899AB /* XCRemoteSwiftPackageReference "WaterfallGrid" */; - productName = WaterfallGrid; - }; - AB65059F26B0027800F91E9D /* SwiftUIPager */ = { - isa = XCSwiftPackageProductDependency; - package = AB65059E26B0027800F91E9D /* XCRemoteSwiftPackageReference "SwiftUIPager" */; - productName = SwiftUIPager; - }; - AB86AC0F27831AD100E61E6A /* ComposableArchitecture */ = { - isa = XCSwiftPackageProductDependency; - package = AB86AC0E27831AD100E61E6A /* XCRemoteSwiftPackageReference "swift-composable-architecture" */; - productName = ComposableArchitecture; - }; - ABAC82FD26BC4A96009F5026 /* OpenCC */ = { - isa = XCSwiftPackageProductDependency; - package = ABAC82FC26BC4866009F5026 /* XCRemoteSwiftPackageReference "SwiftyOpenCC" */; - productName = OpenCC; - }; - ABBB2635278FB888007B6149 /* SwiftUINavigation */ = { - isa = XCSwiftPackageProductDependency; - package = ABBB2634278FB888007B6149 /* XCRemoteSwiftPackageReference "swift-navigation" */; - productName = SwiftUINavigation; - }; - ABC4A0782751B40E00968A4F /* Kingfisher */ = { - isa = XCSwiftPackageProductDependency; - package = ABC4A0772751B40E00968A4F /* XCRemoteSwiftPackageReference "Kingfisher" */; - productName = Kingfisher; - }; - ABD49D5C277C6C9D003D1A07 /* SFSafeSymbols */ = { + A0F00000000000000000F002 /* AppFeature */ = { isa = XCSwiftPackageProductDependency; - package = ABD49D5B277C6C9D003D1A07 /* XCRemoteSwiftPackageReference "SFSafeSymbols" */; - productName = SFSafeSymbols; + package = A0F00000000000000000F003 /* XCLocalSwiftPackageReference "AppPackage" */; + productName = AppFeature; }; - ABD7005826B1C31500DC59C9 /* Kanna */ = { + A66A766C2F77C88A00FC07B8 /* SwiftLintBuildToolPlugin */ = { isa = XCSwiftPackageProductDependency; - package = ABD7005726B1C31500DC59C9 /* XCRemoteSwiftPackageReference "Kanna" */; - productName = Kanna; + package = A66A766B2F77C87400FC07B8 /* XCRemoteSwiftPackageReference "SwiftLintPlugins" */; + productName = "plugin:SwiftLintBuildToolPlugin"; }; - EAE63E2029E2A6330048C601 /* SwiftyBeaver */ = { + A66A766E2F77C89100FC07B8 /* SwiftLintBuildToolPlugin */ = { isa = XCSwiftPackageProductDependency; - package = EAE63E1F29E2A6330048C601 /* XCRemoteSwiftPackageReference "SwiftyBeaver" */; - productName = SwiftyBeaver; + package = A66A766B2F77C87400FC07B8 /* XCRemoteSwiftPackageReference "SwiftLintPlugins" */; + productName = "plugin:SwiftLintBuildToolPlugin"; }; /* End XCSwiftPackageProductDependency section */ - -/* Begin XCVersionGroup section */ - ABC681F126898D46007BBD69 /* Model.xcdatamodeld */ = { - isa = XCVersionGroup; - children = ( - AB41DB5227B7EC5500DD3604 /* Model 7.xcdatamodel */, - AB706F93278A6F2B0025A48A /* Model 6.xcdatamodel */, - ABC4A07A2753084100968A4F /* Model 5.xcdatamodel */, - ABE9401626FF2E610085E158 /* Model 4.xcdatamodel */, - AB543FF126DB7FD9009344C0 /* Model 3.xcdatamodel */, - AB48BCF626D2539B0021A06C /* Model 2.xcdatamodel */, - ABC681F226898D46007BBD69 /* Model.xcdatamodel */, - ); - currentVersion = AB41DB5227B7EC5500DD3604 /* Model 7.xcdatamodel */; - path = Model.xcdatamodeld; - sourceTree = ""; - versionGroupType = wrapper.xcdatamodel; - }; -/* End XCVersionGroup section */ }; rootObject = ABC3C74C2593696C00E0C11B /* Project object */; } diff --git a/EhPanda.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/EhPanda.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index a94821f17..035f30fd1 100644 --- a/EhPanda.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/EhPanda.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -1,15 +1,6 @@ { - "originHash" : "d2c86e73cf55b52b5883b87c93943d803ad451433deeaa7ca1c32e53b38a2565", + "originHash" : "5bac2b3bb2714aa40e3cafdac8915686b3cb750f4525ebe3b0126cfe0f635990", "pins" : [ - { - "identity" : "alertkit", - "kind" : "remoteSourceControl", - "location" : "https://github.com/EhPanda-Team/AlertKit", - "state" : { - "branch" : "custom", - "revision" : "39b01c53ffadf3dab9871dd4c960cd81af5246b6" - } - }, { "identity" : "colorful", "kind" : "remoteSourceControl", @@ -24,8 +15,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/pointfreeco/combine-schedulers", "state" : { - "revision" : "5928286acce13def418ec36d05a001a9641086f2", - "version" : "1.0.3" + "revision" : "dcccb979a2183b8df3334237e3dc1ae2b4116a86", + "version" : "1.2.0" } }, { @@ -38,30 +29,57 @@ } }, { - "identity" : "filepicker", + "identity" : "kanna", "kind" : "remoteSourceControl", - "location" : "https://github.com/markrenaud/FilePicker", + "location" : "https://github.com/tid-kijyun/Kanna", "state" : { - "revision" : "720f8cb5ca0c0efc982ed381afc84ba3e8b3214e", - "version" : "1.0.1" + "revision" : "3c73af6d3859d9240db60aef233941a715387744", + "version" : "6.1.0" } }, { - "identity" : "kanna", + "identity" : "kingfisher", "kind" : "remoteSourceControl", - "location" : "https://github.com/tid-kijyun/Kanna", + "location" : "https://github.com/onevcat/Kingfisher", "state" : { - "revision" : "41c3d28ea0eac07e4551b28def9de1ede702e739", - "version" : "5.3.0" + "revision" : "ac632bd26a1c00f139ff62fd01806f21cf67325e", + "version" : "8.10.0" } }, { - "identity" : "kingfisher", + "identity" : "libwebp-xcode", "kind" : "remoteSourceControl", - "location" : "https://github.com/onevcat/Kingfisher", + "location" : "https://github.com/SDWebImage/libwebp-Xcode.git", "state" : { - "revision" : "2ef543ee21d63734e1c004ad6c870255e8716c50", - "version" : "7.12.0" + "revision" : "0d60654eeefd5d7d2bef3835804892c40225e8b2", + "version" : "1.5.0" + } + }, + { + "identity" : "sdwebimage", + "kind" : "remoteSourceControl", + "location" : "https://github.com/SDWebImage/SDWebImage", + "state" : { + "revision" : "2de3a496eaf6df9a1312862adcfd54acd73c39c0", + "version" : "5.21.7" + } + }, + { + "identity" : "sdwebimageswiftui", + "kind" : "remoteSourceControl", + "location" : "https://github.com/SDWebImage/SDWebImageSwiftUI", + "state" : { + "revision" : "0e331457ca9af2f0b08bcaa138a91ffb907b004f", + "version" : "3.1.4" + } + }, + { + "identity" : "sdwebimagewebpcoder", + "kind" : "remoteSourceControl", + "location" : "https://github.com/SDWebImage/SDWebImageWebPCoder", + "state" : { + "revision" : "12d83edbcc795fb7b5c0c3cb74d739108d3357d2", + "version" : "0.15.0" } }, { @@ -69,8 +87,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/SFSafeSymbols/SFSafeSymbols", "state" : { - "revision" : "7cca2d60925876b5953a2cf7341cd80fbeac983c", - "version" : "4.1.1" + "revision" : "e01b3d4f861412f8dcee8d93c417d2c2b0cdfd77", + "version" : "7.0.0" } }, { @@ -78,8 +96,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/pointfreeco/swift-case-paths", "state" : { - "revision" : "6989976265be3f8d2b5802c722f9ba168e227c71", - "version" : "1.7.2" + "revision" : "1197e80bc7e4b177051b6869ef93d8ac3ad677da", + "version" : "1.8.0" } }, { @@ -87,8 +105,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/pointfreeco/swift-clocks", "state" : { - "revision" : "cc46202b53476d64e824e0b6612da09d84ffde8e", - "version" : "1.0.6" + "revision" : "72d749bf341b78851203066ab421869b783ec42a", + "version" : "1.1.0" } }, { @@ -96,8 +114,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/apple/swift-collections", "state" : { - "revision" : "7b847a3b7008b2dc2f47ca3110d8c782fb2e5c7e", - "version" : "1.3.0" + "revision" : "a0cb0954ecb21e4e31b0070e6ed5674e8556685a", + "version" : "1.6.0" } }, { @@ -105,8 +123,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/pointfreeco/swift-composable-architecture", "state" : { - "revision" : "a9c3fecb5d31fc8aad5d8ba5d830924966d7fb15", - "version" : "1.23.0" + "revision" : "e2fa1df6cd9eec6fa6314aa20513e47da576f24e", + "version" : "1.26.0" } }, { @@ -114,8 +132,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/pointfreeco/swift-concurrency-extras", "state" : { - "revision" : "5a3825302b1a0d744183200915a47b508c828e6f", - "version" : "1.3.2" + "revision" : "a90e2e40a7a840a853dd29e57cbef5dbb72c9d5b", + "version" : "1.4.0" } }, { @@ -123,8 +141,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/pointfreeco/swift-custom-dump", "state" : { - "revision" : "82645ec760917961cfa08c9c0c7104a57a0fa4b1", - "version" : "1.3.3" + "revision" : "a8cd6c976f335ed361dcecddb0dc39ebda51bc3e", + "version" : "1.6.1" } }, { @@ -132,8 +150,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/pointfreeco/swift-dependencies", "state" : { - "revision" : "a10f9feeb214bc72b5337b6ef6d5a029360db4cc", - "version" : "1.10.0" + "revision" : "8dc1fbf2f6255a73dec53b4648164884898db4c5", + "version" : "1.14.1" } }, { @@ -150,8 +168,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/pointfreeco/swift-navigation", "state" : { - "revision" : "bf498690e1f6b4af790260f542e8428a4ba10d78", - "version" : "2.6.0" + "revision" : "6cff006b3607b700029dc44f06b41a6cc7384ac7", + "version" : "2.10.2" } }, { @@ -159,8 +177,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/pointfreeco/swift-perception", "state" : { - "revision" : "4f47ebafed5f0b0172cf5c661454fa8e28fb2ac4", - "version" : "2.0.9" + "revision" : "25ac73741c3436605d61eceb5207e896973918e7", + "version" : "2.0.10" } }, { @@ -168,8 +186,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/pointfreeco/swift-sharing", "state" : { - "revision" : "3bfc408cc2d0bee2287c174da6b1c76768377818", - "version" : "2.7.4" + "revision" : "c8dd3627eb92cef1bcb44ac5a93d558ab32b82ce", + "version" : "2.9.0" } }, { @@ -177,8 +195,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/swiftlang/swift-syntax", "state" : { - "revision" : "4799286537280063c85a32f09884cfbca301b1a1", - "version" : "602.0.0" + "revision" : "79e4b74a295b6eb74a8b585e3a39d29e70c1dbd1", + "version" : "603.0.2" } }, { @@ -191,21 +209,21 @@ } }, { - "identity" : "swiftuipager", + "identity" : "swiftlintplugins", "kind" : "remoteSourceControl", - "location" : "https://github.com/fermoya/SwiftUIPager", + "location" : "https://github.com/SimplyDanny/SwiftLintPlugins", "state" : { - "revision" : "4ddc04c801aac143090bb14cf26603a3bf9c74cb", - "version" : "2.5.0" + "revision" : "8a4640d14777685ba8f14e832373160498fbab92", + "version" : "0.63.2" } }, { - "identity" : "swiftybeaver", + "identity" : "swiftuipager", "kind" : "remoteSourceControl", - "location" : "https://github.com/SwiftyBeaver/SwiftyBeaver", + "location" : "https://github.com/fermoya/SwiftUIPager", "state" : { - "revision" : "8cba041db09596183331d123f337d0eb2e6e8e91", - "version" : "2.1.1" + "revision" : "4ddc04c801aac143090bb14cf26603a3bf9c74cb", + "version" : "2.5.0" } }, { @@ -217,15 +235,6 @@ "version" : "2.0.0-beta" } }, - { - "identity" : "ttprogresshud", - "kind" : "remoteSourceControl", - "location" : "https://github.com/EhPanda-Team/TTProgressHUD", - "state" : { - "branch" : "custom", - "revision" : "349b595c4f0ff86e8d3c8d65be206a02642fd525" - } - }, { "identity" : "uiimagecolors", "kind" : "remoteSourceControl", @@ -249,8 +258,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/pointfreeco/xctest-dynamic-overlay", "state" : { - "revision" : "4c27acf5394b645b70d8ba19dc249c0472d5f618", - "version" : "1.7.0" + "revision" : "401bf70d95bfe8db2a1dc619f9e175a85c089321", + "version" : "1.10.1" } } ], diff --git a/EhPanda.xcodeproj/xcshareddata/xcschemes/EhPanda.xcscheme b/EhPanda.xcodeproj/xcshareddata/xcschemes/EhPanda.xcscheme index e791012ec..50514d90c 100644 --- a/EhPanda.xcodeproj/xcshareddata/xcschemes/EhPanda.xcscheme +++ b/EhPanda.xcodeproj/xcshareddata/xcschemes/EhPanda.xcscheme @@ -1,7 +1,7 @@ + LastUpgradeVersion = "2660" + version = "1.7"> @@ -26,29 +26,13 @@ buildConfiguration = "Debug" selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB" selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB" - shouldUseLaunchSchemeArgsEnv = "YES" - onlyGenerateCoverageForSpecifiedTargets = "YES"> - - - - - - - - - - + shouldUseLaunchSchemeArgsEnv = "YES"> + + + + - - - - - - - - Bool { - currentPoint = touch.location(in: touch.window) - return false - } -} -private extension EhPandaApp { - func addTouchHandler() { - DispatchQueue.main.asyncAfter(deadline: .now() + 1.5) { - let tapGesture = UITapGestureRecognizer( - target: self, action: nil - ) - tapGesture.delegate = TouchHandler.shared - DeviceUtil.keyWindow?.addGestureRecognizer(tapGesture) - } - } -} diff --git a/EhPanda/App/Generated/Strings.swift b/EhPanda/App/Generated/Strings.swift deleted file mode 100644 index 1af4a1778..000000000 --- a/EhPanda/App/Generated/Strings.swift +++ /dev/null @@ -1,2223 +0,0 @@ -// swiftlint:disable all -// Generated using SwiftGen — https://github.com/SwiftGen/SwiftGen - -import Foundation - -// swiftlint:disable superfluous_disable_command file_length implicit_return prefer_self_in_static_references - -// MARK: - Strings - -// swiftlint:disable explicit_type_interface function_parameter_count identifier_name line_length -// swiftlint:disable nesting type_body_length type_name vertical_whitespace_opening_braces -internal enum L10n { - internal enum Constant { - internal enum App { - /// Copyright © 2025 EhPanda Team - internal static let copyright = L10n.tr("Constant", "app.copyright", fallback: "Copyright © 2025 EhPanda Team") - internal enum Acknowledgement { - internal enum Link { - /// https://github.com/rebeloper/AlertKit - internal static let alertKit = L10n.tr("Constant", "app.acknowledgement.link.alertKit", fallback: "https://github.com/rebeloper/AlertKit") - /// https://github.com/Co2333/Colorful - internal static let colorful = L10n.tr("Constant", "app.acknowledgement.link.colorful", fallback: "https://github.com/Co2333/Colorful") - /// https://github.com/EhTagTranslation/Database - internal static let ehTagTranslationDatabase = L10n.tr("Constant", "app.acknowledgement.link.ehTagTranslationDatabase", fallback: "https://github.com/EhTagTranslation/Database") - /// https://github.com/markrenaud/FilePicker - internal static let filePicker = L10n.tr("Constant", "app.acknowledgement.link.filePicker", fallback: "https://github.com/markrenaud/FilePicker") - /// https://github.com/tid-kijyun/Kanna - internal static let kanna = L10n.tr("Constant", "app.acknowledgement.link.kanna", fallback: "https://github.com/tid-kijyun/Kanna") - /// https://github.com/onevcat/Kingfisher - internal static let kingfisher = L10n.tr("Constant", "app.acknowledgement.link.kingfisher", fallback: "https://github.com/onevcat/Kingfisher") - /// https://github.com/SFSafeSymbols/SFSafeSymbols - internal static let sfSafeSymbols = L10n.tr("Constant", "app.acknowledgement.link.sfSafeSymbols", fallback: "https://github.com/SFSafeSymbols/SFSafeSymbols") - /// https://github.com/gonzalezreal/SwiftCommonMark - internal static let swiftCommonMark = L10n.tr("Constant", "app.acknowledgement.link.swiftCommonMark", fallback: "https://github.com/gonzalezreal/SwiftCommonMark") - /// https://github.com/SwiftGen/SwiftGen - internal static let swiftGen = L10n.tr("Constant", "app.acknowledgement.link.swiftGen", fallback: "https://github.com/SwiftGen/SwiftGen") - /// https://github.com/pointfreeco/swiftui-navigation - internal static let swiftUINavigation = L10n.tr("Constant", "app.acknowledgement.link.swiftUINavigation", fallback: "https://github.com/pointfreeco/swiftui-navigation") - /// https://github.com/fermoya/SwiftUIPager - internal static let swiftUIPager = L10n.tr("Constant", "app.acknowledgement.link.swiftUIPager", fallback: "https://github.com/fermoya/SwiftUIPager") - /// https://github.com/SwiftyBeaver/SwiftyBeaver - internal static let swiftyBeaver = L10n.tr("Constant", "app.acknowledgement.link.swiftyBeaver", fallback: "https://github.com/SwiftyBeaver/SwiftyBeaver") - /// https://github.com/ddddxxx/SwiftyOpenCC - internal static let swiftyOpenCC = L10n.tr("Constant", "app.acknowledgement.link.swiftyOpenCC", fallback: "https://github.com/ddddxxx/SwiftyOpenCC") - /// https://github.com/pointfreeco/swift-composable-architecture - internal static let tca = L10n.tr("Constant", "app.acknowledgement.link.tca", fallback: "https://github.com/pointfreeco/swift-composable-architecture") - /// https://github.com/honkmaster/TTProgressHUD - internal static let ttProgressHUD = L10n.tr("Constant", "app.acknowledgement.link.ttProgressHUD", fallback: "https://github.com/honkmaster/TTProgressHUD") - /// https://github.com/jathu/UIImageColors - internal static let uiImageColors = L10n.tr("Constant", "app.acknowledgement.link.uiImageColors", fallback: "https://github.com/jathu/UIImageColors") - /// https://github.com/paololeonardi/WaterfallGrid - internal static let waterfallGrid = L10n.tr("Constant", "app.acknowledgement.link.waterfallGrid", fallback: "https://github.com/paololeonardi/WaterfallGrid") - } - internal enum Text { - /// AlertKit - internal static let alertKit = L10n.tr("Constant", "app.acknowledgement.text.alertKit", fallback: "AlertKit") - /// Colorful - internal static let colorful = L10n.tr("Constant", "app.acknowledgement.text.colorful", fallback: "Colorful") - /// EhTagTranslation/Database - internal static let ehTagTranslationDatabase = L10n.tr("Constant", "app.acknowledgement.text.ehTagTranslationDatabase", fallback: "EhTagTranslation/Database") - /// FilePicker - internal static let filePicker = L10n.tr("Constant", "app.acknowledgement.text.filePicker", fallback: "FilePicker") - /// Kanna - internal static let kanna = L10n.tr("Constant", "app.acknowledgement.text.kanna", fallback: "Kanna") - /// Kingfisher - internal static let kingfisher = L10n.tr("Constant", "app.acknowledgement.text.kingfisher", fallback: "Kingfisher") - /// SFSafeSymbols - internal static let sfSafeSymbols = L10n.tr("Constant", "app.acknowledgement.text.sfSafeSymbols", fallback: "SFSafeSymbols") - /// SwiftCommonMark - internal static let swiftCommonMark = L10n.tr("Constant", "app.acknowledgement.text.swiftCommonMark", fallback: "SwiftCommonMark") - /// SwiftGen - internal static let swiftGen = L10n.tr("Constant", "app.acknowledgement.text.swiftGen", fallback: "SwiftGen") - /// SwiftUI Navigation - internal static let swiftUINavigation = L10n.tr("Constant", "app.acknowledgement.text.swiftUINavigation", fallback: "SwiftUI Navigation") - /// SwiftUIPager - internal static let swiftUIPager = L10n.tr("Constant", "app.acknowledgement.text.swiftUIPager", fallback: "SwiftUIPager") - /// SwiftyBeaver - internal static let swiftyBeaver = L10n.tr("Constant", "app.acknowledgement.text.swiftyBeaver", fallback: "SwiftyBeaver") - /// SwiftyOpenCC - internal static let swiftyOpenCC = L10n.tr("Constant", "app.acknowledgement.text.swiftyOpenCC", fallback: "SwiftyOpenCC") - /// The Composable Architecture - internal static let tca = L10n.tr("Constant", "app.acknowledgement.text.tca", fallback: "The Composable Architecture") - /// TTProgressHUD - internal static let ttProgressHUD = L10n.tr("Constant", "app.acknowledgement.text.ttProgressHUD", fallback: "TTProgressHUD") - /// UIImageColors - internal static let uiImageColors = L10n.tr("Constant", "app.acknowledgement.text.uiImageColors", fallback: "UIImageColors") - /// WaterfallGrid - internal static let waterfallGrid = L10n.tr("Constant", "app.acknowledgement.text.waterfallGrid", fallback: "WaterfallGrid") - } - } - internal enum CodeLevelContributor { - internal enum Link { - /// https://github.com/aalberrty - internal static let aalberrty = L10n.tr("Constant", "app.code_level_contributor.link.aalberrty", fallback: "https://github.com/aalberrty") - /// https://github.com/chihchy - internal static let chihchy = L10n.tr("Constant", "app.code_level_contributor.link.chihchy", fallback: "https://github.com/chihchy") - /// https://github.com/Jimmy-Prime - internal static let jimmyPrime = L10n.tr("Constant", "app.code_level_contributor.link.Jimmy-Prime", fallback: "https://github.com/Jimmy-Prime") - /// https://github.com/vvbbnn00 - internal static let vvbbnn00 = L10n.tr("Constant", "app.code_level_contributor.link.vvbbnn00", fallback: "https://github.com/vvbbnn00") - /// https://github.com/xioxin - internal static let xioxin = L10n.tr("Constant", "app.code_level_contributor.link.xioxin", fallback: "https://github.com/xioxin") - } - internal enum Text { - /// Zack Asahina - internal static let aalberrty = L10n.tr("Constant", "app.code_level_contributor.text.aalberrty", fallback: "Zack Asahina") - /// Chihchy - internal static let chihchy = L10n.tr("Constant", "app.code_level_contributor.text.chihchy", fallback: "Chihchy") - /// Jimmy Prime - internal static let jimmyPrime = L10n.tr("Constant", "app.code_level_contributor.text.Jimmy-Prime", fallback: "Jimmy Prime") - /// vvbbnn00 - internal static let vvbbnn00 = L10n.tr("Constant", "app.code_level_contributor.text.vvbbnn00", fallback: "vvbbnn00") - /// xioxin - internal static let xioxin = L10n.tr("Constant", "app.code_level_contributor.text.xioxin", fallback: "xioxin") - } - } - internal enum Contact { - internal enum Link { - /// altstore://source?url=https://github.com/EhPanda-Team/EhPanda/raw/main/AltStore.json - internal static let altStore = L10n.tr("Constant", "app.contact.link.altStore", fallback: "altstore://source?url=https://github.com/EhPanda-Team/EhPanda/raw/main/AltStore.json") - /// https://discord.gg/BSBE9FCBTq - internal static let discord = L10n.tr("Constant", "app.contact.link.discord", fallback: "https://discord.gg/BSBE9FCBTq") - /// https://github.com/EhPanda-Team/EhPanda - internal static let gitHub = L10n.tr("Constant", "app.contact.link.gitHub", fallback: "https://github.com/EhPanda-Team/EhPanda") - /// https://t.me/ehpanda - internal static let telegram = L10n.tr("Constant", "app.contact.link.telegram", fallback: "https://t.me/ehpanda") - /// https://ehpanda.app - internal static let website = L10n.tr("Constant", "app.contact.link.website", fallback: "https://ehpanda.app") - } - internal enum Text { - /// Discord - internal static let discord = L10n.tr("Constant", "app.contact.text.discord", fallback: "Discord") - /// GitHub - internal static let gitHub = L10n.tr("Constant", "app.contact.text.gitHub", fallback: "GitHub") - /// Telegram - internal static let telegram = L10n.tr("Constant", "app.contact.text.telegram", fallback: "Telegram") - } - } - internal enum SpecialThanks { - internal enum Link { - /// https://github.com/caxerx - internal static let caxerx = L10n.tr("Constant", "app.special_thanks.link.caxerx", fallback: "https://github.com/caxerx") - /// https://github.com/honjow - internal static let honjow = L10n.tr("Constant", "app.special_thanks.link.honjow", fallback: "https://github.com/honjow") - /// - internal static let luminescentYq = L10n.tr("Constant", "app.special_thanks.link.luminescent_yq", fallback: "") - /// https://github.com/taylorlannister - internal static let taylorlannister = L10n.tr("Constant", "app.special_thanks.link.taylorlannister", fallback: "https://github.com/taylorlannister") - } - internal enum Text { - /// caxerx - internal static let caxerx = L10n.tr("Constant", "app.special_thanks.text.caxerx", fallback: "caxerx") - /// honjow - internal static let honjow = L10n.tr("Constant", "app.special_thanks.text.honjow", fallback: "honjow") - /// Luminescent_yq - internal static let luminescentYq = L10n.tr("Constant", "app.special_thanks.text.luminescent_yq", fallback: "Luminescent_yq") - /// taylorlannister - internal static let taylorlannister = L10n.tr("Constant", "app.special_thanks.text.taylorlannister", fallback: "taylorlannister") - } - } - internal enum TranslationContributor { - internal enum Link { - /// https://github.com/caxerx - internal static let caxerx = L10n.tr("Constant", "app.translation_contributor.link.caxerx", fallback: "https://github.com/caxerx") - /// https://github.com/Nebulosa-Cat - internal static let nebulosaCat = L10n.tr("Constant", "app.translation_contributor.link.nebulosa-cat", fallback: "https://github.com/Nebulosa-Cat") - /// https://github.com/NeKoOuO - internal static let neKoOuO = L10n.tr("Constant", "app.translation_contributor.link.NeKoOuO", fallback: "https://github.com/NeKoOuO") - /// https://github.com/PaulHaeussler - internal static let paulHaeussler = L10n.tr("Constant", "app.translation_contributor.link.paulHaeussler", fallback: "https://github.com/PaulHaeussler") - } - internal enum Text { - /// caxerx - internal static let caxerx = L10n.tr("Constant", "app.translation_contributor.text.caxerx", fallback: "caxerx") - /// 雲豹 ΦωΦ - internal static let nebulosaCat = L10n.tr("Constant", "app.translation_contributor.text.nebulosa-cat", fallback: "雲豹 ΦωΦ") - /// ɴᴇᴋᴏ - internal static let neKoOuO = L10n.tr("Constant", "app.translation_contributor.text.NeKoOuO", fallback: "ɴᴇᴋᴏ") - /// PaulHaeussler - internal static let paulHaeussler = L10n.tr("Constant", "app.translation_contributor.text.paulHaeussler", fallback: "PaulHaeussler") - } - } - } - internal enum Website { - internal enum Response { - /// This gallery has been removed or is unavailable. - internal static let galleryUnavailable = L10n.tr("Constant", "website.response.gallery_unavailable", fallback: "This gallery has been removed or is unavailable.") - /// Constant.strings - /// EhPanda - internal static let hathClientNotFound = L10n.tr("Constant", "website.response.hath_client_not_found", fallback: "You must have a H@H client assigned to your account to use this feature.") - /// Your H@H client appears to be offline. Turn it on, then try again. - internal static let hathClientNotOnline = L10n.tr("Constant", "website.response.hath_client_not_online", fallback: "Your H@H client appears to be offline. Turn it on, then try again.") - /// The requested gallery cannot be downloaded with the selected resolution. - internal static let invalidResolution = L10n.tr("Constant", "website.response.invalid_resolution", fallback: "The requested gallery cannot be downloaded with the selected resolution.") - } - } - } - internal enum InfoPlist { - /// InfoPlist.strings - /// EhPanda - internal static let nsFaceIDUsageDescription = L10n.tr("InfoPlist", "NSFaceIDUsageDescription", fallback: "We need this permission to provide Face ID option while unlocking the App.") - /// We need this permission to save images to your photo library. - internal static let nsPhotoLibraryAddUsageDescription = L10n.tr("InfoPlist", "NSPhotoLibraryAddUsageDescription", fallback: "We need this permission to save images to your photo library.") - } - internal enum Localizable { - internal enum AboutView { - internal enum Button { - /// AltStore source - internal static let altStoreSource = L10n.tr("Localizable", "about_view.button.altStore_source", fallback: "AltStore source") - /// Website - internal static let website = L10n.tr("Localizable", "about_view.button.website", fallback: "Website") - } - internal enum Section { - internal enum Title { - /// Acknowledgements - internal static let acknowledgements = L10n.tr("Localizable", "about_view.section.title.acknowledgements", fallback: "Acknowledgements") - /// Code-level contributors - internal static let codeLevelContributors = L10n.tr("Localizable", "about_view.section.title.code_level_contributors", fallback: "Code-level contributors") - /// Special thanks - internal static let specialThanks = L10n.tr("Localizable", "about_view.section.title.special_thanks", fallback: "Special thanks") - /// Translation contributors - internal static let translationContributors = L10n.tr("Localizable", "about_view.section.title.translation_contributors", fallback: "Translation contributors") - } - } - internal enum Title { - /// EhPanda - internal static let ehPanda = L10n.tr("Localizable", "about_view.title.ehPanda", fallback: "EhPanda") - /// Version - internal static let version = L10n.tr("Localizable", "about_view.title.version", fallback: "Version") - } - } - internal enum AccountSettingView { - internal enum Button { - /// Account configuration - internal static let accountConfiguration = L10n.tr("Localizable", "account_setting_view.button.account_configuration", fallback: "Account configuration") - /// Copy cookies - internal static let copyCookies = L10n.tr("Localizable", "account_setting_view.button.copy_cookies", fallback: "Copy cookies") - /// Login - internal static let login = L10n.tr("Localizable", "account_setting_view.button.login", fallback: "Login") - /// Logout - internal static let logout = L10n.tr("Localizable", "account_setting_view.button.logout", fallback: "Logout") - /// Manage tags subscription - internal static let tagsManagement = L10n.tr("Localizable", "account_setting_view.button.tags_management", fallback: "Manage tags subscription") - } - internal enum Title { - /// Account - internal static let account = L10n.tr("Localizable", "account_setting_view.title.account", fallback: "Account") - /// Shows new dawn greeting - internal static let showsNewDawnGreeting = L10n.tr("Localizable", "account_setting_view.title.shows_new_dawn_greeting", fallback: "Shows new dawn greeting") - } - } - internal enum AppIconView { - internal enum Title { - /// App icon - internal static let appIcon = L10n.tr("Localizable", "app_icon_view.title.app_icon", fallback: "App icon") - } - } - internal enum AppearanceSettingView { - internal enum Button { - /// App icon - internal static let appIcon = L10n.tr("Localizable", "appearance_setting_view.button.app_icon", fallback: "App icon") - } - internal enum Menu { - internal enum Title { - /// Infite - internal static let infite = L10n.tr("Localizable", "appearance_setting_view.menu.title.infite", fallback: "Infite") - } - } - internal enum Section { - internal enum Title { - /// Gallery - internal static let gallery = L10n.tr("Localizable", "appearance_setting_view.section.title.gallery", fallback: "Gallery") - /// List - internal static let list = L10n.tr("Localizable", "appearance_setting_view.section.title.list", fallback: "List") - } - } - internal enum Title { - /// Appearance - internal static let appearance = L10n.tr("Localizable", "appearance_setting_view.title.appearance", fallback: "Appearance") - /// Display mode - internal static let displayMode = L10n.tr("Localizable", "appearance_setting_view.title.display_mode", fallback: "Display mode") - /// Displays Japanese title - internal static let displaysJapaneseTitle = L10n.tr("Localizable", "appearance_setting_view.title.displays_japanese_title", fallback: "Displays Japanese title") - /// Maximum number of tags - internal static let maximumNumberOfTags = L10n.tr("Localizable", "appearance_setting_view.title.maximum_number_of_tags", fallback: "Maximum number of tags") - /// Shows tags in list - internal static let showsTagsInList = L10n.tr("Localizable", "appearance_setting_view.title.shows_tags_in_list", fallback: "Shows tags in list") - /// Theme - internal static let theme = L10n.tr("Localizable", "appearance_setting_view.title.theme", fallback: "Theme") - /// Tint color - internal static let tintColor = L10n.tr("Localizable", "appearance_setting_view.title.tint_color", fallback: "Tint color") - } - } - internal enum ArchivesView { - internal enum Button { - /// Download To H@H Client - internal static let downloadToHathClient = L10n.tr("Localizable", "archives_view.button.download_to_hath_client", fallback: "Download To H@H Client") - } - internal enum Title { - /// Archives - internal static let archives = L10n.tr("Localizable", "archives_view.title.archives", fallback: "Archives") - } - } - internal enum CommentsView { - internal enum Title { - /// Comments - internal static let comments = L10n.tr("Localizable", "comments_view.title.comments", fallback: "Comments") - } - } - internal enum Common { - internal enum Value { - /// %@ day - internal static func day(_ p1: Any) -> String { - return L10n.tr("Localizable", "common.value.day", String(describing: p1), fallback: "%@ day") - } - /// %@ days - internal static func days(_ p1: Any) -> String { - return L10n.tr("Localizable", "common.value.days", String(describing: p1), fallback: "%@ days") - } - /// %@ hour - internal static func hour(_ p1: Any) -> String { - return L10n.tr("Localizable", "common.value.hour", String(describing: p1), fallback: "%@ hour") - } - /// %@ hours - internal static func hours(_ p1: Any) -> String { - return L10n.tr("Localizable", "common.value.hours", String(describing: p1), fallback: "%@ hours") - } - /// %@ minute - internal static func minute(_ p1: Any) -> String { - return L10n.tr("Localizable", "common.value.minute", String(describing: p1), fallback: "%@ minute") - } - /// %@ minutes - internal static func minutes(_ p1: Any) -> String { - return L10n.tr("Localizable", "common.value.minutes", String(describing: p1), fallback: "%@ minutes") - } - /// %@ pages - internal static func pages(_ p1: Any) -> String { - return L10n.tr("Localizable", "common.value.pages", String(describing: p1), fallback: "%@ pages") - } - /// %@ records - internal static func records(_ p1: Any) -> String { - return L10n.tr("Localizable", "common.value.records", String(describing: p1), fallback: "%@ records") - } - /// %@ second - internal static func second(_ p1: Any) -> String { - return L10n.tr("Localizable", "common.value.second", String(describing: p1), fallback: "%@ second") - } - /// %@ seconds - internal static func seconds(_ p1: Any) -> String { - return L10n.tr("Localizable", "common.value.seconds", String(describing: p1), fallback: "%@ seconds") - } - /// %@ stars - internal static func stars(_ p1: Any) -> String { - return L10n.tr("Localizable", "common.value.stars", String(describing: p1), fallback: "%@ stars") - } - /// %@ times - internal static func times(_ p1: Any) -> String { - return L10n.tr("Localizable", "common.value.times", String(describing: p1), fallback: "%@ times") - } - } - } - internal enum ConfirmationDialog { - internal enum Button { - /// Clear - internal static let clear = L10n.tr("Localizable", "confirmation_dialog.button.clear", fallback: "Clear") - /// Delete - internal static let delete = L10n.tr("Localizable", "confirmation_dialog.button.delete", fallback: "Delete") - /// Drop the database - internal static let dropDatabase = L10n.tr("Localizable", "confirmation_dialog.button.drop_database", fallback: "Drop the database") - /// Logout - internal static let logout = L10n.tr("Localizable", "confirmation_dialog.button.logout", fallback: "Logout") - /// Remove - internal static let remove = L10n.tr("Localizable", "confirmation_dialog.button.remove", fallback: "Remove") - /// Reset - internal static let reset = L10n.tr("Localizable", "confirmation_dialog.button.reset", fallback: "Reset") - } - internal enum Title { - /// Are you sure to clear? - internal static let clear = L10n.tr("Localizable", "confirmation_dialog.title.clear", fallback: "Are you sure to clear?") - /// Are you sure to delete this item? - internal static let delete = L10n.tr("Localizable", "confirmation_dialog.title.delete", fallback: "Are you sure to delete this item?") - /// You will lose all your data in this app. - /// Are you sure to drop the database? - internal static let dropDatabase = L10n.tr("Localizable", "confirmation_dialog.title.drop_database", fallback: "You will lose all your data in this app.\nAre you sure to drop the database?") - /// Are you sure to logout? - internal static let logout = L10n.tr("Localizable", "confirmation_dialog.title.logout", fallback: "Are you sure to logout?") - /// Are you sure to remove your custom translations? - internal static let removeCustomTranslations = L10n.tr("Localizable", "confirmation_dialog.title.remove_custom_translations", fallback: "Are you sure to remove your custom translations?") - /// Are you sure to reset? - internal static let reset = L10n.tr("Localizable", "confirmation_dialog.title.reset", fallback: "Are you sure to reset?") - } - } - internal enum DetailView { - internal enum ActionSection { - internal enum Button { - /// Give a Rating - internal static let giveARating = L10n.tr("Localizable", "detail_view.action_section.button.give_a_rating", fallback: "Give a Rating") - /// Similar Gallery - internal static let similarGallery = L10n.tr("Localizable", "detail_view.action_section.button.similar_gallery", fallback: "Similar Gallery") - } - } - internal enum Button { - /// Post comment - internal static let postComment = L10n.tr("Localizable", "detail_view.button.post_comment", fallback: "Post comment") - /// Read - internal static let read = L10n.tr("Localizable", "detail_view.button.read", fallback: "Read") - } - internal enum ContextMenu { - internal enum Button { - /// Detail - internal static let detail = L10n.tr("Localizable", "detail_view.context_menu.button.detail", fallback: "Detail") - /// Vote down - internal static let voteDown = L10n.tr("Localizable", "detail_view.context_menu.button.vote_down", fallback: "Vote down") - /// Vote up - internal static let voteUp = L10n.tr("Localizable", "detail_view.context_menu.button.vote_up", fallback: "Vote up") - /// Withdraw vote - internal static let withdrawVote = L10n.tr("Localizable", "detail_view.context_menu.button.withdraw_vote", fallback: "Withdraw vote") - } - } - internal enum DescriptionSection { - internal enum Description { - /// Times - internal static let favorited = L10n.tr("Localizable", "detail_view.description_section.description.favorited", fallback: "Times") - /// Pages - internal static let pageCount = L10n.tr("Localizable", "detail_view.description_section.description.page_count", fallback: "Pages") - } - internal enum Title { - /// Favorited - internal static let favorited = L10n.tr("Localizable", "detail_view.description_section.title.favorited", fallback: "Favorited") - /// File Size - internal static let fileSize = L10n.tr("Localizable", "detail_view.description_section.title.file_size", fallback: "File Size") - /// Language - internal static let language = L10n.tr("Localizable", "detail_view.description_section.title.language", fallback: "Language") - /// Page Count - internal static let pageCount = L10n.tr("Localizable", "detail_view.description_section.title.page_count", fallback: "Page Count") - /// %@ Ratings - internal static func ratings(_ p1: Any) -> String { - return L10n.tr("Localizable", "detail_view.description_section.title.ratings", String(describing: p1), fallback: "%@ Ratings") - } - } - } - internal enum Section { - internal enum Title { - /// Comments - internal static let comments = L10n.tr("Localizable", "detail_view.section.title.comments", fallback: "Comments") - /// Previews - internal static let previews = L10n.tr("Localizable", "detail_view.section.title.previews", fallback: "Previews") - } - } - internal enum ToolbarItem { - internal enum Button { - /// Archives - internal static let archives = L10n.tr("Localizable", "detail_view.toolbar_item.button.archives", fallback: "Archives") - /// Share - internal static let share = L10n.tr("Localizable", "detail_view.toolbar_item.button.share", fallback: "Share") - /// Torrents - internal static let torrents = L10n.tr("Localizable", "detail_view.toolbar_item.button.torrents", fallback: "Torrents") - } - } - } - internal enum EhSettingView { - internal enum Button { - /// Create new - internal static let createNew = L10n.tr("Localizable", "eh_setting_view.button.create_new", fallback: "Create new") - /// Delete profile - internal static let deleteProfile = L10n.tr("Localizable", "eh_setting_view.button.delete_profile", fallback: "Delete profile") - /// Rename - internal static let rename = L10n.tr("Localizable", "eh_setting_view.button.rename", fallback: "Rename") - /// Set as default - internal static let setAsDefault = L10n.tr("Localizable", "eh_setting_view.button.set_as_default", fallback: "Set as default") - } - internal enum Description { - /// The default behavior for the Archiver is to confirm the cost and selection for original or resampled archive, then present a link that can be clicked or copied elsewhere. You can change this behavior here. - internal static let archiverBehavior = L10n.tr("Localizable", "eh_setting_view.description.archiver_behavior", fallback: "The default behavior for the Archiver is to confirm the cost and selection for original or resampled archive, then present a link that can be clicked or copied elsewhere. You can change this behavior here.") - /// You appear to be browsing the site from **%@** or use a VPN or proxy in this country, which means the site will try to load images from H@H clients in this general geographic region. If this is incorrect, or if you want to use a different region for any reason (like if you are using a split tunneling VPN), you can select a different country below. - internal static func browsingCountry(_ p1: Any) -> String { - return L10n.tr("Localizable", "eh_setting_view.description.browsing_country", String(describing: p1), fallback: "You appear to be browsing the site from **%@** or use a VPN or proxy in this country, which means the site will try to load images from H@H clients in this general geographic region. If this is incorrect, or if you want to use a different region for any reason (like if you are using a split tunneling VPN), you can select a different country below.") - } - /// The cover size in gallery list views can be scaled to between 75%% and 150%% when using the Thumbnail or Extended display modes. - internal static let coverScaleFactor = L10n.tr("Localizable", "eh_setting_view.description.cover_scale_factor", fallback: "The cover size in gallery list views can be scaled to between 75%% and 150%% when using the Thumbnail or Extended display modes.") - /// Which display mode would you like to use on the front and search pages? - internal static let displayMode = L10n.tr("Localizable", "eh_setting_view.description.display_mode", fallback: "Which display mode would you like to use on the front and search pages?") - /// If you wish to hide galleries in certain languages from the gallery list and searches, select them from the list below. Note that matching galleries will never appear regardless of your search query. - internal static let excludedLanguages = L10n.tr("Localizable", "eh_setting_view.description.excluded_languages", fallback: "If you wish to hide galleries in certain languages from the gallery list and searches, select them from the list below. Note that matching galleries will never appear regardless of your search query.") - /// If you wish to hide galleries from certain uploaders from the gallery list and searches, add them below. Put one username per line. Note that galleries from these uploaders will never appear regardless of your search query. - internal static let excludedUploaders = L10n.tr("Localizable", "eh_setting_view.description.excluded_uploaders", fallback: "If you wish to hide galleries from certain uploaders from the gallery list and searches, add them below. Put one username per line. Note that galleries from these uploaders will never appear regardless of your search query.") - /// You are currently using **%@ / %@** exclusion slots. - internal static func excludedUploadersCount(_ p1: Any, _ p2: Any) -> String { - return L10n.tr("Localizable", "eh_setting_view.description.excluded_uploaders_count", String(describing: p1), String(describing: p2), fallback: "You are currently using **%@ / %@** exclusion slots.") - } - /// Here you can choose and rename your favorite categories. - internal static let favoriteCategories = L10n.tr("Localizable", "eh_setting_view.description.favorite_categories", fallback: "Here you can choose and rename your favorite categories.") - /// You can also select your default sort order for galleries on your favorites page. Note that favorites added prior to the March 2016 revamp did not store a timestamp, and will use the gallery posted time regardless of this setting. - internal static let favoritesSortOrder = L10n.tr("Localizable", "eh_setting_view.description.favorites_sort_order", fallback: "You can also select your default sort order for galleries on your favorites page. Note that favorites added prior to the March 2016 revamp did not store a timestamp, and will use the gallery posted time regardless of this setting.") - /// Show the "Your default filters removed XX galleries from this page" readout? - internal static let filteredRemovalCount = L10n.tr("Localizable", "eh_setting_view.description.filtered_removal_count", fallback: "Show the \"Your default filters removed XX galleries from this page\" readout?") - /// What categories would you like to show by default on the front page and in searches? - internal static let galleryCategory = L10n.tr("Localizable", "eh_setting_view.description.gallery_category", fallback: "What categories would you like to show by default on the front page and in searches?") - /// Many galleries have both an English/Romanized title and a title in Japanese script. Which gallery name would you like as default? - internal static let galleryName = L10n.tr("Localizable", "eh_setting_view.description.gallery_name", fallback: "Many galleries have both an English/Romanized title and a title in Japanese script. Which gallery name would you like as default?") - /// Normally, images are resampled to 1280 pixels of horizontal resolution for online viewing. You can alternatively select one of the following resample resolutions. To avoid murdering the staging servers, resolutions above 1280x are temporarily restricted to donators, people with any hath perk, and people with a UID below 3,000,000. - internal static let imageResolution = L10n.tr("Localizable", "eh_setting_view.description.image_resolution", fallback: "Normally, images are resampled to 1280 pixels of horizontal resolution for online viewing. You can alternatively select one of the following resample resolutions. To avoid murdering the staging servers, resolutions above 1280x are temporarily restricted to donators, people with any hath perk, and people with a UID below 3,000,000.") - /// While the site will automatically scale down images to fit your screen width, you can also manually restrict the maximum display size of an image. Like the automatic scaling, this does not resample the image, as the resizing is done browser-side. (0 = no limit) - internal static let imageSize = L10n.tr("Localizable", "eh_setting_view.description.image_size", fallback: "While the site will automatically scale down images to fit your screen width, you can also manually restrict the maximum display size of an image. Like the automatic scaling, this does not resample the image, as the resizing is done browser-side. (0 = no limit)") - /// This setting can be used if you have a H@H client running on your local network with the same public IP you browse the site with. Some routers are buggy and cannot route requests back to its own IP; this allows you to work around this problem. - /// If you are running the client on the same device you browse from, use the loopback address (127.0.0.1:port). If the client is running on another device on your network, use its local network IP. Some browser configurations prevent external web sites from accessing URLs with local network IPs, the site must then be whitelisted for this to work. - internal static let ipAddressPort = L10n.tr("Localizable", "eh_setting_view.description.ip_address_port", fallback: "This setting can be used if you have a H@H client running on your local network with the same public IP you browse the site with. Some routers are buggy and cannot route requests back to its own IP; this allows you to work around this problem.\nIf you are running the client on the same device you browse from, use the loopback address (127.0.0.1:port). If the client is running on another device on your network, use its local network IP. Some browser configurations prevent external web sites from accessing URLs with local network IPs, the site must then be whitelisted for this to work.") - /// Some historic UI elements are now disabled by default. You can enable those here. - internal static let optionalUIElements = L10n.tr("Localizable", "eh_setting_view.description.optional_UI_elements", fallback: "Some historic UI elements are now disabled by default. You can enable those here.") - /// By default, galleries that you have rated will appear with red stars for ratings of 2 stars and below, green for ratings between 2.5 and 4 stars, and blue for ratings of 4.5 or 5 stars. You can customize this by entering your desired color combination below. Each letter represents one star. The default RRGGB means R(ed) for the first and second star, G(reen) for the third and fourth, and B(lue) for the fifth. You can also use (Y)ellow for the normal stars. Any five-letter R/G/B/Y combo works. - internal static let ratingsColor = L10n.tr("Localizable", "eh_setting_view.description.ratings_color", fallback: "By default, galleries that you have rated will appear with red stars for ratings of 2 stars and below, green for ratings between 2.5 and 4 stars, and blue for ratings of 4.5 or 5 stars. You can customize this by entering your desired color combination below. Each letter represents one star. The default RRGGB means R(ed) for the first and second star, G(reen) for the third and fourth, and B(lue) for the fifth. You can also use (Y)ellow for the normal stars. Any five-letter R/G/B/Y combo works.") - /// How many results would you like per page for the index/search page and torrent search pages? - /// (Hath Perk: Paging Enlargement Required) - internal static let resultCount = L10n.tr("Localizable", "eh_setting_view.description.result_count", fallback: "How many results would you like per page for the index/search page and torrent search pages?\n(Hath Perk: Paging Enlargement Required)") - /// You can soft filter tags by adding them to My Tags with a negative weight. If a gallery has tags that add up to weight below this value, it is filtered from view. This threshold can be set between 0 and -9999. - internal static let tagFilteringThreshold = L10n.tr("Localizable", "eh_setting_view.description.tag_filtering_threshold", fallback: "You can soft filter tags by adding them to My Tags with a negative weight. If a gallery has tags that add up to weight below this value, it is filtered from view. This threshold can be set between 0 and -9999.") - /// Recently uploaded galleries will be included on the watched screen if it has at least one watched tag with positive weight, and the sum of weights on its watched tags add up to this value or higher. This threshold can be set between 0 and 9999. - internal static let tagWatchingThreshold = L10n.tr("Localizable", "eh_setting_view.description.tag_watching_threshold", fallback: "Recently uploaded galleries will be included on the watched screen if it has at least one watched tag with positive weight, and the sum of weights on its watched tags add up to this value or higher. This threshold can be set between 0 and 9999.") - /// You can set a default thumbnail configuration for all galleries you visit. - internal static let thumbnailConfiguration = L10n.tr("Localizable", "eh_setting_view.description.thumbnail_configuration", fallback: "You can set a default thumbnail configuration for all galleries you visit.") - /// How would you like the mouse-over thumbnails on the front page to load when using List Mode? - internal static let thumbnailLoadTiming = L10n.tr("Localizable", "eh_setting_view.description.thumbnail_load_timing", fallback: "How would you like the mouse-over thumbnails on the front page to load when using List Mode?") - /// Allows you to override the virtual width of the site for mobile devices. This is normally determined automatically by your device based on its DPI. Sensible values at 100%% thumbnail scale are between 640 and 1400. - internal static let virtualWidth = L10n.tr("Localizable", "eh_setting_view.description.virtual_width", fallback: "Allows you to override the virtual width of the site for mobile devices. This is normally determined automatically by your device based on its DPI. Sensible values at 100%% thumbnail scale are between 640 and 1400.") - } - internal enum Promt { - /// RRGGB - internal static let ratingsColor = L10n.tr("Localizable", "eh_setting_view.promt.ratings_color", fallback: "RRGGB") - } - internal enum Section { - internal enum Title { - /// Archiver Settings - internal static let archiverSettings = L10n.tr("Localizable", "eh_setting_view.section.title.archiver_settings", fallback: "Archiver Settings") - /// Cover Scaling - internal static let coverScaling = L10n.tr("Localizable", "eh_setting_view.section.title.cover_scaling", fallback: "Cover Scaling") - /// Excluded Languages - internal static let excludedLanguages = L10n.tr("Localizable", "eh_setting_view.section.title.excluded_languages", fallback: "Excluded Languages") - /// Excluded Uploaders - internal static let excludedUploaders = L10n.tr("Localizable", "eh_setting_view.section.title.excluded_uploaders", fallback: "Excluded Uploaders") - /// Favorites - internal static let favorites = L10n.tr("Localizable", "eh_setting_view.section.title.favorites", fallback: "Favorites") - /// Show Filtered Removal Count - internal static let filteredRemovalCount = L10n.tr("Localizable", "eh_setting_view.section.title.filtered_removal_count", fallback: "Show Filtered Removal Count") - /// Front Page Settings - internal static let frontPageSettings = L10n.tr("Localizable", "eh_setting_view.section.title.front_page_settings", fallback: "Front Page Settings") - /// Gallery Comments - internal static let galleryComments = L10n.tr("Localizable", "eh_setting_view.section.title.gallery_comments", fallback: "Gallery Comments") - /// Gallery Name Display - internal static let galleryNameDisplay = L10n.tr("Localizable", "eh_setting_view.section.title.gallery_name_display", fallback: "Gallery Name Display") - /// Gallery Page Thumbnail Labeling - internal static let galleryPageThumbnailLabeling = L10n.tr("Localizable", "eh_setting_view.section.title.gallery_page_thumbnail_labeling", fallback: "Gallery Page Thumbnail Labeling") - /// Gallery Tags - internal static let galleryTags = L10n.tr("Localizable", "eh_setting_view.section.title.gallery_tags", fallback: "Gallery Tags") - /// Hath Local Network Host - internal static let hathLocalNetworkHost = L10n.tr("Localizable", "eh_setting_view.section.title.hath_local_network_host", fallback: "Hath Local Network Host") - /// Image Load Settings - internal static let imageLoadSettings = L10n.tr("Localizable", "eh_setting_view.section.title.image_load_settings", fallback: "Image Load Settings") - /// Image Size Settings - internal static let imageSizeSettings = L10n.tr("Localizable", "eh_setting_view.section.title.image_size_settings", fallback: "Image Size Settings") - /// Multi-Page Viewer - internal static let multiPageViewer = L10n.tr("Localizable", "eh_setting_view.section.title.multi_page_viewer", fallback: "Multi-Page Viewer") - /// Optional UI Elements - internal static let optionalUIElements = L10n.tr("Localizable", "eh_setting_view.section.title.optional_UI_elements", fallback: "Optional UI Elements") - /// Use original images instead of the resampled versions? Resampled images will still be used if you select a horizontal resolution different than "Auto" above and the image in question is wider, or if the original image is larger than 10 MiB (or 4 MiB for galleries older than one year). - internal static let originalImages = L10n.tr("Localizable", "eh_setting_view.section.title.original_images", fallback: "Use original images instead of the resampled versions? Resampled images will still be used if you select a horizontal resolution different than \"Auto\" above and the image in question is wider, or if the original image is larger than 10 MiB (or 4 MiB for galleries older than one year).") - /// Profile Settings - internal static let profileSettings = L10n.tr("Localizable", "eh_setting_view.section.title.profile_settings", fallback: "Profile Settings") - /// Ratings - internal static let ratings = L10n.tr("Localizable", "eh_setting_view.section.title.ratings", fallback: "Ratings") - /// Search Result Count - internal static let searchResultCount = L10n.tr("Localizable", "eh_setting_view.section.title.search_result_count", fallback: "Search Result Count") - /// Search Range Indicator - internal static let showSearchRangeIndicator = L10n.tr("Localizable", "eh_setting_view.section.title.show_search_range_indicator", fallback: "Search Range Indicator") - /// Tag Filtering Threshold - internal static let tagFilteringThreshold = L10n.tr("Localizable", "eh_setting_view.section.title.tag_filtering_threshold", fallback: "Tag Filtering Threshold") - /// Tag Watching Threshold - internal static let tagWatchingThreshold = L10n.tr("Localizable", "eh_setting_view.section.title.tag_watching_threshold", fallback: "Tag Watching Threshold") - /// Thumbnail Settings - internal static let thumbnailSettings = L10n.tr("Localizable", "eh_setting_view.section.title.thumbnail_settings", fallback: "Thumbnail Settings") - /// Viewport Override - internal static let viewportOverride = L10n.tr("Localizable", "eh_setting_view.section.title.viewport_override", fallback: "Viewport Override") - } - } - internal enum Title { - /// Archiver behavior - internal static let archiverBehavior = L10n.tr("Localizable", "eh_setting_view.title.archiver_behavior", fallback: "Archiver behavior") - /// Browsing country - internal static let browsingCountry = L10n.tr("Localizable", "eh_setting_view.title.browsing_country", fallback: "Browsing country") - /// Comments sort order - internal static let commentsSortOrder = L10n.tr("Localizable", "eh_setting_view.title.comments_sort_order", fallback: "Comments sort order") - /// Comment votes show timing - internal static let commentsVotesShowTiming = L10n.tr("Localizable", "eh_setting_view.title.comments_votes_show_timing", fallback: "Comment votes show timing") - /// Display mode - internal static let displayMode = L10n.tr("Localizable", "eh_setting_view.title.display_mode", fallback: "Display mode") - /// Display style - internal static let displayStyle = L10n.tr("Localizable", "eh_setting_view.title.display_style", fallback: "Display style") - /// Enable thumbnail selector on gallery screen - internal static let enableGalleryThumbnailSelector = L10n.tr("Localizable", "eh_setting_view.title.enable_gallery_thumbnail_selector", fallback: "Enable thumbnail selector on gallery screen") - /// Favorites sort order - internal static let favoritesSortOrder = L10n.tr("Localizable", "eh_setting_view.title.favorites_sort_order", fallback: "Favorites sort order") - /// Gallery name - internal static let galleryName = L10n.tr("Localizable", "eh_setting_view.title.gallery_name", fallback: "Gallery name") - /// Horizontal - internal static let horizontal = L10n.tr("Localizable", "eh_setting_view.title.horizontal", fallback: "Horizontal") - /// %@ settings - internal static func hostSettings(_ p1: Any) -> String { - return L10n.tr("Localizable", "eh_setting_view.title.host_settings", String(describing: p1), fallback: "%@ settings") - } - /// Image resolution - internal static let imageResolution = L10n.tr("Localizable", "eh_setting_view.title.image_resolution", fallback: "Image resolution") - /// Image size - internal static let imageSize = L10n.tr("Localizable", "eh_setting_view.title.image_size", fallback: "Image size") - /// IP address:Port - internal static let ipAddressPort = L10n.tr("Localizable", "eh_setting_view.title.ip_address_port", fallback: "IP address:Port") - /// Load images through the Hath network - internal static let loadImagesThroughTheHathNetwork = L10n.tr("Localizable", "eh_setting_view.title.load_images_through_the_hath_network", fallback: "Load images through the Hath network") - /// Ratings color - internal static let ratingsColor = L10n.tr("Localizable", "eh_setting_view.title.ratings_color", fallback: "Ratings color") - /// Result count - internal static let resultCount = L10n.tr("Localizable", "eh_setting_view.title.result_count", fallback: "Result count") - /// Scale factor - internal static let scaleFactor = L10n.tr("Localizable", "eh_setting_view.title.scale_factor", fallback: "Scale factor") - /// Selected profile - internal static let selectedProfile = L10n.tr("Localizable", "eh_setting_view.title.selected_profile", fallback: "Selected profile") - /// Show filtered removal count - internal static let showFilteredRemovalCount = L10n.tr("Localizable", "eh_setting_view.title.show_filtered_removal_count", fallback: "Show filtered removal count") - /// Show label below gallery thumbnails - internal static let showLabelBelowGalleryThumbnails = L10n.tr("Localizable", "eh_setting_view.title.show_label_below_gallery_thumbnails", fallback: "Show label below gallery thumbnails") - /// Show search range indicator - internal static let showSearchRangeIndicator = L10n.tr("Localizable", "eh_setting_view.title.show_search_range_indicator", fallback: "Show search range indicator") - /// Show thumbnail pane - internal static let showThumbnailPane = L10n.tr("Localizable", "eh_setting_view.title.show_thumbnail_pane", fallback: "Show thumbnail pane") - /// Tag Filtering Threshold - internal static let tagFilteringThreshold = L10n.tr("Localizable", "eh_setting_view.title.tag_filtering_threshold", fallback: "Tag Filtering Threshold") - /// Tag Watching Threshold - internal static let tagWatchingThreshold = L10n.tr("Localizable", "eh_setting_view.title.tag_watching_threshold", fallback: "Tag Watching Threshold") - /// Tags sort order - internal static let tagsSortOrder = L10n.tr("Localizable", "eh_setting_view.title.tags_sort_order", fallback: "Tags sort order") - /// Thumbnail load timing - internal static let thumbnailLoadTiming = L10n.tr("Localizable", "eh_setting_view.title.thumbnail_load_timing", fallback: "Thumbnail load timing") - /// Rows - internal static let thumbnailRowCount = L10n.tr("Localizable", "eh_setting_view.title.thumbnail_row_count", fallback: "Rows") - /// Size - internal static let thumbnailSize = L10n.tr("Localizable", "eh_setting_view.title.thumbnail_size", fallback: "Size") - /// Use Multi-Page Viewer - internal static let useMultiPageViewer = L10n.tr("Localizable", "eh_setting_view.title.use_multi_page_viewer", fallback: "Use Multi-Page Viewer") - /// Use original images - internal static let useOriginalImages = L10n.tr("Localizable", "eh_setting_view.title.use_original_images", fallback: "Use original images") - /// Vertical - internal static let vertical = L10n.tr("Localizable", "eh_setting_view.title.vertical", fallback: "Vertical") - /// Virtual width - internal static let virtualWidth = L10n.tr("Localizable", "eh_setting_view.title.virtual_width", fallback: "Virtual width") - } - internal enum ToolbarItem { - internal enum Button { - /// Done - internal static let done = L10n.tr("Localizable", "eh_setting_view.toolbar_item.button.done", fallback: "Done") - } - } - } - internal enum Enum { - internal enum AppIconType { - internal enum Value { - /// Default - internal static let `default` = L10n.tr("Localizable", "enum.app_icon_type.value.default", fallback: "Default") - /// Developer - internal static let developer = L10n.tr("Localizable", "enum.app_icon_type.value.developer", fallback: "Developer") - /// NOT MY PRESIDENT - internal static let notMyPresident = L10n.tr("Localizable", "enum.app_icon_type.value.not_my_president", fallback: "NOT MY PRESIDENT") - /// Stand With Ukraine (2022) - internal static let standWithUkraine2022 = L10n.tr("Localizable", "enum.app_icon_type.value.stand_with_ukraine_2022", fallback: "Stand With Ukraine (2022)") - /// Ukiyo-e - internal static let ukiyoe = L10n.tr("Localizable", "enum.app_icon_type.value.ukiyoe", fallback: "Ukiyo-e") - } - } - internal enum ArchiveResolution { - internal enum Value { - /// Original - internal static let original = L10n.tr("Localizable", "enum.archive_resolution.value.original", fallback: "Original") - } - } - internal enum AutoLockPolicy { - internal enum Value { - /// Instantly - internal static let instantly = L10n.tr("Localizable", "enum.auto_lock_policy.value.instantly", fallback: "Instantly") - /// Never - internal static let never = L10n.tr("Localizable", "enum.auto_lock_policy.value.never", fallback: "Never") - } - } - internal enum AutoPlayPolicy { - internal enum Value { - /// Off - internal static let off = L10n.tr("Localizable", "enum.auto_play_policy.value.off", fallback: "Off") - } - } - internal enum BanInterval { - internal enum Description { - /// Localizable.strings - /// EhPanda - internal static let and = L10n.tr("Localizable", "enum.ban_interval.description.and", fallback: "and") - } - } - internal enum BrowsingCountry { - internal enum Name { - /// Afghanistan - internal static let afghanistan = L10n.tr("Localizable", "enum.browsing_country.name.afghanistan", fallback: "Afghanistan") - /// Aland Islands - internal static let alandIslands = L10n.tr("Localizable", "enum.browsing_country.name.aland_islands", fallback: "Aland Islands") - /// Albania - internal static let albania = L10n.tr("Localizable", "enum.browsing_country.name.albania", fallback: "Albania") - /// Algeria - internal static let algeria = L10n.tr("Localizable", "enum.browsing_country.name.algeria", fallback: "Algeria") - /// American Samoa - internal static let americanSamoa = L10n.tr("Localizable", "enum.browsing_country.name.american_samoa", fallback: "American Samoa") - /// Andorra - internal static let andorra = L10n.tr("Localizable", "enum.browsing_country.name.andorra", fallback: "Andorra") - /// Angola - internal static let angola = L10n.tr("Localizable", "enum.browsing_country.name.angola", fallback: "Angola") - /// Anguilla - internal static let anguilla = L10n.tr("Localizable", "enum.browsing_country.name.anguilla", fallback: "Anguilla") - /// Antarctica - internal static let antarctica = L10n.tr("Localizable", "enum.browsing_country.name.antarctica", fallback: "Antarctica") - /// Antigua and Barbuda - internal static let antiguaAndBarbuda = L10n.tr("Localizable", "enum.browsing_country.name.antigua_and_barbuda", fallback: "Antigua and Barbuda") - /// Argentina - internal static let argentina = L10n.tr("Localizable", "enum.browsing_country.name.argentina", fallback: "Argentina") - /// Armenia - internal static let armenia = L10n.tr("Localizable", "enum.browsing_country.name.armenia", fallback: "Armenia") - /// Aruba - internal static let aruba = L10n.tr("Localizable", "enum.browsing_country.name.aruba", fallback: "Aruba") - /// Asia-Pacific Region - internal static let asiaPacificRegion = L10n.tr("Localizable", "enum.browsing_country.name.asia_pacific_region", fallback: "Asia-Pacific Region") - /// Australia - internal static let australia = L10n.tr("Localizable", "enum.browsing_country.name.australia", fallback: "Australia") - /// Austria - internal static let austria = L10n.tr("Localizable", "enum.browsing_country.name.austria", fallback: "Austria") - /// Auto-Detect - internal static let autoDetect = L10n.tr("Localizable", "enum.browsing_country.name.auto_detect", fallback: "Auto-Detect") - /// Azerbaijan - internal static let azerbaijan = L10n.tr("Localizable", "enum.browsing_country.name.azerbaijan", fallback: "Azerbaijan") - /// Bahamas - internal static let bahamas = L10n.tr("Localizable", "enum.browsing_country.name.bahamas", fallback: "Bahamas") - /// Bahrain - internal static let bahrain = L10n.tr("Localizable", "enum.browsing_country.name.bahrain", fallback: "Bahrain") - /// Bangladesh - internal static let bangladesh = L10n.tr("Localizable", "enum.browsing_country.name.bangladesh", fallback: "Bangladesh") - /// Barbados - internal static let barbados = L10n.tr("Localizable", "enum.browsing_country.name.barbados", fallback: "Barbados") - /// Belarus - internal static let belarus = L10n.tr("Localizable", "enum.browsing_country.name.belarus", fallback: "Belarus") - /// Belgium - internal static let belgium = L10n.tr("Localizable", "enum.browsing_country.name.belgium", fallback: "Belgium") - /// Belize - internal static let belize = L10n.tr("Localizable", "enum.browsing_country.name.belize", fallback: "Belize") - /// Benin - internal static let benin = L10n.tr("Localizable", "enum.browsing_country.name.benin", fallback: "Benin") - /// Bermuda - internal static let bermuda = L10n.tr("Localizable", "enum.browsing_country.name.bermuda", fallback: "Bermuda") - /// Bhutan - internal static let bhutan = L10n.tr("Localizable", "enum.browsing_country.name.bhutan", fallback: "Bhutan") - /// Bolivia - internal static let bolivia = L10n.tr("Localizable", "enum.browsing_country.name.bolivia", fallback: "Bolivia") - /// Bonaire Saint Eustatius and Saba - internal static let bonaireSaintEustatiusAndSaba = L10n.tr("Localizable", "enum.browsing_country.name.bonaire_saint_eustatius_and_saba", fallback: "Bonaire Saint Eustatius and Saba") - /// Bosnia and Herzegovina - internal static let bosniaAndHerzegovina = L10n.tr("Localizable", "enum.browsing_country.name.bosnia_and_herzegovina", fallback: "Bosnia and Herzegovina") - /// Botswana - internal static let botswana = L10n.tr("Localizable", "enum.browsing_country.name.botswana", fallback: "Botswana") - /// Bouvet Island - internal static let bouvetIsland = L10n.tr("Localizable", "enum.browsing_country.name.bouvet_island", fallback: "Bouvet Island") - /// Brazil - internal static let brazil = L10n.tr("Localizable", "enum.browsing_country.name.brazil", fallback: "Brazil") - /// British Indian Ocean Territory - internal static let britishIndianOceanTerritory = L10n.tr("Localizable", "enum.browsing_country.name.british_indian_ocean_territory", fallback: "British Indian Ocean Territory") - /// Brunei Darussalam - internal static let bruneiDarussalam = L10n.tr("Localizable", "enum.browsing_country.name.brunei_darussalam", fallback: "Brunei Darussalam") - /// Bulgaria - internal static let bulgaria = L10n.tr("Localizable", "enum.browsing_country.name.bulgaria", fallback: "Bulgaria") - /// Burkina Faso - internal static let burkinaFaso = L10n.tr("Localizable", "enum.browsing_country.name.burkina_faso", fallback: "Burkina Faso") - /// Burundi - internal static let burundi = L10n.tr("Localizable", "enum.browsing_country.name.burundi", fallback: "Burundi") - /// Cambodia - internal static let cambodia = L10n.tr("Localizable", "enum.browsing_country.name.cambodia", fallback: "Cambodia") - /// Cameroon - internal static let cameroon = L10n.tr("Localizable", "enum.browsing_country.name.cameroon", fallback: "Cameroon") - /// Canada - internal static let canada = L10n.tr("Localizable", "enum.browsing_country.name.canada", fallback: "Canada") - /// Cape Verde - internal static let capeVerde = L10n.tr("Localizable", "enum.browsing_country.name.cape_verde", fallback: "Cape Verde") - /// Cayman Islands - internal static let caymanIslands = L10n.tr("Localizable", "enum.browsing_country.name.cayman_islands", fallback: "Cayman Islands") - /// Central African Republic - internal static let centralAfricanRepublic = L10n.tr("Localizable", "enum.browsing_country.name.central_african_republic", fallback: "Central African Republic") - /// Chad - internal static let chad = L10n.tr("Localizable", "enum.browsing_country.name.chad", fallback: "Chad") - /// Chile - internal static let chile = L10n.tr("Localizable", "enum.browsing_country.name.chile", fallback: "Chile") - /// China - internal static let china = L10n.tr("Localizable", "enum.browsing_country.name.china", fallback: "China") - /// Christmas Island - internal static let christmasIsland = L10n.tr("Localizable", "enum.browsing_country.name.christmas_island", fallback: "Christmas Island") - /// Cocos Islands - internal static let cocosIslands = L10n.tr("Localizable", "enum.browsing_country.name.cocos_islands", fallback: "Cocos Islands") - /// Colombia - internal static let colombia = L10n.tr("Localizable", "enum.browsing_country.name.colombia", fallback: "Colombia") - /// Comoros - internal static let comoros = L10n.tr("Localizable", "enum.browsing_country.name.comoros", fallback: "Comoros") - /// Congo - internal static let congo = L10n.tr("Localizable", "enum.browsing_country.name.congo", fallback: "Congo") - /// Cook Islands - internal static let cookIslands = L10n.tr("Localizable", "enum.browsing_country.name.cook_islands", fallback: "Cook Islands") - /// Costa Rica - internal static let costaRica = L10n.tr("Localizable", "enum.browsing_country.name.costa_rica", fallback: "Costa Rica") - /// Cote D'Ivoire - internal static let coteDIvoire = L10n.tr("Localizable", "enum.browsing_country.name.cote_d_ivoire", fallback: "Cote D'Ivoire") - /// Croatia - internal static let croatia = L10n.tr("Localizable", "enum.browsing_country.name.croatia", fallback: "Croatia") - /// Cuba - internal static let cuba = L10n.tr("Localizable", "enum.browsing_country.name.cuba", fallback: "Cuba") - /// Curacao - internal static let curacao = L10n.tr("Localizable", "enum.browsing_country.name.curacao", fallback: "Curacao") - /// Cyprus - internal static let cyprus = L10n.tr("Localizable", "enum.browsing_country.name.cyprus", fallback: "Cyprus") - /// Czech Republic - internal static let czechRepublic = L10n.tr("Localizable", "enum.browsing_country.name.czech_republic", fallback: "Czech Republic") - /// Denmark - internal static let denmark = L10n.tr("Localizable", "enum.browsing_country.name.denmark", fallback: "Denmark") - /// Djibouti - internal static let djibouti = L10n.tr("Localizable", "enum.browsing_country.name.djibouti", fallback: "Djibouti") - /// Dominica - internal static let dominica = L10n.tr("Localizable", "enum.browsing_country.name.dominica", fallback: "Dominica") - /// Dominican Republic - internal static let dominicanRepublic = L10n.tr("Localizable", "enum.browsing_country.name.dominican_republic", fallback: "Dominican Republic") - /// Ecuador - internal static let ecuador = L10n.tr("Localizable", "enum.browsing_country.name.ecuador", fallback: "Ecuador") - /// Egypt - internal static let egypt = L10n.tr("Localizable", "enum.browsing_country.name.egypt", fallback: "Egypt") - /// El Salvador - internal static let elSalvador = L10n.tr("Localizable", "enum.browsing_country.name.el_salvador", fallback: "El Salvador") - /// Equatorial Guinea - internal static let equatorialGuinea = L10n.tr("Localizable", "enum.browsing_country.name.equatorial_guinea", fallback: "Equatorial Guinea") - /// Eritrea - internal static let eritrea = L10n.tr("Localizable", "enum.browsing_country.name.eritrea", fallback: "Eritrea") - /// Estonia - internal static let estonia = L10n.tr("Localizable", "enum.browsing_country.name.estonia", fallback: "Estonia") - /// Ethiopia - internal static let ethiopia = L10n.tr("Localizable", "enum.browsing_country.name.ethiopia", fallback: "Ethiopia") - /// Europe - internal static let europe = L10n.tr("Localizable", "enum.browsing_country.name.europe", fallback: "Europe") - /// Falkland Islands - internal static let falklandIslands = L10n.tr("Localizable", "enum.browsing_country.name.falkland_islands", fallback: "Falkland Islands") - /// Faroe Islands - internal static let faroeIslands = L10n.tr("Localizable", "enum.browsing_country.name.faroe_islands", fallback: "Faroe Islands") - /// Fiji - internal static let fiji = L10n.tr("Localizable", "enum.browsing_country.name.fiji", fallback: "Fiji") - /// Finland - internal static let finland = L10n.tr("Localizable", "enum.browsing_country.name.finland", fallback: "Finland") - /// France - internal static let france = L10n.tr("Localizable", "enum.browsing_country.name.france", fallback: "France") - /// French Guiana - internal static let frenchGuiana = L10n.tr("Localizable", "enum.browsing_country.name.french_guiana", fallback: "French Guiana") - /// French Polynesia - internal static let frenchPolynesia = L10n.tr("Localizable", "enum.browsing_country.name.french_polynesia", fallback: "French Polynesia") - /// French Southern Territories - internal static let frenchSouthernTerritories = L10n.tr("Localizable", "enum.browsing_country.name.french_southern_territories", fallback: "French Southern Territories") - /// Gabon - internal static let gabon = L10n.tr("Localizable", "enum.browsing_country.name.gabon", fallback: "Gabon") - /// Gambia - internal static let gambia = L10n.tr("Localizable", "enum.browsing_country.name.gambia", fallback: "Gambia") - /// Georgia - internal static let georgia = L10n.tr("Localizable", "enum.browsing_country.name.georgia", fallback: "Georgia") - /// Germany - internal static let germany = L10n.tr("Localizable", "enum.browsing_country.name.germany", fallback: "Germany") - /// Ghana - internal static let ghana = L10n.tr("Localizable", "enum.browsing_country.name.ghana", fallback: "Ghana") - /// Gibraltar - internal static let gibraltar = L10n.tr("Localizable", "enum.browsing_country.name.gibraltar", fallback: "Gibraltar") - /// Greece - internal static let greece = L10n.tr("Localizable", "enum.browsing_country.name.greece", fallback: "Greece") - /// Greenland - internal static let greenland = L10n.tr("Localizable", "enum.browsing_country.name.greenland", fallback: "Greenland") - /// Grenada - internal static let grenada = L10n.tr("Localizable", "enum.browsing_country.name.grenada", fallback: "Grenada") - /// Guadeloupe - internal static let guadeloupe = L10n.tr("Localizable", "enum.browsing_country.name.guadeloupe", fallback: "Guadeloupe") - /// Guam - internal static let guam = L10n.tr("Localizable", "enum.browsing_country.name.guam", fallback: "Guam") - /// Guatemala - internal static let guatemala = L10n.tr("Localizable", "enum.browsing_country.name.guatemala", fallback: "Guatemala") - /// Guernsey - internal static let guernsey = L10n.tr("Localizable", "enum.browsing_country.name.guernsey", fallback: "Guernsey") - /// Guinea - internal static let guinea = L10n.tr("Localizable", "enum.browsing_country.name.guinea", fallback: "Guinea") - /// Guinea-Bissau - internal static let guineaBissau = L10n.tr("Localizable", "enum.browsing_country.name.guinea_bissau", fallback: "Guinea-Bissau") - /// Guyana - internal static let guyana = L10n.tr("Localizable", "enum.browsing_country.name.guyana", fallback: "Guyana") - /// Haiti - internal static let haiti = L10n.tr("Localizable", "enum.browsing_country.name.haiti", fallback: "Haiti") - /// Heard Island and McDonald Islands - internal static let heardIslandAndMcDonaldIslands = L10n.tr("Localizable", "enum.browsing_country.name.heard_island_and_mc_donald_islands", fallback: "Heard Island and McDonald Islands") - /// Honduras - internal static let honduras = L10n.tr("Localizable", "enum.browsing_country.name.honduras", fallback: "Honduras") - /// Hong Kong - internal static let hongKong = L10n.tr("Localizable", "enum.browsing_country.name.hong_kong", fallback: "Hong Kong") - /// Hungary - internal static let hungary = L10n.tr("Localizable", "enum.browsing_country.name.hungary", fallback: "Hungary") - /// Iceland - internal static let iceland = L10n.tr("Localizable", "enum.browsing_country.name.iceland", fallback: "Iceland") - /// India - internal static let india = L10n.tr("Localizable", "enum.browsing_country.name.india", fallback: "India") - /// Indonesia - internal static let indonesia = L10n.tr("Localizable", "enum.browsing_country.name.indonesia", fallback: "Indonesia") - /// Iran - internal static let iran = L10n.tr("Localizable", "enum.browsing_country.name.iran", fallback: "Iran") - /// Iraq - internal static let iraq = L10n.tr("Localizable", "enum.browsing_country.name.iraq", fallback: "Iraq") - /// Ireland - internal static let ireland = L10n.tr("Localizable", "enum.browsing_country.name.ireland", fallback: "Ireland") - /// Isle of Man - internal static let isleOfMan = L10n.tr("Localizable", "enum.browsing_country.name.isle_of_man", fallback: "Isle of Man") - /// Israel - internal static let israel = L10n.tr("Localizable", "enum.browsing_country.name.israel", fallback: "Israel") - /// Italy - internal static let italy = L10n.tr("Localizable", "enum.browsing_country.name.italy", fallback: "Italy") - /// Jamaica - internal static let jamaica = L10n.tr("Localizable", "enum.browsing_country.name.jamaica", fallback: "Jamaica") - /// Japan - internal static let japan = L10n.tr("Localizable", "enum.browsing_country.name.japan", fallback: "Japan") - /// Jersey - internal static let jersey = L10n.tr("Localizable", "enum.browsing_country.name.jersey", fallback: "Jersey") - /// Jordan - internal static let jordan = L10n.tr("Localizable", "enum.browsing_country.name.jordan", fallback: "Jordan") - /// Kazakhstan - internal static let kazakhstan = L10n.tr("Localizable", "enum.browsing_country.name.kazakhstan", fallback: "Kazakhstan") - /// Kenya - internal static let kenya = L10n.tr("Localizable", "enum.browsing_country.name.kenya", fallback: "Kenya") - /// Kiribati - internal static let kiribati = L10n.tr("Localizable", "enum.browsing_country.name.kiribati", fallback: "Kiribati") - /// Kuwait - internal static let kuwait = L10n.tr("Localizable", "enum.browsing_country.name.kuwait", fallback: "Kuwait") - /// Kyrgyzstan - internal static let kyrgyzstan = L10n.tr("Localizable", "enum.browsing_country.name.kyrgyzstan", fallback: "Kyrgyzstan") - /// Lao People's Democratic Republic - internal static let laoPeoplesDemocraticRepublic = L10n.tr("Localizable", "enum.browsing_country.name.lao_peoples_democratic_republic", fallback: "Lao People's Democratic Republic") - /// Latvia - internal static let latvia = L10n.tr("Localizable", "enum.browsing_country.name.latvia", fallback: "Latvia") - /// Lebanon - internal static let lebanon = L10n.tr("Localizable", "enum.browsing_country.name.lebanon", fallback: "Lebanon") - /// Lesotho - internal static let lesotho = L10n.tr("Localizable", "enum.browsing_country.name.lesotho", fallback: "Lesotho") - /// Liberia - internal static let liberia = L10n.tr("Localizable", "enum.browsing_country.name.liberia", fallback: "Liberia") - /// Libya - internal static let libya = L10n.tr("Localizable", "enum.browsing_country.name.libya", fallback: "Libya") - /// Liechtenstein - internal static let liechtenstein = L10n.tr("Localizable", "enum.browsing_country.name.liechtenstein", fallback: "Liechtenstein") - /// Lithuania - internal static let lithuania = L10n.tr("Localizable", "enum.browsing_country.name.lithuania", fallback: "Lithuania") - /// Luxembourg - internal static let luxembourg = L10n.tr("Localizable", "enum.browsing_country.name.luxembourg", fallback: "Luxembourg") - /// Macau - internal static let macau = L10n.tr("Localizable", "enum.browsing_country.name.macau", fallback: "Macau") - /// Macedonia - internal static let macedonia = L10n.tr("Localizable", "enum.browsing_country.name.macedonia", fallback: "Macedonia") - /// Madagascar - internal static let madagascar = L10n.tr("Localizable", "enum.browsing_country.name.madagascar", fallback: "Madagascar") - /// Malawi - internal static let malawi = L10n.tr("Localizable", "enum.browsing_country.name.malawi", fallback: "Malawi") - /// Malaysia - internal static let malaysia = L10n.tr("Localizable", "enum.browsing_country.name.malaysia", fallback: "Malaysia") - /// Maldives - internal static let maldives = L10n.tr("Localizable", "enum.browsing_country.name.maldives", fallback: "Maldives") - /// Mali - internal static let mali = L10n.tr("Localizable", "enum.browsing_country.name.mali", fallback: "Mali") - /// Malta - internal static let malta = L10n.tr("Localizable", "enum.browsing_country.name.malta", fallback: "Malta") - /// Marshall Islands - internal static let marshallIslands = L10n.tr("Localizable", "enum.browsing_country.name.marshall_islands", fallback: "Marshall Islands") - /// Martinique - internal static let martinique = L10n.tr("Localizable", "enum.browsing_country.name.martinique", fallback: "Martinique") - /// Mauritania - internal static let mauritania = L10n.tr("Localizable", "enum.browsing_country.name.mauritania", fallback: "Mauritania") - /// Mauritius - internal static let mauritius = L10n.tr("Localizable", "enum.browsing_country.name.mauritius", fallback: "Mauritius") - /// Mayotte - internal static let mayotte = L10n.tr("Localizable", "enum.browsing_country.name.mayotte", fallback: "Mayotte") - /// Mexico - internal static let mexico = L10n.tr("Localizable", "enum.browsing_country.name.mexico", fallback: "Mexico") - /// Micronesia - internal static let micronesia = L10n.tr("Localizable", "enum.browsing_country.name.micronesia", fallback: "Micronesia") - /// Moldova - internal static let moldova = L10n.tr("Localizable", "enum.browsing_country.name.moldova", fallback: "Moldova") - /// Monaco - internal static let monaco = L10n.tr("Localizable", "enum.browsing_country.name.monaco", fallback: "Monaco") - /// Mongolia - internal static let mongolia = L10n.tr("Localizable", "enum.browsing_country.name.mongolia", fallback: "Mongolia") - /// Montenegro - internal static let montenegro = L10n.tr("Localizable", "enum.browsing_country.name.montenegro", fallback: "Montenegro") - /// Montserrat - internal static let montserrat = L10n.tr("Localizable", "enum.browsing_country.name.montserrat", fallback: "Montserrat") - /// Morocco - internal static let morocco = L10n.tr("Localizable", "enum.browsing_country.name.morocco", fallback: "Morocco") - /// Mozambique - internal static let mozambique = L10n.tr("Localizable", "enum.browsing_country.name.mozambique", fallback: "Mozambique") - /// Myanmar - internal static let myanmar = L10n.tr("Localizable", "enum.browsing_country.name.myanmar", fallback: "Myanmar") - /// Namibia - internal static let namibia = L10n.tr("Localizable", "enum.browsing_country.name.namibia", fallback: "Namibia") - /// Nauru - internal static let nauru = L10n.tr("Localizable", "enum.browsing_country.name.nauru", fallback: "Nauru") - /// Nepal - internal static let nepal = L10n.tr("Localizable", "enum.browsing_country.name.nepal", fallback: "Nepal") - /// Netherlands - internal static let netherlands = L10n.tr("Localizable", "enum.browsing_country.name.netherlands", fallback: "Netherlands") - /// New Caledonia - internal static let newCaledonia = L10n.tr("Localizable", "enum.browsing_country.name.new_caledonia", fallback: "New Caledonia") - /// New Zealand - internal static let newZealand = L10n.tr("Localizable", "enum.browsing_country.name.new_zealand", fallback: "New Zealand") - /// Nicaragua - internal static let nicaragua = L10n.tr("Localizable", "enum.browsing_country.name.nicaragua", fallback: "Nicaragua") - /// Niger - internal static let niger = L10n.tr("Localizable", "enum.browsing_country.name.niger", fallback: "Niger") - /// Nigeria - internal static let nigeria = L10n.tr("Localizable", "enum.browsing_country.name.nigeria", fallback: "Nigeria") - /// Niue - internal static let niue = L10n.tr("Localizable", "enum.browsing_country.name.niue", fallback: "Niue") - /// Norfolk Island - internal static let norfolkIsland = L10n.tr("Localizable", "enum.browsing_country.name.norfolk_island", fallback: "Norfolk Island") - /// North Korea - internal static let northKorea = L10n.tr("Localizable", "enum.browsing_country.name.north_korea", fallback: "North Korea") - /// Northern Mariana Islands - internal static let northernMarianaIslands = L10n.tr("Localizable", "enum.browsing_country.name.northern_mariana_islands", fallback: "Northern Mariana Islands") - /// Norway - internal static let norway = L10n.tr("Localizable", "enum.browsing_country.name.norway", fallback: "Norway") - /// Oman - internal static let oman = L10n.tr("Localizable", "enum.browsing_country.name.oman", fallback: "Oman") - /// Pakistan - internal static let pakistan = L10n.tr("Localizable", "enum.browsing_country.name.pakistan", fallback: "Pakistan") - /// Palau - internal static let palau = L10n.tr("Localizable", "enum.browsing_country.name.palau", fallback: "Palau") - /// Palestinian Territory - internal static let palestinianTerritory = L10n.tr("Localizable", "enum.browsing_country.name.palestinian_territory", fallback: "Palestinian Territory") - /// Panama - internal static let panama = L10n.tr("Localizable", "enum.browsing_country.name.panama", fallback: "Panama") - /// Papua New Guinea - internal static let papuaNewGuinea = L10n.tr("Localizable", "enum.browsing_country.name.papua_new_guinea", fallback: "Papua New Guinea") - /// Paraguay - internal static let paraguay = L10n.tr("Localizable", "enum.browsing_country.name.paraguay", fallback: "Paraguay") - /// Peru - internal static let peru = L10n.tr("Localizable", "enum.browsing_country.name.peru", fallback: "Peru") - /// Philippines - internal static let philippines = L10n.tr("Localizable", "enum.browsing_country.name.philippines", fallback: "Philippines") - /// Pitcairn Islands - internal static let pitcairnIslands = L10n.tr("Localizable", "enum.browsing_country.name.pitcairn_islands", fallback: "Pitcairn Islands") - /// Poland - internal static let poland = L10n.tr("Localizable", "enum.browsing_country.name.poland", fallback: "Poland") - /// Portugal - internal static let portugal = L10n.tr("Localizable", "enum.browsing_country.name.portugal", fallback: "Portugal") - /// Puerto Rico - internal static let puertoRico = L10n.tr("Localizable", "enum.browsing_country.name.puerto_rico", fallback: "Puerto Rico") - /// Qatar - internal static let qatar = L10n.tr("Localizable", "enum.browsing_country.name.qatar", fallback: "Qatar") - /// Reunion - internal static let reunion = L10n.tr("Localizable", "enum.browsing_country.name.reunion", fallback: "Reunion") - /// Romania - internal static let romania = L10n.tr("Localizable", "enum.browsing_country.name.romania", fallback: "Romania") - /// Russian Federation - internal static let russianFederation = L10n.tr("Localizable", "enum.browsing_country.name.russian_federation", fallback: "Russian Federation") - /// Rwanda - internal static let rwanda = L10n.tr("Localizable", "enum.browsing_country.name.rwanda", fallback: "Rwanda") - /// Saint Barthelemy - internal static let saintBarthelemy = L10n.tr("Localizable", "enum.browsing_country.name.saint_barthelemy", fallback: "Saint Barthelemy") - /// Saint Helena - internal static let saintHelena = L10n.tr("Localizable", "enum.browsing_country.name.saint_helena", fallback: "Saint Helena") - /// Saint Kitts and Nevis - internal static let saintKittsAndNevis = L10n.tr("Localizable", "enum.browsing_country.name.saint_kitts_and_nevis", fallback: "Saint Kitts and Nevis") - /// Saint Lucia - internal static let saintLucia = L10n.tr("Localizable", "enum.browsing_country.name.saint_lucia", fallback: "Saint Lucia") - /// Saint Martin - internal static let saintMartin = L10n.tr("Localizable", "enum.browsing_country.name.saint_martin", fallback: "Saint Martin") - /// Saint Pierre and Miquelon - internal static let saintPierreAndMiquelon = L10n.tr("Localizable", "enum.browsing_country.name.saint_pierre_and_miquelon", fallback: "Saint Pierre and Miquelon") - /// Saint Vincent and the Grenadines - internal static let saintVincentAndTheGrenadines = L10n.tr("Localizable", "enum.browsing_country.name.saint_vincent_and_the_grenadines", fallback: "Saint Vincent and the Grenadines") - /// Samoa - internal static let samoa = L10n.tr("Localizable", "enum.browsing_country.name.samoa", fallback: "Samoa") - /// San Marino - internal static let sanMarino = L10n.tr("Localizable", "enum.browsing_country.name.san_marino", fallback: "San Marino") - /// Sao Tome and Principe - internal static let saoTomeAndPrincipe = L10n.tr("Localizable", "enum.browsing_country.name.sao_tome_and_principe", fallback: "Sao Tome and Principe") - /// Saudi Arabia - internal static let saudiArabia = L10n.tr("Localizable", "enum.browsing_country.name.saudi_arabia", fallback: "Saudi Arabia") - /// Senegal - internal static let senegal = L10n.tr("Localizable", "enum.browsing_country.name.senegal", fallback: "Senegal") - /// Serbia - internal static let serbia = L10n.tr("Localizable", "enum.browsing_country.name.serbia", fallback: "Serbia") - /// Seychelles - internal static let seychelles = L10n.tr("Localizable", "enum.browsing_country.name.seychelles", fallback: "Seychelles") - /// Sierra Leone - internal static let sierraLeone = L10n.tr("Localizable", "enum.browsing_country.name.sierra_leone", fallback: "Sierra Leone") - /// Singapore - internal static let singapore = L10n.tr("Localizable", "enum.browsing_country.name.singapore", fallback: "Singapore") - /// Sint Maarten - internal static let sintMaarten = L10n.tr("Localizable", "enum.browsing_country.name.sint_maarten", fallback: "Sint Maarten") - /// Slovakia - internal static let slovakia = L10n.tr("Localizable", "enum.browsing_country.name.slovakia", fallback: "Slovakia") - /// Slovenia - internal static let slovenia = L10n.tr("Localizable", "enum.browsing_country.name.slovenia", fallback: "Slovenia") - /// Solomon Islands - internal static let solomonIslands = L10n.tr("Localizable", "enum.browsing_country.name.solomon_islands", fallback: "Solomon Islands") - /// Somalia - internal static let somalia = L10n.tr("Localizable", "enum.browsing_country.name.somalia", fallback: "Somalia") - /// South Africa - internal static let southAfrica = L10n.tr("Localizable", "enum.browsing_country.name.south_africa", fallback: "South Africa") - /// South Georgia and the South Sandwich Islands - internal static let southGeorgiaAndTheSouthSandwichIslands = L10n.tr("Localizable", "enum.browsing_country.name.south_georgia_and_the_south_sandwich_islands", fallback: "South Georgia and the South Sandwich Islands") - /// South Korea - internal static let southKorea = L10n.tr("Localizable", "enum.browsing_country.name.south_korea", fallback: "South Korea") - /// South Sudan - internal static let southSudan = L10n.tr("Localizable", "enum.browsing_country.name.south_sudan", fallback: "South Sudan") - /// Spain - internal static let spain = L10n.tr("Localizable", "enum.browsing_country.name.spain", fallback: "Spain") - /// Sri Lanka - internal static let sriLanka = L10n.tr("Localizable", "enum.browsing_country.name.sri_lanka", fallback: "Sri Lanka") - /// Sudan - internal static let sudan = L10n.tr("Localizable", "enum.browsing_country.name.sudan", fallback: "Sudan") - /// Suriname - internal static let suriname = L10n.tr("Localizable", "enum.browsing_country.name.suriname", fallback: "Suriname") - /// Svalbard and Jan Mayen - internal static let svalbardAndJanMayen = L10n.tr("Localizable", "enum.browsing_country.name.svalbard_and_jan_mayen", fallback: "Svalbard and Jan Mayen") - /// Swaziland - internal static let swaziland = L10n.tr("Localizable", "enum.browsing_country.name.swaziland", fallback: "Swaziland") - /// Sweden - internal static let sweden = L10n.tr("Localizable", "enum.browsing_country.name.sweden", fallback: "Sweden") - /// Switzerland - internal static let switzerland = L10n.tr("Localizable", "enum.browsing_country.name.switzerland", fallback: "Switzerland") - /// Syrian Arab Republic - internal static let syrianArabRepublic = L10n.tr("Localizable", "enum.browsing_country.name.syrian_arab_republic", fallback: "Syrian Arab Republic") - /// Taiwan - internal static let taiwan = L10n.tr("Localizable", "enum.browsing_country.name.taiwan", fallback: "Taiwan") - /// Tajikistan - internal static let tajikistan = L10n.tr("Localizable", "enum.browsing_country.name.tajikistan", fallback: "Tajikistan") - /// Tanzania - internal static let tanzania = L10n.tr("Localizable", "enum.browsing_country.name.tanzania", fallback: "Tanzania") - /// Thailand - internal static let thailand = L10n.tr("Localizable", "enum.browsing_country.name.thailand", fallback: "Thailand") - /// The Democratic Republic of the Congo - internal static let theDemocraticRepublicOfTheCongo = L10n.tr("Localizable", "enum.browsing_country.name.the_democratic_republic_of_the_congo", fallback: "The Democratic Republic of the Congo") - /// Timor-Leste - internal static let timorLeste = L10n.tr("Localizable", "enum.browsing_country.name.timor_leste", fallback: "Timor-Leste") - /// Togo - internal static let togo = L10n.tr("Localizable", "enum.browsing_country.name.togo", fallback: "Togo") - /// Tokelau - internal static let tokelau = L10n.tr("Localizable", "enum.browsing_country.name.tokelau", fallback: "Tokelau") - /// Tonga - internal static let tonga = L10n.tr("Localizable", "enum.browsing_country.name.tonga", fallback: "Tonga") - /// Trinidad and Tobago - internal static let trinidadAndTobago = L10n.tr("Localizable", "enum.browsing_country.name.trinidad_and_tobago", fallback: "Trinidad and Tobago") - /// Tunisia - internal static let tunisia = L10n.tr("Localizable", "enum.browsing_country.name.tunisia", fallback: "Tunisia") - /// Turkey - internal static let turkey = L10n.tr("Localizable", "enum.browsing_country.name.turkey", fallback: "Turkey") - /// Turkmenistan - internal static let turkmenistan = L10n.tr("Localizable", "enum.browsing_country.name.turkmenistan", fallback: "Turkmenistan") - /// Turks and Caicos Islands - internal static let turksAndCaicosIslands = L10n.tr("Localizable", "enum.browsing_country.name.turks_and_caicos_islands", fallback: "Turks and Caicos Islands") - /// Tuvalu - internal static let tuvalu = L10n.tr("Localizable", "enum.browsing_country.name.tuvalu", fallback: "Tuvalu") - /// Uganda - internal static let uganda = L10n.tr("Localizable", "enum.browsing_country.name.uganda", fallback: "Uganda") - /// Ukraine - internal static let ukraine = L10n.tr("Localizable", "enum.browsing_country.name.ukraine", fallback: "Ukraine") - /// United Arab Emirates - internal static let unitedArabEmirates = L10n.tr("Localizable", "enum.browsing_country.name.united_arab_emirates", fallback: "United Arab Emirates") - /// United Kingdom - internal static let unitedKingdom = L10n.tr("Localizable", "enum.browsing_country.name.united_kingdom", fallback: "United Kingdom") - /// United States - internal static let unitedStates = L10n.tr("Localizable", "enum.browsing_country.name.united_states", fallback: "United States") - /// United States Minor Outlying Islands - internal static let unitedStatesMinorOutlyingIslands = L10n.tr("Localizable", "enum.browsing_country.name.united_states_minor_outlying_islands", fallback: "United States Minor Outlying Islands") - /// Uruguay - internal static let uruguay = L10n.tr("Localizable", "enum.browsing_country.name.uruguay", fallback: "Uruguay") - /// Uzbekistan - internal static let uzbekistan = L10n.tr("Localizable", "enum.browsing_country.name.uzbekistan", fallback: "Uzbekistan") - /// Vanuatu - internal static let vanuatu = L10n.tr("Localizable", "enum.browsing_country.name.vanuatu", fallback: "Vanuatu") - /// Vatican City State - internal static let vaticanCityState = L10n.tr("Localizable", "enum.browsing_country.name.vatican_city_state", fallback: "Vatican City State") - /// Venezuela - internal static let venezuela = L10n.tr("Localizable", "enum.browsing_country.name.venezuela", fallback: "Venezuela") - /// Vietnam - internal static let vietnam = L10n.tr("Localizable", "enum.browsing_country.name.vietnam", fallback: "Vietnam") - /// British Virgin Islands - internal static let virginIslandsBritish = L10n.tr("Localizable", "enum.browsing_country.name.virgin_islands_british", fallback: "British Virgin Islands") - /// U.S. Virgin Islands - internal static let virginIslandsUS = L10n.tr("Localizable", "enum.browsing_country.name.virgin_islands_US", fallback: "U.S. Virgin Islands") - /// Wallis and Futuna - internal static let wallisAndFutuna = L10n.tr("Localizable", "enum.browsing_country.name.wallis_and_futuna", fallback: "Wallis and Futuna") - /// Western Sahara - internal static let westernSahara = L10n.tr("Localizable", "enum.browsing_country.name.western_sahara", fallback: "Western Sahara") - /// Yemen - internal static let yemen = L10n.tr("Localizable", "enum.browsing_country.name.yemen", fallback: "Yemen") - /// Zambia - internal static let zambia = L10n.tr("Localizable", "enum.browsing_country.name.zambia", fallback: "Zambia") - /// Zimbabwe - internal static let zimbabwe = L10n.tr("Localizable", "enum.browsing_country.name.zimbabwe", fallback: "Zimbabwe") - } - } - internal enum Category { - internal enum Value { - /// Artist CG - internal static let artistCG = L10n.tr("Localizable", "enum.category.value.artist_CG", fallback: "Artist CG") - /// Asian Porn - internal static let asianPorn = L10n.tr("Localizable", "enum.category.value.asian_porn", fallback: "Asian Porn") - /// Cosplay - internal static let cosplay = L10n.tr("Localizable", "enum.category.value.cosplay", fallback: "Cosplay") - /// Doujinshi - internal static let doujinshi = L10n.tr("Localizable", "enum.category.value.doujinshi", fallback: "Doujinshi") - /// Game CG - internal static let gameCG = L10n.tr("Localizable", "enum.category.value.game_CG", fallback: "Game CG") - /// Image Set - internal static let imageSet = L10n.tr("Localizable", "enum.category.value.image_set", fallback: "Image Set") - /// Manga - internal static let manga = L10n.tr("Localizable", "enum.category.value.manga", fallback: "Manga") - /// Misc - internal static let misc = L10n.tr("Localizable", "enum.category.value.misc", fallback: "Misc") - /// Non-H - internal static let nonH = L10n.tr("Localizable", "enum.category.value.non_h", fallback: "Non-H") - /// Private - internal static let `private` = L10n.tr("Localizable", "enum.category.value.private", fallback: "Private") - /// Western - internal static let western = L10n.tr("Localizable", "enum.category.value.western", fallback: "Western") - } - } - internal enum EhSetting { - internal enum ArchiverBehavior { - internal enum Value { - /// Auto Select Original, Auto Start - internal static let autoSelectOriginalAutoStart = L10n.tr("Localizable", "enum.eh_setting.archiver_behavior.value.auto_select_original_auto_start", fallback: "Auto Select Original, Auto Start") - /// Auto Select Original, Manual Start - internal static let autoSelectOriginalManualStart = L10n.tr("Localizable", "enum.eh_setting.archiver_behavior.value.auto_select_original_manual_start", fallback: "Auto Select Original, Manual Start") - /// Auto Select Resample, Auto Start - internal static let autoSelectResampleAutoStart = L10n.tr("Localizable", "enum.eh_setting.archiver_behavior.value.auto_select_resample_auto_start", fallback: "Auto Select Resample, Auto Start") - /// Auto Select Resample, Manual Start - internal static let autoSelectResampleManualStart = L10n.tr("Localizable", "enum.eh_setting.archiver_behavior.value.auto_select_resample_manual_start", fallback: "Auto Select Resample, Manual Start") - /// Manual Select, Auto Start - internal static let manualSelectAutoStart = L10n.tr("Localizable", "enum.eh_setting.archiver_behavior.value.manual_select_auto_start", fallback: "Manual Select, Auto Start") - /// Manual Select, Manual Start (Default) - internal static let manualSelectManualStart = L10n.tr("Localizable", "enum.eh_setting.archiver_behavior.value.manual_select_manual_start", fallback: "Manual Select, Manual Start (Default)") - } - } - internal enum CommentsSortOrder { - internal enum Value { - /// By highest score - internal static let highestScore = L10n.tr("Localizable", "enum.eh_setting.comments_sort_order.value.highest_score", fallback: "By highest score") - /// Oldest comments first - internal static let oldest = L10n.tr("Localizable", "enum.eh_setting.comments_sort_order.value.oldest", fallback: "Oldest comments first") - /// Recent comments first - internal static let recent = L10n.tr("Localizable", "enum.eh_setting.comments_sort_order.value.recent", fallback: "Recent comments first") - } - } - internal enum CommentsVotesShowTiming { - internal enum Value { - /// Always - internal static let always = L10n.tr("Localizable", "enum.eh_setting.comments_votes_show_timing.value.always", fallback: "Always") - /// On score hover or click - internal static let onHoverOrClick = L10n.tr("Localizable", "enum.eh_setting.comments_votes_show_timing.value.on_hover_or_click", fallback: "On score hover or click") - } - } - internal enum DisplayMode { - internal enum Value { - /// Compact - internal static let compact = L10n.tr("Localizable", "enum.eh_setting.display_mode.value.compact", fallback: "Compact") - /// Extended - internal static let extended = L10n.tr("Localizable", "enum.eh_setting.display_mode.value.extended", fallback: "Extended") - /// Minimal - internal static let minimal = L10n.tr("Localizable", "enum.eh_setting.display_mode.value.minimal", fallback: "Minimal") - /// Minimal+ - internal static let minimalPlus = L10n.tr("Localizable", "enum.eh_setting.display_mode.value.minimalPlus", fallback: "Minimal+") - /// Thumbnail - internal static let thumbnail = L10n.tr("Localizable", "enum.eh_setting.display_mode.value.thumbnail", fallback: "Thumbnail") - } - } - internal enum ExcludedLanguagesCategory { - internal enum Value { - /// Original - internal static let original = L10n.tr("Localizable", "enum.eh_setting.excluded_languages_category.value.original", fallback: "Original") - /// Rewrite - internal static let rewrite = L10n.tr("Localizable", "enum.eh_setting.excluded_languages_category.value.rewrite", fallback: "Rewrite") - /// Translated - internal static let translated = L10n.tr("Localizable", "enum.eh_setting.excluded_languages_category.value.translated", fallback: "Translated") - } - } - internal enum FavoritesSortOrder { - internal enum Value { - /// By favorited time - internal static let favoritedTime = L10n.tr("Localizable", "enum.eh_setting.favorites_sort_order.value.favorited_time", fallback: "By favorited time") - /// By last gallery update time - internal static let lastUpdateTime = L10n.tr("Localizable", "enum.eh_setting.favorites_sort_order.value.last_update_time", fallback: "By last gallery update time") - } - } - internal enum GalleryName { - internal enum Value { - /// Default Title - internal static let `default` = L10n.tr("Localizable", "enum.eh_setting.gallery_name.value.default", fallback: "Default Title") - /// Japanese Title (if available) - internal static let japanese = L10n.tr("Localizable", "enum.eh_setting.gallery_name.value.japanese", fallback: "Japanese Title (if available)") - } - } - internal enum GalleryPageNumbering { - internal enum Value { - /// None - internal static let `none` = L10n.tr("Localizable", "enum.eh_setting.gallery_page_numbering.value.none", fallback: "None") - /// Page Number + Name - internal static let pageNumberAndName = L10n.tr("Localizable", "enum.eh_setting.gallery_page_numbering.value.page_number_and_name", fallback: "Page Number + Name") - /// Page Number Only - internal static let pageNumberOnly = L10n.tr("Localizable", "enum.eh_setting.gallery_page_numbering.value.page_number_only", fallback: "Page Number Only") - } - } - internal enum ImageResolution { - internal enum Value { - /// Auto - internal static let auto = L10n.tr("Localizable", "enum.eh_setting.image_resolution.value.auto", fallback: "Auto") - } - } - internal enum LoadThroughHathSetting { - internal enum Description { - /// Recommended. - internal static let anyClient = L10n.tr("Localizable", "enum.eh_setting.load_through_hath_setting.description.any_client", fallback: "Recommended.") - /// Can be slower. Enable if behind firewall/proxy that blocks outgoing non-standard ports. - internal static let defaultPortOnly = L10n.tr("Localizable", "enum.eh_setting.load_through_hath_setting.description.default_port_only", fallback: "Can be slower. Enable if behind firewall/proxy that blocks outgoing non-standard ports.") - /// Donator only. May not work by default in modern browsers. Recommended for legacy/outdated browsers only. - internal static let legacyNo = L10n.tr("Localizable", "enum.eh_setting.load_through_hath_setting.description.legacy_no", fallback: "Donator only. May not work by default in modern browsers. Recommended for legacy/outdated browsers only.") - /// Donator only. You will not be able to browse as many pages. Recommended only if having severe problems. - internal static let modernNo = L10n.tr("Localizable", "enum.eh_setting.load_through_hath_setting.description.modern_no", fallback: "Donator only. You will not be able to browse as many pages. Recommended only if having severe problems.") - } - internal enum Value { - /// Any client - internal static let anyClient = L10n.tr("Localizable", "enum.eh_setting.load_through_hath_setting.value.any_client", fallback: "Any client") - /// Default port clients only - internal static let defaultPortOnly = L10n.tr("Localizable", "enum.eh_setting.load_through_hath_setting.value.default_port_only", fallback: "Default port clients only") - /// No [Legacy/HTTP] - internal static let legacyNo = L10n.tr("Localizable", "enum.eh_setting.load_through_hath_setting.value.legacy_no", fallback: "No [Legacy/HTTP]") - /// No [Modern/HTTPS] - internal static let modernNo = L10n.tr("Localizable", "enum.eh_setting.load_through_hath_setting.value.modern_no", fallback: "No [Modern/HTTPS]") - } - } - internal enum MultiplePageViewerStyle { - internal enum Value { - /// Align center, always scale - internal static let alignCenterAlwaysScale = L10n.tr("Localizable", "enum.eh_setting.multiple_page_viewer_style.value.align_center_always_scale", fallback: "Align center, always scale") - /// Align center, scale if overwidth - internal static let alignCenterScaleIfOverWidth = L10n.tr("Localizable", "enum.eh_setting.multiple_page_viewer_style.value.align_center_scale_if_over_width", fallback: "Align center, scale if overwidth") - /// Align left, scale if overwidth - internal static let alignLeftScaleIfOverWidth = L10n.tr("Localizable", "enum.eh_setting.multiple_page_viewer_style.value.align_left_scale_if_over_width", fallback: "Align left, scale if overwidth") - } - } - internal enum TagsSortOrder { - internal enum Value { - /// Alphabetical - internal static let alphabetical = L10n.tr("Localizable", "enum.eh_setting.tags_sort_order.value.alphabetical", fallback: "Alphabetical") - /// By tag power - internal static let tagPower = L10n.tr("Localizable", "enum.eh_setting.tags_sort_order.value.tag_power", fallback: "By tag power") - } - } - internal enum ThumbnailLoadTiming { - internal enum Description { - /// Pages load faster, but there may be a slight delay before a thumb appears. - internal static let onMouseOver = L10n.tr("Localizable", "enum.eh_setting.thumbnail_load_timing.description.on_mouse_over", fallback: "Pages load faster, but there may be a slight delay before a thumb appears.") - /// Pages take longer to load, but there is no delay for loading a thumb after the page has loaded. - internal static let onPageLoad = L10n.tr("Localizable", "enum.eh_setting.thumbnail_load_timing.description.on_page_load", fallback: "Pages take longer to load, but there is no delay for loading a thumb after the page has loaded.") - } - internal enum Value { - /// On mouse-over - internal static let onMouseOver = L10n.tr("Localizable", "enum.eh_setting.thumbnail_load_timing.value.on_mouse_over", fallback: "On mouse-over") - /// On page load - internal static let onPageLoad = L10n.tr("Localizable", "enum.eh_setting.thumbnail_load_timing.value.on_page_load", fallback: "On page load") - } - } - internal enum ThumbnailSize { - internal enum Value { - /// Auto - internal static let auto = L10n.tr("Localizable", "enum.eh_setting.thumbnail_size.value.auto", fallback: "Auto") - /// Large - internal static let large = L10n.tr("Localizable", "enum.eh_setting.thumbnail_size.value.large", fallback: "Large") - /// Normal - internal static let normal = L10n.tr("Localizable", "enum.eh_setting.thumbnail_size.value.normal", fallback: "Normal") - /// Small - internal static let small = L10n.tr("Localizable", "enum.eh_setting.thumbnail_size.value.small", fallback: "Small") - } - } - } - internal enum FilterRange { - internal enum Value { - /// Global - internal static let global = L10n.tr("Localizable", "enum.filter_range.value.global", fallback: "Global") - /// Search - internal static let search = L10n.tr("Localizable", "enum.filter_range.value.search", fallback: "Search") - /// Watched - internal static let watched = L10n.tr("Localizable", "enum.filter_range.value.watched", fallback: "Watched") - } - } - internal enum GalleryVisibility { - internal enum Value { - /// No (%@) - internal static func no(_ p1: Any) -> String { - return L10n.tr("Localizable", "enum.gallery_visibility.value.no", String(describing: p1), fallback: "No (%@)") - } - /// Yes - internal static let yes = L10n.tr("Localizable", "enum.gallery_visibility.value.yes", fallback: "Yes") - internal enum No { - internal enum Reason { - /// Expunged - internal static let expunged = L10n.tr("Localizable", "enum.gallery_visibility.value.no.reason.expunged", fallback: "Expunged") - } - } - } - } - internal enum HomeMiscGridType { - internal enum Title { - /// History - internal static let history = L10n.tr("Localizable", "enum.home_misc_grid_type.title.history", fallback: "History") - /// Popular - internal static let popular = L10n.tr("Localizable", "enum.home_misc_grid_type.title.popular", fallback: "Popular") - /// Watched - internal static let watched = L10n.tr("Localizable", "enum.home_misc_grid_type.title.watched", fallback: "Watched") - } - } - internal enum Language { - internal enum Value { - /// Afrikaans - internal static let afrikaans = L10n.tr("Localizable", "enum.language.value.afrikaans", fallback: "Afrikaans") - /// Albanian - internal static let albanian = L10n.tr("Localizable", "enum.language.value.albanian", fallback: "Albanian") - /// Arabic - internal static let arabic = L10n.tr("Localizable", "enum.language.value.arabic", fallback: "Arabic") - /// Bengali - internal static let bengali = L10n.tr("Localizable", "enum.language.value.bengali", fallback: "Bengali") - /// Bosnian - internal static let bosnian = L10n.tr("Localizable", "enum.language.value.bosnian", fallback: "Bosnian") - /// Bulgarian - internal static let bulgarian = L10n.tr("Localizable", "enum.language.value.bulgarian", fallback: "Bulgarian") - /// Burmese - internal static let burmese = L10n.tr("Localizable", "enum.language.value.burmese", fallback: "Burmese") - /// Catalan - internal static let catalan = L10n.tr("Localizable", "enum.language.value.catalan", fallback: "Catalan") - /// Cebuano - internal static let cebuano = L10n.tr("Localizable", "enum.language.value.cebuano", fallback: "Cebuano") - /// Chinese - internal static let chinese = L10n.tr("Localizable", "enum.language.value.chinese", fallback: "Chinese") - /// Croatian - internal static let croatian = L10n.tr("Localizable", "enum.language.value.croatian", fallback: "Croatian") - /// Czech - internal static let czech = L10n.tr("Localizable", "enum.language.value.czech", fallback: "Czech") - /// Danish - internal static let danish = L10n.tr("Localizable", "enum.language.value.danish", fallback: "Danish") - /// Dutch - internal static let dutch = L10n.tr("Localizable", "enum.language.value.dutch", fallback: "Dutch") - /// English - internal static let english = L10n.tr("Localizable", "enum.language.value.english", fallback: "English") - /// Esperanto - internal static let esperanto = L10n.tr("Localizable", "enum.language.value.esperanto", fallback: "Esperanto") - /// Estonian - internal static let estonian = L10n.tr("Localizable", "enum.language.value.estonian", fallback: "Estonian") - /// Finnish - internal static let finnish = L10n.tr("Localizable", "enum.language.value.finnish", fallback: "Finnish") - /// French - internal static let french = L10n.tr("Localizable", "enum.language.value.french", fallback: "French") - /// Georgian - internal static let georgian = L10n.tr("Localizable", "enum.language.value.georgian", fallback: "Georgian") - /// German - internal static let german = L10n.tr("Localizable", "enum.language.value.german", fallback: "German") - /// Greek - internal static let greek = L10n.tr("Localizable", "enum.language.value.greek", fallback: "Greek") - /// Hebrew - internal static let hebrew = L10n.tr("Localizable", "enum.language.value.hebrew", fallback: "Hebrew") - /// Hindi - internal static let hindi = L10n.tr("Localizable", "enum.language.value.hindi", fallback: "Hindi") - /// Hmong - internal static let hmong = L10n.tr("Localizable", "enum.language.value.hmong", fallback: "Hmong") - /// Hungarian - internal static let hungarian = L10n.tr("Localizable", "enum.language.value.hungarian", fallback: "Hungarian") - /// Indonesian - internal static let indonesian = L10n.tr("Localizable", "enum.language.value.indonesian", fallback: "Indonesian") - /// N/A - internal static let invalid = L10n.tr("Localizable", "enum.language.value.invalid", fallback: "N/A") - /// Italian - internal static let italian = L10n.tr("Localizable", "enum.language.value.italian", fallback: "Italian") - /// Japanese - internal static let japanese = L10n.tr("Localizable", "enum.language.value.japanese", fallback: "Japanese") - /// Kazakh - internal static let kazakh = L10n.tr("Localizable", "enum.language.value.kazakh", fallback: "Kazakh") - /// Khmer - internal static let khmer = L10n.tr("Localizable", "enum.language.value.khmer", fallback: "Khmer") - /// Korean - internal static let korean = L10n.tr("Localizable", "enum.language.value.korean", fallback: "Korean") - /// Kurdish - internal static let kurdish = L10n.tr("Localizable", "enum.language.value.kurdish", fallback: "Kurdish") - /// Lao - internal static let lao = L10n.tr("Localizable", "enum.language.value.lao", fallback: "Lao") - /// Latin - internal static let latin = L10n.tr("Localizable", "enum.language.value.latin", fallback: "Latin") - /// Mongolian - internal static let mongolian = L10n.tr("Localizable", "enum.language.value.mongolian", fallback: "Mongolian") - /// Ndebele - internal static let ndebele = L10n.tr("Localizable", "enum.language.value.ndebele", fallback: "Ndebele") - /// Nepali - internal static let nepali = L10n.tr("Localizable", "enum.language.value.nepali", fallback: "Nepali") - /// Norwegian - internal static let norwegian = L10n.tr("Localizable", "enum.language.value.norwegian", fallback: "Norwegian") - /// Oromo - internal static let oromo = L10n.tr("Localizable", "enum.language.value.oromo", fallback: "Oromo") - /// Other - internal static let other = L10n.tr("Localizable", "enum.language.value.other", fallback: "Other") - /// Pashto - internal static let pashto = L10n.tr("Localizable", "enum.language.value.pashto", fallback: "Pashto") - /// Persian - internal static let persian = L10n.tr("Localizable", "enum.language.value.persian", fallback: "Persian") - /// Polish - internal static let polish = L10n.tr("Localizable", "enum.language.value.polish", fallback: "Polish") - /// Portuguese - internal static let portuguese = L10n.tr("Localizable", "enum.language.value.portuguese", fallback: "Portuguese") - /// Punjabi - internal static let punjabi = L10n.tr("Localizable", "enum.language.value.punjabi", fallback: "Punjabi") - /// Romanian - internal static let romanian = L10n.tr("Localizable", "enum.language.value.romanian", fallback: "Romanian") - /// Russian - internal static let russian = L10n.tr("Localizable", "enum.language.value.russian", fallback: "Russian") - /// Sango - internal static let sango = L10n.tr("Localizable", "enum.language.value.sango", fallback: "Sango") - /// Serbian - internal static let serbian = L10n.tr("Localizable", "enum.language.value.serbian", fallback: "Serbian") - /// Shona - internal static let shona = L10n.tr("Localizable", "enum.language.value.shona", fallback: "Shona") - /// Slovak - internal static let slovak = L10n.tr("Localizable", "enum.language.value.slovak", fallback: "Slovak") - /// Slovenian - internal static let slovenian = L10n.tr("Localizable", "enum.language.value.slovenian", fallback: "Slovenian") - /// Somali - internal static let somali = L10n.tr("Localizable", "enum.language.value.somali", fallback: "Somali") - /// Spanish - internal static let spanish = L10n.tr("Localizable", "enum.language.value.spanish", fallback: "Spanish") - /// Swahili - internal static let swahili = L10n.tr("Localizable", "enum.language.value.swahili", fallback: "Swahili") - /// Swedish - internal static let swedish = L10n.tr("Localizable", "enum.language.value.swedish", fallback: "Swedish") - /// Tagalog - internal static let tagalog = L10n.tr("Localizable", "enum.language.value.tagalog", fallback: "Tagalog") - /// Thai - internal static let thai = L10n.tr("Localizable", "enum.language.value.thai", fallback: "Thai") - /// Tigrinya - internal static let tigrinya = L10n.tr("Localizable", "enum.language.value.tigrinya", fallback: "Tigrinya") - /// Turkish - internal static let turkish = L10n.tr("Localizable", "enum.language.value.turkish", fallback: "Turkish") - /// Ukrainian - internal static let ukrainian = L10n.tr("Localizable", "enum.language.value.ukrainian", fallback: "Ukrainian") - /// Urdu - internal static let urdu = L10n.tr("Localizable", "enum.language.value.urdu", fallback: "Urdu") - /// Vietnamese - internal static let vietnamese = L10n.tr("Localizable", "enum.language.value.vietnamese", fallback: "Vietnamese") - /// Zulu - internal static let zulu = L10n.tr("Localizable", "enum.language.value.zulu", fallback: "Zulu") - } - } - internal enum ListDisplayMode { - internal enum Value { - /// Detail - internal static let detail = L10n.tr("Localizable", "enum.list_display_mode.value.detail", fallback: "Detail") - /// Thumbnail - internal static let thumbnail = L10n.tr("Localizable", "enum.list_display_mode.value.thumbnail", fallback: "Thumbnail") - } - } - internal enum PreferredColorScheme { - internal enum Value { - /// Automatic - internal static let automatic = L10n.tr("Localizable", "enum.preferred_color_scheme.value.automatic", fallback: "Automatic") - /// Dark - internal static let dark = L10n.tr("Localizable", "enum.preferred_color_scheme.value.dark", fallback: "Dark") - /// Light - internal static let light = L10n.tr("Localizable", "enum.preferred_color_scheme.value.light", fallback: "Light") - } - } - internal enum ReadingDirection { - internal enum Value { - /// Left-to-right - internal static let leftToRight = L10n.tr("Localizable", "enum.reading_direction.value.left_to_right", fallback: "Left-to-right") - /// Right-to-left - internal static let rightToLeft = L10n.tr("Localizable", "enum.reading_direction.value.right_to_left", fallback: "Right-to-left") - /// Vertical - internal static let vertical = L10n.tr("Localizable", "enum.reading_direction.value.vertical", fallback: "Vertical") - } - } - internal enum SettingStateRoute { - internal enum Value { - /// About EhPanda - internal static let about = L10n.tr("Localizable", "enum.setting_state_route.value.about", fallback: "About EhPanda") - /// Account - internal static let account = L10n.tr("Localizable", "enum.setting_state_route.value.account", fallback: "Account") - /// Appearance - internal static let appearance = L10n.tr("Localizable", "enum.setting_state_route.value.appearance", fallback: "Appearance") - /// General - internal static let general = L10n.tr("Localizable", "enum.setting_state_route.value.general", fallback: "General") - /// Laboratory - internal static let laboratory = L10n.tr("Localizable", "enum.setting_state_route.value.laboratory", fallback: "Laboratory") - /// Reading - internal static let reading = L10n.tr("Localizable", "enum.setting_state_route.value.reading", fallback: "Reading") - } - } - internal enum TagNamespace { - internal enum Value { - /// Artist - internal static let artist = L10n.tr("Localizable", "enum.tag_namespace.value.artist", fallback: "Artist") - /// Character - internal static let character = L10n.tr("Localizable", "enum.tag_namespace.value.character", fallback: "Character") - /// Cosplayer - internal static let cosplayer = L10n.tr("Localizable", "enum.tag_namespace.value.cosplayer", fallback: "Cosplayer") - /// Female - internal static let female = L10n.tr("Localizable", "enum.tag_namespace.value.female", fallback: "Female") - /// Group - internal static let group = L10n.tr("Localizable", "enum.tag_namespace.value.group", fallback: "Group") - /// Language - internal static let language = L10n.tr("Localizable", "enum.tag_namespace.value.language", fallback: "Language") - /// Male - internal static let male = L10n.tr("Localizable", "enum.tag_namespace.value.male", fallback: "Male") - /// Mixed - internal static let mixed = L10n.tr("Localizable", "enum.tag_namespace.value.mixed", fallback: "Mixed") - /// Other - internal static let other = L10n.tr("Localizable", "enum.tag_namespace.value.other", fallback: "Other") - /// Parody - internal static let parody = L10n.tr("Localizable", "enum.tag_namespace.value.parody", fallback: "Parody") - /// Reclass - internal static let reclass = L10n.tr("Localizable", "enum.tag_namespace.value.reclass", fallback: "Reclass") - /// Temp - internal static let temp = L10n.tr("Localizable", "enum.tag_namespace.value.temp", fallback: "Temp") - } - } - internal enum ToplistsType { - internal enum Value { - /// All time - internal static let allTime = L10n.tr("Localizable", "enum.toplists_type.value.all_time", fallback: "All time") - /// Past month - internal static let pastMonth = L10n.tr("Localizable", "enum.toplists_type.value.past_month", fallback: "Past month") - /// Past year - internal static let pastYear = L10n.tr("Localizable", "enum.toplists_type.value.past_year", fallback: "Past year") - /// Yesterday - internal static let yesterday = L10n.tr("Localizable", "enum.toplists_type.value.yesterday", fallback: "Yesterday") - } - } - } - internal enum ErrorView { - internal enum Button { - /// Drop the database - internal static let dropDatabase = L10n.tr("Localizable", "error_view.button.drop_database", fallback: "Drop the database") - /// Retry - internal static let retry = L10n.tr("Localizable", "error_view.button.retry", fallback: "Retry") - } - internal enum Title { - /// This gallery is unavailable due to a copyright claim by %@. Sorry about that. - internal static func copyrightClaim(_ p1: Any) -> String { - return L10n.tr("Localizable", "error_view.title.copyright_claim", String(describing: p1), fallback: "This gallery is unavailable due to a copyright claim by %@. Sorry about that.") - } - /// The database is corrupted. - /// Please submit an issue on GitHub. - internal static let databaseCorrupted = L10n.tr("Localizable", "error_view.title.database_corrupted", fallback: "The database is corrupted.\nPlease submit an issue on GitHub.") - /// This gallery has been removed or is unavailable. - internal static let galleryUnavailable = L10n.tr("Localizable", "error_view.title.gallery_unavailable", fallback: "This gallery has been removed or is unavailable.") - /// Your IP address has been temporarily banned for excessive pageloads which indicates that you are using automated mirroring / harvesting software. The ban expires in %@. - internal static func ipBanned(_ p1: Any) -> String { - return L10n.tr("Localizable", "error_view.title.ip_banned", String(describing: p1), fallback: "Your IP address has been temporarily banned for excessive pageloads which indicates that you are using automated mirroring / harvesting software. The ban expires in %@.") - } - /// A network error occurred. - internal static let network = L10n.tr("Localizable", "error_view.title.network", fallback: "A network error occurred.") - /// There seems to be nothing here. - internal static let notFound = L10n.tr("Localizable", "error_view.title.not_found", fallback: "There seems to be nothing here.") - /// A parsing error occurred. - internal static let parsing = L10n.tr("Localizable", "error_view.title.parsing", fallback: "A parsing error occurred.") - /// Please try again later. - internal static let tryLater = L10n.tr("Localizable", "error_view.title.try_later", fallback: "Please try again later.") - /// An unknown error occurred. - internal static let unknown = L10n.tr("Localizable", "error_view.title.unknown", fallback: "An unknown error occurred.") - } - } - internal enum FavoritesView { - internal enum Title { - /// Favorites - internal static let favorites = L10n.tr("Localizable", "favorites_view.title.favorites", fallback: "Favorites") - } - } - internal enum FiltersView { - internal enum Button { - /// Reset filters - internal static let resetFilters = L10n.tr("Localizable", "filters_view.button.reset_filters", fallback: "Reset filters") - } - internal enum Section { - internal enum Title { - /// Advanced - internal static let advanced = L10n.tr("Localizable", "filters_view.section.title.advanced", fallback: "Advanced") - /// Default filter - internal static let defaultFilter = L10n.tr("Localizable", "filters_view.section.title.default_filter", fallback: "Default filter") - } - } - internal enum Title { - /// Advanced settings - internal static let advancedSettings = L10n.tr("Localizable", "filters_view.title.advanced_settings", fallback: "Advanced settings") - /// Disable language filter - internal static let disableLanguageFilter = L10n.tr("Localizable", "filters_view.title.disable_language_filter", fallback: "Disable language filter") - /// Disable tags filter - internal static let disableTagsFilter = L10n.tr("Localizable", "filters_view.title.disable_tags_filter", fallback: "Disable tags filter") - /// Disable uploader filter - internal static let disableUploaderFilter = L10n.tr("Localizable", "filters_view.title.disable_uploader_filter", fallback: "Disable uploader filter") - /// Filters - internal static let filters = L10n.tr("Localizable", "filters_view.title.filters", fallback: "Filters") - /// Minimum rating - internal static let minimumRating = L10n.tr("Localizable", "filters_view.title.minimum_rating", fallback: "Minimum rating") - /// Only show galleries with torrents - internal static let onlyShowGalleriesWithTorrents = L10n.tr("Localizable", "filters_view.title.only_show_galleries_with_torrents", fallback: "Only show galleries with torrents") - /// Pages range - internal static let pagesRange = L10n.tr("Localizable", "filters_view.title.pages_range", fallback: "Pages range") - /// Search downvoted tags - internal static let searchDownvotedTags = L10n.tr("Localizable", "filters_view.title.search_downvoted_tags", fallback: "Search downvoted tags") - /// Search expunged galleries - internal static let searchExpungedGalleries = L10n.tr("Localizable", "filters_view.title.search_expunged_galleries", fallback: "Search expunged galleries") - /// Search gallery description - internal static let searchGalleryDescription = L10n.tr("Localizable", "filters_view.title.search_gallery_description", fallback: "Search gallery description") - /// Search gallery name - internal static let searchGalleryName = L10n.tr("Localizable", "filters_view.title.search_gallery_name", fallback: "Search gallery name") - /// Search gallery tags - internal static let searchGalleryTags = L10n.tr("Localizable", "filters_view.title.search_gallery_tags", fallback: "Search gallery tags") - /// Search Low-Power tags - internal static let searchLowPowerTags = L10n.tr("Localizable", "filters_view.title.search_low_power_tags", fallback: "Search Low-Power tags") - /// Search torrent filenames - internal static let searchTorrentFilenames = L10n.tr("Localizable", "filters_view.title.search_torrent_filenames", fallback: "Search torrent filenames") - /// Set minimum rating - internal static let setMinimumRating = L10n.tr("Localizable", "filters_view.title.set_minimum_rating", fallback: "Set minimum rating") - /// Set pages range - internal static let setPagesRange = L10n.tr("Localizable", "filters_view.title.set_pages_range", fallback: "Set pages range") - } - } - internal enum FrontpageView { - internal enum Title { - /// Frontpage - internal static let frontpage = L10n.tr("Localizable", "frontpage_view.title.frontpage", fallback: "Frontpage") - } - } - internal enum GalleryInfosView { - internal enum Title { - /// Archive URL - internal static let archiveURL = L10n.tr("Localizable", "gallery_infos_view.title.archive_URL", fallback: "Archive URL") - /// Average rating - internal static let averageRating = L10n.tr("Localizable", "gallery_infos_view.title.average_rating", fallback: "Average rating") - /// Category - internal static let category = L10n.tr("Localizable", "gallery_infos_view.title.category", fallback: "Category") - /// Cover URL - internal static let coverURL = L10n.tr("Localizable", "gallery_infos_view.title.cover_URL", fallback: "Cover URL") - /// Favorited - internal static let favorited = L10n.tr("Localizable", "gallery_infos_view.title.favorited", fallback: "Favorited") - /// Favorited times - internal static let favoritedTimes = L10n.tr("Localizable", "gallery_infos_view.title.favorited_times", fallback: "Favorited times") - /// File size - internal static let fileSize = L10n.tr("Localizable", "gallery_infos_view.title.file_size", fallback: "File size") - /// Gallery infos - internal static let galleryInfos = L10n.tr("Localizable", "gallery_infos_view.title.gallery_infos", fallback: "Gallery infos") - /// Gallery URL - internal static let galleryURL = L10n.tr("Localizable", "gallery_infos_view.title.gallery_URL", fallback: "Gallery URL") - /// ID - internal static let id = L10n.tr("Localizable", "gallery_infos_view.title.id", fallback: "ID") - /// Japanese title - internal static let japaneseTitle = L10n.tr("Localizable", "gallery_infos_view.title.japanese_title", fallback: "Japanese title") - /// Language - internal static let language = L10n.tr("Localizable", "gallery_infos_view.title.language", fallback: "Language") - /// My rating - internal static let myRating = L10n.tr("Localizable", "gallery_infos_view.title.my_rating", fallback: "My rating") - /// Page count - internal static let pageCount = L10n.tr("Localizable", "gallery_infos_view.title.page_count", fallback: "Page count") - /// Parent URL - internal static let parentURL = L10n.tr("Localizable", "gallery_infos_view.title.parent_URL", fallback: "Parent URL") - /// Posted date - internal static let postedDate = L10n.tr("Localizable", "gallery_infos_view.title.posted_date", fallback: "Posted date") - /// Rating count - internal static let ratingCount = L10n.tr("Localizable", "gallery_infos_view.title.rating_count", fallback: "Rating count") - /// Title - internal static let title = L10n.tr("Localizable", "gallery_infos_view.title.title", fallback: "Title") - /// Token - internal static let token = L10n.tr("Localizable", "gallery_infos_view.title.token", fallback: "Token") - /// Torrent count - internal static let torrentCount = L10n.tr("Localizable", "gallery_infos_view.title.torrent_count", fallback: "Torrent count") - /// Torrent URL - internal static let torrentURL = L10n.tr("Localizable", "gallery_infos_view.title.torrent_URL", fallback: "Torrent URL") - /// Uploader - internal static let uploader = L10n.tr("Localizable", "gallery_infos_view.title.uploader", fallback: "Uploader") - /// Visibility - internal static let visibility = L10n.tr("Localizable", "gallery_infos_view.title.visibility", fallback: "Visibility") - } - internal enum Value { - /// No - internal static let no = L10n.tr("Localizable", "gallery_infos_view.value.no", fallback: "No") - /// None - internal static let `none` = L10n.tr("Localizable", "gallery_infos_view.value.none", fallback: "None") - /// Yes - internal static let yes = L10n.tr("Localizable", "gallery_infos_view.value.yes", fallback: "Yes") - } - } - internal enum GeneralSettingView { - internal enum Button { - /// Clear image caches - internal static let clearImageCaches = L10n.tr("Localizable", "general_setting_view.button.clear_image_caches", fallback: "Clear image caches") - /// Import custom translations - internal static let importCustomTranslations = L10n.tr("Localizable", "general_setting_view.button.import_custom_translations", fallback: "Import custom translations") - /// Logs - internal static let logs = L10n.tr("Localizable", "general_setting_view.button.logs", fallback: "Logs") - /// Remove custom translations - internal static let removeCustomTranslations = L10n.tr("Localizable", "general_setting_view.button.remove_custom_translations", fallback: "Remove custom translations") - } - internal enum Section { - internal enum Title { - /// Caches - internal static let caches = L10n.tr("Localizable", "general_setting_view.section.title.caches", fallback: "Caches") - /// Navigation - internal static let navigation = L10n.tr("Localizable", "general_setting_view.section.title.navigation", fallback: "Navigation") - /// Security - internal static let security = L10n.tr("Localizable", "general_setting_view.section.title.security", fallback: "Security") - /// Tags - internal static let tags = L10n.tr("Localizable", "general_setting_view.section.title.tags", fallback: "Tags") - } - } - internal enum Title { - /// Auto-Lock - internal static let autoLock = L10n.tr("Localizable", "general_setting_view.title.auto_lock", fallback: "Auto-Lock") - /// Background blur radius - internal static let backgroundBlurRadius = L10n.tr("Localizable", "general_setting_view.title.background_blur_radius", fallback: "Background blur radius") - /// Detects links from the clipboard - internal static let detectsLinksFromClipboard = L10n.tr("Localizable", "general_setting_view.title.detects_links_from_clipboard", fallback: "Detects links from the clipboard") - /// Enables tags extension - internal static let enablesTagsExtension = L10n.tr("Localizable", "general_setting_view.title.enables_tags_extension", fallback: "Enables tags extension") - /// General - internal static let general = L10n.tr("Localizable", "general_setting_view.title.general", fallback: "General") - /// Language - internal static let language = L10n.tr("Localizable", "general_setting_view.title.language", fallback: "Language") - /// Redirects links to the selected host - internal static let redirectsLinksToTheSelectedHost = L10n.tr("Localizable", "general_setting_view.title.redirects_links_to_the_selected_host", fallback: "Redirects links to the selected host") - /// Shows images in tags - internal static let showsImagesInTags = L10n.tr("Localizable", "general_setting_view.title.shows_images_in_tags", fallback: "Shows images in tags") - /// Shows tags search suggestion - internal static let showsTagsSearchSuggestion = L10n.tr("Localizable", "general_setting_view.title.shows_tags_search_suggestion", fallback: "Shows tags search suggestion") - /// Translates tags - internal static let translatesTags = L10n.tr("Localizable", "general_setting_view.title.translates_tags", fallback: "Translates tags") - } - internal enum Value { - /// N/A - internal static let defaultLanguageDescription = L10n.tr("Localizable", "general_setting_view.value.default_language_description", fallback: "N/A") - } - } - internal enum HistoryView { - internal enum Title { - /// History - internal static let history = L10n.tr("Localizable", "history_view.title.history", fallback: "History") - } - } - internal enum HomeView { - internal enum Section { - internal enum Title { - /// Frontpage - internal static let frontpage = L10n.tr("Localizable", "home_view.section.title.frontpage", fallback: "Frontpage") - /// Other - internal static let other = L10n.tr("Localizable", "home_view.section.title.other", fallback: "Other") - /// Toplists - internal static let toplists = L10n.tr("Localizable", "home_view.section.title.toplists", fallback: "Toplists") - } - } - internal enum Title { - /// Home - internal static let home = L10n.tr("Localizable", "home_view.title.home", fallback: "Home") - } - } - internal enum Hud { - internal enum Caption { - /// Copied to clipboard - internal static let copiedToClipboard = L10n.tr("Localizable", "hud.caption.copied_to_clipboard", fallback: "Copied to clipboard") - /// Saved to photo library - internal static let savedToPhotoLibrary = L10n.tr("Localizable", "hud.caption.saved_to_photo_library", fallback: "Saved to photo library") - } - internal enum Title { - /// Communicating... - internal static let communicating = L10n.tr("Localizable", "hud.title.communicating", fallback: "Communicating...") - /// Error - internal static let error = L10n.tr("Localizable", "hud.title.error", fallback: "Error") - /// Loading... - internal static let loading = L10n.tr("Localizable", "hud.title.loading", fallback: "Loading...") - /// Success - internal static let success = L10n.tr("Localizable", "hud.title.success", fallback: "Success") - } - } - internal enum JumpPageView { - internal enum Button { - /// Confirm - internal static let confirm = L10n.tr("Localizable", "jump_page_view.button.confirm", fallback: "Confirm") - } - internal enum Title { - /// Jump page - internal static let jumpPage = L10n.tr("Localizable", "jump_page_view.title.jump_page", fallback: "Jump page") - } - } - internal enum LaboratorySettingView { - internal enum Title { - /// Bypasses SNI Filtering - internal static let bypassesSNIFiltering = L10n.tr("Localizable", "laboratory_setting_view.title.bypasses_SNI_filtering", fallback: "Bypasses SNI Filtering") - /// Laboratory - internal static let laboratory = L10n.tr("Localizable", "laboratory_setting_view.title.laboratory", fallback: "Laboratory") - } - } - internal enum LoadingView { - internal enum Title { - /// Loading... - internal static let loading = L10n.tr("Localizable", "loading_view.title.loading", fallback: "Loading...") - /// Preparing the database... - internal static let preparingDatabase = L10n.tr("Localizable", "loading_view.title.preparing_database", fallback: "Preparing the database...") - } - } - internal enum LocalAuthorization { - /// The App has been locked due to the Auto-Lock expiration. - internal static let reason = L10n.tr("Localizable", "local_authorization.reason", fallback: "The App has been locked due to the Auto-Lock expiration.") - } - internal enum LoginView { - internal enum Title { - /// Login - internal static let login = L10n.tr("Localizable", "login_view.title.login", fallback: "Login") - /// Password - internal static let password = L10n.tr("Localizable", "login_view.title.password", fallback: "Password") - /// Username - internal static let username = L10n.tr("Localizable", "login_view.title.username", fallback: "Username") - } - } - internal enum LogsView { - internal enum Title { - /// Latest - internal static let latest = L10n.tr("Localizable", "logs_view.title.latest", fallback: "Latest") - /// Logs - internal static let logs = L10n.tr("Localizable", "logs_view.title.logs", fallback: "Logs") - } - } - internal enum NewDawnView { - internal enum Title { - /// It is the dawn of a new day! - internal static let first = L10n.tr("Localizable", "new_dawn_view.title.first", fallback: "It is the dawn of a new day!") - /// Reflecting on your journey so far, you find that you are a little wiser. - internal static let second = L10n.tr("Localizable", "new_dawn_view.title.second", fallback: "Reflecting on your journey so far, you find that you are a little wiser.") - } - } - internal enum NotLoginView { - internal enum Button { - /// Login - internal static let login = L10n.tr("Localizable", "not_login_view.button.login", fallback: "Login") - } - internal enum Title { - /// You need to login to access this feature. - internal static let needLogin = L10n.tr("Localizable", "not_login_view.title.need_login", fallback: "You need to login to access this feature.") - } - } - internal enum PopularView { - internal enum Title { - /// Popular - internal static let popular = L10n.tr("Localizable", "popular_view.title.popular", fallback: "Popular") - } - } - internal enum PostCommentView { - internal enum Title { - /// Edit comment - internal static let editComment = L10n.tr("Localizable", "post_comment_view.title.edit_comment", fallback: "Edit comment") - /// Post comment - internal static let postComment = L10n.tr("Localizable", "post_comment_view.title.post_comment", fallback: "Post comment") - } - } - internal enum PreviewsView { - internal enum Title { - /// Previews - internal static let previews = L10n.tr("Localizable", "previews_view.title.previews", fallback: "Previews") - } - } - internal enum QuickSearchView { - internal enum Placeholder { - /// Optional - internal static let `optional` = L10n.tr("Localizable", "quick_search_view.placeholder.optional", fallback: "Optional") - } - internal enum Title { - /// Content - internal static let content = L10n.tr("Localizable", "quick_search_view.title.content", fallback: "Content") - /// Edit word - internal static let editWord = L10n.tr("Localizable", "quick_search_view.title.edit_word", fallback: "Edit word") - /// Name - internal static let name = L10n.tr("Localizable", "quick_search_view.title.name", fallback: "Name") - /// New word - internal static let newWord = L10n.tr("Localizable", "quick_search_view.title.new_word", fallback: "New word") - /// Quick search - internal static let quickSearch = L10n.tr("Localizable", "quick_search_view.title.quick_search", fallback: "Quick search") - } - } - internal enum ReadingSettingView { - internal enum Section { - internal enum Title { - /// Appearance - internal static let appearance = L10n.tr("Localizable", "reading_setting_view.section.title.appearance", fallback: "Appearance") - } - } - internal enum Title { - /// Direction - internal static let direction = L10n.tr("Localizable", "reading_setting_view.title.direction", fallback: "Direction") - /// Double tap scale factor - internal static let doubleTapScaleFactor = L10n.tr("Localizable", "reading_setting_view.title.double_tap_scale_factor", fallback: "Double tap scale factor") - /// Enables landscape - internal static let enablesLandscape = L10n.tr("Localizable", "reading_setting_view.title.enables_landscape", fallback: "Enables landscape") - /// Maximum scale factor - internal static let maximumScaleFactor = L10n.tr("Localizable", "reading_setting_view.title.maximum_scale_factor", fallback: "Maximum scale factor") - /// Preload limit - internal static let preloadLimit = L10n.tr("Localizable", "reading_setting_view.title.preload_limit", fallback: "Preload limit") - /// Reading - internal static let reading = L10n.tr("Localizable", "reading_setting_view.title.reading", fallback: "Reading") - /// Separator height - internal static let separatorHeight = L10n.tr("Localizable", "reading_setting_view.title.separator_height", fallback: "Separator height") - } - } - internal enum ReadingView { - internal enum ContextMenu { - internal enum Button { - /// Copy - internal static let copy = L10n.tr("Localizable", "reading_view.context_menu.button.copy", fallback: "Copy") - /// Reload - internal static let reload = L10n.tr("Localizable", "reading_view.context_menu.button.reload", fallback: "Reload") - /// Save - internal static let save = L10n.tr("Localizable", "reading_view.context_menu.button.save", fallback: "Save") - /// Save original - internal static let saveOriginal = L10n.tr("Localizable", "reading_view.context_menu.button.save_original", fallback: "Save original") - /// Share - internal static let share = L10n.tr("Localizable", "reading_view.context_menu.button.share", fallback: "Share") - } - } - internal enum ToolbarItem { - internal enum Button { - /// Reading setting - internal static let readingSetting = L10n.tr("Localizable", "reading_view.toolbar_item.button.reading_setting", fallback: "Reading setting") - /// Reload all images - internal static let reloadAllImages = L10n.tr("Localizable", "reading_view.toolbar_item.button.reload_all_images", fallback: "Reload all images") - /// Retry all failed images - internal static let retryAllFailedImages = L10n.tr("Localizable", "reading_view.toolbar_item.button.retry_all_failed_images", fallback: "Retry all failed images") - } - internal enum Title { - /// Auto-Play - internal static let autoPlay = L10n.tr("Localizable", "reading_view.toolbar_item.title.auto_play", fallback: "Auto-Play") - /// Dual-Page mode - internal static let dualPageMode = L10n.tr("Localizable", "reading_view.toolbar_item.title.dual_page_mode", fallback: "Dual-Page mode") - /// Except the cover - internal static let exceptTheCover = L10n.tr("Localizable", "reading_view.toolbar_item.title.except_the_cover", fallback: "Except the cover") - } - } - } - internal enum SearchView { - internal enum Section { - internal enum Title { - /// Quick search - internal static let quickSearch = L10n.tr("Localizable", "search_view.section.title.quick_search", fallback: "Quick search") - /// Recently searched - internal static let recentlySearched = L10n.tr("Localizable", "search_view.section.title.recently_searched", fallback: "Recently searched") - /// Recently seen - internal static let recentlySeen = L10n.tr("Localizable", "search_view.section.title.recently_seen", fallback: "Recently seen") - } - } - internal enum Title { - /// Search - internal static let search = L10n.tr("Localizable", "search_view.title.search", fallback: "Search") - } - } - internal enum Searchable { - internal enum Prompt { - /// Filter - internal static let filter = L10n.tr("Localizable", "searchable.prompt.filter", fallback: "Filter") - } - internal enum Title { - /// Found %d matches. - internal static func matchesCount(_ p1: Int) -> String { - return L10n.tr("Localizable", "searchable.title.matches_count", p1, fallback: "Found %d matches.") - } - } - } - internal enum SettingView { - internal enum Title { - /// Setting - internal static let setting = L10n.tr("Localizable", "setting_view.title.setting", fallback: "Setting") - } - } - internal enum Struct { - internal enum CookieValue { - internal enum LocalizedString { - /// Expired - internal static let expired = L10n.tr("Localizable", "struct.cookie_value.localized_string.expired", fallback: "Expired") - /// Rejected - internal static let mystery = L10n.tr("Localizable", "struct.cookie_value.localized_string.mystery", fallback: "Rejected") - /// None - internal static let `none` = L10n.tr("Localizable", "struct.cookie_value.localized_string.none", fallback: "None") - } - } - internal enum Greeting { - internal enum Mark { - /// and - internal static let and = L10n.tr("Localizable", "struct.greeting.mark.and", fallback: " and ") - /// ! - internal static let end = L10n.tr("Localizable", "struct.greeting.mark.end", fallback: "!") - /// , - internal static let separator = L10n.tr("Localizable", "struct.greeting.mark.separator", fallback: ", ") - /// You gain - internal static let start = L10n.tr("Localizable", "struct.greeting.mark.start", fallback: "You gain ") - } - } - internal enum HathArchive { - internal enum Price { - /// Free - internal static let free = L10n.tr("Localizable", "struct.hath_archive.price.free", fallback: "Free") - /// N/A - internal static let notAvailable = L10n.tr("Localizable", "struct.hath_archive.price.not_available", fallback: "N/A") - } - } - internal enum User { - internal enum FavoriteCategory { - /// All - internal static let all = L10n.tr("Localizable", "struct.user.favorite_category.all", fallback: "All") - /// Favorites %@ - internal static func `default`(_ p1: Any) -> String { - return L10n.tr("Localizable", "struct.user.favorite_category.default", String(describing: p1), fallback: "Favorites %@") - } - } - } - } - internal enum SubSection { - internal enum Button { - /// Show all - internal static let showAll = L10n.tr("Localizable", "sub_section.button.show_all", fallback: "Show all") - } - } - internal enum TabItem { - internal enum Title { - /// Favorites - internal static let favorites = L10n.tr("Localizable", "tab_item.title.favorites", fallback: "Favorites") - /// Home - internal static let home = L10n.tr("Localizable", "tab_item.title.home", fallback: "Home") - /// Search - internal static let search = L10n.tr("Localizable", "tab_item.title.search", fallback: "Search") - /// Setting - internal static let setting = L10n.tr("Localizable", "tab_item.title.setting", fallback: "Setting") - } - } - internal enum TagDetailView { - internal enum Section { - internal enum Title { - /// Images - internal static let images = L10n.tr("Localizable", "tag_detail_view.section.title.images", fallback: "Images") - /// Links - internal static let links = L10n.tr("Localizable", "tag_detail_view.section.title.links", fallback: "Links") - } - } - } - internal enum ToolbarItem { - internal enum Button { - /// Filters - internal static let filters = L10n.tr("Localizable", "toolbar_item.button.filters", fallback: "Filters") - /// Jump page - internal static let jumpPage = L10n.tr("Localizable", "toolbar_item.button.jump_page", fallback: "Jump page") - /// Quick search - internal static let quickSearch = L10n.tr("Localizable", "toolbar_item.button.quick_search", fallback: "Quick search") - } - } - internal enum ToplistsView { - internal enum Title { - /// Toplists - internal static let toplists = L10n.tr("Localizable", "toplists_view.title.toplists", fallback: "Toplists") - } - } - internal enum TorrentsView { - internal enum Title { - /// Torrents - internal static let torrents = L10n.tr("Localizable", "torrents_view.title.torrents", fallback: "Torrents") - } - } - internal enum WatchedView { - internal enum Title { - /// Watched - internal static let watched = L10n.tr("Localizable", "watched_view.title.watched", fallback: "Watched") - } - } - internal enum Website { - internal enum Response { - /// You must have a H@H client assigned to your account to use this feature. - internal static let hathClientNotFound = L10n.tr("Localizable", "website.response.hath_client_not_found", fallback: "You must have a H@H client assigned to your account to use this feature.") - /// Your H@H client appears to be offline. Turn it on, then try again. - internal static let hathClientNotOnline = L10n.tr("Localizable", "website.response.hath_client_not_online", fallback: "Your H@H client appears to be offline. Turn it on, then try again.") - /// The requested gallery cannot be downloaded with the selected resolution. - internal static let invalidResolution = L10n.tr("Localizable", "website.response.invalid_resolution", fallback: "The requested gallery cannot be downloaded with the selected resolution.") - } - } - } -} -// swiftlint:enable explicit_type_interface function_parameter_count identifier_name line_length -// swiftlint:enable nesting type_body_length type_name vertical_whitespace_opening_braces - -// MARK: - Implementation Details - -extension L10n { - private static func tr(_ table: String, _ key: String, _ args: CVarArg..., fallback value: String) -> String { - let format = BundleToken.bundle.localizedString(forKey: key, value: value, table: table) - return String(format: format, locale: Locale.current, arguments: args) - } -} - -// swiftlint:disable convenience_type -private final class BundleToken { - static let bundle: Bundle = { - #if SWIFT_PACKAGE - return Bundle.module - #else - return Bundle(for: BundleToken.self) - #endif - }() -} -// swiftlint:enable convenience_type diff --git a/EhPanda/App/Tools/Clients/AppDelegateClient.swift b/EhPanda/App/Tools/Clients/AppDelegateClient.swift deleted file mode 100644 index 877b4bcee..000000000 --- a/EhPanda/App/Tools/Clients/AppDelegateClient.swift +++ /dev/null @@ -1,63 +0,0 @@ -// -// AppDelegateClient.swift -// EhPanda -// - -import SwiftUI -import ComposableArchitecture - -struct AppDelegateClient { - let setOrientation: @MainActor (UIInterfaceOrientationMask) -> Void - let setOrientationMask: (UIInterfaceOrientationMask) -> Void -} - -extension AppDelegateClient { - static let live: Self = .init( - setOrientation: { mask in - DeviceUtil.keyWindow?.windowScene?.requestGeometryUpdate(.iOS(interfaceOrientations: mask)) - }, - setOrientationMask: { mask in - AppDelegate.orientationMask = mask - } - ) - - @MainActor - func setPortraitOrientation() { - setOrientation(.portrait) - } - func setAllOrientationMask() { - setOrientationMask([.all]) - } - func setPortraitOrientationMask() { - setOrientationMask([.portrait, .portraitUpsideDown]) - } -} - -// MARK: API -enum AppDelegateClientKey: DependencyKey { - static let liveValue = AppDelegateClient.live - static let previewValue = AppDelegateClient.noop - static let testValue = AppDelegateClient.unimplemented -} - -extension DependencyValues { - var appDelegateClient: AppDelegateClient { - get { self[AppDelegateClientKey.self] } - set { self[AppDelegateClientKey.self] = newValue } - } -} - -// MARK: Test -extension AppDelegateClient { - static let noop: Self = .init( - setOrientation: { _ in }, - setOrientationMask: { _ in } - ) - - static func placeholder() -> Result { fatalError() } - - static let unimplemented: Self = .init( - setOrientation: IssueReporting.unimplemented(placeholder: placeholder()), - setOrientationMask: IssueReporting.unimplemented(placeholder: placeholder()) - ) -} diff --git a/EhPanda/App/Tools/Clients/ClipboardClient.swift b/EhPanda/App/Tools/Clients/ClipboardClient.swift deleted file mode 100644 index fabfbce42..000000000 --- a/EhPanda/App/Tools/Clients/ClipboardClient.swift +++ /dev/null @@ -1,77 +0,0 @@ -// -// ClipboardClient.swift -// EhPanda -// - -import SwiftUI -import ComposableArchitecture -import UniformTypeIdentifiers - -struct ClipboardClient { - let url: () -> URL? - let changeCount: () -> Int - let saveText: (String) -> Void - let saveImage: (UIImage, Bool) -> Void -} - -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: { text in - UIPasteboard.general.string = text - }, - saveImage: { (image, isAnimated) in - if isAnimated { - DispatchQueue.global(qos: .utility).async { - if let data = image.kf.data(format: .GIF) { - UIPasteboard.general.setData(data, forPasteboardType: UTType.gif.identifier) - } - } - } else { - UIPasteboard.general.image = image - } - } - ) -} - -// MARK: API -enum ClipboardClientKey: DependencyKey { - static let liveValue = ClipboardClient.live - static let previewValue = ClipboardClient.noop - static let testValue = ClipboardClient.unimplemented -} - -extension DependencyValues { - var clipboardClient: ClipboardClient { - get { self[ClipboardClientKey.self] } - set { self[ClipboardClientKey.self] = newValue } - } -} - -// MARK: Test -extension ClipboardClient { - static let noop: Self = .init( - url: { nil }, - changeCount: { 0 }, - saveText: { _ in }, - saveImage: { _, _ in } - ) - - static func placeholder() -> Result { fatalError() } - - static let unimplemented: Self = .init( - url: IssueReporting.unimplemented(placeholder: placeholder()), - changeCount: IssueReporting.unimplemented(placeholder: placeholder()), - saveText: IssueReporting.unimplemented(placeholder: placeholder()), - saveImage: IssueReporting.unimplemented(placeholder: placeholder()) - ) -} diff --git a/EhPanda/App/Tools/Clients/CookieClient.swift b/EhPanda/App/Tools/Clients/CookieClient.swift deleted file mode 100644 index b2d89ff4b..000000000 --- a/EhPanda/App/Tools/Clients/CookieClient.swift +++ /dev/null @@ -1,282 +0,0 @@ -// -// CookieClient.swift -// EhPanda -// - -import Foundation -import ComposableArchitecture - -struct CookieClient { - let clearAll: () -> Void - let getCookie: (URL, String) -> CookieValue - private let removeCookie: (URL, String) -> Void - private let checkExistence: (URL, String) -> Bool - private let initializeCookie: (HTTPCookie, String) -> HTTPCookie -} - -extension CookieClient { - static let live: Self = .init( - clearAll: { - if let historyCookies = HTTPCookieStorage.shared.cookies { - historyCookies.forEach { - HTTPCookieStorage.shared.deleteCookie($0) - } - } - }, - getCookie: { url, key in - var value = CookieValue( - rawValue: "", localizedString: L10n.Localizable.Struct.CookieValue.LocalizedString.none - ) - guard let cookies = HTTPCookieStorage.shared.cookies(for: url), !cookies.isEmpty else { return value } - - cookies.forEach { cookie in - guard let expiresDate = cookie.expiresDate, cookie.name == key && !cookie.value.isEmpty else { return } - guard expiresDate > .now else { - value = CookieValue( - rawValue: "", localizedString: L10n.Localizable.Struct.CookieValue.LocalizedString.expired - ) - return - } - guard cookie.value != Defaults.Cookie.mystery else { - value = CookieValue( - rawValue: cookie.value, localizedString: - L10n.Localizable.Struct.CookieValue.LocalizedString.mystery - ) - return - } - value = CookieValue(rawValue: cookie.value, localizedString: "") - } - - 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 CookieClient { - private func setCookie( - for url: URL, key: String, value: String, path: String = "/", - expiresTime: TimeInterval = .oneYear - ) { - 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) { - if checkExistence(url, key) { - editCookie(for: url, key: key, value: value) - } else { - setCookie(for: url, key: key, value: value) - } - } -} - -// MARK: Accessor -extension CookieClient { - var didLogin: Bool { - CookieUtil.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() { - removeCookie(Defaults.URL.exhentai, Defaults.Cookie.yay) - removeCookie(Defaults.URL.sexhentai, Defaults.Cookie.yay) - } - func syncExCookies() { - let cookies = [ - Defaults.Cookie.ipbMemberId, - Defaults.Cookie.ipbPassHash, - Defaults.Cookie.igneous - ] - for cookie in cookies { - setOrEditCookie( - for: Defaults.URL.sexhentai, - key: cookie, - value: getCookie(Defaults.URL.exhentai, cookie).rawValue - ) - } - } - func ignoreOffensive() { - setOrEditCookie(for: Defaults.URL.ehentai, key: Defaults.Cookie.ignoreOffensive, value: "1") - setOrEditCookie(for: Defaults.URL.exhentai, key: Defaults.Cookie.ignoreOffensive, value: "1") - } - func fulfillAnotherHostField() { - 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) { - setOrEditCookie(for: exURL, key: memberIdKey, value: ehMemberId) - setOrEditCookie(for: exURL, key: passHashKey, value: ehPassHash) - } else if !exMemberId.isEmpty && !exPassHash.isEmpty && (ehMemberId.isEmpty || ehPassHash.isEmpty) { - setOrEditCookie(for: ehURL, key: memberIdKey, value: exMemberId) - setOrEditCookie(for: ehURL, key: passHashKey, value: exPassHash) - } - } - 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 CookieClient { - func setCookies(state: CookiesState, trimsSpaces: Bool = true) { - for subState in state.allCases { - for cookie in state.host.cookieURLs { - setOrEditCookie( - for: cookie, - key: subState.key, - value: trimsSpaces - ? subState.editingText .trimmingCharacters(in: .whitespaces) : subState.editingText - ) - } - } - - } - func setCredentials(response: HTTPURLResponse) { - guard let setString = response.allHeaderFields["Set-Cookie"] as? String else { return } - setString.components(separatedBy: ", ") - .flatMap { $0.components(separatedBy: "; ") }.forEach { value in - [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) { - 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/" - ) - } - } - } -} - -// MARK: API -enum CookieClientKey: DependencyKey { - static let liveValue = CookieClient.live - static let previewValue = CookieClient.noop - static let testValue = CookieClient.unimplemented -} - -extension DependencyValues { - var cookieClient: CookieClient { - get { self[CookieClientKey.self] } - set { self[CookieClientKey.self] = newValue } - } -} - -// MARK: Test -extension CookieClient { - static let noop: Self = .init( - clearAll: {}, - getCookie: { _, _ in .empty }, - removeCookie: { _, _ in }, - checkExistence: { _, _ in false }, - initializeCookie: { _, _ in .init() } - ) - - static func placeholder() -> Result { fatalError() } - - static let unimplemented: Self = .init( - clearAll: IssueReporting.unimplemented(placeholder: placeholder()), - getCookie: IssueReporting.unimplemented(placeholder: placeholder()), - removeCookie: IssueReporting.unimplemented(placeholder: placeholder()), - checkExistence: IssueReporting.unimplemented(placeholder: placeholder()), - initializeCookie: IssueReporting.unimplemented(placeholder: placeholder()) - ) -} diff --git a/EhPanda/App/Tools/Clients/DatabaseClient.swift b/EhPanda/App/Tools/Clients/DatabaseClient.swift deleted file mode 100644 index bd0d55bab..000000000 --- a/EhPanda/App/Tools/Clients/DatabaseClient.swift +++ /dev/null @@ -1,505 +0,0 @@ -// -// DatabaseClient.swift -// EhPanda -// - -import SwiftUI -import Combine -import CoreData -import ComposableArchitecture - -struct DatabaseClient { - let prepareDatabase: () async -> Result - let dropDatabase: () async -> Result - private let saveContext: () -> Void - private let materializedObjects: (NSManagedObjectContext, NSPredicate) -> [NSManagedObject] -} - -extension DatabaseClient { - static let live: Self = .init( - prepareDatabase: { - await withCheckedContinuation { continuation in - PersistenceController.shared.prepare { result in - continuation.resume(returning: result) - } - } - }, - dropDatabase: { - await withCheckedContinuation { continuation in - PersistenceController.shared.rebuild { result in - continuation.resume(returning: result) - } - } - }, - 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 - } - @MainActor func fetchAppEnv() -> AppEnv { - fetchOrCreate(entityType: AppEnvMO.self).toEntity() - } - func fetchAppEnvSynchronously() -> AppEnv { - fetchOrCreate(entityType: AppEnvMO.self).toEntity() - } - @MainActor func fetchGalleryState(gid: String) async -> GalleryState? { - guard gid.isValidGID else { return nil } - return fetchOrCreate(entityType: GalleryStateMO.self, gid: gid).toEntity() - } - @MainActor func fetchHistoryGalleries(fetchLimit: Int = 0) -> [Gallery] { - let predicate = NSPredicate(format: "lastOpenDate != nil") - let sortDescriptor = NSSortDescriptor( - keyPath: \GalleryMO.lastOpenDate, ascending: false - ) - let galleries = batchFetch( - entityType: GalleryMO.self, fetchLimit: fetchLimit, predicate: predicate, - findBeforeFetch: false, sortDescriptors: [sortDescriptor] - ) - .map { $0.toEntity() } - return galleries - } -} -// MARK: FetchAccessor -extension DatabaseClient { - func fetchFilterSynchronously(range: FilterRange) -> Filter { - switch range { - case .search: - return fetchAppEnvSynchronously().searchFilter - case .global: - return fetchAppEnvSynchronously().globalFilter - case .watched: - return fetchAppEnvSynchronously().watchedFilter - } - } - @MainActor func fetchHistoryKeywords() -> [String] { - fetchAppEnv().historyKeywords - } - @MainActor func fetchQuickSearchWords() -> [QuickSearchWord] { - fetchAppEnv().quickSearchWords - } - @MainActor func fetchGalleryPreviewURLs(gid: String) async -> [Int: URL]? { - guard gid.isValidGID else { return nil } - return await fetchGalleryState(gid: gid).map(\.previewURLs) - } -} - -// MARK: UpdateGallery -extension DatabaseClient { - @MainActor func updateGallery(gid: String, key: String, value: Any?) { - guard gid.isValidGID else { return } - update( - entityType: GalleryMO.self, gid: gid, createIfNil: true, - commitChanges: { $0.setValue(value, forKeyPath: key) } - ) - } - @MainActor func updateLastOpenDate(gid: String, date: Date = .now) { - guard gid.isValidGID else { return } - updateGallery(gid: gid, key: "lastOpenDate", value: date) - } - @MainActor func clearHistoryGalleries() { - let predicate = NSPredicate(format: "lastOpenDate != nil") - batchUpdate(entityType: GalleryMO.self, predicate: predicate) { galleryMOs in - galleryMOs.forEach { galleryMO in - galleryMO.lastOpenDate = nil - } - } - } - @MainActor func cacheGalleries(_ galleries: [Gallery]) { - for gallery in galleries.filter({ $0.id.isValidGID }) { - let storedMO = fetch( - entityType: GalleryMO.self, gid: gallery.gid - ) { managedObject in - managedObject?.category = gallery.category.rawValue - managedObject?.coverURL = gallery.coverURL - managedObject?.galleryURL = gallery.galleryURL - // managedObject?.lastOpenDate = gallery.lastOpenDate - managedObject?.pageCount = Int64(gallery.pageCount) - managedObject?.postedDate = gallery.postedDate - managedObject?.rating = gallery.rating - managedObject?.tags = gallery.tags.toData() - managedObject?.title = gallery.title - managedObject?.token = gallery.token - if let uploader = gallery.uploader { - managedObject?.uploader = uploader - } - } - if storedMO == nil { - gallery.toManagedObject(in: PersistenceController.shared.container.viewContext) - } - } - saveContext() - } -} - -// MARK: UpdateGalleryDetail -extension DatabaseClient { - @MainActor func cacheGalleryDetail(_ detail: GalleryDetail) { - guard detail.gid.isValidGID else { return } - let storedMO = fetch( - entityType: GalleryDetailMO.self, gid: detail.gid - ) { managedObject in - managedObject?.archiveURL = detail.archiveURL - managedObject?.category = detail.category.rawValue - managedObject?.coverURL = detail.coverURL - managedObject?.isFavorited = detail.isFavorited - managedObject?.visibility = detail.visibility.toData() - managedObject?.jpnTitle = detail.jpnTitle - managedObject?.language = detail.language.rawValue - managedObject?.favoritedCount = Int64(detail.favoritedCount) - managedObject?.pageCount = Int64(detail.pageCount) - managedObject?.parentURL = detail.parentURL - managedObject?.postedDate = detail.postedDate - managedObject?.rating = detail.rating - managedObject?.userRating = detail.userRating - managedObject?.ratingCount = Int64(detail.ratingCount) - managedObject?.sizeCount = detail.sizeCount - managedObject?.sizeType = detail.sizeType - managedObject?.title = detail.title - managedObject?.torrentCount = Int64(detail.torrentCount) - managedObject?.uploader = detail.uploader - } - if storedMO == nil { - detail.toManagedObject(in: PersistenceController.shared.container.viewContext) - } - saveContext() - } -} - -// MARK: UpdateGalleryState -extension DatabaseClient { - @MainActor func updateGalleryState(gid: String, commitChanges: @escaping (GalleryStateMO) -> Void) { - guard gid.isValidGID else { return } - update( - entityType: GalleryStateMO.self, gid: gid, createIfNil: true, - commitChanges: commitChanges - ) - } - @MainActor func updateGalleryState(gid: String, key: String, value: Any?) { - guard gid.isValidGID else { return } - updateGalleryState(gid: gid) { stateMO in - stateMO.setValue(value, forKeyPath: key) - } - } - @MainActor func updateGalleryTags(gid: String, tags: [GalleryTag]) { - guard gid.isValidGID else { return } - updateGalleryState(gid: gid, key: "tags", value: tags.toData()) - } - @MainActor func updatePreviewConfig(gid: String, config: PreviewConfig) { - guard gid.isValidGID else { return } - updateGalleryState(gid: gid, key: "previewConfig", value: config.toData()) - } - @MainActor func updateReadingProgress(gid: String, progress: Int) { - guard gid.isValidGID else { return } - updateGalleryState(gid: gid, key: "readingProgress", value: Int64(progress)) - } - @MainActor func updateComments(gid: String, comments: [GalleryComment]) { - guard gid.isValidGID else { return } - updateGalleryState(gid: gid, key: "comments", value: comments.toData()) - } - - @MainActor func removeImageURLs(gid: String) { - guard gid.isValidGID else { return } - updateGalleryState(gid: gid) { galleryStateMO in - galleryStateMO.imageURLs = nil - galleryStateMO.previewURLs = nil - galleryStateMO.thumbnailURLs = nil - galleryStateMO.originalImageURLs = nil - } - } - @MainActor func removeImageURLs() { - batchUpdate(entityType: GalleryStateMO.self) { galleryStateMOs in - galleryStateMOs.forEach { galleryStateMO in - galleryStateMO.imageURLs = nil - galleryStateMO.previewURLs = nil - galleryStateMO.thumbnailURLs = nil - galleryStateMO.originalImageURLs = nil - } - } - } - @MainActor func removeExpiredImageURLs() { - fetchHistoryGalleries() - .filter { Date().timeIntervalSince($0.lastOpenDate ?? .distantPast) > .oneWeek } - .forEach { removeImageURLs(gid: $0.id) } - } - @MainActor func updateThumbnailURLs(gid: String, thumbnailURLs: [Int: URL]) { - guard gid.isValidGID else { return } - updateGalleryState(gid: gid) { galleryStateMO in - update(gid: gid, storedData: &galleryStateMO.thumbnailURLs, new: thumbnailURLs) - } - } - @MainActor func updateImageURLs(gid: String, imageURLs: [Int: URL], originalImageURLs: [Int: URL]) { - guard gid.isValidGID else { return } - updateGalleryState(gid: gid) { galleryStateMO in - update(gid: gid, storedData: &galleryStateMO.imageURLs, new: imageURLs) - update(gid: gid, storedData: &galleryStateMO.originalImageURLs, new: originalImageURLs) - } - } - @MainActor func updatePreviewURLs(gid: String, previewURLs: [Int: URL]) { - guard gid.isValidGID else { return } - updateGalleryState(gid: gid) { galleryStateMO in - update(gid: gid, storedData: &galleryStateMO.previewURLs, new: previewURLs) - } - } - - private func update( - gid: String, storedData: inout Data?, new: [Int: T] - ) { - guard !new.isEmpty, gid.isValidGID else { return } - - if let storedDictionary = storedData?.toObject() as [Int: T]? { - storedData = storedDictionary.merging( - new, uniquingKeysWith: { _, new in new } - ).toData() - } else { - storedData = new.toData() - } - } -} - -// MARK: UpdateAppEnv -extension DatabaseClient { - @MainActor func updateAppEnv(key: String, value: Any?) { - update( - entityType: AppEnvMO.self, createIfNil: true, - commitChanges: { $0.setValue(value, forKeyPath: key) } - ) - } - @MainActor func updateSetting(_ setting: Setting) { - updateAppEnv(key: "setting", value: setting.toData()) - } - @MainActor func updateFilter(_ filter: Filter, range: FilterRange) { - let key: String - switch range { - case .search: - key = "searchFilter" - case .global: - key = "globalFilter" - case .watched: - key = "watchedFilter" - } - updateAppEnv(key: key, value: filter.toData()) - } - @MainActor func updateTagTranslator(_ tagTranslator: TagTranslator) { - updateAppEnv(key: "tagTranslator", value: tagTranslator.toData()) - } - @MainActor func updateUser(_ user: User) { - updateAppEnv(key: "user", value: user.toData()) - } - @MainActor func updateHistoryKeywords(_ keywords: [String]) { - updateAppEnv(key: "historyKeywords", value: keywords.toData()) - } - @MainActor func updateQuickSearchWords(_ words: [QuickSearchWord]) { - updateAppEnv(key: "quickSearchWords", value: words.toData()) - } - - // Update User - @MainActor func updateUserProperty(_ commitChanges: @escaping (inout User) -> Void) { - var user = fetchAppEnv().user - commitChanges(&user) - updateUser(user) - } - @MainActor func updateGreeting(_ greeting: Greeting) { - updateUserProperty { user in - user.greeting = greeting - } - } - @MainActor func updateGalleryFunds(galleryPoints: String, credits: String) { - updateUserProperty { user in - user.credits = credits - user.galleryPoints = galleryPoints - } - } -} - -// MARK: API -enum DatabaseClientKey: DependencyKey { - static let liveValue = DatabaseClient.live - static let previewValue = DatabaseClient.noop - static let testValue = DatabaseClient.unimplemented -} - -extension DependencyValues { - var databaseClient: DatabaseClient { - get { self[DatabaseClientKey.self] } - set { self[DatabaseClientKey.self] = newValue } - } -} - -// MARK: Test -extension DatabaseClient { - static let noop: Self = .init( - prepareDatabase: { .success(()) }, - dropDatabase: { .success(()) }, - saveContext: {}, - materializedObjects: { _, _ in .init() } - ) - - static func placeholder() -> Result { fatalError() } - - static let unimplemented: Self = .init( - prepareDatabase: IssueReporting.unimplemented(placeholder: placeholder()), - dropDatabase: IssueReporting.unimplemented(placeholder: placeholder()), - saveContext: IssueReporting.unimplemented(placeholder: placeholder()), - materializedObjects: IssueReporting.unimplemented(placeholder: placeholder()) - ) -} diff --git a/EhPanda/App/Tools/Clients/DeviceClient.swift b/EhPanda/App/Tools/Clients/DeviceClient.swift deleted file mode 100644 index 27bc12657..000000000 --- a/EhPanda/App/Tools/Clients/DeviceClient.swift +++ /dev/null @@ -1,64 +0,0 @@ -// -// DeviceClient.swift -// EhPanda -// - -import SwiftUI -import Dependencies - -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 - } - ) -} - -// MARK: API -enum DeviceClientKey: DependencyKey { - static let liveValue = DeviceClient.live - static let previewValue = DeviceClient.noop - static let testValue = DeviceClient.unimplemented -} - -extension DependencyValues { - var deviceClient: DeviceClient { - get { self[DeviceClientKey.self] } - set { self[DeviceClientKey.self] = newValue } - } -} - -// MARK: Test -extension DeviceClient { - static let noop: Self = .init( - isPad: { false }, - absWindowW: { .zero }, - absWindowH: { .zero }, - touchPoint: { .zero } - ) - - static func placeholder() -> Result { fatalError() } - - static let unimplemented: Self = .init( - isPad: IssueReporting.unimplemented(placeholder: placeholder()), - absWindowW: IssueReporting.unimplemented(placeholder: placeholder()), - absWindowH: IssueReporting.unimplemented(placeholder: placeholder()), - touchPoint: IssueReporting.unimplemented(placeholder: placeholder()) - ) -} diff --git a/EhPanda/App/Tools/Clients/FileClient.swift b/EhPanda/App/Tools/Clients/FileClient.swift deleted file mode 100644 index f82d9b315..000000000 --- a/EhPanda/App/Tools/Clients/FileClient.swift +++ /dev/null @@ -1,124 +0,0 @@ -// -// FileClient.swift -// EhPanda -// - -import Combine -import Foundation -import ComposableArchitecture - -struct FileClient { - let createFile: (String, Data?) -> Bool - let fetchLogs: () async -> Result<[Log], AppError> - let deleteLog: (String) async -> Result - let importTagTranslator: (URL) async -> Result -} - -extension FileClient { - static let live: Self = .init( - createFile: { path, data in - FileManager.default.createFile(atPath: path, contents: data, attributes: nil) - }, - fetchLogs: { - await withCheckedContinuation { continuation in - 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 { - continuation.resume(returning: .failure(.notFound)) - return - } - - let logs: [Log] = fileNames.compactMap { name in - guard let fileURL = FileUtil.logsDirectoryURL?.appendingPathComponent(name), - let content = try? String(contentsOf: fileURL, encoding: .utf8) - else { return nil } - - return Log( - fileName: name, contents: content - .components(separatedBy: "\n") - .filter({ !$0.isEmpty }) - ) - } - .sorted() - continuation.resume(returning: .success(logs)) - } - }, - deleteLog: { fileName in - await withCheckedContinuation { continuation in - guard let fileURL = FileUtil.logsDirectoryURL?.appendingPathComponent(fileName) - else { - continuation.resume(returning: .failure(.notFound)) - return - } - - try? FileManager.default.removeItem(at: fileURL) - - if FileManager.default.fileExists(atPath: fileURL.path) { - continuation.resume(returning: .failure(.unknown)) - } - continuation.resume(returning: .success(fileName)) - } - }, - importTagTranslator: { url in - await withCheckedContinuation { continuation in - guard let data = try? Data(contentsOf: url), - let translations = try? JSONDecoder().decode( - EhTagTranslationDatabaseResponse.self, from: data - ).tagTranslations - else { - continuation.resume(returning: .failure(.parseFailed)) - return - } - guard !translations.isEmpty else { - continuation.resume(returning: .failure(.parseFailed)) - return - } - continuation.resume(returning: .success(.init(hasCustomTranslations: true, translations: translations))) - } - } - ) - - 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 - } - } -} - -// MARK: API -enum FileClientKey: DependencyKey { - static let liveValue = FileClient.live - static let previewValue = FileClient.noop - static let testValue = FileClient.unimplemented -} - -extension DependencyValues { - var fileClient: FileClient { - get { self[FileClientKey.self] } - set { self[FileClientKey.self] = newValue } - } -} - -// MARK: Test -extension FileClient { - static let noop: Self = .init( - createFile: { _, _ in false }, - fetchLogs: { .success([]) }, - deleteLog: { _ in .success("") }, - importTagTranslator: { _ in .success(.init()) } - ) - - static func placeholder() -> Result { fatalError() } - - static let unimplemented: Self = .init( - createFile: IssueReporting.unimplemented(placeholder: placeholder()), - fetchLogs: IssueReporting.unimplemented(placeholder: placeholder()), - deleteLog: IssueReporting.unimplemented(placeholder: placeholder()), - importTagTranslator: IssueReporting.unimplemented(placeholder: placeholder()) - ) -} diff --git a/EhPanda/App/Tools/Clients/ImageClient.swift b/EhPanda/App/Tools/Clients/ImageClient.swift deleted file mode 100644 index 1f14343b0..000000000 --- a/EhPanda/App/Tools/Clients/ImageClient.swift +++ /dev/null @@ -1,125 +0,0 @@ -// -// ImageClient.swift -// EhPanda -// - -import Photos -import SwiftUI -import Combine -import Kingfisher -import ComposableArchitecture - -struct ImageClient { - let prefetchImages: ([URL]) -> Void - let saveImageToPhotoLibrary: (UIImage, Bool) async -> Bool - let downloadImage: (URL) async -> Result - let retrieveImage: (String) async -> Result -} - -extension ImageClient { - static let live: Self = .init( - prefetchImages: { urls in - ImagePrefetcher(urls: urls).start() - }, - saveImageToPhotoLibrary: { (image, isAnimated) in - await withCheckedContinuation { continuation in - if let data = image.kf.data(format: isAnimated ? .GIF : .unknown) { - PHPhotoLibrary.shared().performChanges { - let request = PHAssetCreationRequest.forAsset() - request.addResource(with: .photo, data: data, options: nil) - } completionHandler: { (isSuccess, _) in - continuation.resume(returning: isSuccess) - } - } else { - continuation.resume(returning: false) - } - } - }, - downloadImage: { url in - await withCheckedContinuation { continuation in - KingfisherManager.shared.downloader.downloadImage(with: url, options: nil) { result in - switch result { - case .success(let result): - continuation.resume(returning: .success(result.image)) - case .failure(let error): - continuation.resume(returning: .failure(error)) - } - } - } - }, - retrieveImage: { key in - await withCheckedContinuation { continuation in - KingfisherManager.shared.cache.retrieveImage(forKey: key) { result in - switch result { - case .success(let result): - if let image = result.image { - continuation.resume(returning: .success(image)) - } else { - continuation.resume(returning: .failure(AppError.notFound)) - } - case .failure(let error): - continuation.resume(returning: .failure(error)) - } - } - } - } - ) - - func fetchImage(url: URL) async -> Result { - if KingfisherManager.shared.cache.isCached(forKey: url.absoluteString) { - return await retrieveImage(url.absoluteString) - } else { - return await 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) - } -} - -// MARK: API -enum ImageClientKey: DependencyKey { - static let liveValue = ImageClient.live - static let previewValue = ImageClient.noop - static let testValue = ImageClient.unimplemented -} - -extension DependencyValues { - var imageClient: ImageClient { - get { self[ImageClientKey.self] } - set { self[ImageClientKey.self] = newValue } - } -} - -// MARK: Test -extension ImageClient { - static let noop: Self = .init( - prefetchImages: { _ in }, - saveImageToPhotoLibrary: { _, _ in false }, - downloadImage: { _ in .success(UIImage()) }, - retrieveImage: { _ in .success(UIImage()) } - ) - - static func placeholder() -> Result { fatalError() } - - static let unimplemented: Self = .init( - prefetchImages: IssueReporting.unimplemented(placeholder: placeholder()), - saveImageToPhotoLibrary: IssueReporting.unimplemented(placeholder: placeholder()), - downloadImage: IssueReporting.unimplemented(placeholder: placeholder()), - retrieveImage: IssueReporting.unimplemented(placeholder: placeholder()) - ) -} diff --git a/EhPanda/App/Tools/Clients/LibraryClient.swift b/EhPanda/App/Tools/Clients/LibraryClient.swift deleted file mode 100644 index 85ccc1364..000000000 --- a/EhPanda/App/Tools/Clients/LibraryClient.swift +++ /dev/null @@ -1,112 +0,0 @@ -// -// LibraryClient.swift -// EhPanda -// - -import SwiftUI -import Combine -import Foundation -import Kingfisher -import SwiftyBeaver -import UIImageColors -import ComposableArchitecture - -struct LibraryClient { - let initializeLogger: () -> Void - let initializeWebImage: () -> Void - let clearWebImageDiskCache: () -> Void - let analyzeImageColors: (UIImage) async -> UIImageColors? - let calculateWebImageDiskCacheSize: () async -> UInt? -} - -extension LibraryClient { - static let live: Self = .init( - initializeLogger: { - // MARK: SwiftyBeaver - let file = FileDestination() - let console = ConsoleDestination() - let format = [ - "$Dyyyy-MM-dd HH:mm:ss.SSS$d", - "$C$L$c $N.$F:$l - $M $X" - ].joined(separator: " ") - - file.format = format - file.logFileAmount = 10 - file.calendar = Calendar(identifier: .gregorian) - file.logFileURL = FileUtil.logsDirectoryURL? - .appendingPathComponent(Defaults.FilePath.ehpandaLog) - - console.format = format - console.calendar = Calendar(identifier: .gregorian) - console.asynchronously = false - console.levelColor.verbose = "😪" - console.levelColor.warning = "⚠️" - console.levelColor.error = "‼️" - console.levelColor.debug = "🐛" - console.levelColor.info = "📖" - - SwiftyBeaver.addDestination(file) - #if DEBUG - SwiftyBeaver.addDestination(console) - #endif - }, - initializeWebImage: { - let config = KingfisherManager.shared.downloader.sessionConfiguration - config.httpCookieStorage = HTTPCookieStorage.shared - KingfisherManager.shared.downloader.sessionConfiguration = config - }, - clearWebImageDiskCache: { - KingfisherManager.shared.cache.clearDiskCache() - }, - analyzeImageColors: { image in - await withCheckedContinuation { continuation in - image.getColors(quality: .lowest) { colors in - continuation.resume(returning: colors) - } - } - }, - calculateWebImageDiskCacheSize: { - await withCheckedContinuation { continuation in - KingfisherManager.shared.cache.calculateDiskStorageSize { - continuation.resume(returning: try? $0.get()) - } - } - } - ) -} - -// MARK: API -enum LibraryClientKey: DependencyKey { - static let liveValue = LibraryClient.live - static let previewValue = LibraryClient.noop - static let testValue = LibraryClient.unimplemented -} - -extension DependencyValues { - var libraryClient: LibraryClient { - get { self[LibraryClientKey.self] } - set { self[LibraryClientKey.self] = newValue } - } -} - -// MARK: Test -extension LibraryClient { - static let noop: Self = .init( - initializeLogger: {}, - initializeWebImage: {}, - clearWebImageDiskCache: {}, - analyzeImageColors: { _ in .none }, - calculateWebImageDiskCacheSize: { .none } - ) - - static func placeholder() -> Result { fatalError() } - - static let unimplemented: Self = .init( - initializeLogger: IssueReporting.unimplemented(placeholder: placeholder()), - initializeWebImage: IssueReporting.unimplemented(placeholder: placeholder()), - clearWebImageDiskCache: IssueReporting.unimplemented(placeholder: placeholder()), - analyzeImageColors: IssueReporting.unimplemented(placeholder: placeholder()), - calculateWebImageDiskCacheSize: - IssueReporting.unimplemented(placeholder: placeholder()) - ) -} diff --git a/EhPanda/App/Tools/Clients/LoggerClient.swift b/EhPanda/App/Tools/Clients/LoggerClient.swift deleted file mode 100644 index 7b6c87115..000000000 --- a/EhPanda/App/Tools/Clients/LoggerClient.swift +++ /dev/null @@ -1,51 +0,0 @@ -// -// LoggerClient.swift -// EhPanda -// - -import ComposableArchitecture - -struct LoggerClient { - let info: (Any, Any?) -> Void - let error: (Any, Any?) -> Void -} - -extension LoggerClient { - static let live: Self = .init( - info: { message, context in - Logger.info(message, context: context) - }, - error: { message, context in - Logger.error(message, context: context) - } - ) -} - -// MARK: API -enum LoggerClientKey: DependencyKey { - static let liveValue = LoggerClient.live - static let previewValue = LoggerClient.noop - static let testValue = LoggerClient.unimplemented -} - -extension DependencyValues { - var loggerClient: LoggerClient { - get { self[LoggerClientKey.self] } - set { self[LoggerClientKey.self] = newValue } - } -} - -// MARK: Test -extension LoggerClient { - static let noop: Self = .init( - info: { _, _ in }, - error: { _, _ in } - ) - - static func placeholder() -> Result { fatalError() } - - static let unimplemented: Self = .init( - info: IssueReporting.unimplemented(placeholder: placeholder()), - error: IssueReporting.unimplemented(placeholder: placeholder()) - ) -} diff --git a/EhPanda/App/Tools/Clients/UserDefaultsClient.swift b/EhPanda/App/Tools/Clients/UserDefaultsClient.swift deleted file mode 100644 index f886a8b23..000000000 --- a/EhPanda/App/Tools/Clients/UserDefaultsClient.swift +++ /dev/null @@ -1,50 +0,0 @@ -// -// UserDefaultsClient.swift -// EhPanda -// - -import Foundation -import ComposableArchitecture - -struct UserDefaultsClient { - let setValue: (Any, AppUserDefaults) -> Void -} - -extension UserDefaultsClient { - static let live: Self = .init( - setValue: { value, key in - UserDefaults.standard.set(value, forKey: key.rawValue) - } - ) - - func getValue(_ key: AppUserDefaults) -> T? { - UserDefaultsUtil.value(forKey: key) - } -} - -// MARK: API -enum UserDefaultsClientKey: DependencyKey { - static let liveValue = UserDefaultsClient.live - static let previewValue = UserDefaultsClient.noop - static let testValue = UserDefaultsClient.unimplemented -} - -extension DependencyValues { - var userDefaultsClient: UserDefaultsClient { - get { self[UserDefaultsClientKey.self] } - set { self[UserDefaultsClientKey.self] = newValue } - } -} - -// MARK: Test -extension UserDefaultsClient { - static let noop: Self = .init( - setValue: { _, _ in } - ) - - static func placeholder() -> Result { fatalError() } - - static let unimplemented: Self = .init( - setValue: IssueReporting.unimplemented(placeholder: placeholder()) - ) -} diff --git a/EhPanda/App/Tools/Defaults.swift b/EhPanda/App/Tools/Defaults.swift deleted file mode 100644 index e13c56ccd..000000000 --- a/EhPanda/App/Tools/Defaults.swift +++ /dev/null @@ -1,161 +0,0 @@ -// -// Defaults.swift -// EhPanda -// - -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 headerAspect: CGFloat = 8/11 - static let previewAspect: CGFloat = 8/11 - static let contentAspect: CGFloat = 7/10 - static let webtoonMinAspect: CGFloat = 1/4 - static let webtoonIdealAspect: CGFloat = 2/3 - - static let rowW: CGFloat = rowH * rowAspect - static let rowH: CGFloat = 120 - static let headerW: CGFloat = headerH * headerAspect - static let headerH: CGFloat = 150 - static 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 Regex { - static let tagSuggestion: NSRegularExpression? = try? .init(pattern: "(\\S+:\".+?\"|\".+?\"|\\S+:\\S+|\\S+)") - } - struct URL { - static var host: Foundation.URL { AppUtil.galleryHost == .exhentai ? exhentai : ehentai } - static let ehentai: Foundation.URL = .init(string: "https://e-hentai.org/").forceUnwrapped - static let exhentai: Foundation.URL = .init(string: "https://exhentai.org/").forceUnwrapped - static let sexhentai: Foundation.URL = .init(string: "https://s.exhentai.org/").forceUnwrapped - - static let torrentDownload: Foundation.URL = .init(string: "https://ehgt.org/g/t.png").forceUnwrapped - static let torrentDownloadInvalid: Foundation.URL = .init(string: "https://ehgt.org/g/td.png").forceUnwrapped - - static let forum: Foundation.URL = .init(string: "https://forums.e-hentai.org/index.php").forceUnwrapped - static let login = forum.appending(queryItems: [.act: .loginAct, .code: .zeroOne]) - static let webLogin = forum.appending(queryItems: [.act: .loginAct]) - - static var api: Foundation.URL { host.appendingPathComponent("api.php") } - static var myTags: Foundation.URL { host.appendingPathComponent("mytags") } - static let news = ehentai.appendingPathComponent("news.php") - static var uConfig: Foundation.URL { host.appendingPathComponent("uconfig.php") } - static var galleryPopups: Foundation.URL { host.appendingPathComponent("gallerypopups.php") } - static var galleryTorrents: Foundation.URL { host.appendingPathComponent("gallerytorrents.php") } - - static var popular: Foundation.URL { host.appendingPathComponent("popular") } - static var watched: Foundation.URL { host.appendingPathComponent("watched") } - static let toplist = ehentai.appendingPathComponent("toplist.php") - static var favorites: Foundation.URL { host.appendingPathComponent("favorites.php") } - - // GitHub - static let github: Foundation.URL = .init(string: "https://github.com/").forceUnwrapped - static let githubAPI: Foundation.URL = .init(string: "https://api.github.com/repos/").forceUnwrapped - - // swiftlint:disable nesting identifier_name - enum Component { - enum Key: String { - // Functional Pages - case token = "t" - case gid = "gid" - case letterP = "p" - case page = "page" - case from = "from" - case next = "next" - case favcat = "favcat" - case topcat = "tl" - case showUser = "showuser" - case fSearch = "f_search" - - case code = "CODE" - case act = "act" - case showComments = "hc" - case inlineSet = "inline_set" - case skipServerIdentifier = "nl" - - // Search favorites - case sn = "sn" - case st = "st" - case sf = "sf" - - // Filter - case fCats = "f_cats" - case advSearch = "advsearch" - case fSname = "f_sname" - case fStags = "f_stags" - case fSdesc = "f_sdesc" - case fStorr = "f_storr" - case fSto = "f_sto" - case fSdt1 = "f_sdt1" - case fSdt2 = "f_sdt2" - case fSh = "f_sh" - case fSr = "f_sr" - case fSrdd = "f_srdd" - case fSp = "f_sp" - case fSpf = "f_spf" - case fSpt = "f_spt" - case fSfl = "f_sfl" - case fSfu = "f_sfu" - case fSft = "f_sft" - - // Custom - case ehpandaWidth = "ehpandaWidth" - case ehpandaHeight = "ehpandaHeight" - case ehpandaOffset = "ehpandaOffset" - } - enum Value: String { - case one = "1" - case all = "all" - case zeroOne = "01" - case filterOn = "on" - case loginAct = "Login" - case addFavAct = "addfav" - case sortOrderByUpdateTime = "fs_p" - case sortOrderByFavoritedTime = "fs_f" - } - } - // swiftlint:enable nesting identifier_name - } -} diff --git a/EhPanda/App/Tools/Extensions/AlertKit_Extension.swift b/EhPanda/App/Tools/Extensions/AlertKit_Extension.swift deleted file mode 100644 index 16574b855..000000000 --- a/EhPanda/App/Tools/Extensions/AlertKit_Extension.swift +++ /dev/null @@ -1,79 +0,0 @@ -// -// AlertKit_Extension.swift -// EhPanda -// - -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(L10n.Localizable.JumpPageView.Button.confirm) }, - action: jumpAction - ) - ] - ) - .synchronize($isFocused, $focused) - .synchronize($isPresented, $manager.isPresented) - } -} diff --git a/EhPanda/App/Tools/Extensions/Extensions.swift b/EhPanda/App/Tools/Extensions/Extensions.swift deleted file mode 100644 index 00d8275b2..000000000 --- a/EhPanda/App/Tools/Extensions/Extensions.swift +++ /dev/null @@ -1,300 +0,0 @@ -// -// Extensions.swift -// EhPanda -// - -import SwiftUI -import Foundation - -// MARK: Encodable -extension Encodable { - func toData() -> Data? { - try? JSONEncoder().encode(self) - } -} - -// MARK: UIApplication -extension UIApplication { - @MainActor - func endEditing() { - sendAction(#selector(UIResponder.resignFirstResponder), to: nil, from: nil, for: nil) - } -} - -// MARK: Data -extension Data { - func toObject() -> O? { - try? JSONDecoder().decode(O.self, from: self) - } - var utf8InvalidCharactersRipped: Data { - var data = self - data.append(0) - - let str = Array(self).withUnsafeBufferPointer { ptr -> String? in - guard let address = ptr.baseAddress else { return nil } - return String(cString: address) - } - guard let string = str else { return data } - data = string.data(using: .utf8) ?? self - return data - } -} - -// MARK: Float -extension Float { - var halfRounded: Float { - let lowerbound = Int(self) - let upperbound = lowerbound + 1 - let decimal: Float = self - Float(lowerbound) - - if decimal < 0.25 { - return Float(lowerbound) - } else if decimal >= 0.25 && decimal < 0.75 { - return Float(lowerbound) + 0.5 - } else { - return Float(upperbound) - } - } -} - -// MARK: URL -extension URL { - static let mock = Defaults.URL.ehentai - - var isGIF: Bool { - pathExtension == "gif" - } - - func appending(queryItems: [URLQueryItem]) -> URL { - guard !queryItems.isEmpty else { return self } - var components: URLComponents = .init( - url: self, resolvingAgainstBaseURL: false - ) - .forceUnwrapped - if components.queryItems == nil { - components.queryItems = [] - } - components.queryItems?.append(contentsOf: queryItems) - return components.url.forceUnwrapped - } - func appending(queryItems: [String: String]) -> URL { - appending(queryItems: queryItems.map(URLQueryItem.init)) - } - func appending(queryItems: [Defaults.URL.Component.Key: Defaults.URL.Component.Value]) -> URL { - appending(queryItems: queryItems.map({ URLQueryItem(name: $0.rawValue, value: $1.rawValue) })) - } - func appending(queryItems: [Defaults.URL.Component.Key: String]) -> URL { - appending(queryItems: queryItems.map({ URLQueryItem(name: $0.rawValue, value: $1) })) - } - mutating func append(queryItems: [URLQueryItem]) { - self = appending(queryItems: queryItems) - } - mutating func append(queryItems: [String: String]) { - self = appending(queryItems: queryItems) - } - mutating func append(queryItems: [Defaults.URL.Component.Key: Defaults.URL.Component.Value]) { - self = appending(queryItems: queryItems) - } - mutating func append(queryItems: [Defaults.URL.Component.Key: String]) { - self = appending(queryItems: queryItems) - } -} - -// MARK: String -extension String { - var notEmpty: Bool { - !isEmpty - } - var isInteger: Bool { - Int(self) != nil - } - var isValidGID: Bool { - notEmpty && isInteger - } - var localizedKey: LocalizedStringKey { - .init(self) - } - var linkStyled: String { - "[\(self)](\(Defaults.URL.ehentai.absoluteString))" - } - var stringsBesideColon: (String?, String) { - let strings = split(separator: ":").map(String.init) - if strings.count == 2, strings[0].notEmpty { - return (strings[0], strings[1]) - } - return (nil, self) - } - var emojisRipped: String { - unicodeScalars - .filter { !$0.properties.isEmojiPresentation } - .reduce("") { $0 + .init($1) } - } - - var urlEncoded: String { - addingPercentEncoding( - withAllowedCharacters: .urlQueryAllowed - ) ?? "" - } - - var firstLetterCapitalized: 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 - ) -> String { - var result = self - - while let rangeA = result.range(of: subString1), - let rangeB = result.range(of: subString2), - rangeA.lowerBound < rangeB.upperBound - { - let unwanted = result[rangeA.lowerBound.. Bool { - range(of: other, options: .caseInsensitive) != nil - } - func caseInsensitiveEqualsTo(_ other: String) -> Bool { - caseInsensitiveContains(other) && count == other.count - } -} - -// MARK: UIImage -extension UIImage { - func cropping(to rect: CGRect) -> UIImage? { - let scaledRect = CGRect( - x: rect.origin.x * scale, - y: rect.origin.y * scale, - width: rect.size.width * scale, - height: rect.size.height * scale - ) - - guard let cgImage = cgImage?.cropping(to: scaledRect) else { return nil } - return UIImage(cgImage: cgImage, scale: scale, orientation: imageOrientation) - } - - func cropping(size: CGSize, offset: CGSize) -> UIImage? { - let origin = CGPoint(x: offset.width, y: offset.height) - let rect = CGRect(origin: origin, size: size) - return cropping(to: rect) - } - - func withRoundedCorners(radius: CGFloat) -> UIImage? { - let maxRadius = min(size.width, size.height) / 2 - - let cornerRadius: CGFloat - if radius > 0 && radius <= maxRadius { - cornerRadius = radius - } else { - cornerRadius = maxRadius - } - - UIGraphicsBeginImageContextWithOptions(size, false, scale) - - let rect = CGRect(origin: .zero, size: size) - UIBezierPath(roundedRect: rect, cornerRadius: cornerRadius).addClip() - draw(in: rect) - - let image = UIGraphicsGetImageFromCurrentImageContext() - UIGraphicsEndImageContext() - return image - } -} - -// MARK: Optional -extension Optional { - var forceUnwrapped: Wrapped! { - if let value = self { - return value - } - Logger.error( - "Failed in force unwrapping...", - context: ["type": Wrapped.self] - ) - return nil - } -} - -// MARK: Color -extension Color { - init(hex: String) { - let hex = hex.trimmingCharacters( - in: CharacterSet.alphanumerics.inverted - ) - var int: UInt64 = 0 - Scanner(string: hex).scanHexInt64(&int) - - let alpha, red, green, blue: UInt64 - switch hex.count { - case 3: - (alpha, red, green, blue) = (255, (int >> 8) * 17, (int >> 4 & 0xF) * 17, (int & 0xF) * 17) - case 6: - (alpha, red, green, blue) = (255, int >> 16, int >> 8 & 0xFF, int & 0xFF) - case 8: - (alpha, red, green, blue) = (int >> 24, int >> 16 & 0xFF, int >> 8 & 0xFF, int & 0xFF) - default: - (alpha, red, green, blue) = (1, 1, 1, 0) - } - - self.init( - .sRGB, red: Double(red) / 255.0, green: Double(green) / 255.0, - blue: Double(blue) / 255.0, opacity: Double(alpha) / 255.0 - ) - } -} - -// MARK: Array -extension Array { - func removeDuplicates(by predicate: (Element, Element) -> Bool) -> Self { - var result = [Element]() - for value in self where result.filter({ predicate($0, value) }).isEmpty { - result.append(value) - } - return result - } - func removeDuplicates(by keyPath: KeyPath) -> Self { - 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) }) - } -} - -// MARK: TimeInterval -extension TimeInterval { - static let oneYear: Self = .init(60 * 60 * 24 * 365) - static let oneWeek: Self = .init(60 * 60 * 24 * 7) -} diff --git a/EhPanda/App/Tools/Extensions/Reducer_Extension.swift b/EhPanda/App/Tools/Extensions/Reducer_Extension.swift deleted file mode 100644 index c1ea651a2..000000000 --- a/EhPanda/App/Tools/Extensions/Reducer_Extension.swift +++ /dev/null @@ -1,72 +0,0 @@ -// -// Reducer_Extension.swift -// EhPanda -// - -import SwiftUI -import ComposableArchitecture - -extension Reducer { - func haptics( - unwrapping enum: @escaping (State) -> Enum?, - case caseKeyPath: CaseKeyPath, - hapticsClient: HapticsClient, - style: UIImpactFeedbackGenerator.FeedbackStyle = .light - ) -> some Reducer { - onBecomeNonNil(unwrapping: `enum`, case: caseKeyPath) { _, _ in - .run(operation: { _ in hapticsClient.generateFeedback(style) }) - } - } - - private func onBecomeNonNil( - unwrapping enum: @escaping (State) -> Enum?, - case caseKeyPath: CaseKeyPath, - perform additionalEffects: @escaping (inout State, Action) -> Effect - ) -> some Reducer { - Reduce { state, action in - let previousCase = Binding.constant(`enum`(state)).case(caseKeyPath).wrappedValue - let effects = reduce(into: &state, action: action) - let currentCase = Binding.constant(`enum`(state)).case(caseKeyPath).wrappedValue - - return previousCase == nil && currentCase != nil - ? .merge(effects, additionalEffects(&state, action)) - : effects - } - } -} - -// MARK: Recurse -struct RecurseReducer: Reducer -where State == Base.State, Action == Base.Action { - let base: (Reduce) -> Base - - public init(@ReducerBuilder base: @escaping (Reduce) -> Base) { - self.base = base - } - - public var body: some Reducer { - var `self`: Reduce! - self = Reduce { state, action in - base(self).reduce(into: &state, action: action) - } - return self - } -} - -// MARK: Logging -struct LoggingReducer: Reducer -where State == Base.State, Action == Base.Action { - let base: Base - - init(@ReducerBuilder base: () -> Base) { - self.base = base() - } - - @ReducerBuilder - var body: some Reducer { - Reduce { state, action in - Logger.info(action) - return base.reduce(into: &state, action: action) - } - } -} diff --git a/EhPanda/App/Tools/Extensions/SwiftUINavigation_Extension.swift b/EhPanda/App/Tools/Extensions/SwiftUINavigation_Extension.swift deleted file mode 100644 index d9eb463c0..000000000 --- a/EhPanda/App/Tools/Extensions/SwiftUINavigation_Extension.swift +++ /dev/null @@ -1,116 +0,0 @@ -// -// SwiftUINavigation_Extension.swift -// EhPanda -// - -import SwiftUI -import TTProgressHUD -import SwiftUINavigation - -extension NavigationLink { - init( - _ title: S, - unwrapping value: Binding, - @ViewBuilder destination: @escaping (Binding) -> WrappedDestination - ) where Destination == WrappedDestination?, Label == Text { - self.init( - title, - destination: Binding(unwrapping: value).map(destination), - isActive: .init(value) - ) - } - init( - unwrapping enum: Binding, - case caseKeyPath: CaseKeyPath, - @ViewBuilder destination: @escaping (Binding) -> WrappedDestination - ) where Destination == WrappedDestination?, Label == Text { - self.init( - "", unwrapping: `enum`.case(caseKeyPath), - destination: destination - ) - } -} - -extension View { - func confirmationDialog( - message: String, - unwrapping enum: Binding, - case caseKeyPath: CaseKeyPath, - @ViewBuilder actions: @escaping (Case) -> A - ) -> some View { - self.confirmationDialog( - item: `enum`.case(caseKeyPath), - titleVisibility: .hidden, - title: { _ in Text("") }, - actions: actions, - message: { _ in Text(message) } - ) - } - func confirmationDialog( - message: String, - unwrapping enum: Binding, - case caseKeyPath: CaseKeyPath, - matching case: Case, - @ViewBuilder actions: @escaping (Case) -> A - ) -> some View { - self.confirmationDialog( - item: { - let unwrapping = `enum`.case(caseKeyPath) - let isMatched = `case` == unwrapping.wrappedValue - return isMatched ? unwrapping : .constant(nil) - }(), - titleVisibility: .hidden, - title: { _ in Text("") }, - actions: actions, - message: { _ in Text(message) } - ) - } - - func sheet( - unwrapping enum: Binding, - case caseKeyPath: CaseKeyPath, - @ViewBuilder content: @escaping (Case) -> Content - ) -> some View { - self.sheet( - isPresented: .constant(`enum`.case(caseKeyPath).wrappedValue != nil), - content: { `enum`.case(caseKeyPath).wrappedValue.map(content) } - ) - } - - func progressHUD( - config: TTProgressHUDConfig, - unwrapping enum: Binding, - case caseKeyPath: CaseKeyPath - ) -> some View { - ZStack { - self - TTProgressHUD( - `enum`.case(caseKeyPath).isRemovedDuplicatesPresent(), - config: config - ) - } - } -} - -extension Binding { - func `case`(_ caseKeyPath: CaseKeyPath) -> Binding where Value == Enum? { - .init( - get: { self.wrappedValue.flatMap(AnyCasePath(caseKeyPath).extract(from:)) }, - set: { newValue, transaction in - self.transaction(transaction).wrappedValue = newValue.map(AnyCasePath(caseKeyPath).embed) - } - ) - } - - func isRemovedDuplicatesPresent() -> Binding where Value == Wrapped? { - .init( - get: { wrappedValue != nil }, - set: { isPresent, transaction in - guard self.transaction(transaction).wrappedValue != nil else { return } - if !isPresent { - self.transaction(transaction).wrappedValue = nil - } - } - ) - } -} diff --git a/EhPanda/App/Tools/Extensions/TTProgressHUD_Extension.swift b/EhPanda/App/Tools/Extensions/TTProgressHUD_Extension.swift deleted file mode 100644 index 80c0910f7..000000000 --- a/EhPanda/App/Tools/Extensions/TTProgressHUD_Extension.swift +++ /dev/null @@ -1,27 +0,0 @@ -// -// TTProgressHUD_Extension.swift -// EhPanda -// - -import TTProgressHUD - -extension TTProgressHUDConfig { - static let error: Self = error(caption: nil) - static let loading: Self = loading(title: L10n.Localizable.Hud.Title.loading) - static let communicating: Self = loading(title: L10n.Localizable.Hud.Title.communicating) - static let savedToPhotoLibrary: Self = success(caption: L10n.Localizable.Hud.Caption.savedToPhotoLibrary) - static let copiedToClipboardSucceeded: Self = success(caption: L10n.Localizable.Hud.Caption.copiedToClipboard) - - static func loading(title: String? = nil) -> Self { - .init(type: .loading, title: title) - } - static func error(caption: String? = nil) -> Self { - autoHide(type: .error, title: L10n.Localizable.Hud.Title.error, caption: caption) - } - static func success(caption: String? = nil) -> Self { - autoHide(type: .success, title: L10n.Localizable.Hud.Title.success, 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/Tools/Parser.swift b/EhPanda/App/Tools/Parser.swift deleted file mode 100644 index 7782db410..000000000 --- a/EhPanda/App/Tools/Parser.swift +++ /dev/null @@ -1,1828 +0,0 @@ -// -// Parser.swift -// EhPanda -// - -import Kanna -import OpenCC -import SwiftUI - -struct Parser { - // MARK: List - static func parseGalleries(doc: HTMLDocument) throws -> [Gallery] { - func parseDisplayMode(doc: HTMLDocument) throws -> String { - guard let containerNode = doc.at_xpath("//div [@id='dms']") ?? doc.at_xpath("//div [@class='searchnav']") - else { throw AppError.parseFailed } - - var dmsNode: XMLElement? - for select in containerNode.xpath("//select") where select["onchange"]?.contains("inline_set=dm_") == true { - dmsNode = select - break - } - guard let dmsNode else { throw AppError.parseFailed } - - for option in dmsNode.xpath("//option") where option["selected"] == "selected" { - if let displayMode = option.text { - return displayMode - } - } - throw AppError.parseFailed - } - func parseThumbnailPanel(node: XMLElement) throws -> (URL, Category, Float, Date, Int, String?) { - var tmpCoverURL: URL? - var tmpCategory: Category? - var tmpPublishedDate: Date? - var tmpPageCount: Int? - var uploader: String? - - for div in node.xpath("//div") { - if let imgNode = div.at_css("img"), - let urlString = imgNode["data-src"] ?? imgNode["src"], let url = URL(string: urlString), - [Defaults.URL.torrentDownload, Defaults.URL.torrentDownloadInvalid].map(\.absoluteString) - .contains(where: { $0 == urlString }) == false, imgNode["alt"] != "T" - { - tmpCoverURL = url - } - if let rawValue = div.text, let category = Category(rawValue: rawValue) { - tmpCategory = category - } - if let onClick = div["onclick"], !onClick.isEmpty, let dateString = div.text, - let date = try? parseDate(time: dateString, format: Defaults.DateFormat.publish) - { - tmpPublishedDate = date - } - if let components = div.text?.split(separator: " "), components.count == 2, - ["page", "pages"].contains(components[1]), let pageCount = Int(components[0]) - { - tmpPageCount = pageCount - } - // Extended display mode uses this - if let aLink = div.at_xpath("//a"), aLink["href"]?.contains("uploader") == true { - uploader = aLink.text - } else if div.text == "(Disowned)" { - uploader = div.text - } - } - - guard let coverURL = tmpCoverURL, - let category = tmpCategory, - let (rating, _, _) = try? parseRating(node: node), - let publishedDate = tmpPublishedDate, - let pageCount = tmpPageCount - else { throw AppError.parseFailed } - return (coverURL, category, rating, publishedDate, pageCount, uploader) - } - func parseGalleryTitle(node: XMLElement) throws -> (String, URL) { - func findTitle(glink: XMLElement) throws -> (String, URL) { - guard let glinkParentNode = glink.parent, - let glinkGrandParentNode = glinkParentNode.parent, - let title = glink.text, - let urlString = glinkParentNode["href"] ?? glinkGrandParentNode["href"], - let url = URL(string: urlString), - url.pathComponents.count >= 4 - else { throw AppError.parseFailed } - return (title, url) - } - - for glink in node.xpath("//div") where glink.className?.contains("glink") == true { - if let result = try? findTitle(glink: glink) { - return result - } - } - for glink in node.xpath("//span") where glink.className?.contains("glink") == true { - if let result = try? findTitle(glink: glink) { - return result - } - } - throw AppError.parseFailed - } - func parseGalleryTags(node: XMLElement?) throws -> [GalleryTag] { - guard let node = node else { throw AppError.parseFailed } - var tags = [GalleryTag]() - for tagLink in node.xpath("//div") - where ["gt", "gtl"].contains(tagLink.className) && tagLink["title"]?.isEmpty == false { - guard let titleComponents = tagLink["title"]?.split(separator: ":"), - titleComponents.count == 2 - else { continue } - var contentTextColor: Color? - var contentBackgroundColor: Color? - let namespace = String(titleComponents[0]) - let contentText = String(titleComponents[1]) - if let style = tagLink["style"], let rangeB = style.range(of: ",#"), - let rangeA = style.range(of: "background:radial-gradient(#") - { - let hex = String(style[rangeA.upperBound.. 151 { - contentTextColor = .secondary - } else { - contentTextColor = .white - } - } - } - if let index = tags.firstIndex(where: { $0.rawNamespace == namespace }) { - let contents = tags[index].contents - let galleryTagContent = GalleryTag.Content( - rawNamespace: namespace, text: contentText, - isVotedUp: false, isVotedDown: false, - textColor: contentTextColor, - backgroundColor: contentBackgroundColor - ) - let newContents = contents + [galleryTagContent] - tags[index] = .init(rawNamespace: namespace, contents: newContents) - } else { - let galleryTagContent = GalleryTag.Content( - rawNamespace: namespace, text: contentText, - isVotedUp: false, isVotedDown: false, - textColor: contentTextColor, - backgroundColor: contentBackgroundColor - ) - tags.append(.init(rawNamespace: namespace, contents: [galleryTagContent])) - } - } - return tags - } - func parseUploader(node: XMLElement) throws -> String { - var tmpUploader: String? - for link in node.xpath("//td") where link.className?.contains("glhide") == true { - for divLink in link.xpath("//div") - where ["page", "pages"].contains(where: { divLink.text?.contains($0) != false }) == false { - if let aLink = divLink.at_xpath("//a"), - aLink["href"]?.contains("uploader") == true, - let aText = aLink.text - { - tmpUploader = aText - } else if divLink.text == "(Disowned)" { - tmpUploader = divLink.text - } - } - } - guard let uploader = tmpUploader else { throw AppError.parseFailed } - return uploader - } - - // MARK: Galleries (Minimal) - func parseMinimalModeGalleries(doc: HTMLDocument, parsesTags: Bool) throws -> [Gallery] { - var galleries = [Gallery]() - for link in doc.xpath("//tr") { - let gltmNode = link.at_xpath("//div [@class='gltm']") - let tags = (try? parseGalleryTags(node: gltmNode)) ?? [] - guard let gl2mNode = link.at_xpath("//td [@class='gl2m']"), - let gl3mNode = link.at_xpath("//td [@class='gl3m glname']"), - let (coverURL, category, rating, publishedDate, pageCount, _) = - try? parseThumbnailPanel(node: gl2mNode), - let (galleryTitle, galleryURL) = try? parseGalleryTitle(node: gl3mNode) - else { continue } - galleries.append( - .init( - gid: galleryURL.pathComponents[2], - token: galleryURL.pathComponents[3], - title: galleryTitle, - rating: rating, - tags: parsesTags ? tags : [], - category: category, - uploader: try? parseUploader(node: link), - pageCount: pageCount, - postedDate: publishedDate, - coverURL: coverURL, - galleryURL: galleryURL - ) - ) - } - return galleries - } - // MARK: Galleries (Compact) - func parseCompactModeGalleries(doc: HTMLDocument) throws -> [Gallery] { - var galleries = [Gallery]() - for link in doc.xpath("//tr") { - guard let gl2cNode = link.at_xpath("//td [@class='gl2c']"), - let gl3cNode = link.at_xpath("//td [@class='gl3c glname']"), - let (coverURL, category, rating, publishedDate, pageCount, _) = - try? parseThumbnailPanel(node: gl2cNode), - let (galleryTitle, galleryURL) = try? parseGalleryTitle(node: gl3cNode) - else { continue } - galleries.append( - .init( - gid: galleryURL.pathComponents[2], - token: galleryURL.pathComponents[3], - title: galleryTitle, - rating: rating, - tags: (try? parseGalleryTags(node: gl3cNode)) ?? [], - category: category, - uploader: try? parseUploader(node: link), - pageCount: pageCount, - postedDate: publishedDate, - coverURL: coverURL, - galleryURL: galleryURL - ) - ) - } - - return galleries - } - // MARK: Galleries (Extended) - func parseExtendedModeGalleries(doc: HTMLDocument) throws -> [Gallery] { - var galleries = [Gallery]() - for link in doc.xpath("//tr") { - guard let gl3eSiblingNode = link.at_xpath("//div [@class='gl3e']")?.nextSibling, - let (coverURL, category, rating, publishedDate, pageCount, uploader) = - try? parseThumbnailPanel(node: link), - let (galleryTitle, galleryURL) = try? parseGalleryTitle(node: gl3eSiblingNode) - else { continue } - galleries.append( - .init( - gid: galleryURL.pathComponents[2], - token: galleryURL.pathComponents[3], - title: galleryTitle, - rating: rating, - tags: (try? parseGalleryTags(node: gl3eSiblingNode)) ?? [], - category: category, - uploader: uploader, - pageCount: pageCount, - postedDate: publishedDate, - coverURL: coverURL, - galleryURL: galleryURL - ) - ) - } - return galleries - } - // MARK: Galleries (Thumbnail) - func parseThumbnailModeGalleries(doc: HTMLDocument) throws -> [Gallery] { - var galleries = [Gallery]() - for link in doc.xpath("//div [@class='gl1t']") { - let gl6tNode = link.at_xpath("//div [@class='gl6t']") - guard let (coverURL, category, rating, publishedDate, pageCount, _) = - try? parseThumbnailPanel(node: link), - let (galleryTitle, galleryURL) = try? parseGalleryTitle(node: link) - else { continue } - galleries.append( - .init( - gid: galleryURL.pathComponents[2], - token: galleryURL.pathComponents[3], - title: galleryTitle, - rating: rating, - tags: (try? parseGalleryTags(node: gl6tNode)) ?? [], - category: category, - pageCount: pageCount, - postedDate: publishedDate, - coverURL: coverURL, - galleryURL: galleryURL - ) - ) - } - return galleries - } - - let galleries: [Gallery] - switch try? parseDisplayMode(doc: doc) { - case "Minimal": - galleries = (try? parseMinimalModeGalleries(doc: doc, parsesTags: false)) ?? [] - case "Minimal+": - galleries = (try? parseMinimalModeGalleries(doc: doc, parsesTags: true)) ?? [] - case "Compact": - galleries = (try? parseCompactModeGalleries(doc: doc)) ?? [] - case "Extended": - galleries = (try? parseExtendedModeGalleries(doc: doc)) ?? [] - case "Thumbnail": - galleries = (try? parseThumbnailModeGalleries(doc: doc)) ?? [] - default: - // Toplists doesn't have a display mode selector and it's compact mode - galleries = (try? parseCompactModeGalleries(doc: doc)) ?? [] - } - - if galleries.isEmpty, let banInterval = parseBanInterval(doc: doc) { - throw AppError.ipBanned(banInterval) - } - return galleries - } - - // MARK: Detail - static func parseGalleryURL(doc: HTMLDocument) throws -> URL { - guard let galleryURLString = doc.at_xpath("//div [@class='sb']")?.at_xpath("//a")?["href"], - let galleryURL = URL(string: galleryURLString) else { throw AppError.parseFailed } - return galleryURL - } - static func parseGalleryDetail(doc: HTMLDocument, gid: String) throws -> (GalleryDetail, GalleryState) { - func parsePreviewConfig(doc: HTMLDocument) throws -> PreviewConfig { - guard let previewMode = try? parsePreviewMode(doc: doc), - let gpcText = doc.at_xpath("//p [@class='gpc']")?.text, - let rangeA = gpcText.range(of: "Showing 1 - "), - let rangeB = gpcText.range(of: " of "), - let singlePageCount = Int(gpcText[rangeA.upperBound.. URL { - guard let coverHTML = node?.at_xpath("//div [@id='gd1']")?.innerHTML, - let rangeA = coverHTML.range(of: "url("), let rangeB = coverHTML.range(of: ")"), - let url = URL(string: .init(coverHTML[rangeA.upperBound.. [GalleryTag] { - var tags = [GalleryTag]() - for link in node.xpath("//tr") { - guard let tcText = link.at_xpath("//td [@class='tc']")?.text else { continue } - let namespace = String(tcText.dropLast()) - var contents = [GalleryTag.Content]() - for divLink in link.xpath("//div") { - guard var text = divLink.text, let aClass = divLink.at_xpath("//a")?.className else { continue } - if let range = text.range(of: " | ") { - text = .init(text[.. (URL?, Int) { - guard let node = node else { throw AppError.parseFailed } - - var archiveURL: URL? - for g2gspLink in node.xpath("//p [@class='g2 gsp']") { - if archiveURL == nil { - archiveURL = try? parseArchiveURL(node: g2gspLink) - } else { - break - } - } - - var tmpTorrentCount: Int? - for g2Link in node.xpath("//p [@class='g2']") { - if let aText = g2Link.at_xpath("//a")?.text, - let rangeA = aText.range(of: "Torrent Download ("), - let rangeB = aText.range(of: ")") - { - tmpTorrentCount = Int(aText[rangeA.upperBound.. [String] { - guard let object = node?.xpath("//tr") - else { throw AppError.parseFailed } - - var infoPanel = Array( - repeating: "", - count: 8 - ) - for gddLink in object { - guard let gdt1Text = gddLink.at_xpath("//td [@class='gdt1']")?.text, - let gdt2Text = gddLink.at_xpath("//td [@class='gdt2']")?.text - else { continue } - let aHref = gddLink.at_xpath("//td [@class='gdt2']")?.at_xpath("//a")?["href"] - - if gdt1Text.contains("Posted") { - infoPanel[0] = gdt2Text - } - if gdt1Text.contains("Parent") { - infoPanel[1] = aHref ?? "None" - } - if gdt1Text.contains("Visible") { - infoPanel[2] = gdt2Text - } - if gdt1Text.contains("Language") { - let words = gdt2Text.split(separator: " ") - if !words.isEmpty { - infoPanel[3] = words[0] - .trimmingCharacters(in: .whitespaces) - } - } - if gdt1Text.contains("File Size") { - infoPanel[4] = gdt2Text - .replacingOccurrences(of: " KiB", with: "") - .replacingOccurrences(of: " MiB", with: "") - .replacingOccurrences(of: " GiB", with: "") - - if gdt2Text.contains("KiB") { infoPanel[5] = "KiB" } - if gdt2Text.contains("MiB") { infoPanel[5] = "MiB" } - if gdt2Text.contains("GiB") { infoPanel[5] = "GiB" } - } - if gdt1Text.contains("Length") { - infoPanel[6] = gdt2Text.replacingOccurrences(of: " pages", with: "") - } - if gdt1Text.contains("Favorited") { - infoPanel[7] = gdt2Text - .replacingOccurrences(of: " times", with: "") - .replacingOccurrences(of: "Never", with: "0") - .replacingOccurrences(of: "Once", with: "1") - } - } - - guard infoPanel.filter({ !$0.isEmpty }).count == 8 - else { throw AppError.parseFailed } - - return infoPanel - } - - func parseVisibility(value: String) throws -> GalleryVisibility { - guard value != "Yes" else { return .yes } - guard let rangeA = value.range(of: "("), - let rangeB = value.range(of: ")") - else { throw AppError.parseFailed } - - let reason = String(value[rangeA.upperBound.. String { - guard let gdnNode = node?.at_xpath("//div [@id='gdn']") else { - throw AppError.parseFailed - } - - if let aText = gdnNode.at_xpath("//a")?.text { - return aText - } else if let gdnText = gdnNode.text { - return gdnText - } else { - throw AppError.parseFailed - } - } - - var tmpGalleryDetail: GalleryDetail? - var tmpGalleryState: GalleryState? - for link in doc.xpath("//div [@class='gm']") { - guard tmpGalleryDetail == nil, tmpGalleryState == nil, - let gd3Node = link.at_xpath("//div [@id='gd3']"), - let gd4Node = link.at_xpath("//div [@id='gd4']"), - let gd5Node = link.at_xpath("//div [@id='gd5']"), - let gddNode = gd3Node.at_xpath("//div [@id='gdd']"), - let gdrNode = gd3Node.at_xpath("//div [@id='gdr']"), - let gdfNode = gd3Node.at_xpath("//div [@id='gdf']"), - let coverURL = try? parseCoverURL(node: link), - let tags = try? parseGalleryTags(node: gd4Node), - let previewURLs = try? parsePreviewURLs(doc: doc), - let arcAndTor = try? parseArcAndTor(node: gd5Node), - let infoPanel = try? parseInfoPanel(node: gddNode), - let visibility = try? parseVisibility(value: infoPanel[2]), - let sizeCount = Float(infoPanel[4]), - let pageCount = Int(infoPanel[6]), - let favoritedCount = Int(infoPanel[7]), - let language = Language(rawValue: infoPanel[3]), - let engTitle = link.at_xpath("//h1 [@id='gn']")?.text, - let uploader = try? parseUploader(node: gd3Node), - let (imgRating, textRating, containsUserRating) = try? parseRating(node: gdrNode), - let ratingCount = Int(gdrNode.at_xpath("//span [@id='rating_count']")?.text ?? ""), - let category = Category(rawValue: gd3Node.at_xpath("//div [@id='gdc']")?.text ?? ""), - let postedDate = try? parseDate(time: infoPanel[0], format: Defaults.DateFormat.publish) - else { continue } - - let isFavorited = gdfNode - .at_xpath("//a [@id='favoritelink']")? - .text?.contains("Add to Favorites") == false - let gjText = link.at_xpath("//h1 [@id='gj']")?.text - let jpnTitle = gjText?.isEmpty != false ? nil : gjText - let parentURLString = infoPanel[1].isValidURL ? infoPanel[1] : "" - - tmpGalleryDetail = GalleryDetail( - gid: gid, - title: engTitle, - jpnTitle: jpnTitle, - isFavorited: isFavorited, - visibility: visibility, - rating: containsUserRating ? textRating ?? 0.0 : imgRating, - userRating: containsUserRating ? imgRating : 0.0, - ratingCount: ratingCount, - category: category, - language: language, - uploader: uploader, - postedDate: postedDate, - coverURL: coverURL, - archiveURL: arcAndTor.0, - parentURL: URL(string: parentURLString), - favoritedCount: favoritedCount, - pageCount: pageCount, - sizeCount: sizeCount, - sizeType: infoPanel[5], - torrentCount: arcAndTor.1 - ) - tmpGalleryState = GalleryState( - gid: gid, - tags: tags, - previewURLs: previewURLs, - previewConfig: try? parsePreviewConfig(doc: doc), - comments: parseComments(doc: doc) - ) - break - } - - guard let galleryDetail = tmpGalleryDetail, - let galleryState = tmpGalleryState - else { - if let reason = doc.at_xpath("//div [@class='d']")?.at_xpath("//p")?.text { - if let rangeA = reason.range(of: "copyright claim by "), - let rangeB = reason.range(of: ".Sorry about that.") - { - let owner = String(reason[rangeA.upperBound.. [Int: URL] { - func parseCombinedPreviewURLs(node: XMLElement) -> [Int: URL] { - var previewURLs = [Int: URL]() - - for link in node.xpath("//a") { - if let divNode = link.at_xpath(".//div[@title and @style]"), - let style = divNode["style"], - let rangeA = style.range(of: "width:"), - let rangeB = style.range(of: "px;height:"), - let rangeC = style.range(of: "px;background"), - let rangeD = style.range(of: "url("), - let rangeE = style.range(of: ") -"), - let rangeF = style[rangeE.upperBound...].range(of: "px "), - let urlString = style[rangeD.upperBound.. [Int: URL] { - var previewURLs = [Int: URL]() - - for link in node.xpath("//a") { - if let divNode = link.at_xpath(".//div[@title and @style]"), - let style = divNode["style"], - let rangeA = style.range(of: "url("), - let rangeB = style.range(of: ")"), - let urlString = style[rangeA.upperBound.. [GalleryComment] { - var comments = [GalleryComment]() - for link in doc.xpath("//div [@id='cdiv']") { - for c1Link in link.xpath("//div [@class='c1']") { - guard let c3Node = c1Link.at_xpath("//div [@class='c3']")?.text, - let c6Node = c1Link.at_xpath("//div [@class='c6']"), - let commentID = c6Node["id"]? - .replacingOccurrences(of: "comment_", with: ""), - let rangeA = c3Node.range(of: "Posted on "), - let rangeB = c3Node.range(of: " by:   ") - else { continue } - - var score: String? - if let c5Node = c1Link.at_xpath("//div [@class='c5 nosel']") { - score = c5Node.at_xpath("//span")?.text - } - let author = String(c3Node[rangeB.upperBound...]) - let commentTime = String(c3Node[rangeA.upperBound.. Int? { - // The probable format of page title is "Page [Number]: filename" - ( - title - .components(separatedBy: ":") - .first? - .replacingOccurrences(of: "Page ", with: "") - .trimmingCharacters(in: .whitespaces) - ) - .flatMap(Int.init) - } - - // MARK: ImageURL - static func parseThumbnailURLs(doc: HTMLDocument) throws -> [Int: URL] { - var thumbnailURLs = [Int: URL]() - - guard let gdtNode = doc.at_xpath("//div [@id='gdt']") - else { throw AppError.parseFailed } - - for aLink in gdtNode.xpath("a") { - guard let href = aLink["href"], - let thumbnailURL = URL(string: href), - let divNode = aLink.at_xpath(".//div[@title and @style]"), - let title = divNode["title"], - let index = parseGTX00IndexFromTitle(from: title) - else { continue } - - thumbnailURLs[index] = thumbnailURL - } - - return thumbnailURLs - } - - static func parseSkipServerIdentifier(doc: HTMLDocument) throws -> String { - guard let text = doc.at_xpath("//div [@id='i6']")?.at_xpath("//a [@id='loadfail']")?["onclick"], - let rangeA = text.range(of: "nl('"), let rangeB = text.range(of: "')") - else { throw AppError.parseFailed } - return .init(text[rangeA.upperBound.. (Int, URL, URL?) { - guard let i3Node = doc.at_xpath("//div [@id='i3']"), - let imageURLString = i3Node.at_css("img")?["src"], - let imageURL = URL(string: imageURLString) - else { throw AppError.parseFailed } - - guard let i7Node = doc.at_xpath("//div [@id='i7']"), - let originalImageURLString = i7Node.at_xpath("//a")?["href"], - let originalImageURL = URL(string: originalImageURLString) - else { return (index, imageURL, nil) } - - return (index, imageURL, originalImageURL) - } - - static func parsePreviewMode(doc: HTMLDocument) throws -> String { - if doc.at_xpath("//div [@class='gt100']") != nil { - return "gt100" - } else if doc.at_xpath("//div [@class='gt200']") != nil { - return "gt200" - } else { - throw AppError.parseFailed - } - } - - static func parseMPVKeys(doc: HTMLDocument) throws -> (String, [Int: String]) { - var tmpMPVKey: String? - var imgKeys = [Int: String]() - - for link in doc.xpath("//script [@type='text/javascript']") { - guard let text = link.text, - let rangeA = text.range(of: "mpvkey = \""), - let rangeB = text.range(of: "\";\nvar imagelist = "), - let rangeC = text.range(of: "\"}]") - else { continue } - - tmpMPVKey = String(text[rangeA.upperBound.. User { - var displayName: String? - var avatarURL: URL? - - for ipbLink in doc.xpath("//table [@class='ipbtable']") { - guard let profileName = ipbLink.at_xpath("//div [@id='profilename']")?.text - else { continue } - - displayName = profileName - - for imgLink in ipbLink.xpath("//img") { - guard let imgURLString = imgLink["src"], - imgURLString.contains("forums.e-hentai.org/uploads"), - let imgURL = URL(string: imgURLString) - else { continue } - - avatarURL = imgURL - } - } - if displayName != nil { - return User(displayName: displayName, avatarURL: avatarURL) - } else { - throw AppError.parseFailed - } - } - - // MARK: Archive - static func parseGalleryArchive(doc: HTMLDocument) throws -> GalleryArchive { - guard let node = doc.at_xpath("//table") - else { throw AppError.parseFailed } - - var hathArchives = [GalleryArchive.HathArchive]() - for link in node.xpath("//td") { - var tmpResolution: ArchiveResolution? - var tmpFileSize: String? - var tmpGPPrice: String? - - for pLink in link.xpath("//p") { - if let pText = pLink.text { - if let res = ArchiveResolution(rawValue: pText) { - tmpResolution = res - } - if pText.contains("N/A") { - tmpFileSize = "N/A" - tmpGPPrice = "N/A" - - if tmpResolution != nil { - break - } - } else { - if pText.contains("KiB") - || pText.contains("MiB") - || pText.contains("GiB") - { - tmpFileSize = pText - } else { - tmpGPPrice = pText - } - } - } - } - - guard let resolution = tmpResolution, - let fileSize = tmpFileSize, - let gpPrice = tmpGPPrice - else { continue } - - hathArchives.append( - GalleryArchive.HathArchive( - resolution: resolution, - fileSize: fileSize, - gpPrice: gpPrice - ) - ) - } - - return GalleryArchive(hathArchives: hathArchives) - } - - // MARK: Torrent - static func parseGalleryTorrents(doc: HTMLDocument) -> [GalleryTorrent] { - var torrents = [GalleryTorrent]() - - for link in doc.xpath("//form") { - var tmpPostedTime: String? - var tmpFileSize: String? - var tmpSeedCount: Int? - var tmpPeerCount: Int? - var tmpDownloadCount: Int? - var tmpUploader: String? - var tmpFileName: String? - var tmpHash: String? - var tmpTorrentURL: URL? - - for trLink in link.xpath("//tr") { - for tdLink in trLink.xpath("//td") { - if let tdText = tdLink.text { - if tdText.contains("Posted: ") { - tmpPostedTime = tdText.replacingOccurrences(of: "Posted: ", with: "") - } - if tdText.contains("Size: ") { - tmpFileSize = tdText.replacingOccurrences(of: "Size: ", with: "") - } - if tdText.contains("Seeds: ") { - tmpSeedCount = Int(tdText.replacingOccurrences(of: "Seeds: ", with: "")) - } - if tdText.contains("Peers: ") { - tmpPeerCount = Int(tdText.replacingOccurrences(of: "Peers: ", with: "")) - } - if tdText.contains("Downloads: ") { - tmpDownloadCount = Int(tdText.replacingOccurrences(of: "Downloads: ", with: "")) - } - if tdText.contains("Uploader: ") { - tmpUploader = tdText.replacingOccurrences(of: "Uploader: ", with: "") - } - } - if let aLink = tdLink.at_xpath("//a"), - let aHref = aLink["href"], - let aText = aLink.text, - let aURL = URL(string: aHref), - let range = aURL.lastPathComponent.range(of: ".torrent") - { - tmpHash = String(aURL.lastPathComponent[.. Greeting { - func trim(string: String) -> String? { - if string.contains("EXP") { - return "EXP" - } else if string.contains("Credits") { - return "Credits" - } else if string.contains("GP") { - return "GP" - } else if string.contains("Hath") { - return "Hath" - } else { - return nil - } - } - - func trim(int: String) -> Int? { - Int(int.replacingOccurrences(of: ",", with: "") - .replacingOccurrences(of: " ", with: "")) - } - - guard let node = doc.at_xpath("//div [@id='eventpane']") - else { throw AppError.parseFailed } - - var greeting = Greeting() - for link in node.xpath("//p") { - guard var text = link.text, - text.contains("You gain") == true - else { continue } - - var gainedValues = [String]() - for strongLink in link.xpath("//strong") { - if let strongText = strongLink.text { - gainedValues.append(strongText) - } - } - - var gainedTypes = [String]() - for value in gainedValues { - guard let range = text.range(of: value) else { break } - let removeText = String(text[.. EhSetting { - func parseInt(node: XMLElement, name: String) -> Int? { - var value: Int? - for link in node.xpath("//input [@name='\(name)']") - where link["checked"] == "checked" { - value = Int(link["value"] ?? "") - } - return value - } - func parseEnum(node: XMLElement, name: String) -> T? - where T.RawValue == Int - { - guard let rawValue = parseInt( - node: node, name: name - ) else { return nil } - return T(rawValue: rawValue) - } - func parseString(node: XMLElement, name: String) -> String? { - node.at_xpath("//input [@name='\(name)']")?["value"] - } - func parseTextEditorString(node: XMLElement, name: String) -> String? { - node.at_xpath("//textarea [@name='\(name)']")?.text - } - func parseBool(node: XMLElement, name: String) -> Bool? { - switch parseString(node: node, name: name) { - case "0": return false - case "1": return true - default: return nil - } - } - func parseCheckBoxBool(node: XMLElement, name: String) -> Bool? { - node.at_xpath("//input [@name='\(name)']")?["checked"] == "checked" - } - func parseCapability(node: XMLElement, name: String) -> T? - where T.RawValue == Int - { - var maxValue: Int? - for link in node.xpath("//input [@name='\(name)']") - where link["disabled"] != "disabled" - { - let value = Int(link["value"] ?? "") ?? 0 - if maxValue == nil { - maxValue = value - } else if maxValue ?? 0 < value { - maxValue = value - } - } - return T(rawValue: maxValue ?? 0) - } - func parseSelections(node: XMLElement, name: String) -> [(String, String, Bool)] { - guard let select = node.at_xpath("//select [@name='\(name)']") - else { return [] } - - var selections = [(String, String, Bool)]() - for link in select.xpath("//option") { - guard let name = link.text, - let value = link["value"] - else { continue } - - selections.append((name, value, link["selected"] == "selected")) - } - - return selections - } - - var tmpForm: XMLElement? - for link in doc.xpath("//form [@method='post']") - where link["id"] == nil { - tmpForm = link - } - guard let profileOuter = doc.at_xpath("//div [@id='profile_outer']"), - let form = tmpForm else { throw AppError.parseFailed } - - // swiftlint:disable line_length - var ehProfiles = [EhProfile](); var isCapableOfCreatingNewProfile: Bool?; var capableLoadThroughHathSetting: EhSetting.LoadThroughHathSetting?; var capableImageResolution: EhSetting.ImageResolution?; var capableSearchResultCount: EhSetting.SearchResultCount?; var capableThumbnailConfigSizes = [EhSetting.ThumbnailSize](); var capableThumbnailConfigRowCount: EhSetting.ThumbnailRowCount?; var loadThroughHathSetting: EhSetting.LoadThroughHathSetting?; var browsingCountry: EhSetting.BrowsingCountry?; var imageResolution: EhSetting.ImageResolution?; var imageSizeWidth: Float?; var imageSizeHeight: Float?; var galleryName: EhSetting.GalleryName?; var literalBrowsingCountry: String?; var archiverBehavior: EhSetting.ArchiverBehavior?; var displayMode: EhSetting.DisplayMode?; var showSearchRangeIndicator: Bool?; var enableGalleryThumbnailSelector: Bool?; var disabledCategories = [Bool](); var favoriteCategories = [String](); var favoritesSortOrder: EhSetting.FavoritesSortOrder?; var ratingsColor: String?; var tagFilteringThreshold: Float?; var tagWatchingThreshold: Float?; var showFilteredRemovalCount: Bool?; var excludedLanguages = [Bool](); var excludedUploaders: String?; var searchResultCount: EhSetting.SearchResultCount?; var thumbnailLoadTiming: EhSetting.ThumbnailLoadTiming?; var thumbnailConfigSize: EhSetting.ThumbnailSize?; var thumbnailConfigRows: EhSetting.ThumbnailRowCount?; var coverScaleFactor: Float?; var viewportVirtualWidth: Float?; var commentsSortOrder: EhSetting.CommentsSortOrder?; var commentVotesShowTiming: EhSetting.CommentVotesShowTiming?; var tagsSortOrder: EhSetting.TagsSortOrder?; var galleryPageNumbers: EhSetting.GalleryPageNumbering?; var useOriginalImages: Bool?; var useMultiplePageViewer: Bool?; var multiplePageViewerStyle: EhSetting.MultiplePageViewerStyle?; var multiplePageViewerShowThumbnailPane: Bool? - // swiftlint:enable line_length - - ehProfiles = parseSelections(node: profileOuter, name: "profile_set") - .compactMap { (name, value, isSelected) in - guard let value = Int(value) else { return nil } - return EhProfile(value: value, name: name, isSelected: isSelected) - } - - for button in profileOuter.xpath("//input [@type='button']") { - if button["value"] == "Create New" { - isCapableOfCreatingNewProfile = true - break - } else { - isCapableOfCreatingNewProfile = false - } - } - - for optouter in form.xpath("//div [@class='optouter']") { - if optouter.at_xpath("//input [@name='uh']") != nil { - loadThroughHathSetting = parseEnum(node: optouter, name: "uh") - capableLoadThroughHathSetting = parseCapability(node: optouter, name: "uh") - } - if optouter.at_xpath("//select [@name='co']") != nil { - var value = parseSelections(node: optouter, name: "co").filter(\.2).first?.1 - - if value == "" { value = "-" } - browsingCountry = EhSetting.BrowsingCountry(rawValue: value ?? "") - - if let pText = optouter.at_xpath("//p")?.text, - let rangeA = pText.range(of: "You appear to be browsing the site from "), - let rangeB = pText.range(of: " or use a VPN or proxy in this country") - { - literalBrowsingCountry = String(pText[rangeA.upperBound.. EhSetting.ThumbnailSize? = { - switch $0 { - case 0: .auto - case 1: .normal - case 2: .small - default: nil - } - } - for option in options where option.isEnabled { - if let size = thumbnailSize(option.value) { - capableThumbnailConfigSizes.append(size) - } - } - if let selectedSize = (options.first(where: \.isSelected)?.value).flatMap(thumbnailSize) { - thumbnailConfigSize = selectedSize - } - } - if optouter.at_xpath("//input [@name='tr']") != nil { - thumbnailConfigRows = parseEnum(node: optouter, name: "tr") - capableThumbnailConfigRowCount = parseCapability(node: optouter, name: "tr") - } - if optouter.at_xpath("//input [@name='tp']") != nil { - coverScaleFactor = Float(parseString(node: optouter, name: "tp") ?? "100") - if coverScaleFactor == nil { coverScaleFactor = 100 } - } - if optouter.at_xpath("//input [@name='vp']") != nil { - viewportVirtualWidth = Float(parseString(node: optouter, name: "vp") ?? "0") - if viewportVirtualWidth == nil { viewportVirtualWidth = 0 } - } - if optouter.at_xpath("//input [@name='cs']") != nil { - commentsSortOrder = parseEnum(node: optouter, name: "cs") - } - if optouter.at_xpath("//input [@name='sc']") != nil { - commentVotesShowTiming = parseEnum(node: optouter, name: "sc") - } - if optouter.at_xpath("//input [@name='tb']") != nil { - tagsSortOrder = parseEnum(node: optouter, name: "tb") - } - if optouter.at_xpath("//input [@name='pn']") != nil { - galleryPageNumbers = parseEnum(node: optouter, name: "pn") - } - if optouter.at_xpath("//input [@name='oi']") != nil { - useOriginalImages = parseInt(node: optouter, name: "oi") == 1 - } - if optouter.at_xpath("//input [@name='qb']") != nil { - useMultiplePageViewer = parseInt(node: optouter, name: "qb") == 1 - } - if optouter.at_xpath("//input [@name='ms']") != nil { - multiplePageViewerStyle = parseEnum(node: optouter, name: "ms") - } - if optouter.at_xpath("//input [@name='mt']") != nil { - multiplePageViewerShowThumbnailPane = parseInt(node: optouter, name: "mt") == 0 - } - } - - // swiftlint:disable line_length - guard !ehProfiles.filter(\.isSelected).isEmpty, let isCapableOfCreatingNewProfile, let capableLoadThroughHathSetting, let capableImageResolution, let capableSearchResultCount, !capableThumbnailConfigSizes.isEmpty, let capableThumbnailConfigRowCount, let loadThroughHathSetting, let browsingCountry, let literalBrowsingCountry, let imageResolution, let imageSizeWidth, let imageSizeHeight, let galleryName, let archiverBehavior, let displayMode, let showSearchRangeIndicator, let enableGalleryThumbnailSelector, disabledCategories.count == 10, favoriteCategories.count == 10, let favoritesSortOrder, let ratingsColor, let tagFilteringThreshold, let tagWatchingThreshold, let showFilteredRemovalCount, excludedLanguages.count == 50, let excludedUploaders, let searchResultCount, let thumbnailLoadTiming, let thumbnailConfigSize, let thumbnailConfigRows, let coverScaleFactor, let viewportVirtualWidth, let commentsSortOrder, let commentVotesShowTiming, let tagsSortOrder, let galleryPageNumbers - else { throw AppError.parseFailed } - - return EhSetting(ehProfiles: ehProfiles.sorted(), isCapableOfCreatingNewProfile: isCapableOfCreatingNewProfile, capableLoadThroughHathSetting: capableLoadThroughHathSetting, capableImageResolution: capableImageResolution, capableSearchResultCount: capableSearchResultCount, capableThumbnailConfigRowCount: capableThumbnailConfigRowCount, capableThumbnailConfigSizes: capableThumbnailConfigSizes, loadThroughHathSetting: loadThroughHathSetting, browsingCountry: browsingCountry, literalBrowsingCountry: literalBrowsingCountry, imageResolution: imageResolution, imageSizeWidth: imageSizeWidth, imageSizeHeight: imageSizeHeight, galleryName: galleryName, archiverBehavior: archiverBehavior, displayMode: displayMode, showSearchRangeIndicator: showSearchRangeIndicator, enableGalleryThumbnailSelector: enableGalleryThumbnailSelector, disabledCategories: disabledCategories, favoriteCategories: favoriteCategories, favoritesSortOrder: favoritesSortOrder, ratingsColor: ratingsColor, tagFilteringThreshold: tagFilteringThreshold, tagWatchingThreshold: tagWatchingThreshold, showFilteredRemovalCount: showFilteredRemovalCount, excludedLanguages: excludedLanguages, excludedUploaders: excludedUploaders, searchResultCount: searchResultCount, thumbnailLoadTiming: thumbnailLoadTiming, thumbnailConfigSize: thumbnailConfigSize, thumbnailConfigRows: thumbnailConfigRows, coverScaleFactor: coverScaleFactor, viewportVirtualWidth: viewportVirtualWidth, commentsSortOrder: commentsSortOrder, commentVotesShowTiming: commentVotesShowTiming, tagsSortOrder: tagsSortOrder, galleryPageNumbering: galleryPageNumbers, useOriginalImages: useOriginalImages, useMultiplePageViewer: useMultiplePageViewer, multiplePageViewerStyle: multiplePageViewerStyle, multiplePageViewerShowThumbnailPane: multiplePageViewerShowThumbnailPane - ) - // swiftlint:enable line_length - } - - // MARK: APIKey - static func parseAPIKey(doc: HTMLDocument) throws -> String { - var tmpKey: String? - - for link in doc.xpath("//script [@type='text/javascript']") { - guard let script = link.text, script.contains("apikey"), - let rangeA = script.range(of: ";\nvar apikey = \""), - let rangeB = script.range(of: "\";\nvar average_rating") - else { continue } - - tmpKey = String(script[rangeA.upperBound.. Date { - let formatter = DateFormatter() - formatter.dateFormat = format - formatter.timeZone = TimeZone(secondsFromGMT: 0) - formatter.locale = Locale(identifier: "en_US_POSIX") - - guard let date = formatter.date(from: time) - else { throw AppError.parseFailed } - - return date - } - - // MARK: Rating - /// Returns ratings parsed from stars image / text and if the return contains a userRating . - static func parseRating(node: XMLElement) throws -> (Float, Float?, Bool) { - func parseTextRating(node: XMLElement) throws -> Float { - guard let ratingString = node - .at_xpath("//td [@id='rating_label']")?.text? - .replacingOccurrences(of: "Average: ", with: "") - .replacingOccurrences(of: "Not Yet Rated", with: "0"), - let rating = Float(ratingString) - else { throw AppError.parseFailed } - - return rating - } - - var tmpRatingString: String? - var containsUserRating = false - - for link in node.xpath("//div") where - link.className?.contains("ir") == true - && link["style"]?.isEmpty == false - { - if tmpRatingString != nil { break } - tmpRatingString = link["style"] - containsUserRating = link.className != "ir" - } - - guard let ratingString = tmpRatingString - else { throw AppError.parseFailed } - - var tmpRating: Float? - if ratingString.contains("0px") { tmpRating = 5.0 } - if ratingString.contains("-16px") { tmpRating = 4.0 } - if ratingString.contains("-32px") { tmpRating = 3.0 } - if ratingString.contains("-48px") { tmpRating = 2.0 } - if ratingString.contains("-64px") { tmpRating = 1.0 } - if ratingString.contains("-80px") { tmpRating = 0.0 } - - guard var rating = tmpRating - else { throw AppError.parseFailed } - - if ratingString.contains("-21px") { rating -= 0.5 } - return (rating, try? parseTextRating(node: node), containsUserRating) - } - - // MARK: PageNumber - static func parsePageNum(doc: HTMLDocument) -> PageNumber { - var current = 0 - var maximum = 0 - - guard let link = doc.at_xpath("//table [@class='ptt']"), - let currentStr = link.at_xpath("//td [@class='ptds']")?.text - else { - if let link = doc.at_xpath("//div [@class='searchnav']") { - var timestamp: String? - var isEnabled = false - - for aLink in link.xpath("//a") where aLink.text?.contains("Next") == true { - timestamp = aLink["href"] - .map(URLComponents.init)?? - .queryItems? - .first(where: { $0.name == "next" })? - .value? - .split(separator: "-") - .last - .map(String.init) - - isEnabled = true - break - } - - return PageNumber(lastItemTimestamp: timestamp, isNextButtonEnabled: isEnabled) - } else { - return PageNumber(isNextButtonEnabled: false) - } - } - - if let range = currentStr.range(of: "-") { - current = (Int(currentStr[range.upperBound...]) ?? 1) - 1 - } else { - current = (Int(currentStr) ?? 1) - 1 - } - for aLink in link.xpath("//a") { - if let num = Int(aLink.text ?? "") { - maximum = num - 1 - } - } - return PageNumber(current: current, maximum: maximum) - } - - // MARK: SortOrder - static func parseFavoritesSortOrder(doc: HTMLDocument) -> FavoritesSortOrder? { - guard let idoNode = doc.at_xpath("//div [@class='ido']") else { return nil } - for link in idoNode.xpath("//div") where link.className == nil { - guard let aText = link.at_xpath("//div")?.at_xpath("//a")?.text else { continue } - if aText == "Use Posted" { - return .favoritedTime - } else if aText == "Use Favorited" { - return .lastUpdateTime - } - } - return nil - } - - // MARK: Balance - static func parseCurrentFunds(doc: HTMLDocument) throws -> (String, String) { - var tmpGP: String? - var tmpCredits: String? - - for element in doc.xpath("//p") { - if let text = element.text, - let rangeA = text.range(of: "GP"), - let rangeB = text.range(of: "[?]"), - let rangeC = text.range(of: "Credits") - { - tmpGP = String(text[.. String { - guard let dbNode = doc.at_xpath("//div [@id='db']") - else { throw AppError.parseFailed } - - var response = [String]() - for pLink in dbNode.xpath("//p") { - if let pText = pLink.text { - response.append(pText) - } - } - - var respString = response.joined(separator: " ") - - if let rangeA = respString.range(of: "A ") ?? respString.range(of: "An "), - let rangeB = respString.range(of: "resolution"), - let rangeC = respString.range(of: "client"), - let rangeD = respString.range(of: "Downloads") - { - let resp = String(respString[rangeA.upperBound.. " + clientName - } else { - respString = resp - } - } - } - - return respString - } - - // MARK: ArchiveURL - static func parseArchiveURL(node: XMLElement) throws -> URL { - var archiveURL: URL? - if let aLink = node.at_xpath("//a"), - aLink.text?.contains("Archive Download") == true, let onClick = aLink["onclick"], - let rangeA = onClick.range(of: "popUp('"), let rangeB = onClick.range(of: "',") - { - archiveURL = URL(string: .init(onClick[rangeA.upperBound.. [Int: String] { - var favoriteCategories = [Int: String]() - - for link in doc.xpath("//div [@id='favsel']") { - for inputLink in link.xpath("//input") { - guard let name = inputLink["name"], - let value = inputLink["value"], - let type = FavoritesType(rawValue: name) - else { continue } - - favoriteCategories[type.index] = value - } - } - - if !favoriteCategories.isEmpty { - return favoriteCategories - } else { - throw AppError.parseFailed - } - } - - // MARK: Profile - static func parseProfileIndex(doc: HTMLDocument) throws -> VerifyEhProfileResponse { - var profileNotFound = true - var profileValue: Int? - - let selector = doc.at_xpath("//select [@name='profile_set']") - let options = selector?.xpath("//option") - - guard let options = options, options.count >= 1 - else { throw AppError.parseFailed } - - for link in options where EhSetting.verifyEhPandaProfileName(with: link.text) { - profileNotFound = false - profileValue = Int(link["value"] ?? "") - } - - return .init(profileValue: profileValue, isProfileNotFound: profileNotFound) - } - - // MARK: CommentContent - static func parseCommentContent(node: XMLElement) -> [CommentContent] { - var contents = [CommentContent]() - - for div in node.xpath("//div") { - node.removeChild(div) - } - for span in node.xpath("span") { - node.removeChild(span) - } - - guard var rawContent = node.innerHTML? - .replacingOccurrences(of: "
", with: "\n") - .replacingOccurrences(of: "", with: "") - else { return [] } - - while (node.xpath("//a").count - + node.xpath("//img").count) > 0 - { - var tmpLink: XMLElement? - - let links = [ - node.at_xpath("//a"), - node.at_xpath("//img") - ] - .compactMap({ $0 }) - - links.forEach { newLink in - if tmpLink == nil { - tmpLink = newLink - } else { - if let tmpHTML = tmpLink?.toHTML, - let newHTML = newLink.toHTML, - let tmpBound = rawContent.range(of: tmpHTML)?.lowerBound, - let newBound = rawContent.range(of: newHTML)?.lowerBound, - newBound < tmpBound - { - tmpLink = newLink - } - } - } - - guard let link = tmpLink, - let html = link.toHTML? - .replacingOccurrences(of: "
", with: "\n") - .replacingOccurrences(of: "", with: ""), - let range = rawContent.range(of: html) - else { continue } - - let text = String(rawContent[.. (URL, CGSize, CGSize)? { - guard var components = URLComponents( - url: url, resolvingAgainstBaseURL: false - ), - let queryItems = components.queryItems - else { return nil } - - let keys = [ - Defaults.URL.Component.Key.ehpandaWidth, - Defaults.URL.Component.Key.ehpandaHeight, - Defaults.URL.Component.Key.ehpandaOffset - ] - let configs = keys.map(\.rawValue).compactMap { key in - queryItems.filter({ $0.name == key }).first?.value - } - .compactMap(Int.init) - - components.queryItems = nil - guard configs.count == keys.count, - let plainURL = components.url - else { return nil } - - let size = CGSize(width: configs[0], height: configs[1]) - return (plainURL, size, CGSize(width: configs[2], height: 0)) - } - - // MARK: parseBanInterval - static func parseBanInterval(doc: HTMLDocument) -> BanInterval? { - guard let text = doc.body?.text, let range = text.range(of: "The ban expires in ") - else { return nil } - - let expireDescription = String(text[range.upperBound...]) - - if let daysRange = expireDescription.range(of: "days"), - let days = Int(expireDescription[.. URL? { - try? FileManager.default.url(for: searchPathDirectory, in: .userDomainMask, appropriateFor: nil, create: true) - } -} diff --git a/EhPanda/App/Tools/Utilities/UserDefaultsUtil.swift b/EhPanda/App/Tools/Utilities/UserDefaultsUtil.swift deleted file mode 100644 index f727a2d06..000000000 --- a/EhPanda/App/Tools/Utilities/UserDefaultsUtil.swift +++ /dev/null @@ -1,17 +0,0 @@ -// -// UserDefaultsUtil.swift -// EhPanda -// - -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/de.lproj/InfoPlist.strings b/EhPanda/App/de.lproj/InfoPlist.strings deleted file mode 100644 index f8593f4bd..000000000 --- a/EhPanda/App/de.lproj/InfoPlist.strings +++ /dev/null @@ -1,7 +0,0 @@ -/* - InfoPlist.strings - EhPanda -*/ - -"NSFaceIDUsageDescription" = "Diese Berechtigung ist notwendig um Face ID zum Entsperren der Anwendung verwenden zu können."; -"NSPhotoLibraryAddUsageDescription" = "We need this permission to save images to your photo library."; diff --git a/EhPanda/App/de.lproj/Localizable.strings b/EhPanda/App/de.lproj/Localizable.strings deleted file mode 100644 index b505d8d49..000000000 --- a/EhPanda/App/de.lproj/Localizable.strings +++ /dev/null @@ -1,906 +0,0 @@ -/* - Localizable.strings - EhPanda -*/ - -// MARK: BanInterval -"enum.ban_interval.description.and" = "and"; - -// MARK: ToplistsType -"enum.toplists_type.value.yesterday" = "Yesterday"; -"enum.toplists_type.value.past_month" = "Past month"; -"enum.toplists_type.value.past_year" = "Past year"; -"enum.toplists_type.value.all_time" = "All time"; - -// MARK: Response -"website.response.hath_client_not_found" = "Du benötigst einen deinem Konto zugehörigen H@H Client um diese Funktion nutzen zu können"; -"website.response.hath_client_not_online" = "Dein H@H Client scheint offline zu sein. Sieh nach, ob er läuft und probier's nochmal"; -"website.response.invalid_resolution" = "Die gewünschte Galerie kann nicht in der gewählten Auflösung heruntergeladen werden"; - -// MARK: HUD -"hud.title.error" = "Fehler"; -"hud.title.success" = "Erfolg"; -"hud.title.loading" = "Wird geladen..."; -"hud.title.communicating" = "Verbinde..."; -"hud.caption.copied_to_clipboard" = "In Zwischenablage kopiert"; -"hud.caption.saved_to_photo_library" = "Saved to photo library"; - -// 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.jump_page" = "Jump page"; -"toolbar_item.button.quick_search" = "Quick search"; - -// MARK: JumpPage -"jump_page_view.title.jump_page" = "Jump page"; -"jump_page_view.button.confirm" = "Confirm"; - -// MARK: AlertView -"loading_view.title.loading" = "Wird geladen..."; -"loading_view.title.preparing_database" = "Preparing the database..."; -"not_login_view.title.need_login" = "You need to login to access this feature."; -"not_login_view.button.login" = "Login"; -"error_view.button.retry" = "Erneut versuchen"; -"error_view.button.drop_database" = "Drop the database"; -"error_view.title.try_later" = "Please try again later."; -"error_view.title.network" = "A network error occurred."; -"error_view.title.parsing" = "A parsing error occurred."; -"error_view.title.unknown" = "An unknown error occurred."; -"error_view.title.not_found" = "There seems to be nothing here."; -"error_view.title.database_corrupted" = "The database is corrupted.\nPlease submit an issue on GitHub."; -"error_view.title.ip_banned" = "Your IP address has been temporarily banned for excessive pageloads which indicates that you are using automated mirroring / harvesting software. The ban expires in %@."; -"error_view.title.copyright_claim" = "This gallery is unavailable due to a copyright claim by %@. Sorry about that."; -"error_view.title.gallery_unavailable" = "This gallery has been removed or is unavailable."; - -// MARK: ConfirmationDialog -"confirmation_dialog.title.drop_database" = "You will lose all your data in this app.\nAre you sure to drop the database?"; -"confirmation_dialog.title.remove_custom_translations" = "Are you sure to remove your custom translations?"; -"confirmation_dialog.title.logout" = "Bist du sicher das du dich ausloggen möchtest?"; -"confirmation_dialog.title.delete" = "Are you sure to delete this item?"; -"confirmation_dialog.title.clear" = "Bist du sicher das du das löschen möchtest?"; -"confirmation_dialog.title.reset" = "Bist du sicher?"; -"confirmation_dialog.button.drop_database" = "Drop the database"; -"confirmation_dialog.button.remove" = "Remove"; -"confirmation_dialog.button.logout" = "Ausloggen"; -"confirmation_dialog.button.delete" = "Delete"; -"confirmation_dialog.button.clear" = "Löschen"; -"confirmation_dialog.button.reset" = "Zurücksetzen"; - -// MARK: SubSection -"sub_section.button.show_all" = "Alle anzeigen"; - -// MARK: NewDawnView -"new_dawn_view.title.first" = "Es ist der Beginn eines neuen Tages!"; -"new_dawn_view.title.second" = "Als du auf deine bisherige Reise zurückblickst wirst du ein kleines bisschen weiser."; -// Greeting -"struct.greeting.mark.start" = "Du erhälst "; -"struct.greeting.mark.separator" = ", "; -"struct.greeting.mark.and" = " und "; -"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 -"enum.home_misc_grid_type.title.popular" = "Beliebt"; -"enum.home_misc_grid_type.title.watched" = "Meine Tags"; -"enum.home_misc_grid_type.title.history" = "Verlauf"; - -// 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 -"struct.user.favorite_category.default" = "Favoriten %@"; -"struct.user.favorite_category.all" = "Alle"; - -// MARK: SearchView -"search_view.title.search" = "Suche"; -"search_view.section.title.recently_searched" = "Recently searched"; -"search_view.section.title.recently_seen" = "Recently seen"; -"search_view.section.title.quick_search" = "Quick search"; -// Searchable -"searchable.prompt.filter" = "Filter"; -"searchable.title.matches_count" = "Found %d matches."; - -// MARK: QuickSearchView -"quick_search_view.title.quick_search" = "Quick search"; -"quick_search_view.title.edit_word" = "Edit word"; -"quick_search_view.title.new_word" = "New word"; -"quick_search_view.title.content" = "Content"; -"quick_search_view.title.name" = "Name"; -"quick_search_view.placeholder.optional" = "Optional"; - -// 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.about" = "Über EhPanda"; - -// MARK: AccountSettingView -"account_setting_view.title.account" = "Konto"; -"account_setting_view.title.shows_new_dawn_greeting" = "Neuer-Tag-Meldung anzeigen"; -"account_setting_view.button.login" = "Einloggen"; -"account_setting_view.button.logout" = "Ausloggen"; -"account_setting_view.button.account_configuration" = "Kontoeinstellungen"; -"account_setting_view.button.tags_management" = "Meine Tags bearbeiten"; -"account_setting_view.button.copy_cookies" = "Cookies kopieren"; -// 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.auto_lock" = "Auto-Lock"; -"general_setting_view.title.enables_tags_extension" = "Enables tags extension"; -"general_setting_view.title.translates_tags" = "Translates tags"; -"general_setting_view.title.shows_tags_search_suggestion" = "Shows tags search suggestion"; -"general_setting_view.title.shows_images_in_tags" = "Shows images in tags"; -"general_setting_view.title.redirects_links_to_the_selected_host" = "Links zum ausgewählten Host umleiten"; -"general_setting_view.title.detects_links_from_clipboard" = "Übernimmt automatisch Links aus der Zwischenablage"; -"general_setting_view.title.background_blur_radius" = "Background blur radius"; -"general_setting_view.button.logs" = "Logs"; -"general_setting_view.button.import_custom_translations" = "Import custom translations"; -"general_setting_view.button.remove_custom_translations" = "Remove custom translations"; -"general_setting_view.button.clear_image_caches" = "Zwischengespeicherte Bilder (Cache) löschen"; -"general_setting_view.value.default_language_description" = "N/A"; -"general_setting_view.section.title.tags" = "Tags"; -"general_setting_view.section.title.navigation" = "Navigation"; -"general_setting_view.section.title.security" = "Sicherheit"; -"general_setting_view.section.title.caches" = "Caches"; -// 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.tint_color" = "Farbe"; -"appearance_setting_view.title.display_mode" = "Display mode"; -"appearance_setting_view.title.shows_tags_in_list" = "Tags als Liste anzeigen"; -"appearance_setting_view.title.maximum_number_of_tags" = "Maximale Anzahl an Tags"; -"appearance_setting_view.title.displays_japanese_title" = "Displays Japanese title"; -"appearance_setting_view.button.app_icon" = "App icon"; -"appearance_setting_view.menu.title.infite" = "Infite"; -"appearance_setting_view.section.title.list" = "List"; -"appearance_setting_view.section.title.gallery" = "Gallery"; -// PreferredColorScheme -"enum.preferred_color_scheme.value.automatic" = "Automatisch"; -"enum.preferred_color_scheme.value.light" = "Hell"; -"enum.preferred_color_scheme.value.dark" = "Dunkel"; -// AppIconType -"enum.app_icon_type.value.default" = "Standard"; -"enum.app_icon_type.value.ukiyoe" = "Ukiyo-e"; -"enum.app_icon_type.value.developer" = "Developer"; -"enum.app_icon_type.value.stand_with_ukraine_2022" = "Stand With Ukraine (2022)"; -"enum.app_icon_type.value.not_my_president" = "NOT MY PRESIDENT"; -// ListDisplayMode -"enum.list_display_mode.value.detail" = "Detail"; -"enum.list_display_mode.value.thumbnail" = "Thumbnail"; - -// MARK: AppIconView -"app_icon_view.title.app_icon" = "App icon"; - -// MARK: reading_settingView -"reading_setting_view.title.reading" = "Am Lesen"; -"reading_setting_view.title.direction" = "Direction"; -"reading_setting_view.title.preload_limit" = "Preload limit"; -"reading_setting_view.title.enables_landscape" = "Enables landscape"; -"reading_setting_view.title.separator_height" = "Höhe der Teilung"; -"reading_setting_view.title.maximum_scale_factor" = "Maximaler Skalierungsfaktor"; -"reading_setting_view.title.double_tap_scale_factor" = "Doppel-Tap Skalierungsfaktor"; -"reading_setting_view.section.title.appearance" = "Oberfläche"; -// ReadingDirection -"enum.reading_direction.value.vertical" = "Vertikal"; -"enum.reading_direction.value.right_to_left" = "Von rechts nach links"; -"enum.reading_direction.value.left_to_right" = "Von links nach rechts"; - -// MARK: LaboratorySettingView -"laboratory_setting_view.title.laboratory" = "Experimentelles"; -"laboratory_setting_view.title.bypasses_SNI_filtering" = "SNI Filter umgehen"; - -// MARK: AboutView -"about_view.title.ehPanda" = "EhPanda"; -"about_view.button.website" = "Website"; -"about_view.button.altStore_source" = "AltStore Quelle"; -"about_view.title.version" = "Version"; -"about_view.section.title.special_thanks" = "Special thanks"; -"about_view.section.title.code_level_contributors" = "Code-level contributors"; -"about_view.section.title.translation_contributors" = "Translation contributors"; -"about_view.section.title.acknowledgements" = "OK"; - -// MARK: DetailView -"detail_view.button.read" = "Lesen"; -"detail_view.button.post_comment" = "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.context_menu.button.detail" = "Detail"; -"detail_view.context_menu.button.withdraw_vote" = "Withdraw vote"; -"detail_view.context_menu.button.vote_up" = "Vote up"; -"detail_view.context_menu.button.vote_down" = "Vote down"; -"detail_view.description_section.title.favorited" = "Favorisiert"; -"detail_view.description_section.title.language" = "Sprache"; -"detail_view.description_section.title.ratings" = "%@ Bewertungen"; -"detail_view.description_section.title.page_count" = "Seitenzahl"; -"detail_view.description_section.title.file_size" = "Dateigröße"; -"detail_view.description_section.description.favorited" = "mal"; -"detail_view.description_section.description.page_count" = "Seiten"; -"detail_view.action_section.button.give_a_rating" = "Bewertung abgeben"; -"detail_view.action_section.button.similar_gallery" = "Ähnliche Galerien"; -"detail_view.section.title.previews" = "Vorschau"; -"detail_view.section.title.comments" = "Kommentar"; - -// MARK: ArchivesView -"archives_view.title.archives" = "Archiv"; -"archives_view.button.download_to_hath_client" = "Mit H@H Client herunterladen"; -// HathArchive -"struct.hath_archive.price.free" = "Frei"; -"struct.hath_archive.price.not_available" = "./."; -// ArchiveResolution -"enum.archive_resolution.value.original" = "Original"; - -// MARK: TorrentsView -"torrents_view.title.torrents" = "Torrents"; - -// MARK: GalleryInfosView -"gallery_infos_view.title.gallery_infos" = "Gallery infos"; -"gallery_infos_view.title.id" = "ID"; -"gallery_infos_view.title.token" = "Token"; -"gallery_infos_view.title.title" = "Title"; -"gallery_infos_view.title.japanese_title" = "Japanese title"; -"gallery_infos_view.title.gallery_URL" = "Gallery URL"; -"gallery_infos_view.title.cover_URL" = "Cover URL"; -"gallery_infos_view.title.archive_URL" = "Archive URL"; -"gallery_infos_view.title.torrent_URL" = "Torrent URL"; -"gallery_infos_view.title.parent_URL" = "Parent URL"; -"gallery_infos_view.title.category" = "Category"; -"gallery_infos_view.title.uploader" = "Uploader"; -"gallery_infos_view.title.posted_date" = "Posted date"; -"gallery_infos_view.title.visibility" = "Visibility"; -"gallery_infos_view.title.language" = "Language"; -"gallery_infos_view.title.page_count" = "Page count"; -"gallery_infos_view.title.file_size" = "File size"; -"gallery_infos_view.title.favorited_times" = "Favorited times"; -"gallery_infos_view.title.favorited" = "Favorited"; -"gallery_infos_view.title.rating_count" = "Rating count"; -"gallery_infos_view.title.average_rating" = "Average rating"; -"gallery_infos_view.title.my_rating" = "My rating"; -"gallery_infos_view.title.torrent_count" = "Torrent count"; -"gallery_infos_view.value.none" = "None"; -"gallery_infos_view.value.yes" = "Yes"; -"gallery_infos_view.value.no" = "No"; -// GalleryVisibility -"enum.gallery_visibility.value.yes" = "Yes"; -"enum.gallery_visibility.value.no" = "No (%@)"; -"enum.gallery_visibility.value.no.reason.expunged" = "Expunged"; - -// MARK: TagDetailView -"tag_detail_view.section.title.images" = "Images"; -"tag_detail_view.section.title.links" = "Links"; - -// MARK: CommentsView -"comments_view.title.comments" = "Kommentar"; - -// MARK: PostCommentView -"post_comment_view.title.post_comment" = "Kommentar abgeben"; -"post_comment_view.title.edit_comment" = "Kommentar bearbeiten"; - -// MARK: PreviewsView -"previews_view.title.previews" = "Vorschau"; - -// MARK: ReadingView -"reading_view.context_menu.button.reload" = "Reload"; -"reading_view.context_menu.button.copy" = "Copy"; -"reading_view.context_menu.button.save" = "Save"; -"reading_view.context_menu.button.save_original" = "Save original"; -"reading_view.context_menu.button.share" = "Teilen"; -"reading_view.toolbar_item.title.auto_play" = "Auto-Play"; -"reading_view.toolbar_item.title.dual_page_mode" = "Dual-Page mode"; -"reading_view.toolbar_item.title.except_the_cover" = "Except the cover"; -"reading_view.toolbar_item.button.retry_all_failed_images" = "Retry failed images"; -"reading_view.toolbar_item.button.reload_all_images" = "Reload all images"; -"reading_view.toolbar_item.button.reading_setting" = "Reading setting"; -// AutoPlayPolicy -"enum.auto_play_policy.value.off" = "Off"; - -// MARK: FiltersView -"filters_view.title.filters" = "Filters"; -"filters_view.title.advanced_settings" = "Erweiterte Einstellungen"; -"filters_view.title.search_gallery_name" = "Galerienamen durchsuchen"; -"filters_view.title.search_gallery_tags" = "Galerietags durchsuchen"; -"filters_view.title.search_gallery_description" = "Galeriebeschreibung durchsuchen"; -"filters_view.title.search_torrent_filenames" = "Torrents durchsuchen"; -"filters_view.title.only_show_galleries_with_torrents" = "Nur Galerien mit Torrents zeigen"; -"filters_view.title.search_low_power_tags" = "Low-Power Tags miteinbeziehen"; -"filters_view.title.search_downvoted_tags" = "Negativ bewertete Tags miteinbeziehen"; -"filters_view.title.search_expunged_galleries" = "Gelöschte Galerien zeigen"; -"filters_view.title.set_minimum_rating" = "Minimaleste Bewertung festlegen"; -"filters_view.title.minimum_rating" = "Minimale Bewertung"; -"filters_view.title.set_pages_range" = "Seitenzahl-Bereich festlegen"; -"filters_view.title.pages_range" = "Seitenzahl-Bereich"; -"filters_view.title.disable_language_filter" = "Gefilterte Sprachen miteinbeziehen"; -"filters_view.title.disable_uploader_filter" = "Gefilterte Uploader miteinbeziehen"; -"filters_view.title.disable_tags_filter" = "Gefilterte Tags miteinbeziehen"; -"filters_view.button.reset_filters" = "Filter zurücksetzen"; -"filters_view.section.title.advanced" = "Erweitert"; -"filters_view.section.title.default_filter" = "Standardfilter"; -// FilterRange -"enum.filter_range.value.search" = "Suche"; -"enum.filter_range.value.global" = "Global"; -"enum.filter_range.value.watched" = "Meine Tags"; - -// MARK: EhSettingView -"eh_setting_view.title.host_settings" = "%@ settings"; -"eh_setting_view.section.title.profile_settings" = "Profile Settings"; -"eh_setting_view.title.selected_profile" = "Selected profile"; -"eh_setting_view.button.set_as_default" = "Set as default"; -"eh_setting_view.button.delete_profile" = "Delete profile"; -"eh_setting_view.button.rename" = "Rename"; -"eh_setting_view.button.create_new" = "Create new"; -"eh_setting_view.toolbar_item.button.done" = "Done"; - -"eh_setting_view.section.title.image_load_settings" = "Image Load Settings"; -"eh_setting_view.title.load_images_through_the_hath_network" = "Load images through the Hath network"; -"eh_setting_view.title.browsing_country" = "Browsing country"; -"eh_setting_view.description.browsing_country" = "You appear to be browsing the site from **%@** or use a VPN or proxy in this country, which means the site will try to load images from H@H clients in this general geographic region. If this is incorrect, or if you want to use a different region for any reason (like if you are using a split tunneling VPN), you can select a different country below."; -// EhSetting.LoadThroughHathSetting -"enum.eh_setting.load_through_hath_setting.value.any_client" = "Any client"; -"enum.eh_setting.load_through_hath_setting.value.default_port_only" = "Default port clients only"; -"enum.eh_setting.load_through_hath_setting.value.modern_no" = "No [Modern/HTTPS]"; -"enum.eh_setting.load_through_hath_setting.value.legacy_no" = "No [Legacy/HTTP]"; -"enum.eh_setting.load_through_hath_setting.description.any_client" = "Recommended."; -"enum.eh_setting.load_through_hath_setting.description.default_port_only" = "Can be slower. Enable if behind firewall/proxy that blocks outgoing non-standard ports."; -"enum.eh_setting.load_through_hath_setting.description.modern_no" = "Donator only. You will not be able to browse as many pages. Recommended only if having severe problems."; -"enum.eh_setting.load_through_hath_setting.description.legacy_no" = "Donator only. May not work by default in modern browsers. Recommended for legacy/outdated browsers only."; - -"eh_setting_view.section.title.image_size_settings" = "Image Size Settings"; -"eh_setting_view.title.image_resolution" = "Image resolution"; -"eh_setting_view.description.image_resolution" = "Normally, images are resampled to 1280 pixels of horizontal resolution for online viewing. You can alternatively select one of the following resample resolutions. To avoid murdering the staging servers, resolutions above 1280x are temporarily restricted to donators, people with any hath perk, and people with a UID below 3,000,000."; -"eh_setting_view.title.image_size" = "Image size"; -"eh_setting_view.description.image_size" = "While the site will automatically scale down images to fit your screen width, you can also manually restrict the maximum display size of an image. Like the automatic scaling, this does not resample the image, as the resizing is done browser-side. (0 = no limit)"; -"eh_setting_view.title.horizontal" = "Horizontal"; -"eh_setting_view.title.vertical" = "Vertical"; -// EhSetting.ImageResolution -"enum.eh_setting.image_resolution.value.auto" = "Auto"; - -"eh_setting_view.section.title.gallery_name_display" = "Gallery Name Display"; -"eh_setting_view.title.gallery_name" = "Gallery name"; -"eh_setting_view.description.gallery_name" = "Many galleries have both an English/Romanized title and a title in Japanese script. Which gallery name would you like as default?"; -// 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.archiver_settings" = "Archiver Settings"; -"eh_setting_view.title.archiver_behavior" = "Archiver behavior"; -"eh_setting_view.description.archiver_behavior" = "The default behavior for the Archiver is to confirm the cost and selection for original or resampled archive, then present a link that can be clicked or copied elsewhere. You can change this behavior here."; -// EhSetting.ArchiverBehavior -"enum.eh_setting.archiver_behavior.value.manual_select_manual_start" = "Manual Select, Manual Start (Default)"; -"enum.eh_setting.archiver_behavior.value.manual_select_auto_start" = "Manual Select, Auto Start"; -"enum.eh_setting.archiver_behavior.value.auto_select_original_manual_start" = "Auto Select Original, Manual Start"; -"enum.eh_setting.archiver_behavior.value.auto_select_original_auto_start" = "Auto Select Original, Auto Start"; -"enum.eh_setting.archiver_behavior.value.auto_select_resample_manual_start" = "Auto Select Resample, Manual Start"; -"enum.eh_setting.archiver_behavior.value.auto_select_resample_auto_start" = "Auto Select Resample, Auto Start"; - -"eh_setting_view.section.title.front_page_settings" = "Front Page Settings"; -"eh_setting_view.title.display_mode" = "Display mode"; -"eh_setting_view.description.display_mode" = "Which display mode would you like to use on the front and search pages?"; -"eh_setting_view.section.title.show_search_range_indicator" = "Search Range Indicator"; -"eh_setting_view.title.show_search_range_indicator" = "Show search range indicator"; -"eh_setting_view.description.gallery_category" = "What categories would you like to show by default on the front page and in searches?"; -// EhSetting.DisplayMode -"enum.eh_setting.display_mode.value.compact" = "Compact"; -"enum.eh_setting.display_mode.value.thumbnail" = "Thumbnail"; -"enum.eh_setting.display_mode.value.extended" = "Extended"; -"enum.eh_setting.display_mode.value.minimal" = "Minimal"; -"enum.eh_setting.display_mode.value.minimalPlus" = "Minimal+"; - -"eh_setting_view.section.title.optional_UI_elements" = "Optional UI Elements"; -"eh_setting_view.description.optional_UI_elements" = "Some historic UI elements are now disabled by default. You can enable those here."; -"eh_setting_view.title.enable_gallery_thumbnail_selector" = "Enable thumbnail selector on gallery screen"; - -"eh_setting_view.section.title.favorites" = "Favorites"; -"eh_setting_view.description.favorite_categories" = "Here you can choose and rename your favorite categories."; -"eh_setting_view.title.favorites_sort_order" = "Favorites sort order"; -"eh_setting_view.description.favorites_sort_order" = "You can also select your default sort order for galleries on your favorites page. Note that favorites added prior to the March 2016 revamp did not store a timestamp, and will use the gallery posted time regardless of this setting."; -// EhSetting.FavoritesSortOrder -"enum.eh_setting.favorites_sort_order.value.last_update_time" = "By last gallery update time"; -"enum.eh_setting.favorites_sort_order.value.favorited_time" = "By favorited time"; - -"eh_setting_view.section.title.ratings" = "Ratings"; -"eh_setting_view.title.ratings_color" = "Ratings color"; -"eh_setting_view.promt.ratings_color" = "RRGGB"; -"eh_setting_view.description.ratings_color" = "By default, galleries that you have rated will appear with red stars for ratings of 2 stars and below, green for ratings between 2.5 and 4 stars, and blue for ratings of 4.5 or 5 stars. You can customize this by entering your desired color combination below. Each letter represents one star. The default RRGGB means R(ed) for the first and second star, G(reen) for the third and fourth, and B(lue) for the fifth. You can also use (Y)ellow for the normal stars. Any five-letter R/G/B/Y combo works."; - -"eh_setting_view.section.title.tag_filtering_threshold" = "Tag Filtering Threshold"; -"eh_setting_view.title.tag_filtering_threshold" = "Tag Filtering Threshold"; -"eh_setting_view.description.tag_filtering_threshold" = "You can soft filter tags by adding them to My Tags with a negative weight. If a gallery has tags that add up to weight below this value, it is filtered from view. This threshold can be set between 0 and -9999."; - -"eh_setting_view.section.title.tag_watching_threshold" = "Tag Watching Threshold"; -"eh_setting_view.title.tag_watching_threshold" = "Tag Watching Threshold"; -"eh_setting_view.description.tag_watching_threshold" = "Recently uploaded galleries will be included on the watched screen if it has at least one watched tag with positive weight, and the sum of weights on its watched tags add up to this value or higher. This threshold can be set between 0 and 9999."; - -"eh_setting_view.section.title.filtered_removal_count" = "Show Filtered Removal Count"; -"eh_setting_view.description.filtered_removal_count" = "Show the \"Your default filters removed XX galleries from this page\" readout?"; -"eh_setting_view.title.show_filtered_removal_count" = "Show filtered removal count"; - -"eh_setting_view.section.title.excluded_languages" = "Excluded Languages"; -"eh_setting_view.description.excluded_languages" = "If you wish to hide galleries in certain languages from the gallery list and searches, select them from the list below. Note that matching galleries will never appear regardless of your search query."; -// EhSetting.ExcludedLanguagesCategory -"enum.eh_setting.excluded_languages_category.value.original" = "Original"; -"enum.eh_setting.excluded_languages_category.value.translated" = "Translated"; -"enum.eh_setting.excluded_languages_category.value.rewrite" = "Rewrite"; - -"eh_setting_view.section.title.excluded_uploaders" = "Excluded Uploaders"; -"eh_setting_view.description.excluded_uploaders" = "If you wish to hide galleries from certain uploaders from the gallery list and searches, add them below. Put one username per line. Note that galleries from these uploaders will never appear regardless of your search query."; -"eh_setting_view.description.excluded_uploaders_count" = "You are currently using **%@ / %@** exclusion slots."; - -"eh_setting_view.section.title.search_result_count" = "Search Result Count"; -"eh_setting_view.title.result_count" = "Result count"; -"eh_setting_view.description.result_count" = "How many results would you like per page for the index/search page and torrent search pages?\n(Hath Perk: Paging Enlargement Required)"; - -"eh_setting_view.section.title.thumbnail_settings" = "Thumbnail Settings"; -"eh_setting_view.title.thumbnail_load_timing" = "Thumbnail load timing"; -"eh_setting_view.description.thumbnail_load_timing" = "How would you like the mouse-over thumbnails on the front page to load when using List Mode?"; -"eh_setting_view.description.thumbnail_configuration" = "You can set a default thumbnail configuration for all galleries you visit."; -"eh_setting_view.title.thumbnail_size" = "Size"; -"eh_setting_view.title.thumbnail_row_count" = "Rows"; -// EhSetting.ThumbnailLoadTiming -"enum.eh_setting.thumbnail_load_timing.value.on_mouse_over" = "On mouse-over"; -"enum.eh_setting.thumbnail_load_timing.value.on_page_load" = "On page load"; -"enum.eh_setting.thumbnail_load_timing.description.on_mouse_over" = "Pages load faster, but there may be a slight delay before a thumb appears."; -"enum.eh_setting.thumbnail_load_timing.description.on_page_load" = "Pages take longer to load, but there is no delay for loading a thumb after the page has loaded."; -// EhSetting.ThumbnailSize -"enum.eh_setting.thumbnail_size.value.normal" = "Normal"; -"enum.eh_setting.thumbnail_size.value.large" = "Large"; -"enum.eh_setting.thumbnail_size.value.small" = "Small"; -"enum.eh_setting.thumbnail_size.value.auto" = "Auto"; - -"eh_setting_view.section.title.cover_scaling" = "Cover Scaling"; -"eh_setting_view.title.scale_factor" = "Scale factor"; -"eh_setting_view.description.cover_scale_factor" = "The cover size in gallery list views can be scaled to between 75%% and 150%% when using the Thumbnail or Extended display modes."; - -"eh_setting_view.section.title.viewport_override" = "Viewport Override"; -"eh_setting_view.title.virtual_width" = "Virtual width"; -"eh_setting_view.description.virtual_width" = "Allows you to override the virtual width of the site for mobile devices. This is normally determined automatically by your device based on its DPI. Sensible values at 100%% thumbnail scale are between 640 and 1400."; - -"eh_setting_view.section.title.gallery_comments" = "Gallery Comments"; -"eh_setting_view.title.comments_sort_order" = "Comments sort order"; -"eh_setting_view.title.comments_votes_show_timing" = "Comment votes show timing"; -// EhSetting.CommentsSortOrder -"enum.eh_setting.comments_sort_order.value.oldest" = "Oldest comments first"; -"enum.eh_setting.comments_sort_order.value.recent" = "Recent comments first"; -"enum.eh_setting.comments_sort_order.value.highest_score" = "By highest score"; -// EhSetting.CommentVotesShowTiming -"enum.eh_setting.comments_votes_show_timing.value.on_hover_or_click" = "On score hover or click"; -"enum.eh_setting.comments_votes_show_timing.value.always" = "Always"; - -"eh_setting_view.section.title.gallery_tags" = "Gallery Tags"; -"eh_setting_view.title.tags_sort_order" = "Tags sort order"; -// EhSetting.tags_sort_order -"enum.eh_setting.tags_sort_order.value.alphabetical" = "Alphabetical"; -"enum.eh_setting.tags_sort_order.value.tag_power" = "By tag power"; - -"eh_setting_view.section.title.gallery_page_thumbnail_labeling" = "Gallery Page Thumbnail Labeling"; -"eh_setting_view.title.show_label_below_gallery_thumbnails" = "Show label below gallery thumbnails"; - -"eh_setting_view.section.title.hath_local_network_host" = "Hath Local Network Host"; -"eh_setting_view.title.ip_address_port" = "IP address:Port"; -"eh_setting_view.description.ip_address_port" = "This setting can be used if you have a H@H client running on your local network with the same public IP you browse the site with. Some routers are buggy and cannot route requests back to its own IP; this allows you to work around this problem.\nIf you are running the client on the same device you browse from, use the loopback address (127.0.0.1:port). If the client is running on another device on your network, use its local network IP. Some browser configurations prevent external web sites from accessing URLs with local network IPs, the site must then be whitelisted for this to work."; - -"eh_setting_view.section.title.original_images" = "Use original images instead of the resampled versions? Resampled images will still be used if you select a horizontal resolution different than \"Auto\" above and the image in question is wider, or if the original image is larger than 10 MiB (or 4 MiB for galleries older than one year)."; -"eh_setting_view.title.use_original_images" = "Use original images"; - -"eh_setting_view.section.title.multi_page_viewer" = "Multi-Page Viewer"; -"eh_setting_view.title.use_multi_page_viewer" = "Use Multi-Page Viewer"; -"eh_setting_view.title.display_style" = "Display style"; -"eh_setting_view.title.show_thumbnail_pane" = "Show thumbnail pane"; -// EhSetting.MultiplePageViewerStyle -"enum.eh_setting.multiple_page_viewer_style.value.align_left_scale_if_over_width" = "Align left, scale if overwidth"; -"enum.eh_setting.multiple_page_viewer_style.value.align_center_scale_if_over_width" = "Align center, scale if overwidth"; -"enum.eh_setting.multiple_page_viewer_style.value.align_center_always_scale" = "Align center, always scale"; -// EhSetting.GalleryPageNumbering -"enum.eh_setting.gallery_page_numbering.value.none" = "None"; -"enum.eh_setting.gallery_page_numbering.value.page_number_only" = "Page Number Only"; -"enum.eh_setting.gallery_page_numbering.value.page_number_and_name" = "Page Number + Name"; - -// MARK: Category -"enum.category.value.doujinshi" = "Doujinshi"; -"enum.category.value.manga" = "Manga"; -"enum.category.value.artist_CG" = "Artist CG"; -"enum.category.value.game_CG" = "Game CG"; -"enum.category.value.western" = "Western"; -"enum.category.value.non_h" = "Non-H"; -"enum.category.value.image_set" = "Image Set"; -"enum.category.value.cosplay" = "Cosplay"; -"enum.category.value.asian_porn" = "Asian Porn"; -"enum.category.value.misc" = "Misc"; -"enum.category.value.private" = "Private"; - -// MARK: TagNamespace -"enum.tag_namespace.value.reclass" = "Reclass"; -"enum.tag_namespace.value.language" = "Sprache"; -"enum.tag_namespace.value.parody" = "Parodie"; -"enum.tag_namespace.value.character" = "Charakter"; -"enum.tag_namespace.value.group" = "Gruppe"; -"enum.tag_namespace.value.artist" = "Künstler"; -"enum.tag_namespace.value.male" = "Männlich"; -"enum.tag_namespace.value.female" = "Weiblich"; -"enum.tag_namespace.value.mixed" = "Mixed"; -"enum.tag_namespace.value.cosplayer" = "Cosplayer"; -"enum.tag_namespace.value.other" = "Other"; -"enum.tag_namespace.value.temp" = "Temp"; - -// MARK: Language -"enum.language.value.invalid" = "./."; -"enum.language.value.other" = "Other"; -"enum.language.value.afrikaans" = "Afrikaan"; -"enum.language.value.albanian" = "Albanisch"; -"enum.language.value.arabic" = "Arabisch"; -"enum.language.value.bengali" = "Bengali"; -"enum.language.value.bosnian" = "Bosnisch"; -"enum.language.value.bulgarian" = "Bulgarisch"; -"enum.language.value.burmese" = "Birmanisch"; -"enum.language.value.catalan" = "Katalanisch"; -"enum.language.value.cebuano" = "Cebuano"; -"enum.language.value.chinese" = "Chinesisch"; -"enum.language.value.croatian" = "Kroatisch"; -"enum.language.value.czech" = "Tschechisch"; -"enum.language.value.danish" = "Dänisch"; -"enum.language.value.dutch" = "Niederländisch"; -"enum.language.value.english" = "Englisch"; -"enum.language.value.esperanto" = "Esperanto"; -"enum.language.value.estonian" = "Estländisch"; -"enum.language.value.finnish" = "Finnisch"; -"enum.language.value.french" = "Französisch"; -"enum.language.value.georgian" = "Georgisch"; -"enum.language.value.german" = "Deutsch"; -"enum.language.value.greek" = "Griechisch"; -"enum.language.value.hebrew" = "Hebräisch"; -"enum.language.value.hindi" = "Hindi"; -"enum.language.value.hmong" = "Hmong"; -"enum.language.value.hungarian" = "Ungarisch"; -"enum.language.value.indonesian" = "Indonesisch"; -"enum.language.value.italian" = "Italian"; -"enum.language.value.japanese" = "Japanisch"; -"enum.language.value.kazakh" = "Kazakhstanisch"; -"enum.language.value.khmer" = "Khmer"; -"enum.language.value.korean" = "Koreanisch"; -"enum.language.value.kurdish" = "Kurdisch"; -"enum.language.value.lao" = "Lao"; -"enum.language.value.latin" = "Latein"; -"enum.language.value.mongolian" = "Mongolisch"; -"enum.language.value.ndebele" = "Ndebele"; -"enum.language.value.nepali" = "Nepali"; -"enum.language.value.norwegian" = "Norwegisch"; -"enum.language.value.oromo" = "Oromo"; -"enum.language.value.pashto" = "Pashto"; -"enum.language.value.persian" = "Persisch"; -"enum.language.value.polish" = "Polnisch"; -"enum.language.value.portuguese" = "Portugiesisch"; -"enum.language.value.punjabi" = "Punjabi"; -"enum.language.value.romanian" = "Rumänisch"; -"enum.language.value.russian" = "Russisch"; -"enum.language.value.sango" = "Sango"; -"enum.language.value.serbian" = "Serbisch"; -"enum.language.value.shona" = "Shona"; -"enum.language.value.slovak" = "Slovakisch"; -"enum.language.value.slovenian" = "Slovenisch"; -"enum.language.value.somali" = "Somali"; -"enum.language.value.spanish" = "Spanisch"; -"enum.language.value.swahili" = "Swahili"; -"enum.language.value.swedish" = "Schwedisch"; -"enum.language.value.tagalog" = "Tagalog"; -"enum.language.value.thai" = "Thai"; -"enum.language.value.tigrinya" = "Tigrinya"; -"enum.language.value.turkish" = "Türkisch"; -"enum.language.value.ukrainian" = "Ukrainisch"; -"enum.language.value.urdu" = "Urdu"; -"enum.language.value.vietnamese" = "Vietnamesisch"; -"enum.language.value.zulu" = "Zulu"; - -// MARK: BrowsingCountry -"enum.browsing_country.name.auto_detect" = "Auto-Detect"; -"enum.browsing_country.name.afghanistan" = "Afghanistan"; -"enum.browsing_country.name.aland_islands" = "Aland Islands"; -"enum.browsing_country.name.albania" = "Albania"; -"enum.browsing_country.name.algeria" = "Algeria"; -"enum.browsing_country.name.american_samoa" = "American Samoa"; -"enum.browsing_country.name.andorra" = "Andorra"; -"enum.browsing_country.name.angola" = "Angola"; -"enum.browsing_country.name.anguilla" = "Anguilla"; -"enum.browsing_country.name.antarctica" = "Antarctica"; -"enum.browsing_country.name.antigua_and_barbuda" = "Antigua and Barbuda"; -"enum.browsing_country.name.argentina" = "Argentina"; -"enum.browsing_country.name.armenia" = "Armenia"; -"enum.browsing_country.name.aruba" = "Aruba"; -"enum.browsing_country.name.asia_pacific_region" = "Asia-Pacific Region"; -"enum.browsing_country.name.australia" = "Australia"; -"enum.browsing_country.name.austria" = "Austria"; -"enum.browsing_country.name.azerbaijan" = "Azerbaijan"; -"enum.browsing_country.name.bahamas" = "Bahamas"; -"enum.browsing_country.name.bahrain" = "Bahrain"; -"enum.browsing_country.name.bangladesh" = "Bangladesh"; -"enum.browsing_country.name.barbados" = "Barbados"; -"enum.browsing_country.name.belarus" = "Belarus"; -"enum.browsing_country.name.belgium" = "Belgium"; -"enum.browsing_country.name.belize" = "Belize"; -"enum.browsing_country.name.benin" = "Benin"; -"enum.browsing_country.name.bermuda" = "Bermuda"; -"enum.browsing_country.name.bhutan" = "Bhutan"; -"enum.browsing_country.name.bolivia" = "Bolivia"; -"enum.browsing_country.name.bonaire_saint_eustatius_and_saba" = "Bonaire Saint Eustatius and Saba"; -"enum.browsing_country.name.bosnia_and_herzegovina" = "Bosnia and Herzegovina"; -"enum.browsing_country.name.botswana" = "Botswana"; -"enum.browsing_country.name.bouvet_island" = "Bouvet Island"; -"enum.browsing_country.name.brazil" = "Brazil"; -"enum.browsing_country.name.british_indian_ocean_territory" = "British Indian Ocean Territory"; -"enum.browsing_country.name.brunei_darussalam" = "Brunei Darussalam"; -"enum.browsing_country.name.bulgaria" = "Bulgaria"; -"enum.browsing_country.name.burkina_faso" = "Burkina Faso"; -"enum.browsing_country.name.burundi" = "Burundi"; -"enum.browsing_country.name.cambodia" = "Cambodia"; -"enum.browsing_country.name.cameroon" = "Cameroon"; -"enum.browsing_country.name.canada" = "Canada"; -"enum.browsing_country.name.cape_verde" = "Cape Verde"; -"enum.browsing_country.name.cayman_islands" = "Cayman Islands"; -"enum.browsing_country.name.central_african_republic" = "Central African Republic"; -"enum.browsing_country.name.chad" = "Chad"; -"enum.browsing_country.name.chile" = "Chile"; -"enum.browsing_country.name.china" = "China"; -"enum.browsing_country.name.christmas_island" = "Christmas Island"; -"enum.browsing_country.name.cocos_islands" = "Cocos Islands"; -"enum.browsing_country.name.colombia" = "Colombia"; -"enum.browsing_country.name.comoros" = "Comoros"; -"enum.browsing_country.name.congo" = "Congo"; -"enum.browsing_country.name.the_democratic_republic_of_the_congo" = "The Democratic Republic of the Congo"; -"enum.browsing_country.name.cook_islands" = "Cook Islands"; -"enum.browsing_country.name.costa_rica" = "Costa Rica"; -"enum.browsing_country.name.cote_d_ivoire" = "Cote D'Ivoire"; -"enum.browsing_country.name.croatia" = "Croatia"; -"enum.browsing_country.name.cuba" = "Cuba"; -"enum.browsing_country.name.curacao" = "Curacao"; -"enum.browsing_country.name.cyprus" = "Cyprus"; -"enum.browsing_country.name.czech_republic" = "Czech Republic"; -"enum.browsing_country.name.denmark" = "Denmark"; -"enum.browsing_country.name.djibouti" = "Djibouti"; -"enum.browsing_country.name.dominica" = "Dominica"; -"enum.browsing_country.name.dominican_republic" = "Dominican Republic"; -"enum.browsing_country.name.ecuador" = "Ecuador"; -"enum.browsing_country.name.egypt" = "Egypt"; -"enum.browsing_country.name.el_salvador" = "El Salvador"; -"enum.browsing_country.name.equatorial_guinea" = "Equatorial Guinea"; -"enum.browsing_country.name.eritrea" = "Eritrea"; -"enum.browsing_country.name.estonia" = "Estonia"; -"enum.browsing_country.name.ethiopia" = "Ethiopia"; -"enum.browsing_country.name.europe" = "Europe"; -"enum.browsing_country.name.falkland_islands" = "Falkland Islands"; -"enum.browsing_country.name.faroe_islands" = "Faroe Islands"; -"enum.browsing_country.name.fiji" = "Fiji"; -"enum.browsing_country.name.finland" = "Finland"; -"enum.browsing_country.name.france" = "France"; -"enum.browsing_country.name.french_guiana" = "French Guiana"; -"enum.browsing_country.name.french_polynesia" = "French Polynesia"; -"enum.browsing_country.name.french_southern_territories" = "French Southern Territories"; -"enum.browsing_country.name.gabon" = "Gabon"; -"enum.browsing_country.name.gambia" = "Gambia"; -"enum.browsing_country.name.georgia" = "Georgia"; -"enum.browsing_country.name.germany" = "Germany"; -"enum.browsing_country.name.ghana" = "Ghana"; -"enum.browsing_country.name.gibraltar" = "Gibraltar"; -"enum.browsing_country.name.greece" = "Greece"; -"enum.browsing_country.name.greenland" = "Greenland"; -"enum.browsing_country.name.grenada" = "Grenada"; -"enum.browsing_country.name.guadeloupe" = "Guadeloupe"; -"enum.browsing_country.name.guam" = "Guam"; -"enum.browsing_country.name.guatemala" = "Guatemala"; -"enum.browsing_country.name.guernsey" = "Guernsey"; -"enum.browsing_country.name.guinea" = "Guinea"; -"enum.browsing_country.name.guinea_bissau" = "Guinea-Bissau"; -"enum.browsing_country.name.guyana" = "Guyana"; -"enum.browsing_country.name.haiti" = "Haiti"; -"enum.browsing_country.name.heard_island_and_mc_donald_islands" = "Heard Island and McDonald Islands"; -"enum.browsing_country.name.vatican_city_state" = "Vatican City State"; -"enum.browsing_country.name.honduras" = "Honduras"; -"enum.browsing_country.name.hong_kong" = "Hong Kong"; -"enum.browsing_country.name.hungary" = "Hungary"; -"enum.browsing_country.name.iceland" = "Iceland"; -"enum.browsing_country.name.india" = "India"; -"enum.browsing_country.name.indonesia" = "Indonesia"; -"enum.browsing_country.name.iran" = "Iran"; -"enum.browsing_country.name.iraq" = "Iraq"; -"enum.browsing_country.name.ireland" = "Ireland"; -"enum.browsing_country.name.isle_of_man" = "Isle of Man"; -"enum.browsing_country.name.israel" = "Israel"; -"enum.browsing_country.name.italy" = "Italy"; -"enum.browsing_country.name.jamaica" = "Jamaica"; -"enum.browsing_country.name.japan" = "Japan"; -"enum.browsing_country.name.jersey" = "Jersey"; -"enum.browsing_country.name.jordan" = "Jordan"; -"enum.browsing_country.name.kazakhstan" = "Kazakhstan"; -"enum.browsing_country.name.kenya" = "Kenya"; -"enum.browsing_country.name.kiribati" = "Kiribati"; -"enum.browsing_country.name.kuwait" = "Kuwait"; -"enum.browsing_country.name.kyrgyzstan" = "Kyrgyzstan"; -"enum.browsing_country.name.lao_peoples_democratic_republic" = "Lao People's Democratic Republic"; -"enum.browsing_country.name.latvia" = "Latvia"; -"enum.browsing_country.name.lebanon" = "Lebanon"; -"enum.browsing_country.name.lesotho" = "Lesotho"; -"enum.browsing_country.name.liberia" = "Liberia"; -"enum.browsing_country.name.libya" = "Libya"; -"enum.browsing_country.name.liechtenstein" = "Liechtenstein"; -"enum.browsing_country.name.lithuania" = "Lithuania"; -"enum.browsing_country.name.luxembourg" = "Luxembourg"; -"enum.browsing_country.name.macau" = "Macau"; -"enum.browsing_country.name.macedonia" = "Macedonia"; -"enum.browsing_country.name.madagascar" = "Madagascar"; -"enum.browsing_country.name.malawi" = "Malawi"; -"enum.browsing_country.name.malaysia" = "Malaysia"; -"enum.browsing_country.name.maldives" = "Maldives"; -"enum.browsing_country.name.mali" = "Mali"; -"enum.browsing_country.name.malta" = "Malta"; -"enum.browsing_country.name.marshall_islands" = "Marshall Islands"; -"enum.browsing_country.name.martinique" = "Martinique"; -"enum.browsing_country.name.mauritania" = "Mauritania"; -"enum.browsing_country.name.mauritius" = "Mauritius"; -"enum.browsing_country.name.mayotte" = "Mayotte"; -"enum.browsing_country.name.mexico" = "Mexico"; -"enum.browsing_country.name.micronesia" = "Micronesia"; -"enum.browsing_country.name.moldova" = "Moldova"; -"enum.browsing_country.name.monaco" = "Monaco"; -"enum.browsing_country.name.mongolia" = "Mongolia"; -"enum.browsing_country.name.montenegro" = "Montenegro"; -"enum.browsing_country.name.montserrat" = "Montserrat"; -"enum.browsing_country.name.morocco" = "Morocco"; -"enum.browsing_country.name.mozambique" = "Mozambique"; -"enum.browsing_country.name.myanmar" = "Myanmar"; -"enum.browsing_country.name.namibia" = "Namibia"; -"enum.browsing_country.name.nauru" = "Nauru"; -"enum.browsing_country.name.nepal" = "Nepal"; -"enum.browsing_country.name.netherlands" = "Netherlands"; -"enum.browsing_country.name.new_caledonia" = "New Caledonia"; -"enum.browsing_country.name.new_zealand" = "New Zealand"; -"enum.browsing_country.name.nicaragua" = "Nicaragua"; -"enum.browsing_country.name.niger" = "Niger"; -"enum.browsing_country.name.nigeria" = "Nigeria"; -"enum.browsing_country.name.niue" = "Niue"; -"enum.browsing_country.name.norfolk_island" = "Norfolk Island"; -"enum.browsing_country.name.north_korea" = "North Korea"; -"enum.browsing_country.name.northern_mariana_islands" = "Northern Mariana Islands"; -"enum.browsing_country.name.norway" = "Norway"; -"enum.browsing_country.name.oman" = "Oman"; -"enum.browsing_country.name.pakistan" = "Pakistan"; -"enum.browsing_country.name.palau" = "Palau"; -"enum.browsing_country.name.palestinian_territory" = "Palestinian Territory"; -"enum.browsing_country.name.panama" = "Panama"; -"enum.browsing_country.name.papua_new_guinea" = "Papua New Guinea"; -"enum.browsing_country.name.paraguay" = "Paraguay"; -"enum.browsing_country.name.peru" = "Peru"; -"enum.browsing_country.name.philippines" = "Philippines"; -"enum.browsing_country.name.pitcairn_islands" = "Pitcairn Islands"; -"enum.browsing_country.name.poland" = "Poland"; -"enum.browsing_country.name.portugal" = "Portugal"; -"enum.browsing_country.name.puerto_rico" = "Puerto Rico"; -"enum.browsing_country.name.qatar" = "Qatar"; -"enum.browsing_country.name.reunion" = "Reunion"; -"enum.browsing_country.name.romania" = "Romania"; -"enum.browsing_country.name.russian_federation" = "Russian Federation"; -"enum.browsing_country.name.rwanda" = "Rwanda"; -"enum.browsing_country.name.saint_barthelemy" = "Saint Barthelemy"; -"enum.browsing_country.name.saint_helena" = "Saint Helena"; -"enum.browsing_country.name.saint_kitts_and_nevis" = "Saint Kitts and Nevis"; -"enum.browsing_country.name.saint_lucia" = "Saint Lucia"; -"enum.browsing_country.name.saint_martin" = "Saint Martin"; -"enum.browsing_country.name.saint_pierre_and_miquelon" = "Saint Pierre and Miquelon"; -"enum.browsing_country.name.saint_vincent_and_the_grenadines" = "Saint Vincent and the Grenadines"; -"enum.browsing_country.name.samoa" = "Samoa"; -"enum.browsing_country.name.san_marino" = "San Marino"; -"enum.browsing_country.name.sao_tome_and_principe" = "Sao Tome and Principe"; -"enum.browsing_country.name.saudi_arabia" = "Saudi Arabia"; -"enum.browsing_country.name.senegal" = "Senegal"; -"enum.browsing_country.name.serbia" = "Serbia"; -"enum.browsing_country.name.seychelles" = "Seychelles"; -"enum.browsing_country.name.sierra_leone" = "Sierra Leone"; -"enum.browsing_country.name.singapore" = "Singapore"; -"enum.browsing_country.name.sint_maarten" = "Sint Maarten"; -"enum.browsing_country.name.slovakia" = "Slovakia"; -"enum.browsing_country.name.slovenia" = "Slovenia"; -"enum.browsing_country.name.solomon_islands" = "Solomon Islands"; -"enum.browsing_country.name.somalia" = "Somalia"; -"enum.browsing_country.name.south_africa" = "South Africa"; -"enum.browsing_country.name.south_georgia_and_the_south_sandwich_islands" = "South Georgia and the South Sandwich Islands"; -"enum.browsing_country.name.south_korea" = "South Korea"; -"enum.browsing_country.name.south_sudan" = "South Sudan"; -"enum.browsing_country.name.spain" = "Spain"; -"enum.browsing_country.name.sri_lanka" = "Sri Lanka"; -"enum.browsing_country.name.sudan" = "Sudan"; -"enum.browsing_country.name.suriname" = "Suriname"; -"enum.browsing_country.name.svalbard_and_jan_mayen" = "Svalbard and Jan Mayen"; -"enum.browsing_country.name.swaziland" = "Swaziland"; -"enum.browsing_country.name.sweden" = "Sweden"; -"enum.browsing_country.name.switzerland" = "Switzerland"; -"enum.browsing_country.name.syrian_arab_republic" = "Syrian Arab Republic"; -"enum.browsing_country.name.taiwan" = "Taiwan"; -"enum.browsing_country.name.tajikistan" = "Tajikistan"; -"enum.browsing_country.name.tanzania" = "Tanzania"; -"enum.browsing_country.name.thailand" = "Thailand"; -"enum.browsing_country.name.timor_leste" = "Timor-Leste"; -"enum.browsing_country.name.togo" = "Togo"; -"enum.browsing_country.name.tokelau" = "Tokelau"; -"enum.browsing_country.name.tonga" = "Tonga"; -"enum.browsing_country.name.trinidad_and_tobago" = "Trinidad and Tobago"; -"enum.browsing_country.name.tunisia" = "Tunisia"; -"enum.browsing_country.name.turkey" = "Turkey"; -"enum.browsing_country.name.turkmenistan" = "Turkmenistan"; -"enum.browsing_country.name.turks_and_caicos_islands" = "Turks and Caicos Islands"; -"enum.browsing_country.name.tuvalu" = "Tuvalu"; -"enum.browsing_country.name.uganda" = "Uganda"; -"enum.browsing_country.name.ukraine" = "Ukraine"; -"enum.browsing_country.name.united_arab_emirates" = "United Arab Emirates"; -"enum.browsing_country.name.united_kingdom" = "United Kingdom"; -"enum.browsing_country.name.united_states" = "United States"; -"enum.browsing_country.name.united_states_minor_outlying_islands" = "United States Minor Outlying Islands"; -"enum.browsing_country.name.uruguay" = "Uruguay"; -"enum.browsing_country.name.uzbekistan" = "Uzbekistan"; -"enum.browsing_country.name.vanuatu" = "Vanuatu"; -"enum.browsing_country.name.venezuela" = "Venezuela"; -"enum.browsing_country.name.vietnam" = "Vietnam"; -"enum.browsing_country.name.virgin_islands_british" = "British Virgin Islands"; -"enum.browsing_country.name.virgin_islands_US" = "U.S. Virgin Islands"; -"enum.browsing_country.name.wallis_and_futuna" = "Wallis and Futuna"; -"enum.browsing_country.name.western_sahara" = "Western Sahara"; -"enum.browsing_country.name.yemen" = "Yemen"; -"enum.browsing_country.name.zambia" = "Zambia"; -"enum.browsing_country.name.zimbabwe" = "Zimbabwe"; diff --git a/EhPanda/App/en.lproj/Constant.strings b/EhPanda/App/en.lproj/Constant.strings deleted file mode 100644 index c08edd416..000000000 --- a/EhPanda/App/en.lproj/Constant.strings +++ /dev/null @@ -1,93 +0,0 @@ -/* - Constant.strings - EhPanda -*/ - -// MARK: Website response -"website.response.hath_client_not_found" = "You must have a H@H client assigned to your account to use this feature."; -"website.response.hath_client_not_online" = "Your H@H client appears to be offline. Turn it on, then try again."; -"website.response.invalid_resolution" = "The requested gallery cannot be downloaded with the selected resolution."; -"website.response.gallery_unavailable" = "This gallery has been removed or is unavailable."; - -// MARK: App -"app.copyright" = "Copyright © 2025 EhPanda Team"; - -// Contact -"app.contact.link.website" = "https://ehpanda.app"; -"app.contact.link.gitHub" = "https://github.com/EhPanda-Team/EhPanda"; -"app.contact.link.discord" = "https://discord.gg/BSBE9FCBTq"; -"app.contact.link.telegram" = "https://t.me/ehpanda"; -"app.contact.link.altStore" = "altstore://source?url=https://github.com/EhPanda-Team/EhPanda/raw/main/AltStore.json"; -"app.contact.text.gitHub" = "GitHub"; -"app.contact.text.discord" = "Discord"; -"app.contact.text.telegram" = "Telegram"; - -// Special thanks -"app.special_thanks.link.taylorlannister" = "https://github.com/taylorlannister"; -"app.special_thanks.link.luminescent_yq" = ""; -"app.special_thanks.link.caxerx" = "https://github.com/caxerx"; -"app.special_thanks.link.honjow" = "https://github.com/honjow"; -"app.special_thanks.text.taylorlannister" = "taylorlannister"; -"app.special_thanks.text.luminescent_yq" = "Luminescent_yq"; -"app.special_thanks.text.caxerx" = "caxerx"; -"app.special_thanks.text.honjow" = "honjow"; - -// Code level contributor -"app.code_level_contributor.link.chihchy" = "https://github.com/chihchy"; -"app.code_level_contributor.link.aalberrty" = "https://github.com/aalberrty"; -"app.code_level_contributor.link.Jimmy-Prime" = "https://github.com/Jimmy-Prime"; -"app.code_level_contributor.link.xioxin" = "https://github.com/xioxin"; -"app.code_level_contributor.link.vvbbnn00" = "https://github.com/vvbbnn00"; -"app.code_level_contributor.text.chihchy" = "Chihchy"; -"app.code_level_contributor.text.aalberrty" = "Zack Asahina"; -"app.code_level_contributor.text.Jimmy-Prime" = "Jimmy Prime"; -"app.code_level_contributor.text.xioxin" = "xioxin"; -"app.code_level_contributor.text.vvbbnn00" = "vvbbnn00"; - -// Translation contributor -"app.translation_contributor.link.nebulosa-cat" = "https://github.com/Nebulosa-Cat"; -"app.translation_contributor.link.paulHaeussler" = "https://github.com/PaulHaeussler"; -"app.translation_contributor.link.caxerx" = "https://github.com/caxerx"; -"app.translation_contributor.link.NeKoOuO" = "https://github.com/NeKoOuO"; -"app.translation_contributor.text.nebulosa-cat" = "雲豹 ΦωΦ"; -"app.translation_contributor.text.paulHaeussler" = "PaulHaeussler"; -"app.translation_contributor.text.caxerx" = "caxerx"; -"app.translation_contributor.text.NeKoOuO" = "ɴᴇᴋᴏ"; - -// Acknowledgement link -"app.acknowledgement.link.kanna" = "https://github.com/tid-kijyun/Kanna"; -"app.acknowledgement.link.swiftGen" = "https://github.com/SwiftGen/SwiftGen"; -"app.acknowledgement.link.alertKit" = "https://github.com/rebeloper/AlertKit"; -"app.acknowledgement.link.colorful" = "https://github.com/Co2333/Colorful"; -"app.acknowledgement.link.filePicker" = "https://github.com/markrenaud/FilePicker"; -"app.acknowledgement.link.kingfisher" = "https://github.com/onevcat/Kingfisher"; -"app.acknowledgement.link.swiftUIPager" = "https://github.com/fermoya/SwiftUIPager"; -"app.acknowledgement.link.swiftyBeaver" = "https://github.com/SwiftyBeaver/SwiftyBeaver"; -"app.acknowledgement.link.waterfallGrid" = "https://github.com/paololeonardi/WaterfallGrid"; -"app.acknowledgement.link.swiftyOpenCC" = "https://github.com/ddddxxx/SwiftyOpenCC"; -"app.acknowledgement.link.uiImageColors" = "https://github.com/jathu/UIImageColors"; -"app.acknowledgement.link.sfSafeSymbols" = "https://github.com/SFSafeSymbols/SFSafeSymbols"; -"app.acknowledgement.link.ttProgressHUD" = "https://github.com/honkmaster/TTProgressHUD"; -"app.acknowledgement.link.swiftUINavigation" = "https://github.com/pointfreeco/swiftui-navigation"; -"app.acknowledgement.link.swiftCommonMark" = "https://github.com/gonzalezreal/SwiftCommonMark"; -"app.acknowledgement.link.ehTagTranslationDatabase" = "https://github.com/EhTagTranslation/Database"; -"app.acknowledgement.link.tca" = "https://github.com/pointfreeco/swift-composable-architecture"; - -// Acknowledgement text -"app.acknowledgement.text.kanna" = "Kanna"; -"app.acknowledgement.text.swiftGen" = "SwiftGen"; -"app.acknowledgement.text.alertKit" = "AlertKit"; -"app.acknowledgement.text.colorful" = "Colorful"; -"app.acknowledgement.text.filePicker" = "FilePicker"; -"app.acknowledgement.text.kingfisher" = "Kingfisher"; -"app.acknowledgement.text.swiftUIPager" = "SwiftUIPager"; -"app.acknowledgement.text.swiftyBeaver" = "SwiftyBeaver"; -"app.acknowledgement.text.waterfallGrid" = "WaterfallGrid"; -"app.acknowledgement.text.swiftyOpenCC" = "SwiftyOpenCC"; -"app.acknowledgement.text.uiImageColors" = "UIImageColors"; -"app.acknowledgement.text.sfSafeSymbols" = "SFSafeSymbols"; -"app.acknowledgement.text.ttProgressHUD" = "TTProgressHUD"; -"app.acknowledgement.text.swiftUINavigation" = "SwiftUI Navigation"; -"app.acknowledgement.text.swiftCommonMark" = "SwiftCommonMark"; -"app.acknowledgement.text.ehTagTranslationDatabase" = "EhTagTranslation/Database"; -"app.acknowledgement.text.tca" = "The Composable Architecture"; diff --git a/EhPanda/App/en.lproj/InfoPlist.strings b/EhPanda/App/en.lproj/InfoPlist.strings deleted file mode 100644 index a924bb3b3..000000000 --- a/EhPanda/App/en.lproj/InfoPlist.strings +++ /dev/null @@ -1,7 +0,0 @@ -/* - InfoPlist.strings - EhPanda -*/ - -"NSFaceIDUsageDescription" = "We need this permission to provide Face ID option while unlocking the App."; -"NSPhotoLibraryAddUsageDescription" = "We need this permission to save images to your photo library."; diff --git a/EhPanda/App/en.lproj/Localizable.strings b/EhPanda/App/en.lproj/Localizable.strings deleted file mode 100644 index a3ca9bacc..000000000 --- a/EhPanda/App/en.lproj/Localizable.strings +++ /dev/null @@ -1,906 +0,0 @@ -/* - Localizable.strings - EhPanda -*/ - -// MARK: BanInterval -"enum.ban_interval.description.and" = "and"; - -// MARK: ToplistsType -"enum.toplists_type.value.yesterday" = "Yesterday"; -"enum.toplists_type.value.past_month" = "Past month"; -"enum.toplists_type.value.past_year" = "Past year"; -"enum.toplists_type.value.all_time" = "All time"; - -// MARK: Response -"website.response.hath_client_not_found" = "You must have a H@H client assigned to your account to use this feature."; -"website.response.hath_client_not_online" = "Your H@H client appears to be offline. Turn it on, then try again."; -"website.response.invalid_resolution" = "The requested gallery cannot be downloaded with the selected resolution."; - -// MARK: HUD -"hud.title.error" = "Error"; -"hud.title.success" = "Success"; -"hud.title.loading" = "Loading..."; -"hud.title.communicating" = "Communicating..."; -"hud.caption.copied_to_clipboard" = "Copied to clipboard"; -"hud.caption.saved_to_photo_library" = "Saved to photo library"; - -// MARK: AutoLock -"local_authorization.reason" = "The App has been locked due to the Auto-Lock expiration."; - -// MARK: Common value -"common.value.stars" = "%@ stars"; -"common.value.pages" = "%@ pages"; -"common.value.times" = "%@ times"; -"common.value.day" = "%@ day"; -"common.value.days" = "%@ days"; -"common.value.hour" = "%@ hour"; -"common.value.hours" = "%@ hours"; -"common.value.minute" = "%@ minute"; -"common.value.minutes" = "%@ minutes"; -"common.value.second" = "%@ second"; -"common.value.seconds" = "%@ seconds"; -"common.value.records" = "%@ records"; - -// 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.jump_page" = "Jump page"; -"toolbar_item.button.quick_search" = "Quick search"; - -// MARK: JumpPage -"jump_page_view.title.jump_page" = "Jump page"; -"jump_page_view.button.confirm" = "Confirm"; - -// MARK: AlertView -"loading_view.title.loading" = "Loading..."; -"loading_view.title.preparing_database" = "Preparing the database..."; -"not_login_view.title.need_login" = "You need to login to access this feature."; -"not_login_view.button.login" = "Login"; -"error_view.button.retry" = "Retry"; -"error_view.button.drop_database" = "Drop the database"; -"error_view.title.try_later" = "Please try again later."; -"error_view.title.network" = "A network error occurred."; -"error_view.title.parsing" = "A parsing error occurred."; -"error_view.title.unknown" = "An unknown error occurred."; -"error_view.title.not_found" = "There seems to be nothing here."; -"error_view.title.database_corrupted" = "The database is corrupted.\nPlease submit an issue on GitHub."; -"error_view.title.ip_banned" = "Your IP address has been temporarily banned for excessive pageloads which indicates that you are using automated mirroring / harvesting software. The ban expires in %@."; -"error_view.title.copyright_claim" = "This gallery is unavailable due to a copyright claim by %@. Sorry about that."; -"error_view.title.gallery_unavailable" = "This gallery has been removed or is unavailable."; - -// MARK: ConfirmationDialog -"confirmation_dialog.title.drop_database" = "You will lose all your data in this app.\nAre you sure to drop the database?"; -"confirmation_dialog.title.remove_custom_translations" = "Are you sure to remove your custom translations?"; -"confirmation_dialog.title.logout" = "Are you sure to logout?"; -"confirmation_dialog.title.delete" = "Are you sure to delete this item?"; -"confirmation_dialog.title.clear" = "Are you sure to clear?"; -"confirmation_dialog.title.reset" = "Are you sure to reset?"; -"confirmation_dialog.button.drop_database" = "Drop the database"; -"confirmation_dialog.button.remove" = "Remove"; -"confirmation_dialog.button.logout" = "Logout"; -"confirmation_dialog.button.delete" = "Delete"; -"confirmation_dialog.button.clear" = "Clear"; -"confirmation_dialog.button.reset" = "Reset"; - -// MARK: SubSection -"sub_section.button.show_all" = "Show all"; - -// MARK: NewDawnView -"new_dawn_view.title.first" = "It is the dawn of a new day!"; -"new_dawn_view.title.second" = "Reflecting on your journey so far, you find that you are a little wiser."; -// 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 -"enum.home_misc_grid_type.title.popular" = "Popular"; -"enum.home_misc_grid_type.title.watched" = "Watched"; -"enum.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 -"struct.user.favorite_category.default" = "Favorites %@"; -"struct.user.favorite_category.all" = "All"; - -// MARK: SearchView -"search_view.title.search" = "Search"; -"search_view.section.title.recently_searched" = "Recently searched"; -"search_view.section.title.recently_seen" = "Recently seen"; -"search_view.section.title.quick_search" = "Quick search"; -// Searchable -"searchable.prompt.filter" = "Filter"; -"searchable.title.matches_count" = "Found %d matches."; - -// MARK: QuickSearchView -"quick_search_view.title.quick_search" = "Quick search"; -"quick_search_view.title.edit_word" = "Edit word"; -"quick_search_view.title.new_word" = "New word"; -"quick_search_view.title.content" = "Content"; -"quick_search_view.title.name" = "Name"; -"quick_search_view.placeholder.optional" = "Optional"; - -// 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.about" = "About EhPanda"; - -// MARK: AccountSettingView -"account_setting_view.title.account" = "Account"; -"account_setting_view.title.shows_new_dawn_greeting" = "Shows new dawn greeting"; -"account_setting_view.button.login" = "Login"; -"account_setting_view.button.logout" = "Logout"; -"account_setting_view.button.account_configuration" = "Account configuration"; -"account_setting_view.button.tags_management" = "Manage tags subscription"; -"account_setting_view.button.copy_cookies" = "Copy cookies"; -// 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.auto_lock" = "Auto-Lock"; -"general_setting_view.title.enables_tags_extension" = "Enables tags extension"; -"general_setting_view.title.translates_tags" = "Translates tags"; -"general_setting_view.title.shows_tags_search_suggestion" = "Shows tags search suggestion"; -"general_setting_view.title.shows_images_in_tags" = "Shows images in tags"; -"general_setting_view.title.redirects_links_to_the_selected_host" = "Redirects links to the selected host"; -"general_setting_view.title.detects_links_from_clipboard" = "Detects links from the clipboard"; -"general_setting_view.title.background_blur_radius" = "Background blur radius"; -"general_setting_view.button.logs" = "Logs"; -"general_setting_view.button.import_custom_translations" = "Import custom translations"; -"general_setting_view.button.remove_custom_translations" = "Remove custom translations"; -"general_setting_view.button.clear_image_caches" = "Clear image caches"; -"general_setting_view.value.default_language_description" = "N/A"; -"general_setting_view.section.title.tags" = "Tags"; -"general_setting_view.section.title.navigation" = "Navigation"; -"general_setting_view.section.title.security" = "Security"; -"general_setting_view.section.title.caches" = "Caches"; -// 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.tint_color" = "Tint color"; -"appearance_setting_view.title.display_mode" = "Display mode"; -"appearance_setting_view.title.shows_tags_in_list" = "Shows tags in list"; -"appearance_setting_view.title.maximum_number_of_tags" = "Maximum number of tags"; -"appearance_setting_view.title.displays_japanese_title" = "Displays Japanese title"; -"appearance_setting_view.button.app_icon" = "App icon"; -"appearance_setting_view.menu.title.infite" = "Infite"; -"appearance_setting_view.section.title.list" = "List"; -"appearance_setting_view.section.title.gallery" = "Gallery"; -// PreferredColorScheme -"enum.preferred_color_scheme.value.automatic" = "Automatic"; -"enum.preferred_color_scheme.value.light" = "Light"; -"enum.preferred_color_scheme.value.dark" = "Dark"; -// AppIconType -"enum.app_icon_type.value.default" = "Default"; -"enum.app_icon_type.value.ukiyoe" = "Ukiyo-e"; -"enum.app_icon_type.value.developer" = "Developer"; -"enum.app_icon_type.value.stand_with_ukraine_2022" = "Stand With Ukraine (2022)"; -"enum.app_icon_type.value.not_my_president" = "NOT MY PRESIDENT"; -// ListDisplayMode -"enum.list_display_mode.value.detail" = "Detail"; -"enum.list_display_mode.value.thumbnail" = "Thumbnail"; - -// MARK: AppIconView -"app_icon_view.title.app_icon" = "App icon"; - -// MARK: reading_settingView -"reading_setting_view.title.reading" = "Reading"; -"reading_setting_view.title.direction" = "Direction"; -"reading_setting_view.title.preload_limit" = "Preload limit"; -"reading_setting_view.title.enables_landscape" = "Enables landscape"; -"reading_setting_view.title.separator_height" = "Separator height"; -"reading_setting_view.title.maximum_scale_factor" = "Maximum scale factor"; -"reading_setting_view.title.double_tap_scale_factor" = "Double tap scale factor"; -"reading_setting_view.section.title.appearance" = "Appearance"; -// ReadingDirection -"enum.reading_direction.value.vertical" = "Vertical"; -"enum.reading_direction.value.right_to_left" = "Right-to-left"; -"enum.reading_direction.value.left_to_right" = "Left-to-right"; - -// MARK: LaboratorySettingView -"laboratory_setting_view.title.laboratory" = "Laboratory"; -"laboratory_setting_view.title.bypasses_SNI_filtering" = "Bypasses SNI Filtering"; - -// MARK: AboutView -"about_view.title.ehPanda" = "EhPanda"; -"about_view.button.website" = "Website"; -"about_view.button.altStore_source" = "AltStore source"; -"about_view.title.version" = "Version"; -"about_view.section.title.special_thanks" = "Special thanks"; -"about_view.section.title.code_level_contributors" = "Code-level contributors"; -"about_view.section.title.translation_contributors" = "Translation contributors"; -"about_view.section.title.acknowledgements" = "Acknowledgements"; - -// MARK: DetailView -"detail_view.button.read" = "Read"; -"detail_view.button.post_comment" = "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.context_menu.button.detail" = "Detail"; -"detail_view.context_menu.button.withdraw_vote" = "Withdraw vote"; -"detail_view.context_menu.button.vote_up" = "Vote up"; -"detail_view.context_menu.button.vote_down" = "Vote down"; -"detail_view.description_section.title.favorited" = "Favorited"; -"detail_view.description_section.title.language" = "Language"; -"detail_view.description_section.title.ratings" = "%@ Ratings"; -"detail_view.description_section.title.page_count" = "Page Count"; -"detail_view.description_section.title.file_size" = "File Size"; -"detail_view.description_section.description.favorited" = "Times"; -"detail_view.description_section.description.page_count" = "Pages"; -"detail_view.action_section.button.give_a_rating" = "Give a Rating"; -"detail_view.action_section.button.similar_gallery" = "Similar Gallery"; -"detail_view.section.title.previews" = "Previews"; -"detail_view.section.title.comments" = "Comments"; - -// MARK: ArchivesView -"archives_view.title.archives" = "Archives"; -"archives_view.button.download_to_hath_client" = "Download To H@H Client"; -// HathArchive -"struct.hath_archive.price.free" = "Free"; -"struct.hath_archive.price.not_available" = "N/A"; -// ArchiveResolution -"enum.archive_resolution.value.original" = "Original"; - -// MARK: TorrentsView -"torrents_view.title.torrents" = "Torrents"; - -// MARK: GalleryInfosView -"gallery_infos_view.title.gallery_infos" = "Gallery infos"; -"gallery_infos_view.title.id" = "ID"; -"gallery_infos_view.title.token" = "Token"; -"gallery_infos_view.title.title" = "Title"; -"gallery_infos_view.title.japanese_title" = "Japanese title"; -"gallery_infos_view.title.gallery_URL" = "Gallery URL"; -"gallery_infos_view.title.cover_URL" = "Cover URL"; -"gallery_infos_view.title.archive_URL" = "Archive URL"; -"gallery_infos_view.title.torrent_URL" = "Torrent URL"; -"gallery_infos_view.title.parent_URL" = "Parent URL"; -"gallery_infos_view.title.category" = "Category"; -"gallery_infos_view.title.uploader" = "Uploader"; -"gallery_infos_view.title.posted_date" = "Posted date"; -"gallery_infos_view.title.visibility" = "Visibility"; -"gallery_infos_view.title.language" = "Language"; -"gallery_infos_view.title.page_count" = "Page count"; -"gallery_infos_view.title.file_size" = "File size"; -"gallery_infos_view.title.favorited_times" = "Favorited times"; -"gallery_infos_view.title.favorited" = "Favorited"; -"gallery_infos_view.title.rating_count" = "Rating count"; -"gallery_infos_view.title.average_rating" = "Average rating"; -"gallery_infos_view.title.my_rating" = "My rating"; -"gallery_infos_view.title.torrent_count" = "Torrent count"; -"gallery_infos_view.value.none" = "None"; -"gallery_infos_view.value.yes" = "Yes"; -"gallery_infos_view.value.no" = "No"; -// GalleryVisibility -"enum.gallery_visibility.value.yes" = "Yes"; -"enum.gallery_visibility.value.no" = "No (%@)"; -"enum.gallery_visibility.value.no.reason.expunged" = "Expunged"; - -// MARK: TagDetailView -"tag_detail_view.section.title.images" = "Images"; -"tag_detail_view.section.title.links" = "Links"; - -// MARK: CommentsView -"comments_view.title.comments" = "Comments"; - -// MARK: PostCommentView -"post_comment_view.title.post_comment" = "Post comment"; -"post_comment_view.title.edit_comment" = "Edit comment"; - -// 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.save_original" = "Save original"; -"reading_view.context_menu.button.share" = "Share"; -"reading_view.toolbar_item.title.auto_play" = "Auto-Play"; -"reading_view.toolbar_item.title.dual_page_mode" = "Dual-Page mode"; -"reading_view.toolbar_item.title.except_the_cover" = "Except the cover"; -"reading_view.toolbar_item.button.retry_all_failed_images" = "Retry all failed images"; -"reading_view.toolbar_item.button.reload_all_images" = "Reload all images"; -"reading_view.toolbar_item.button.reading_setting" = "Reading setting"; -// AutoPlayPolicy -"enum.auto_play_policy.value.off" = "Off"; - -// MARK: FiltersView -"filters_view.title.filters" = "Filters"; -"filters_view.title.advanced_settings" = "Advanced settings"; -"filters_view.title.search_gallery_name" = "Search gallery name"; -"filters_view.title.search_gallery_tags" = "Search gallery tags"; -"filters_view.title.search_gallery_description" = "Search gallery description"; -"filters_view.title.search_torrent_filenames" = "Search torrent filenames"; -"filters_view.title.only_show_galleries_with_torrents" = "Only show galleries with torrents"; -"filters_view.title.search_low_power_tags" = "Search Low-Power tags"; -"filters_view.title.search_downvoted_tags" = "Search downvoted tags"; -"filters_view.title.search_expunged_galleries" = "Search expunged galleries"; -"filters_view.title.set_minimum_rating" = "Set minimum rating"; -"filters_view.title.minimum_rating" = "Minimum rating"; -"filters_view.title.set_pages_range" = "Set pages range"; -"filters_view.title.pages_range" = "Pages range"; -"filters_view.title.disable_language_filter" = "Disable language filter"; -"filters_view.title.disable_uploader_filter" = "Disable uploader filter"; -"filters_view.title.disable_tags_filter" = "Disable tags filter"; -"filters_view.button.reset_filters" = "Reset filters"; -"filters_view.section.title.advanced" = "Advanced"; -"filters_view.section.title.default_filter" = "Default filter"; -// FilterRange -"enum.filter_range.value.search" = "Search"; -"enum.filter_range.value.global" = "Global"; -"enum.filter_range.value.watched" = "Watched"; - -// MARK: EhSettingView -"eh_setting_view.title.host_settings" = "%@ settings"; -"eh_setting_view.section.title.profile_settings" = "Profile Settings"; -"eh_setting_view.title.selected_profile" = "Selected profile"; -"eh_setting_view.button.set_as_default" = "Set as default"; -"eh_setting_view.button.delete_profile" = "Delete profile"; -"eh_setting_view.button.rename" = "Rename"; -"eh_setting_view.button.create_new" = "Create new"; -"eh_setting_view.toolbar_item.button.done" = "Done"; - -"eh_setting_view.section.title.image_load_settings" = "Image Load Settings"; -"eh_setting_view.title.load_images_through_the_hath_network" = "Load images through the Hath network"; -"eh_setting_view.title.browsing_country" = "Browsing country"; -"eh_setting_view.description.browsing_country" = "You appear to be browsing the site from **%@** or use a VPN or proxy in this country, which means the site will try to load images from H@H clients in this general geographic region. If this is incorrect, or if you want to use a different region for any reason (like if you are using a split tunneling VPN), you can select a different country below."; -// EhSetting.LoadThroughHathSetting -"enum.eh_setting.load_through_hath_setting.value.any_client" = "Any client"; -"enum.eh_setting.load_through_hath_setting.value.default_port_only" = "Default port clients only"; -"enum.eh_setting.load_through_hath_setting.value.modern_no" = "No [Modern/HTTPS]"; -"enum.eh_setting.load_through_hath_setting.value.legacy_no" = "No [Legacy/HTTP]"; -"enum.eh_setting.load_through_hath_setting.description.any_client" = "Recommended."; -"enum.eh_setting.load_through_hath_setting.description.default_port_only" = "Can be slower. Enable if behind firewall/proxy that blocks outgoing non-standard ports."; -"enum.eh_setting.load_through_hath_setting.description.modern_no" = "Donator only. You will not be able to browse as many pages. Recommended only if having severe problems."; -"enum.eh_setting.load_through_hath_setting.description.legacy_no" = "Donator only. May not work by default in modern browsers. Recommended for legacy/outdated browsers only."; - -"eh_setting_view.section.title.image_size_settings" = "Image Size Settings"; -"eh_setting_view.title.image_resolution" = "Image resolution"; -"eh_setting_view.description.image_resolution" = "Normally, images are resampled to 1280 pixels of horizontal resolution for online viewing. You can alternatively select one of the following resample resolutions. To avoid murdering the staging servers, resolutions above 1280x are temporarily restricted to donators, people with any hath perk, and people with a UID below 3,000,000."; -"eh_setting_view.title.image_size" = "Image size"; -"eh_setting_view.description.image_size" = "While the site will automatically scale down images to fit your screen width, you can also manually restrict the maximum display size of an image. Like the automatic scaling, this does not resample the image, as the resizing is done browser-side. (0 = no limit)"; -"eh_setting_view.title.horizontal" = "Horizontal"; -"eh_setting_view.title.vertical" = "Vertical"; -// EhSetting.ImageResolution -"enum.eh_setting.image_resolution.value.auto" = "Auto"; - -"eh_setting_view.section.title.gallery_name_display" = "Gallery Name Display"; -"eh_setting_view.title.gallery_name" = "Gallery name"; -"eh_setting_view.description.gallery_name" = "Many galleries have both an English/Romanized title and a title in Japanese script. Which gallery name would you like as default?"; -// 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.archiver_settings" = "Archiver Settings"; -"eh_setting_view.title.archiver_behavior" = "Archiver behavior"; -"eh_setting_view.description.archiver_behavior" = "The default behavior for the Archiver is to confirm the cost and selection for original or resampled archive, then present a link that can be clicked or copied elsewhere. You can change this behavior here."; -// EhSetting.ArchiverBehavior -"enum.eh_setting.archiver_behavior.value.manual_select_manual_start" = "Manual Select, Manual Start (Default)"; -"enum.eh_setting.archiver_behavior.value.manual_select_auto_start" = "Manual Select, Auto Start"; -"enum.eh_setting.archiver_behavior.value.auto_select_original_manual_start" = "Auto Select Original, Manual Start"; -"enum.eh_setting.archiver_behavior.value.auto_select_original_auto_start" = "Auto Select Original, Auto Start"; -"enum.eh_setting.archiver_behavior.value.auto_select_resample_manual_start" = "Auto Select Resample, Manual Start"; -"enum.eh_setting.archiver_behavior.value.auto_select_resample_auto_start" = "Auto Select Resample, Auto Start"; - -"eh_setting_view.section.title.front_page_settings" = "Front Page Settings"; -"eh_setting_view.title.display_mode" = "Display mode"; -"eh_setting_view.description.display_mode" = "Which display mode would you like to use on the front and search pages?"; -"eh_setting_view.section.title.show_search_range_indicator" = "Search Range Indicator"; -"eh_setting_view.title.show_search_range_indicator" = "Show search range indicator"; -"eh_setting_view.description.gallery_category" = "What categories would you like to show by default on the front page and in searches?"; -// EhSetting.DisplayMode -"enum.eh_setting.display_mode.value.compact" = "Compact"; -"enum.eh_setting.display_mode.value.thumbnail" = "Thumbnail"; -"enum.eh_setting.display_mode.value.extended" = "Extended"; -"enum.eh_setting.display_mode.value.minimal" = "Minimal"; -"enum.eh_setting.display_mode.value.minimalPlus" = "Minimal+"; - -"eh_setting_view.section.title.optional_UI_elements" = "Optional UI Elements"; -"eh_setting_view.description.optional_UI_elements" = "Some historic UI elements are now disabled by default. You can enable those here."; -"eh_setting_view.title.enable_gallery_thumbnail_selector" = "Enable thumbnail selector on gallery screen"; - -"eh_setting_view.section.title.favorites" = "Favorites"; -"eh_setting_view.description.favorite_categories" = "Here you can choose and rename your favorite categories."; -"eh_setting_view.title.favorites_sort_order" = "Favorites sort order"; -"eh_setting_view.description.favorites_sort_order" = "You can also select your default sort order for galleries on your favorites page. Note that favorites added prior to the March 2016 revamp did not store a timestamp, and will use the gallery posted time regardless of this setting."; -// EhSetting.FavoritesSortOrder -"enum.eh_setting.favorites_sort_order.value.last_update_time" = "By last gallery update time"; -"enum.eh_setting.favorites_sort_order.value.favorited_time" = "By favorited time"; - -"eh_setting_view.section.title.ratings" = "Ratings"; -"eh_setting_view.title.ratings_color" = "Ratings color"; -"eh_setting_view.promt.ratings_color" = "RRGGB"; -"eh_setting_view.description.ratings_color" = "By default, galleries that you have rated will appear with red stars for ratings of 2 stars and below, green for ratings between 2.5 and 4 stars, and blue for ratings of 4.5 or 5 stars. You can customize this by entering your desired color combination below. Each letter represents one star. The default RRGGB means R(ed) for the first and second star, G(reen) for the third and fourth, and B(lue) for the fifth. You can also use (Y)ellow for the normal stars. Any five-letter R/G/B/Y combo works."; - -"eh_setting_view.section.title.tag_filtering_threshold" = "Tag Filtering Threshold"; -"eh_setting_view.title.tag_filtering_threshold" = "Tag Filtering Threshold"; -"eh_setting_view.description.tag_filtering_threshold" = "You can soft filter tags by adding them to My Tags with a negative weight. If a gallery has tags that add up to weight below this value, it is filtered from view. This threshold can be set between 0 and -9999."; - -"eh_setting_view.section.title.tag_watching_threshold" = "Tag Watching Threshold"; -"eh_setting_view.title.tag_watching_threshold" = "Tag Watching Threshold"; -"eh_setting_view.description.tag_watching_threshold" = "Recently uploaded galleries will be included on the watched screen if it has at least one watched tag with positive weight, and the sum of weights on its watched tags add up to this value or higher. This threshold can be set between 0 and 9999."; - -"eh_setting_view.section.title.filtered_removal_count" = "Show Filtered Removal Count"; -"eh_setting_view.description.filtered_removal_count" = "Show the \"Your default filters removed XX galleries from this page\" readout?"; -"eh_setting_view.title.show_filtered_removal_count" = "Show filtered removal count"; - -"eh_setting_view.section.title.excluded_languages" = "Excluded Languages"; -"eh_setting_view.description.excluded_languages" = "If you wish to hide galleries in certain languages from the gallery list and searches, select them from the list below. Note that matching galleries will never appear regardless of your search query."; -// EhSetting.ExcludedLanguagesCategory -"enum.eh_setting.excluded_languages_category.value.original" = "Original"; -"enum.eh_setting.excluded_languages_category.value.translated" = "Translated"; -"enum.eh_setting.excluded_languages_category.value.rewrite" = "Rewrite"; - -"eh_setting_view.section.title.excluded_uploaders" = "Excluded Uploaders"; -"eh_setting_view.description.excluded_uploaders" = "If you wish to hide galleries from certain uploaders from the gallery list and searches, add them below. Put one username per line. Note that galleries from these uploaders will never appear regardless of your search query."; -"eh_setting_view.description.excluded_uploaders_count" = "You are currently using **%@ / %@** exclusion slots."; - -"eh_setting_view.section.title.search_result_count" = "Search Result Count"; -"eh_setting_view.title.result_count" = "Result count"; -"eh_setting_view.description.result_count" = "How many results would you like per page for the index/search page and torrent search pages?\n(Hath Perk: Paging Enlargement Required)"; - -"eh_setting_view.section.title.thumbnail_settings" = "Thumbnail Settings"; -"eh_setting_view.title.thumbnail_load_timing" = "Thumbnail load timing"; -"eh_setting_view.description.thumbnail_load_timing" = "How would you like the mouse-over thumbnails on the front page to load when using List Mode?"; -"eh_setting_view.description.thumbnail_configuration" = "You can set a default thumbnail configuration for all galleries you visit."; -"eh_setting_view.title.thumbnail_size" = "Size"; -"eh_setting_view.title.thumbnail_row_count" = "Rows"; -// EhSetting.ThumbnailLoadTiming -"enum.eh_setting.thumbnail_load_timing.value.on_mouse_over" = "On mouse-over"; -"enum.eh_setting.thumbnail_load_timing.value.on_page_load" = "On page load"; -"enum.eh_setting.thumbnail_load_timing.description.on_mouse_over" = "Pages load faster, but there may be a slight delay before a thumb appears."; -"enum.eh_setting.thumbnail_load_timing.description.on_page_load" = "Pages take longer to load, but there is no delay for loading a thumb after the page has loaded."; -// EhSetting.ThumbnailSize -"enum.eh_setting.thumbnail_size.value.normal" = "Normal"; -"enum.eh_setting.thumbnail_size.value.large" = "Large"; -"enum.eh_setting.thumbnail_size.value.small" = "Small"; -"enum.eh_setting.thumbnail_size.value.auto" = "Auto"; - -"eh_setting_view.section.title.cover_scaling" = "Cover Scaling"; -"eh_setting_view.title.scale_factor" = "Scale factor"; -"eh_setting_view.description.cover_scale_factor" = "The cover size in gallery list views can be scaled to between 75%% and 150%% when using the Thumbnail or Extended display modes."; - -"eh_setting_view.section.title.viewport_override" = "Viewport Override"; -"eh_setting_view.title.virtual_width" = "Virtual width"; -"eh_setting_view.description.virtual_width" = "Allows you to override the virtual width of the site for mobile devices. This is normally determined automatically by your device based on its DPI. Sensible values at 100%% thumbnail scale are between 640 and 1400."; - -"eh_setting_view.section.title.gallery_comments" = "Gallery Comments"; -"eh_setting_view.title.comments_sort_order" = "Comments sort order"; -"eh_setting_view.title.comments_votes_show_timing" = "Comment votes show timing"; -// EhSetting.CommentsSortOrder -"enum.eh_setting.comments_sort_order.value.oldest" = "Oldest comments first"; -"enum.eh_setting.comments_sort_order.value.recent" = "Recent comments first"; -"enum.eh_setting.comments_sort_order.value.highest_score" = "By highest score"; -// EhSetting.CommentVotesShowTiming -"enum.eh_setting.comments_votes_show_timing.value.on_hover_or_click" = "On score hover or click"; -"enum.eh_setting.comments_votes_show_timing.value.always" = "Always"; - -"eh_setting_view.section.title.gallery_tags" = "Gallery Tags"; -"eh_setting_view.title.tags_sort_order" = "Tags sort order"; -// EhSetting.tags_sort_order -"enum.eh_setting.tags_sort_order.value.alphabetical" = "Alphabetical"; -"enum.eh_setting.tags_sort_order.value.tag_power" = "By tag power"; - -"eh_setting_view.section.title.gallery_page_thumbnail_labeling" = "Gallery Page Thumbnail Labeling"; -"eh_setting_view.title.show_label_below_gallery_thumbnails" = "Show label below gallery thumbnails"; - -"eh_setting_view.section.title.hath_local_network_host" = "Hath Local Network Host"; -"eh_setting_view.title.ip_address_port" = "IP address:Port"; -"eh_setting_view.description.ip_address_port" = "This setting can be used if you have a H@H client running on your local network with the same public IP you browse the site with. Some routers are buggy and cannot route requests back to its own IP; this allows you to work around this problem.\nIf you are running the client on the same device you browse from, use the loopback address (127.0.0.1:port). If the client is running on another device on your network, use its local network IP. Some browser configurations prevent external web sites from accessing URLs with local network IPs, the site must then be whitelisted for this to work."; - -"eh_setting_view.section.title.original_images" = "Use original images instead of the resampled versions? Resampled images will still be used if you select a horizontal resolution different than \"Auto\" above and the image in question is wider, or if the original image is larger than 10 MiB (or 4 MiB for galleries older than one year)."; -"eh_setting_view.title.use_original_images" = "Use original images"; - -"eh_setting_view.section.title.multi_page_viewer" = "Multi-Page Viewer"; -"eh_setting_view.title.use_multi_page_viewer" = "Use Multi-Page Viewer"; -"eh_setting_view.title.display_style" = "Display style"; -"eh_setting_view.title.show_thumbnail_pane" = "Show thumbnail pane"; -// EhSetting.MultiplePageViewerStyle -"enum.eh_setting.multiple_page_viewer_style.value.align_left_scale_if_over_width" = "Align left, scale if overwidth"; -"enum.eh_setting.multiple_page_viewer_style.value.align_center_scale_if_over_width" = "Align center, scale if overwidth"; -"enum.eh_setting.multiple_page_viewer_style.value.align_center_always_scale" = "Align center, always scale"; -// EhSetting.GalleryPageNumbering -"enum.eh_setting.gallery_page_numbering.value.none" = "None"; -"enum.eh_setting.gallery_page_numbering.value.page_number_only" = "Page Number Only"; -"enum.eh_setting.gallery_page_numbering.value.page_number_and_name" = "Page Number + Name"; - -// MARK: Category -"enum.category.value.doujinshi" = "Doujinshi"; -"enum.category.value.manga" = "Manga"; -"enum.category.value.artist_CG" = "Artist CG"; -"enum.category.value.game_CG" = "Game CG"; -"enum.category.value.western" = "Western"; -"enum.category.value.non_h" = "Non-H"; -"enum.category.value.image_set" = "Image Set"; -"enum.category.value.cosplay" = "Cosplay"; -"enum.category.value.asian_porn" = "Asian Porn"; -"enum.category.value.misc" = "Misc"; -"enum.category.value.private" = "Private"; - -// MARK: TagNamespace -"enum.tag_namespace.value.reclass" = "Reclass"; -"enum.tag_namespace.value.language" = "Language"; -"enum.tag_namespace.value.parody" = "Parody"; -"enum.tag_namespace.value.character" = "Character"; -"enum.tag_namespace.value.group" = "Group"; -"enum.tag_namespace.value.artist" = "Artist"; -"enum.tag_namespace.value.male" = "Male"; -"enum.tag_namespace.value.female" = "Female"; -"enum.tag_namespace.value.mixed" = "Mixed"; -"enum.tag_namespace.value.cosplayer" = "Cosplayer"; -"enum.tag_namespace.value.other" = "Other"; -"enum.tag_namespace.value.temp" = "Temp"; - -// MARK: Language -"enum.language.value.invalid" = "N/A"; -"enum.language.value.other" = "Other"; -"enum.language.value.afrikaans" = "Afrikaans"; -"enum.language.value.albanian" = "Albanian"; -"enum.language.value.arabic" = "Arabic"; -"enum.language.value.bengali" = "Bengali"; -"enum.language.value.bosnian" = "Bosnian"; -"enum.language.value.bulgarian" = "Bulgarian"; -"enum.language.value.burmese" = "Burmese"; -"enum.language.value.catalan" = "Catalan"; -"enum.language.value.cebuano" = "Cebuano"; -"enum.language.value.chinese" = "Chinese"; -"enum.language.value.croatian" = "Croatian"; -"enum.language.value.czech" = "Czech"; -"enum.language.value.danish" = "Danish"; -"enum.language.value.dutch" = "Dutch"; -"enum.language.value.english" = "English"; -"enum.language.value.esperanto" = "Esperanto"; -"enum.language.value.estonian" = "Estonian"; -"enum.language.value.finnish" = "Finnish"; -"enum.language.value.french" = "French"; -"enum.language.value.georgian" = "Georgian"; -"enum.language.value.german" = "German"; -"enum.language.value.greek" = "Greek"; -"enum.language.value.hebrew" = "Hebrew"; -"enum.language.value.hindi" = "Hindi"; -"enum.language.value.hmong" = "Hmong"; -"enum.language.value.hungarian" = "Hungarian"; -"enum.language.value.indonesian" = "Indonesian"; -"enum.language.value.italian" = "Italian"; -"enum.language.value.japanese" = "Japanese"; -"enum.language.value.kazakh" = "Kazakh"; -"enum.language.value.khmer" = "Khmer"; -"enum.language.value.korean" = "Korean"; -"enum.language.value.kurdish" = "Kurdish"; -"enum.language.value.lao" = "Lao"; -"enum.language.value.latin" = "Latin"; -"enum.language.value.mongolian" = "Mongolian"; -"enum.language.value.ndebele" = "Ndebele"; -"enum.language.value.nepali" = "Nepali"; -"enum.language.value.norwegian" = "Norwegian"; -"enum.language.value.oromo" = "Oromo"; -"enum.language.value.pashto" = "Pashto"; -"enum.language.value.persian" = "Persian"; -"enum.language.value.polish" = "Polish"; -"enum.language.value.portuguese" = "Portuguese"; -"enum.language.value.punjabi" = "Punjabi"; -"enum.language.value.romanian" = "Romanian"; -"enum.language.value.russian" = "Russian"; -"enum.language.value.sango" = "Sango"; -"enum.language.value.serbian" = "Serbian"; -"enum.language.value.shona" = "Shona"; -"enum.language.value.slovak" = "Slovak"; -"enum.language.value.slovenian" = "Slovenian"; -"enum.language.value.somali" = "Somali"; -"enum.language.value.spanish" = "Spanish"; -"enum.language.value.swahili" = "Swahili"; -"enum.language.value.swedish" = "Swedish"; -"enum.language.value.tagalog" = "Tagalog"; -"enum.language.value.thai" = "Thai"; -"enum.language.value.tigrinya" = "Tigrinya"; -"enum.language.value.turkish" = "Turkish"; -"enum.language.value.ukrainian" = "Ukrainian"; -"enum.language.value.urdu" = "Urdu"; -"enum.language.value.vietnamese" = "Vietnamese"; -"enum.language.value.zulu" = "Zulu"; - -// MARK: BrowsingCountry -"enum.browsing_country.name.auto_detect" = "Auto-Detect"; -"enum.browsing_country.name.afghanistan" = "Afghanistan"; -"enum.browsing_country.name.aland_islands" = "Aland Islands"; -"enum.browsing_country.name.albania" = "Albania"; -"enum.browsing_country.name.algeria" = "Algeria"; -"enum.browsing_country.name.american_samoa" = "American Samoa"; -"enum.browsing_country.name.andorra" = "Andorra"; -"enum.browsing_country.name.angola" = "Angola"; -"enum.browsing_country.name.anguilla" = "Anguilla"; -"enum.browsing_country.name.antarctica" = "Antarctica"; -"enum.browsing_country.name.antigua_and_barbuda" = "Antigua and Barbuda"; -"enum.browsing_country.name.argentina" = "Argentina"; -"enum.browsing_country.name.armenia" = "Armenia"; -"enum.browsing_country.name.aruba" = "Aruba"; -"enum.browsing_country.name.asia_pacific_region" = "Asia-Pacific Region"; -"enum.browsing_country.name.australia" = "Australia"; -"enum.browsing_country.name.austria" = "Austria"; -"enum.browsing_country.name.azerbaijan" = "Azerbaijan"; -"enum.browsing_country.name.bahamas" = "Bahamas"; -"enum.browsing_country.name.bahrain" = "Bahrain"; -"enum.browsing_country.name.bangladesh" = "Bangladesh"; -"enum.browsing_country.name.barbados" = "Barbados"; -"enum.browsing_country.name.belarus" = "Belarus"; -"enum.browsing_country.name.belgium" = "Belgium"; -"enum.browsing_country.name.belize" = "Belize"; -"enum.browsing_country.name.benin" = "Benin"; -"enum.browsing_country.name.bermuda" = "Bermuda"; -"enum.browsing_country.name.bhutan" = "Bhutan"; -"enum.browsing_country.name.bolivia" = "Bolivia"; -"enum.browsing_country.name.bonaire_saint_eustatius_and_saba" = "Bonaire Saint Eustatius and Saba"; -"enum.browsing_country.name.bosnia_and_herzegovina" = "Bosnia and Herzegovina"; -"enum.browsing_country.name.botswana" = "Botswana"; -"enum.browsing_country.name.bouvet_island" = "Bouvet Island"; -"enum.browsing_country.name.brazil" = "Brazil"; -"enum.browsing_country.name.british_indian_ocean_territory" = "British Indian Ocean Territory"; -"enum.browsing_country.name.brunei_darussalam" = "Brunei Darussalam"; -"enum.browsing_country.name.bulgaria" = "Bulgaria"; -"enum.browsing_country.name.burkina_faso" = "Burkina Faso"; -"enum.browsing_country.name.burundi" = "Burundi"; -"enum.browsing_country.name.cambodia" = "Cambodia"; -"enum.browsing_country.name.cameroon" = "Cameroon"; -"enum.browsing_country.name.canada" = "Canada"; -"enum.browsing_country.name.cape_verde" = "Cape Verde"; -"enum.browsing_country.name.cayman_islands" = "Cayman Islands"; -"enum.browsing_country.name.central_african_republic" = "Central African Republic"; -"enum.browsing_country.name.chad" = "Chad"; -"enum.browsing_country.name.chile" = "Chile"; -"enum.browsing_country.name.china" = "China"; -"enum.browsing_country.name.christmas_island" = "Christmas Island"; -"enum.browsing_country.name.cocos_islands" = "Cocos Islands"; -"enum.browsing_country.name.colombia" = "Colombia"; -"enum.browsing_country.name.comoros" = "Comoros"; -"enum.browsing_country.name.congo" = "Congo"; -"enum.browsing_country.name.the_democratic_republic_of_the_congo" = "The Democratic Republic of the Congo"; -"enum.browsing_country.name.cook_islands" = "Cook Islands"; -"enum.browsing_country.name.costa_rica" = "Costa Rica"; -"enum.browsing_country.name.cote_d_ivoire" = "Cote D'Ivoire"; -"enum.browsing_country.name.croatia" = "Croatia"; -"enum.browsing_country.name.cuba" = "Cuba"; -"enum.browsing_country.name.curacao" = "Curacao"; -"enum.browsing_country.name.cyprus" = "Cyprus"; -"enum.browsing_country.name.czech_republic" = "Czech Republic"; -"enum.browsing_country.name.denmark" = "Denmark"; -"enum.browsing_country.name.djibouti" = "Djibouti"; -"enum.browsing_country.name.dominica" = "Dominica"; -"enum.browsing_country.name.dominican_republic" = "Dominican Republic"; -"enum.browsing_country.name.ecuador" = "Ecuador"; -"enum.browsing_country.name.egypt" = "Egypt"; -"enum.browsing_country.name.el_salvador" = "El Salvador"; -"enum.browsing_country.name.equatorial_guinea" = "Equatorial Guinea"; -"enum.browsing_country.name.eritrea" = "Eritrea"; -"enum.browsing_country.name.estonia" = "Estonia"; -"enum.browsing_country.name.ethiopia" = "Ethiopia"; -"enum.browsing_country.name.europe" = "Europe"; -"enum.browsing_country.name.falkland_islands" = "Falkland Islands"; -"enum.browsing_country.name.faroe_islands" = "Faroe Islands"; -"enum.browsing_country.name.fiji" = "Fiji"; -"enum.browsing_country.name.finland" = "Finland"; -"enum.browsing_country.name.france" = "France"; -"enum.browsing_country.name.french_guiana" = "French Guiana"; -"enum.browsing_country.name.french_polynesia" = "French Polynesia"; -"enum.browsing_country.name.french_southern_territories" = "French Southern Territories"; -"enum.browsing_country.name.gabon" = "Gabon"; -"enum.browsing_country.name.gambia" = "Gambia"; -"enum.browsing_country.name.georgia" = "Georgia"; -"enum.browsing_country.name.germany" = "Germany"; -"enum.browsing_country.name.ghana" = "Ghana"; -"enum.browsing_country.name.gibraltar" = "Gibraltar"; -"enum.browsing_country.name.greece" = "Greece"; -"enum.browsing_country.name.greenland" = "Greenland"; -"enum.browsing_country.name.grenada" = "Grenada"; -"enum.browsing_country.name.guadeloupe" = "Guadeloupe"; -"enum.browsing_country.name.guam" = "Guam"; -"enum.browsing_country.name.guatemala" = "Guatemala"; -"enum.browsing_country.name.guernsey" = "Guernsey"; -"enum.browsing_country.name.guinea" = "Guinea"; -"enum.browsing_country.name.guinea_bissau" = "Guinea-Bissau"; -"enum.browsing_country.name.guyana" = "Guyana"; -"enum.browsing_country.name.haiti" = "Haiti"; -"enum.browsing_country.name.heard_island_and_mc_donald_islands" = "Heard Island and McDonald Islands"; -"enum.browsing_country.name.vatican_city_state" = "Vatican City State"; -"enum.browsing_country.name.honduras" = "Honduras"; -"enum.browsing_country.name.hong_kong" = "Hong Kong"; -"enum.browsing_country.name.hungary" = "Hungary"; -"enum.browsing_country.name.iceland" = "Iceland"; -"enum.browsing_country.name.india" = "India"; -"enum.browsing_country.name.indonesia" = "Indonesia"; -"enum.browsing_country.name.iran" = "Iran"; -"enum.browsing_country.name.iraq" = "Iraq"; -"enum.browsing_country.name.ireland" = "Ireland"; -"enum.browsing_country.name.isle_of_man" = "Isle of Man"; -"enum.browsing_country.name.israel" = "Israel"; -"enum.browsing_country.name.italy" = "Italy"; -"enum.browsing_country.name.jamaica" = "Jamaica"; -"enum.browsing_country.name.japan" = "Japan"; -"enum.browsing_country.name.jersey" = "Jersey"; -"enum.browsing_country.name.jordan" = "Jordan"; -"enum.browsing_country.name.kazakhstan" = "Kazakhstan"; -"enum.browsing_country.name.kenya" = "Kenya"; -"enum.browsing_country.name.kiribati" = "Kiribati"; -"enum.browsing_country.name.kuwait" = "Kuwait"; -"enum.browsing_country.name.kyrgyzstan" = "Kyrgyzstan"; -"enum.browsing_country.name.lao_peoples_democratic_republic" = "Lao People's Democratic Republic"; -"enum.browsing_country.name.latvia" = "Latvia"; -"enum.browsing_country.name.lebanon" = "Lebanon"; -"enum.browsing_country.name.lesotho" = "Lesotho"; -"enum.browsing_country.name.liberia" = "Liberia"; -"enum.browsing_country.name.libya" = "Libya"; -"enum.browsing_country.name.liechtenstein" = "Liechtenstein"; -"enum.browsing_country.name.lithuania" = "Lithuania"; -"enum.browsing_country.name.luxembourg" = "Luxembourg"; -"enum.browsing_country.name.macau" = "Macau"; -"enum.browsing_country.name.macedonia" = "Macedonia"; -"enum.browsing_country.name.madagascar" = "Madagascar"; -"enum.browsing_country.name.malawi" = "Malawi"; -"enum.browsing_country.name.malaysia" = "Malaysia"; -"enum.browsing_country.name.maldives" = "Maldives"; -"enum.browsing_country.name.mali" = "Mali"; -"enum.browsing_country.name.malta" = "Malta"; -"enum.browsing_country.name.marshall_islands" = "Marshall Islands"; -"enum.browsing_country.name.martinique" = "Martinique"; -"enum.browsing_country.name.mauritania" = "Mauritania"; -"enum.browsing_country.name.mauritius" = "Mauritius"; -"enum.browsing_country.name.mayotte" = "Mayotte"; -"enum.browsing_country.name.mexico" = "Mexico"; -"enum.browsing_country.name.micronesia" = "Micronesia"; -"enum.browsing_country.name.moldova" = "Moldova"; -"enum.browsing_country.name.monaco" = "Monaco"; -"enum.browsing_country.name.mongolia" = "Mongolia"; -"enum.browsing_country.name.montenegro" = "Montenegro"; -"enum.browsing_country.name.montserrat" = "Montserrat"; -"enum.browsing_country.name.morocco" = "Morocco"; -"enum.browsing_country.name.mozambique" = "Mozambique"; -"enum.browsing_country.name.myanmar" = "Myanmar"; -"enum.browsing_country.name.namibia" = "Namibia"; -"enum.browsing_country.name.nauru" = "Nauru"; -"enum.browsing_country.name.nepal" = "Nepal"; -"enum.browsing_country.name.netherlands" = "Netherlands"; -"enum.browsing_country.name.new_caledonia" = "New Caledonia"; -"enum.browsing_country.name.new_zealand" = "New Zealand"; -"enum.browsing_country.name.nicaragua" = "Nicaragua"; -"enum.browsing_country.name.niger" = "Niger"; -"enum.browsing_country.name.nigeria" = "Nigeria"; -"enum.browsing_country.name.niue" = "Niue"; -"enum.browsing_country.name.norfolk_island" = "Norfolk Island"; -"enum.browsing_country.name.north_korea" = "North Korea"; -"enum.browsing_country.name.northern_mariana_islands" = "Northern Mariana Islands"; -"enum.browsing_country.name.norway" = "Norway"; -"enum.browsing_country.name.oman" = "Oman"; -"enum.browsing_country.name.pakistan" = "Pakistan"; -"enum.browsing_country.name.palau" = "Palau"; -"enum.browsing_country.name.palestinian_territory" = "Palestinian Territory"; -"enum.browsing_country.name.panama" = "Panama"; -"enum.browsing_country.name.papua_new_guinea" = "Papua New Guinea"; -"enum.browsing_country.name.paraguay" = "Paraguay"; -"enum.browsing_country.name.peru" = "Peru"; -"enum.browsing_country.name.philippines" = "Philippines"; -"enum.browsing_country.name.pitcairn_islands" = "Pitcairn Islands"; -"enum.browsing_country.name.poland" = "Poland"; -"enum.browsing_country.name.portugal" = "Portugal"; -"enum.browsing_country.name.puerto_rico" = "Puerto Rico"; -"enum.browsing_country.name.qatar" = "Qatar"; -"enum.browsing_country.name.reunion" = "Reunion"; -"enum.browsing_country.name.romania" = "Romania"; -"enum.browsing_country.name.russian_federation" = "Russian Federation"; -"enum.browsing_country.name.rwanda" = "Rwanda"; -"enum.browsing_country.name.saint_barthelemy" = "Saint Barthelemy"; -"enum.browsing_country.name.saint_helena" = "Saint Helena"; -"enum.browsing_country.name.saint_kitts_and_nevis" = "Saint Kitts and Nevis"; -"enum.browsing_country.name.saint_lucia" = "Saint Lucia"; -"enum.browsing_country.name.saint_martin" = "Saint Martin"; -"enum.browsing_country.name.saint_pierre_and_miquelon" = "Saint Pierre and Miquelon"; -"enum.browsing_country.name.saint_vincent_and_the_grenadines" = "Saint Vincent and the Grenadines"; -"enum.browsing_country.name.samoa" = "Samoa"; -"enum.browsing_country.name.san_marino" = "San Marino"; -"enum.browsing_country.name.sao_tome_and_principe" = "Sao Tome and Principe"; -"enum.browsing_country.name.saudi_arabia" = "Saudi Arabia"; -"enum.browsing_country.name.senegal" = "Senegal"; -"enum.browsing_country.name.serbia" = "Serbia"; -"enum.browsing_country.name.seychelles" = "Seychelles"; -"enum.browsing_country.name.sierra_leone" = "Sierra Leone"; -"enum.browsing_country.name.singapore" = "Singapore"; -"enum.browsing_country.name.sint_maarten" = "Sint Maarten"; -"enum.browsing_country.name.slovakia" = "Slovakia"; -"enum.browsing_country.name.slovenia" = "Slovenia"; -"enum.browsing_country.name.solomon_islands" = "Solomon Islands"; -"enum.browsing_country.name.somalia" = "Somalia"; -"enum.browsing_country.name.south_africa" = "South Africa"; -"enum.browsing_country.name.south_georgia_and_the_south_sandwich_islands" = "South Georgia and the South Sandwich Islands"; -"enum.browsing_country.name.south_korea" = "South Korea"; -"enum.browsing_country.name.south_sudan" = "South Sudan"; -"enum.browsing_country.name.spain" = "Spain"; -"enum.browsing_country.name.sri_lanka" = "Sri Lanka"; -"enum.browsing_country.name.sudan" = "Sudan"; -"enum.browsing_country.name.suriname" = "Suriname"; -"enum.browsing_country.name.svalbard_and_jan_mayen" = "Svalbard and Jan Mayen"; -"enum.browsing_country.name.swaziland" = "Swaziland"; -"enum.browsing_country.name.sweden" = "Sweden"; -"enum.browsing_country.name.switzerland" = "Switzerland"; -"enum.browsing_country.name.syrian_arab_republic" = "Syrian Arab Republic"; -"enum.browsing_country.name.taiwan" = "Taiwan"; -"enum.browsing_country.name.tajikistan" = "Tajikistan"; -"enum.browsing_country.name.tanzania" = "Tanzania"; -"enum.browsing_country.name.thailand" = "Thailand"; -"enum.browsing_country.name.timor_leste" = "Timor-Leste"; -"enum.browsing_country.name.togo" = "Togo"; -"enum.browsing_country.name.tokelau" = "Tokelau"; -"enum.browsing_country.name.tonga" = "Tonga"; -"enum.browsing_country.name.trinidad_and_tobago" = "Trinidad and Tobago"; -"enum.browsing_country.name.tunisia" = "Tunisia"; -"enum.browsing_country.name.turkey" = "Turkey"; -"enum.browsing_country.name.turkmenistan" = "Turkmenistan"; -"enum.browsing_country.name.turks_and_caicos_islands" = "Turks and Caicos Islands"; -"enum.browsing_country.name.tuvalu" = "Tuvalu"; -"enum.browsing_country.name.uganda" = "Uganda"; -"enum.browsing_country.name.ukraine" = "Ukraine"; -"enum.browsing_country.name.united_arab_emirates" = "United Arab Emirates"; -"enum.browsing_country.name.united_kingdom" = "United Kingdom"; -"enum.browsing_country.name.united_states" = "United States"; -"enum.browsing_country.name.united_states_minor_outlying_islands" = "United States Minor Outlying Islands"; -"enum.browsing_country.name.uruguay" = "Uruguay"; -"enum.browsing_country.name.uzbekistan" = "Uzbekistan"; -"enum.browsing_country.name.vanuatu" = "Vanuatu"; -"enum.browsing_country.name.venezuela" = "Venezuela"; -"enum.browsing_country.name.vietnam" = "Vietnam"; -"enum.browsing_country.name.virgin_islands_british" = "British Virgin Islands"; -"enum.browsing_country.name.virgin_islands_US" = "U.S. Virgin Islands"; -"enum.browsing_country.name.wallis_and_futuna" = "Wallis and Futuna"; -"enum.browsing_country.name.western_sahara" = "Western Sahara"; -"enum.browsing_country.name.yemen" = "Yemen"; -"enum.browsing_country.name.zambia" = "Zambia"; -"enum.browsing_country.name.zimbabwe" = "Zimbabwe"; diff --git a/EhPanda/App/ja.lproj/InfoPlist.strings b/EhPanda/App/ja.lproj/InfoPlist.strings deleted file mode 100644 index db744b850..000000000 --- a/EhPanda/App/ja.lproj/InfoPlist.strings +++ /dev/null @@ -1,7 +0,0 @@ -/* - InfoPlist.strings - EhPanda -*/ - -"NSFaceIDUsageDescription" = "アプリアンロック認証時に Face ID オプションを提供するにはこの権限が必要です"; -"NSPhotoLibraryAddUsageDescription" = "画像をライブラリに保存するにはこの権限が必要です"; diff --git a/EhPanda/App/ja.lproj/Localizable.strings b/EhPanda/App/ja.lproj/Localizable.strings deleted file mode 100644 index fa68dba1a..000000000 --- a/EhPanda/App/ja.lproj/Localizable.strings +++ /dev/null @@ -1,906 +0,0 @@ -/* - Localizable.strings - EhPanda -*/ - -// MARK: BanInterval -"enum.ban_interval.description.and" = ""; - -// MARK: ToplistsType -"enum.toplists_type.value.yesterday" = "昨日"; -"enum.toplists_type.value.past_month" = "先月"; -"enum.toplists_type.value.past_year" = "去年"; -"enum.toplists_type.value.all_time" = "すべて"; - -// MARK: Response -"website.response.hath_client_not_found" = "この機能を使うには、アカウントに関連付けられている H@H クライアントが必要です"; -"website.response.hath_client_not_online" = "H@H クライアントは現在オフラインのようです、起動してからもう一度お試しください"; -"website.response.invalid_resolution" = "このギャラリーは選択された解像度ではダウンロードできません"; - -// MARK: HUD -"hud.title.error" = "エラー"; -"hud.title.success" = "成功"; -"hud.title.loading" = "読み込み中..."; -"hud.title.communicating" = "通信中..."; -"hud.caption.copied_to_clipboard" = "クリップボードにコピーしました"; -"hud.caption.saved_to_photo_library" = "ライブラリに保存しました"; - -// MARK: AutoLock -"local_authorization.reason" = "自動ロック期限が切れたため、アプリがロックされています"; - -// MARK: Common value -"common.value.stars" = "%@ つ星"; -"common.value.pages" = "%@ ページ"; -"common.value.times" = "%@ 回"; -"common.value.day" = "%@ 日"; -"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.jump_page" = "ページジャンプ"; -"toolbar_item.button.quick_search" = "クイック検索"; - -// MARK: JumpPage -"jump_page_view.title.jump_page" = "ページジャンプ"; -"jump_page_view.button.confirm" = "確認"; - -// MARK: AlertView -"loading_view.title.loading" = "読み込み中..."; -"loading_view.title.preparing_database" = "データベース準備中..."; -"not_login_view.title.need_login" = "本機能をご利用になるにはログインが必要です"; -"not_login_view.button.login" = "ログイン"; -"error_view.button.retry" = "リトライ"; -"error_view.button.drop_database" = "データベースを削除"; -"error_view.title.try_later" = "しばらくしてからもう一度お試しください"; -"error_view.title.network" = "ネットワーク障害が発生しました"; -"error_view.title.parsing" = "解析中に問題が発生しました"; -"error_view.title.unknown" = "不明なエラーが発生しました"; -"error_view.title.not_found" = "ここには何もないようです"; -"error_view.title.database_corrupted" = "データベースが破損しています。\nGitHub で Issue を作成していただくようお願いいたします。"; -"error_view.title.ip_banned" = "この IP アドレスを経由して過剰なページロードが行われました。クローラの疑いがあるため、この IP アドレスは一時的にブロックされました。ブロックは %@後に解除されます。"; -"error_view.title.copyright_claim" = "申し訳ありませんが、このギャラリーは %@ の著作権主張によってアクセス不可になっています。"; -"error_view.title.gallery_unavailable" = "このギャラリーはすでに削除済みまたは無効です。"; - -// MARK: ConfirmationDialog -"confirmation_dialog.title.drop_database" = "本アプリでのすべてのデータを失うことになります。\n本当にデータベースを削除してもよろしいですか?"; -"confirmation_dialog.title.remove_custom_translations" = "本当にカスタム翻訳を削除してもよろしいですか?"; -"confirmation_dialog.title.logout" = "本当にログアウトしてもよろしいですか?"; -"confirmation_dialog.title.delete" = "本当にこれを削除してもよろしいですか?"; -"confirmation_dialog.title.clear" = "本当に削除してもよろしいですか?"; -"confirmation_dialog.title.reset" = "本当に戻してもよろしいですか?"; -"confirmation_dialog.button.drop_database" = "データベースを削除"; -"confirmation_dialog.button.remove" = "削除"; -"confirmation_dialog.button.logout" = "ログアウト"; -"confirmation_dialog.button.delete" = "削除"; -"confirmation_dialog.button.clear" = "削除"; -"confirmation_dialog.button.reset" = "戻す"; - -// MARK: SubSection -"sub_section.button.show_all" = "すべて表示"; - -// MARK: NewDawnView -"new_dawn_view.title.first" = "新しい一日の夜明けです!"; -"new_dawn_view.title.second" = "今までの歩みを振り返り、少し賢くなった気がする。"; -// Greeting -"struct.greeting.mark.start" = ""; -"struct.greeting.mark.separator" = "、"; -"struct.greeting.mark.and" = " と "; -"struct.greeting.mark.end" = "を手に入れた!"; - -// MARK: HomeView -"home_view.title.home" = "ホーム"; -"home_view.section.title.frontpage" = "フロントページ"; -"home_view.section.title.toplists" = "ランキング"; -"home_view.section.title.other" = "その他"; -// HomeMiscGridType -"enum.home_misc_grid_type.title.popular" = "人気"; -"enum.home_misc_grid_type.title.watched" = "タグの購読"; -"enum.home_misc_grid_type.title.history" = "閲覧履歴"; - -// MARK: FrontpageView -"frontpage_view.title.frontpage" = "フロントページ"; - -// MARK: ToplistsView -"toplists_view.title.toplists" = "ランキング"; - -// MARK: PopularView -"popular_view.title.popular" = "人気"; - -// MARK: WatchedView -"watched_view.title.watched" = "タグの購読"; - -// MARK: HistoryView -"history_view.title.history" = "閲覧履歴"; - -// MARK: FavoritesView -"favorites_view.title.favorites" = "お気に入り"; -// FavoriteCategory -"struct.user.favorite_category.default" = "お気に入り %@"; -"struct.user.favorite_category.all" = "すべて"; - -// MARK: SearchView -"search_view.title.search" = "検索"; -"search_view.section.title.recently_searched" = "最近検索した項目"; -"search_view.section.title.recently_seen" = "最近閲覧した項目"; -"search_view.section.title.quick_search" = "クイック検索"; -// Searchable -"searchable.prompt.filter" = "フィルター"; -"searchable.title.matches_count" = "%d 件の該当項目"; - -// MARK: QuickSearchView -"quick_search_view.title.quick_search" = "クイック検索"; -"quick_search_view.title.edit_word" = "キーワードを編集"; -"quick_search_view.title.new_word" = "キーワードを追加"; -"quick_search_view.title.content" = "内容"; -"quick_search_view.title.name" = "名前"; -"quick_search_view.placeholder.optional" = "任意"; - -// MARK: SettingView -"setting_view.title.setting" = "設定"; -// SettingStateRoute -"enum.setting_state_route.value.account" = "アカウント"; -"enum.setting_state_route.value.general" = "一般"; -"enum.setting_state_route.value.appearance" = "外観"; -"enum.setting_state_route.value.reading" = "閲覧"; -"enum.setting_state_route.value.laboratory" = "ラボ"; -"enum.setting_state_route.value.about" = "EhPanda について"; - -// MARK: AccountSettingView -"account_setting_view.title.account" = "アカウント"; -"account_setting_view.title.shows_new_dawn_greeting" = "夜明けの挨拶を表示"; -"account_setting_view.button.login" = "ログイン"; -"account_setting_view.button.logout" = "ログアウト"; -"account_setting_view.button.account_configuration" = "アカウント設定"; -"account_setting_view.button.tags_management" = "タグの購読を管理"; -"account_setting_view.button.copy_cookies" = "クッキーをコピー"; -// CookieValue -"struct.cookie_value.localized_string.expired" = "期限切れ"; -"struct.cookie_value.localized_string.mystery" = "拒否"; -"struct.cookie_value.localized_string.none" = "なし"; - -// MARK: LoginView -"login_view.title.login" = "ログイン"; -"login_view.title.username" = "ユーザー名"; -"login_view.title.password" = "パスワード"; - -// MARK: GeneralSettingView -"general_setting_view.title.general" = "一般"; -"general_setting_view.title.language" = "言語"; -"general_setting_view.title.auto_lock" = "自動ロック"; -"general_setting_view.title.enables_tags_extension" = "タグの拡張機能を有効"; -"general_setting_view.title.translates_tags" = "タグを訳す"; -"general_setting_view.title.shows_tags_search_suggestion" = "タグの検索提案を表示"; -"general_setting_view.title.shows_images_in_tags" = "タグの画像を表示"; -"general_setting_view.title.redirects_links_to_the_selected_host" = "リンクを選択されたホストへリダイレクト"; -"general_setting_view.title.detects_links_from_clipboard" = "クリップボードからリンクを探知"; -"general_setting_view.title.background_blur_radius" = "バッググラウンドぼかし度"; -"general_setting_view.button.logs" = "ログ"; -"general_setting_view.button.import_custom_translations" = "カスタム翻訳を取り込む"; -"general_setting_view.button.remove_custom_translations" = "カスタム翻訳を削除"; -"general_setting_view.button.clear_image_caches" = "画像キャッシュを削除"; -"general_setting_view.value.default_language_description" = "無効"; -"general_setting_view.section.title.tags" = "タグ"; -"general_setting_view.section.title.navigation" = "ナビゲーション"; -"general_setting_view.section.title.security" = "セキュリティ"; -"general_setting_view.section.title.caches" = "キャッシュ"; -// AutoLockPolicy -"enum.auto_lock_policy.value.never" = "なし"; -"enum.auto_lock_policy.value.instantly" = "すぐに"; - -// MARK: LogsView -"logs_view.title.logs" = "ログ"; -"logs_view.title.latest" = "最新"; - -// MARK: AppearanceSettingView -"appearance_setting_view.title.appearance" = "外観"; -"appearance_setting_view.title.theme" = "テーマ"; -"appearance_setting_view.title.tint_color" = "テーマの色"; -"appearance_setting_view.title.display_mode" = "表示モード"; -"appearance_setting_view.title.shows_tags_in_list" = "リストでタグを表示"; -"appearance_setting_view.title.maximum_number_of_tags" = "タグ数上限"; -"appearance_setting_view.title.displays_japanese_title" = "日本語タイトルを表示"; -"appearance_setting_view.button.app_icon" = "アプリアイコン"; -"appearance_setting_view.menu.title.infite" = "無制限"; -"appearance_setting_view.section.title.list" = "リスト"; -"appearance_setting_view.section.title.gallery" = "ギャラリー"; -// PreferredColorScheme -"enum.preferred_color_scheme.value.automatic" = "自動"; -"enum.preferred_color_scheme.value.light" = "ライト"; -"enum.preferred_color_scheme.value.dark" = "ダーク"; -// AppIconType -"enum.app_icon_type.value.default" = "デフォルト"; -"enum.app_icon_type.value.ukiyoe" = "浮世絵"; -"enum.app_icon_type.value.developer" = "デベロッパー"; -"enum.app_icon_type.value.stand_with_ukraine_2022" = "ウクライナと共に (2022)"; -"enum.app_icon_type.value.not_my_president" = "私の大統領ではない"; -// ListDisplayMode -"enum.list_display_mode.value.detail" = "詳細"; -"enum.list_display_mode.value.thumbnail" = "サムネイル"; - -// MARK: AppIconView -"app_icon_view.title.app_icon" = "アプリアイコン"; - -// MARK: reading_settingView -"reading_setting_view.title.reading" = "閲覧"; -"reading_setting_view.title.direction" = "方向"; -"reading_setting_view.title.preload_limit" = "プリロード上限数"; -"reading_setting_view.title.enables_landscape" = "横向きを有効"; -"reading_setting_view.title.separator_height" = "仕切りの高さ"; -"reading_setting_view.title.maximum_scale_factor" = "最大スケール係数"; -"reading_setting_view.title.double_tap_scale_factor" = "ダブルタップスケール係数"; -"reading_setting_view.section.title.appearance" = "外観"; -// ReadingDirection -"enum.reading_direction.value.vertical" = "縦読み"; -"enum.reading_direction.value.right_to_left" = "右開き"; -"enum.reading_direction.value.left_to_right" = "左開き"; - -// MARK: LaboratorySettingView -"laboratory_setting_view.title.laboratory" = "ラボ"; -"laboratory_setting_view.title.bypasses_SNI_filtering" = "SNI フィルタリング回避"; - -// MARK: AboutView -"about_view.title.ehPanda" = "EhPanda"; -"about_view.button.website" = "ウェブサイト"; -"about_view.button.altStore_source" = "AltStore ソース"; -"about_view.title.version" = "バージョン"; -"about_view.section.title.special_thanks" = "特別な感謝"; -"about_view.section.title.code_level_contributors" = "コードレベル貢献者"; -"about_view.section.title.translation_contributors" = "翻訳貢献者"; -"about_view.section.title.acknowledgements" = "謝辞"; - -// MARK: DetailView -"detail_view.button.read" = "閲覧"; -"detail_view.button.post_comment" = "コメントを書く"; -"detail_view.toolbar_item.button.archives" = "アーカイブ"; -"detail_view.toolbar_item.button.torrents" = "トレント"; -"detail_view.toolbar_item.button.share" = "共有"; -"detail_view.context_menu.button.detail" = "詳細"; -"detail_view.context_menu.button.withdraw_vote" = "投票を取り消す"; -"detail_view.context_menu.button.vote_up" = "賛成票を投じる"; -"detail_view.context_menu.button.vote_down" = "反対票を投じる"; -"detail_view.description_section.title.favorited" = "気に入り"; -"detail_view.description_section.title.language" = "言語"; -"detail_view.description_section.title.ratings" = "%@ 件の評価"; -"detail_view.description_section.title.page_count" = "ページ数"; -"detail_view.description_section.title.file_size" = "ファイルサイズ"; -"detail_view.description_section.description.favorited" = "回"; -"detail_view.description_section.description.page_count" = "ページ"; -"detail_view.action_section.button.give_a_rating" = "評価する"; -"detail_view.action_section.button.similar_gallery" = "類似ギャラリー"; -"detail_view.section.title.previews" = "プレビュー"; -"detail_view.section.title.comments" = "コメント"; - -// MARK: ArchivesView -"archives_view.title.archives" = "アーカイブ"; -"archives_view.button.download_to_hath_client" = "H@H クライアントにダウンロード"; -// HathArchive -"struct.hath_archive.price.free" = "無料"; -"struct.hath_archive.price.not_available" = "無効"; -// ArchiveResolution -"enum.archive_resolution.value.original" = "オリジナル"; - -// MARK: TorrentsView -"torrents_view.title.torrents" = "トレント"; - -// MARK: GalleryInfosView -"gallery_infos_view.title.gallery_infos" = "ギャラリー情報"; -"gallery_infos_view.title.id" = "ID"; -"gallery_infos_view.title.token" = "Token"; -"gallery_infos_view.title.title" = "タイトル"; -"gallery_infos_view.title.japanese_title" = "日本語タイトル"; -"gallery_infos_view.title.gallery_URL" = "ギャラリーリンク"; -"gallery_infos_view.title.cover_URL" = "カバーリンク"; -"gallery_infos_view.title.archive_URL" = "アーカイブリンク"; -"gallery_infos_view.title.torrent_URL" = "トレントリンク"; -"gallery_infos_view.title.parent_URL" = "親ギャラリーリンク"; -"gallery_infos_view.title.category" = "カテゴリー"; -"gallery_infos_view.title.uploader" = "アップローダー"; -"gallery_infos_view.title.posted_date" = "投稿日付"; -"gallery_infos_view.title.visibility" = "可視"; -"gallery_infos_view.title.language" = "言語"; -"gallery_infos_view.title.page_count" = "ページ数"; -"gallery_infos_view.title.file_size" = "ファイルサイズ"; -"gallery_infos_view.title.favorited_times" = "気に入り数"; -"gallery_infos_view.title.favorited" = "お気に入り済み"; -"gallery_infos_view.title.rating_count" = "評価数"; -"gallery_infos_view.title.average_rating" = "平均評価"; -"gallery_infos_view.title.my_rating" = "自分の評価"; -"gallery_infos_view.title.torrent_count" = "トレント数"; -"gallery_infos_view.value.none" = "なし"; -"gallery_infos_view.value.yes" = "はい"; -"gallery_infos_view.value.no" = "いいえ"; -// GalleryVisibility -"enum.gallery_visibility.value.yes" = "はい"; -"enum.gallery_visibility.value.no" = "いいえ (%@)"; -"enum.gallery_visibility.value.no.reason.expunged" = "削除済み"; - -// MARK: TagDetailView -"tag_detail_view.section.title.images" = "画像"; -"tag_detail_view.section.title.links" = "リンク"; - -// MARK: CommentsView -"comments_view.title.comments" = "コメント"; - -// MARK: PostCommentView -"post_comment_view.title.post_comment" = "コメントを書く"; -"post_comment_view.title.edit_comment" = "コメントを編集"; - -// MARK: PreviewsView -"previews_view.title.previews" = "プレビュー"; - -// MARK: ReadingView -"reading_view.context_menu.button.reload" = "再読み込み"; -"reading_view.context_menu.button.copy" = "コピー"; -"reading_view.context_menu.button.save" = "保存"; -"reading_view.context_menu.button.save_original" = "オリジナルを保存"; -"reading_view.context_menu.button.share" = "共有"; -"reading_view.toolbar_item.title.auto_play" = "自動再生"; -"reading_view.toolbar_item.title.dual_page_mode" = "デュアルページモード"; -"reading_view.toolbar_item.title.except_the_cover" = "カバーを除く"; -"reading_view.toolbar_item.button.retry_all_failed_images" = "読み込み失敗した画像をすべてリトライ"; -"reading_view.toolbar_item.button.reload_all_images" = "画像をすべて再読み込み"; -"reading_view.toolbar_item.button.reading_setting" = "閲覧設定"; -// AutoPlayPolicy -"enum.auto_play_policy.value.off" = "オフ"; - -// MARK: FiltersView -"filters_view.title.filters" = "フィルター"; -"filters_view.title.advanced_settings" = "高度な設定"; -"filters_view.title.search_gallery_name" = "ギャラリー名を検索"; -"filters_view.title.search_gallery_tags" = "ギャラリータグを検索"; -"filters_view.title.search_gallery_description" = "ギャラリー説明を検索"; -"filters_view.title.search_torrent_filenames" = "トレントファイル名を検索"; -"filters_view.title.only_show_galleries_with_torrents" = "トレントを含むもののみを表示"; -"filters_view.title.search_low_power_tags" = "低希望タグを検索"; -"filters_view.title.search_downvoted_tags" = "低評価タグを検索"; -"filters_view.title.search_expunged_galleries" = "削除済みのギャラリーを表示"; -"filters_view.title.set_minimum_rating" = "評価の下限を指定"; -"filters_view.title.minimum_rating" = "評価の下限"; -"filters_view.title.set_pages_range" = "ページ数範囲を指定"; -"filters_view.title.pages_range" = "ページ数範囲"; -"filters_view.title.disable_language_filter" = "言語フィルターを無効化"; -"filters_view.title.disable_uploader_filter" = "アップローダフィルターを無効化"; -"filters_view.title.disable_tags_filter" = "タグフィルターを無効化"; -"filters_view.button.reset_filters" = "既定値に戻す"; -"filters_view.section.title.advanced" = "高度"; -"filters_view.section.title.default_filter" = "既定フィルター"; -// FilterRange -"enum.filter_range.value.search" = "検索"; -"enum.filter_range.value.global" = "全般"; -"enum.filter_range.value.watched" = "タグの購読"; - -// MARK: EhSettingView -"eh_setting_view.title.host_settings" = "%@ 設定"; -"eh_setting_view.section.title.profile_settings" = "プロファイル設定"; -"eh_setting_view.title.selected_profile" = "選択されたプロファイル"; -"eh_setting_view.button.set_as_default" = "デフォルトに設定"; -"eh_setting_view.button.delete_profile" = "プロファイルを削除"; -"eh_setting_view.button.rename" = "名前を変更"; -"eh_setting_view.button.create_new" = "新規作成"; -"eh_setting_view.toolbar_item.button.done" = "完了"; - -"eh_setting_view.section.title.image_load_settings" = "画像読み込み設定"; -"eh_setting_view.title.load_images_through_the_hath_network" = "Hath ネットワーク経由で画像を読み込む"; -"eh_setting_view.title.browsing_country" = "閲覧国"; -"eh_setting_view.description.browsing_country" = "**%@** から本サイトを閲覧している、またはその国の VPN・プロキシを使用しているようです。本サイトはその地域の H@H クライアントから画像を読み込もうとしますが、もし自動検知の結果が誤っている、または特別な事情でほかの地域のクライアントを希望する場合(例えばスプリットトンネル VPN を使用している)は下に手動選択できます。"; -// EhSetting.LoadThroughHathSetting -"enum.eh_setting.load_through_hath_setting.value.any_client" = "任意のクライアント"; -"enum.eh_setting.load_through_hath_setting.value.default_port_only" = "デフォルトポートのクライアントのみ"; -"enum.eh_setting.load_through_hath_setting.value.modern_no" = "使わない [モダン / HTTPS]"; -"enum.eh_setting.load_through_hath_setting.value.legacy_no" = "使わない [レガシー / HTTP]"; -"enum.eh_setting.load_through_hath_setting.description.any_client" = "推奨。"; -"enum.eh_setting.load_through_hath_setting.description.default_port_only" = "遅くなることがあります。非標準発信ポートがファイヤーウォール・プロキシにブロックされた場合のみ有効にしてください。"; -"enum.eh_setting.load_through_hath_setting.description.modern_no" = "寄付者独占オプション。閲覧による割当額の消耗は激しくなります。厳重な問題が起こった場合以外おすすめしません。"; -"enum.eh_setting.load_through_hath_setting.description.legacy_no" = "寄付者独占オプション。モダンブラウザでは機能しないこともあります。レガシー・旧型ブラウザの場合以外おすすめしません。"; - -"eh_setting_view.section.title.image_size_settings" = "画像サイズ設定"; -"eh_setting_view.title.image_resolution" = "画像解像度"; -"eh_setting_view.description.image_resolution" = "一般的に、オンライン閲覧の画像は 1280x までにリサンプリングされます。下のいずれかのリサンプリング解像度に変更できます。サーバー負荷軽減のため、1280x 以上の解像度は現時点で寄付者、Hath Perks 利用者または UID が 3,000,000 以下の者に限定されます。"; -"eh_setting_view.title.image_size" = "画像サイズ"; -"eh_setting_view.description.image_size" = "サイト側は画像を自動的にスクリーンに適したサイズにスケールしますが、手動的にその画像の表示サイズ最大値を指定することも可能です。ブラウザが処理を実行するため、画像のリサンプリングは行われません。(ゼロは無制限を意味します)"; -"eh_setting_view.title.horizontal" = "幅"; -"eh_setting_view.title.vertical" = "高さ"; -// EhSetting.ImageResolution -"enum.eh_setting.image_resolution.value.auto" = "自動"; - -"eh_setting_view.section.title.gallery_name_display" = "ギャラリー名表示"; -"eh_setting_view.title.gallery_name" = "ギャラリー名"; -"eh_setting_view.description.gallery_name" = "英語・ローマ字と日本語両方のタイトルを持つギャラリーはたくさんあります。どちらをデフォルトにしますか?"; -// EhSetting.GalleryName -"enum.eh_setting.gallery_name.value.default" = "デフォルトタイトル"; -"enum.eh_setting.gallery_name.value.japanese" = "日本語タイトル(可能なら)"; - -"eh_setting_view.section.title.archiver_settings" = "アーカイバー設定"; -"eh_setting_view.title.archiver_behavior" = "アーカイバー動作"; -"eh_setting_view.description.archiver_behavior" = "アーカイバーのデフォルト動作はオリジナルとリサンプルのアーカイブのコストと選択を確認してからリンクを提供し、それからそのリンクをクリックしたりどこかにペーストしたりすることも可能です。そのデフォルト動作はここで変更できます。"; -// EhSetting.ArchiverBehavior -"enum.eh_setting.archiver_behavior.value.manual_select_manual_start" = "手動で選択、手動で開始(デフォルト)"; -"enum.eh_setting.archiver_behavior.value.manual_select_auto_start" = "手動で選択、自動で開始"; -"enum.eh_setting.archiver_behavior.value.auto_select_original_manual_start" = "自動でオリジナルを選択、手動で開始"; -"enum.eh_setting.archiver_behavior.value.auto_select_original_auto_start" = "自動でオリジナルを選択、自動で開始"; -"enum.eh_setting.archiver_behavior.value.auto_select_resample_manual_start" = "自動でリサンプルを選択、手動で開始"; -"enum.eh_setting.archiver_behavior.value.auto_select_resample_auto_start" = "自動でリサンプルを選択、自動で開始"; - -"eh_setting_view.section.title.front_page_settings" = "フロントページ設定"; -"eh_setting_view.title.display_mode" = "表示モード"; -"eh_setting_view.description.display_mode" = "フロント・検索ページで使う表示モードはどれにしますか?"; -"eh_setting_view.section.title.show_search_range_indicator" = "検索範囲インジケーター"; -"eh_setting_view.title.show_search_range_indicator" = "検索範囲インジケーターを表示"; -"eh_setting_view.description.gallery_category" = "フロント・検索ページでどれらのカテゴリーのギャラリーを表示しますか?"; -// EhSetting.DisplayMode -"enum.eh_setting.display_mode.value.compact" = "コンパクト"; -"enum.eh_setting.display_mode.value.thumbnail" = "サムネイル"; -"enum.eh_setting.display_mode.value.extended" = "拡張"; -"enum.eh_setting.display_mode.value.minimal" = "最小化"; -"enum.eh_setting.display_mode.value.minimalPlus" = "最小化+"; - -"eh_setting_view.section.title.optional_UI_elements" = "UI の表示制御"; -"eh_setting_view.description.optional_UI_elements" = "一部の従来の UI はデフォルトで無効になっています。ここで有効にすることができます。"; -"eh_setting_view.title.enable_gallery_thumbnail_selector" = "ギャラリーのサムネイルセレクタ"; - -"eh_setting_view.section.title.favorites" = "お気に入り"; -"eh_setting_view.description.favorite_categories" = "ここではお気に入りカテゴリー名の変更ができます。"; -"eh_setting_view.title.favorites_sort_order" = "お気に入りの並び替え"; -"eh_setting_view.description.favorites_sort_order" = "お気に入りページのデフォルト並び替え順序も変更可能です。注意:平成28年3月の改修前にお気に入りに追加した項目はタイムスタンプが含まれていないため、この設定を無視して代わりにギャラリーの投稿時間を使います。"; -// EhSetting.FavoritesSortOrder -"enum.eh_setting.favorites_sort_order.value.last_update_time" = "更新時間の新しい順"; -"enum.eh_setting.favorites_sort_order.value.favorited_time" = "気に入った時間の新しい順"; - -"eh_setting_view.section.title.ratings" = "評価"; -"eh_setting_view.title.ratings_color" = "評価の色"; -"eh_setting_view.promt.ratings_color" = "RRGGB"; -"eh_setting_view.description.ratings_color" = "デフォルトでは、評価済みのギャラリーは 2 以下の評価に赤い星を使う、2.5 ~ 4 には緑、4.5 以上には青。下に色の組み合わせを入れることでこのルールをカスタマイズできます。一つの星の色は一つの文字で指定します。デフォルトの「RRGGB」は「一番目と二番目の星は赤(Red)、三番と四番は緑(Green)、五番は青(Blue)」を意味します。黄色(Yellow)も使用可能です。R・G・B・Yで組み合わせた五文字はどれも機能します。"; - -"eh_setting_view.section.title.tag_filtering_threshold" = "タグフィルタリングしきい値"; -"eh_setting_view.title.tag_filtering_threshold" = "タグフィルタリングしきい値"; -"eh_setting_view.description.tag_filtering_threshold" = "負の重み付きでマイタグに追加することでタグをソフトフィルタリングすることができます。もしあるギャラリーが持つタグの重み総和がこのしきい値より低ければ、そのギャラリーはフィルタリングされます。このしきい値はゼロから -9999 まで設定できます。"; - -"eh_setting_view.section.title.tag_watching_threshold" = "タグ購読しきい値"; -"eh_setting_view.title.tag_watching_threshold" = "タグ購読しきい値"; -"eh_setting_view.description.tag_watching_threshold" = "もしあるギャラリーは最近投稿されたもので、少なくても一つの正の重みの購読タグを持っていて、購読タグの重み総和がこのしきい値と同じまたはより高ければ、そのギャラリーは購読画面で表示されます。このしきい値はゼロから 9999 まで設定できます。"; - -"eh_setting_view.section.title.filtered_removal_count" = "フィルター除去数"; -"eh_setting_view.description.filtered_removal_count" = "「既定フィルターにより本ページから XX 個のギャラリーが除去されました」を表示しますか?"; -"eh_setting_view.title.show_filtered_removal_count" = "フィルター除去数を表示"; - -"eh_setting_view.section.title.excluded_languages" = "排除された言語"; -"eh_setting_view.description.excluded_languages" = "特定の言語のギャラリーをリストと検索結果から隠したい場合、下に選択してください。注意:どんな検索クエリーを使ってもこれらの言語のギャラリーは表示されません。"; -// EhSetting.ExcludedLanguagesCategory -"enum.eh_setting.excluded_languages_category.value.original" = "オリジナル"; -"enum.eh_setting.excluded_languages_category.value.translated" = "翻訳版"; -"enum.eh_setting.excluded_languages_category.value.rewrite" = "書き換え版"; - -"eh_setting_view.section.title.excluded_uploaders" = "排除された投稿者"; -"eh_setting_view.description.excluded_uploaders" = "特定の投稿者のギャラリーをリストと検索結果から隠したい場合、下に名前を記入してください。一行に一つのユーザー名で。注意:どんな検索クエリーを使ってもこの投稿者たちのギャラリーは表示されません。"; -"eh_setting_view.description.excluded_uploaders_count" = "現時点で **%@ / %@** の排除スロットが使用済みです。"; - -"eh_setting_view.section.title.search_result_count" = "検索結果数"; -"eh_setting_view.title.result_count" = "結果数"; -"eh_setting_view.description.result_count" = "インデックス・トレントの検索ページで、各ページにどれくらいの結果数がお望みですか?\n(「Hath Perk:ページング拡張」が必要)"; - -"eh_setting_view.section.title.thumbnail_settings" = "サムネイル設定"; -"eh_setting_view.title.thumbnail_load_timing" = "サムネイル読み込みタイミング"; -"eh_setting_view.description.thumbnail_load_timing" = "リストでは、どんなタイミングでホームページのマウスオーバーサムネイルを読み込みますか?"; -"eh_setting_view.description.thumbnail_configuration" = "すべてのギャラリーに適応するデフォルトのサムネイル構成を設定できます。"; -"eh_setting_view.title.thumbnail_size" = "サイズ"; -"eh_setting_view.title.thumbnail_row_count" = "行数"; -// EhSetting.ThumbnailLoadTiming -"enum.eh_setting.thumbnail_load_timing.value.on_mouse_over" = "マウス経過時"; -"enum.eh_setting.thumbnail_load_timing.value.on_page_load" = "ページ読み込み時"; -"enum.eh_setting.thumbnail_load_timing.description.on_mouse_over" = "ページの読み込みが速くなりますが、サムネイルの表示はちょっぴり遅れてきます。"; -"enum.eh_setting.thumbnail_load_timing.description.on_page_load" = "ページの読み込み時間が増えますが、サムネイルはすぐに表示できます。"; -// EhSetting.ThumbnailSize -"enum.eh_setting.thumbnail_size.value.normal" = "普通"; -"enum.eh_setting.thumbnail_size.value.large" = "大きめ"; -"enum.eh_setting.thumbnail_size.value.small" = "小さめ"; -"enum.eh_setting.thumbnail_size.value.auto" = "自動"; - -"eh_setting_view.section.title.cover_scaling" = "カバースケーリング"; -"eh_setting_view.title.scale_factor" = "スケール係数"; -"eh_setting_view.description.cover_scale_factor" = "サムネイル・拡張表示モードでのカバーを 75%% ~ 150%% にスケールすることができます。"; - -"eh_setting_view.section.title.viewport_override" = "表示領域オーバーライド"; -"eh_setting_view.title.virtual_width" = "仮想幅"; -"eh_setting_view.description.virtual_width" = "モバイルデバイスの仮想幅をオーバーライドすることができます。一般的にはデバイスの DPI に基づいて自動的に決定されます。例えばサムネイルスケール係数が 100%% の場合、640 ~ 1400 の幅が合理的です。"; - -"eh_setting_view.section.title.gallery_comments" = "ギャラリーコメント"; -"eh_setting_view.title.comments_sort_order" = "コメントの並び替え"; -"eh_setting_view.title.comments_votes_show_timing" = "コメントスコア表示タイミング"; -// EhSetting.CommentsSortOrder -"enum.eh_setting.comments_sort_order.value.oldest" = "コメントの古い順"; -"enum.eh_setting.comments_sort_order.value.recent" = "コメントの新しい順"; -"enum.eh_setting.comments_sort_order.value.highest_score" = "スコアの高い順"; -// EhSetting.CommentVotesShowTiming -"enum.eh_setting.comments_votes_show_timing.value.on_hover_or_click" = "スコアに経過・クリック時"; -"enum.eh_setting.comments_votes_show_timing.value.always" = "常時"; - -"eh_setting_view.section.title.gallery_tags" = "ギャラリータグ"; -"eh_setting_view.title.tags_sort_order" = "タグの並び替え"; -// EhSetting.tags_sort_order -"enum.eh_setting.tags_sort_order.value.alphabetical" = "アルファベット順"; -"enum.eh_setting.tags_sort_order.value.tag_power" = "タグパワーの高い順"; - -"eh_setting_view.section.title.gallery_page_thumbnail_labeling" = "ギャラリーサムネイルのラベル"; -"eh_setting_view.title.show_label_below_gallery_thumbnails" = "ギャラリーサムネイルの下にラベルを表示"; - -"eh_setting_view.section.title.hath_local_network_host" = "Hath ローカルネットワークホスト"; -"eh_setting_view.title.ip_address_port" = "IP アドレス:ポート"; -"eh_setting_view.description.ip_address_port" = "ローカルネットワークで今と同じパブリック IP を使う H@H クライアントがお持ちの場合、この設定が役立ちます。ルーターがバグが多くてリクエストを自分の IP にルートすることができないこともあります、それをこの設定で回避できます。\nH@H クライアントが今と同じデバイスで運行している場合はループバックアドレス(127.0.0.1:ポート)を使ってください。別のデバイスの場合はそのローカル IP を使ってください。かなりのブラウザの構成では外部サイトがローカル IP にアクセスすることをブロックしています、この設定を有効にするには本サイトをホワイトリストに入れてください。"; - -"eh_setting_view.section.title.original_images" = "オリジナル画像を使いますか?リサンプリングされた画像は、上記の解像度で「自動」以外を選択し、該当する画像の方が幅が広い場合、またはオリジナル画像が 10 MiB(一年以上前のギャラリーの場合は 4 MiB)より大きい場合に使用されます。"; -"eh_setting_view.title.use_original_images" = "オリジナル画像を使う"; - -"eh_setting_view.section.title.multi_page_viewer" = "マルチページビューア"; -"eh_setting_view.title.use_multi_page_viewer" = "マルチページビューアを使う"; -"eh_setting_view.title.display_style" = "表示仕様"; -"eh_setting_view.title.show_thumbnail_pane" = "サムネイルパネルを表示"; -// EhSetting.MultiplePageViewerStyle -"enum.eh_setting.multiple_page_viewer_style.value.align_left_scale_if_over_width" = "左寄せ、幅によってスケール"; -"enum.eh_setting.multiple_page_viewer_style.value.align_center_scale_if_over_width" = "中央揃え、幅によってスケール"; -"enum.eh_setting.multiple_page_viewer_style.value.align_center_always_scale" = "中央揃え、常時スケール"; -// EhSetting.GalleryPageNumbering -"enum.eh_setting.gallery_page_numbering.value.none" = "表示しない"; -"enum.eh_setting.gallery_page_numbering.value.page_number_only" = "ページ番号のみ表示"; -"enum.eh_setting.gallery_page_numbering.value.page_number_and_name" = "ページ番号と名前を表示"; - -// MARK: Category -"enum.category.value.doujinshi" = "同人誌"; -"enum.category.value.manga" = "漫画"; -"enum.category.value.artist_CG" = "イラスト"; -"enum.category.value.game_CG" = "ゲーム CG"; -"enum.category.value.western" = "西洋"; -"enum.category.value.non_h" = "健全"; -"enum.category.value.image_set" = "画像集"; -"enum.category.value.cosplay" = "コスプレ"; -"enum.category.value.asian_porn" = "アジア"; -"enum.category.value.misc" = "その他"; -"enum.category.value.private" = "プライベート"; - -// MARK: TagNamespace -"enum.tag_namespace.value.reclass" = "再分類"; -"enum.tag_namespace.value.language" = "言語"; -"enum.tag_namespace.value.parody" = "原作"; -"enum.tag_namespace.value.character" = "キャラ"; -"enum.tag_namespace.value.group" = "団体"; -"enum.tag_namespace.value.artist" = "作者"; -"enum.tag_namespace.value.male" = "男性"; -"enum.tag_namespace.value.female" = "女性"; -"enum.tag_namespace.value.mixed" = "混在性別"; -"enum.tag_namespace.value.cosplayer" = "レイヤー"; -"enum.tag_namespace.value.other" = "その他"; -"enum.tag_namespace.value.temp" = "一時的"; - -// MARK: Language -"enum.language.value.invalid" = "無効"; -"enum.language.value.other" = "その他"; -"enum.language.value.afrikaans" = "アフリカーンス語"; -"enum.language.value.albanian" = "アルバニア語"; -"enum.language.value.arabic" = "アラビア語"; -"enum.language.value.bengali" = "ベンガル語"; -"enum.language.value.bosnian" = "ボスニア語"; -"enum.language.value.bulgarian" = "ブルガリア語"; -"enum.language.value.burmese" = "ビルマ語"; -"enum.language.value.catalan" = "カタルーニャ語"; -"enum.language.value.cebuano" = "セブアノ語"; -"enum.language.value.chinese" = "中国語"; -"enum.language.value.croatian" = "クロアチア語"; -"enum.language.value.czech" = "チェコ語"; -"enum.language.value.danish" = "デンマーク語"; -"enum.language.value.dutch" = "オランダ語"; -"enum.language.value.english" = "英語"; -"enum.language.value.esperanto" = "国際語"; -"enum.language.value.estonian" = "エストニア語"; -"enum.language.value.finnish" = "フィンランド語"; -"enum.language.value.french" = "フランス語"; -"enum.language.value.georgian" = "グルジア語"; -"enum.language.value.german" = "ドイツ語"; -"enum.language.value.greek" = "ギリシア語"; -"enum.language.value.hebrew" = "ヘブライ語"; -"enum.language.value.hindi" = "ヒンディー語"; -"enum.language.value.hmong" = "ミャオ語"; -"enum.language.value.hungarian" = "ハンガリー語"; -"enum.language.value.indonesian" = "インドネシア語"; -"enum.language.value.italian" = "イタリア語"; -"enum.language.value.japanese" = "日本語"; -"enum.language.value.kazakh" = "カザフ語"; -"enum.language.value.khmer" = "クメール語"; -"enum.language.value.korean" = "韓国語"; -"enum.language.value.kurdish" = "クルド語"; -"enum.language.value.lao" = "ラーオ語"; -"enum.language.value.latin" = "ラテン語"; -"enum.language.value.mongolian" = "モンゴル語"; -"enum.language.value.ndebele" = "ンデベレ語"; -"enum.language.value.nepali" = "ネパール語"; -"enum.language.value.norwegian" = "ノルウェー語"; -"enum.language.value.oromo" = "オロモ語"; -"enum.language.value.pashto" = "パシュトー語"; -"enum.language.value.persian" = "ペルシア語"; -"enum.language.value.polish" = "ポーランド語"; -"enum.language.value.portuguese" = "ポルトガル語"; -"enum.language.value.punjabi" = "パンジャーブ語"; -"enum.language.value.romanian" = "ルーマニア語"; -"enum.language.value.russian" = "ロシア語"; -"enum.language.value.sango" = "サンゴ語"; -"enum.language.value.serbian" = "セルビア語"; -"enum.language.value.shona" = "ショナ語"; -"enum.language.value.slovak" = "スロバキア語"; -"enum.language.value.slovenian" = "スロベニア語"; -"enum.language.value.somali" = "ソマリ語"; -"enum.language.value.spanish" = "スペイン語"; -"enum.language.value.swahili" = "スワヒリ語"; -"enum.language.value.swedish" = "スウェーデン語"; -"enum.language.value.tagalog" = "タガログ語"; -"enum.language.value.thai" = "タイ語"; -"enum.language.value.tigrinya" = "ティグリニャ語"; -"enum.language.value.turkish" = "トルコ語"; -"enum.language.value.ukrainian" = "ウクライナ語"; -"enum.language.value.urdu" = "ウルドゥー語"; -"enum.language.value.vietnamese" = "ベトナム語"; -"enum.language.value.zulu" = "ズールー語"; - -// MARK: BrowsingCountry -"enum.browsing_country.name.auto_detect" = "自動検出"; -"enum.browsing_country.name.afghanistan" = "アフガニスタン"; -"enum.browsing_country.name.aland_islands" = "オーランド諸島"; -"enum.browsing_country.name.albania" = "アルバニア"; -"enum.browsing_country.name.algeria" = "アルジェリア"; -"enum.browsing_country.name.american_samoa" = "アメリカ領サモア"; -"enum.browsing_country.name.andorra" = "アンドラ"; -"enum.browsing_country.name.angola" = "アンゴラ"; -"enum.browsing_country.name.anguilla" = "アンギラ"; -"enum.browsing_country.name.antarctica" = "南極大陸"; -"enum.browsing_country.name.antigua_and_barbuda" = "アンティグア・バーブーダ"; -"enum.browsing_country.name.argentina" = "アルゼンチン"; -"enum.browsing_country.name.armenia" = "アルメニア"; -"enum.browsing_country.name.aruba" = "アルバ"; -"enum.browsing_country.name.asia_pacific_region" = "アジア太平洋地域"; -"enum.browsing_country.name.australia" = "オーストラリア"; -"enum.browsing_country.name.austria" = "オーストリア"; -"enum.browsing_country.name.azerbaijan" = "アゼルバイジャン"; -"enum.browsing_country.name.bahamas" = "バハマ"; -"enum.browsing_country.name.bahrain" = "バーレーン"; -"enum.browsing_country.name.bangladesh" = "バングラデシュ"; -"enum.browsing_country.name.barbados" = "バルバドス"; -"enum.browsing_country.name.belarus" = "ベラルーシ"; -"enum.browsing_country.name.belgium" = "ベルギー"; -"enum.browsing_country.name.belize" = "ベリーズ"; -"enum.browsing_country.name.benin" = "ベナン"; -"enum.browsing_country.name.bermuda" = "バミューダ諸島"; -"enum.browsing_country.name.bhutan" = "ブータン"; -"enum.browsing_country.name.bolivia" = "ボリビア"; -"enum.browsing_country.name.bonaire_saint_eustatius_and_saba" = "ボネール、シント・ユースタティウスおよびサバ"; -"enum.browsing_country.name.bosnia_and_herzegovina" = "ボスニア・ヘルツェゴビナ"; -"enum.browsing_country.name.botswana" = "ボツワナ"; -"enum.browsing_country.name.bouvet_island" = "ブーベ島"; -"enum.browsing_country.name.brazil" = "ブラジル"; -"enum.browsing_country.name.british_indian_ocean_territory" = "イギリス領インド洋地域"; -"enum.browsing_country.name.brunei_darussalam" = "ブルネイ・ダルサラーム"; -"enum.browsing_country.name.bulgaria" = "ブルガリア"; -"enum.browsing_country.name.burkina_faso" = "ブルキナファソ"; -"enum.browsing_country.name.burundi" = "ブルンジ"; -"enum.browsing_country.name.cambodia" = "カンボジア"; -"enum.browsing_country.name.cameroon" = "カメルーン"; -"enum.browsing_country.name.canada" = "カナダ"; -"enum.browsing_country.name.cape_verde" = "カーボベルデ"; -"enum.browsing_country.name.cayman_islands" = "ケイマン諸島"; -"enum.browsing_country.name.central_african_republic" = "中央アフリカ共和国"; -"enum.browsing_country.name.chad" = "チャド"; -"enum.browsing_country.name.chile" = "チリ"; -"enum.browsing_country.name.china" = "中華人民共和国"; -"enum.browsing_country.name.christmas_island" = "クリスマス島"; -"enum.browsing_country.name.cocos_islands" = "ココス諸島"; -"enum.browsing_country.name.colombia" = "コロンビア"; -"enum.browsing_country.name.comoros" = "コモロ"; -"enum.browsing_country.name.congo" = "コンゴ共和国"; -"enum.browsing_country.name.the_democratic_republic_of_the_congo" = "コンゴ民主共和国"; -"enum.browsing_country.name.cook_islands" = "クック諸島"; -"enum.browsing_country.name.costa_rica" = "コスタリカ"; -"enum.browsing_country.name.cote_d_ivoire" = "コートジボワール"; -"enum.browsing_country.name.croatia" = "クロアチア"; -"enum.browsing_country.name.cuba" = "キューバ"; -"enum.browsing_country.name.curacao" = "キュラソー島"; -"enum.browsing_country.name.cyprus" = "キプロス"; -"enum.browsing_country.name.czech_republic" = "チェコ"; -"enum.browsing_country.name.denmark" = "デンマーク"; -"enum.browsing_country.name.djibouti" = "ジブチ"; -"enum.browsing_country.name.dominica" = "ドミニカ"; -"enum.browsing_country.name.dominican_republic" = "ドミニカ共和国"; -"enum.browsing_country.name.ecuador" = "エクアドル"; -"enum.browsing_country.name.egypt" = "エジプト"; -"enum.browsing_country.name.el_salvador" = "エルサルバドル"; -"enum.browsing_country.name.equatorial_guinea" = "赤道ギニア"; -"enum.browsing_country.name.eritrea" = "エリトリア"; -"enum.browsing_country.name.estonia" = "エストニア"; -"enum.browsing_country.name.ethiopia" = "エチオピア"; -"enum.browsing_country.name.europe" = "ヨーロッパ"; -"enum.browsing_country.name.falkland_islands" = "フォークランド諸島"; -"enum.browsing_country.name.faroe_islands" = "フェロー諸島"; -"enum.browsing_country.name.fiji" = "フィジー"; -"enum.browsing_country.name.finland" = "フィンランド"; -"enum.browsing_country.name.france" = "フランス"; -"enum.browsing_country.name.french_guiana" = "フランス領ギアナ"; -"enum.browsing_country.name.french_polynesia" = "フランス領ポリネシア"; -"enum.browsing_country.name.french_southern_territories" = "フランス領南方・南極地域"; -"enum.browsing_country.name.gabon" = "ガボン"; -"enum.browsing_country.name.gambia" = "ガンビア"; -"enum.browsing_country.name.georgia" = "ジョージア"; -"enum.browsing_country.name.germany" = "ドイツ"; -"enum.browsing_country.name.ghana" = "ガーナ"; -"enum.browsing_country.name.gibraltar" = "ジブラルタル"; -"enum.browsing_country.name.greece" = "ギリシャ"; -"enum.browsing_country.name.greenland" = "グリーンランド"; -"enum.browsing_country.name.grenada" = "グレナダ"; -"enum.browsing_country.name.guadeloupe" = "グアドループ"; -"enum.browsing_country.name.guam" = "グアム"; -"enum.browsing_country.name.guatemala" = "グアテマラ"; -"enum.browsing_country.name.guernsey" = "ガーンジー"; -"enum.browsing_country.name.guinea" = "ギニア"; -"enum.browsing_country.name.guinea_bissau" = "ギニアビサウ"; -"enum.browsing_country.name.guyana" = "ガイアナ"; -"enum.browsing_country.name.haiti" = "ハイチ"; -"enum.browsing_country.name.heard_island_and_mc_donald_islands" = "ハード島とマクドナルド諸島"; -"enum.browsing_country.name.vatican_city_state" = "バチカン市国"; -"enum.browsing_country.name.honduras" = "ホンジュラス"; -"enum.browsing_country.name.hong_kong" = "香港"; -"enum.browsing_country.name.hungary" = "ハンガリー"; -"enum.browsing_country.name.iceland" = "アイスランド"; -"enum.browsing_country.name.india" = "インド"; -"enum.browsing_country.name.indonesia" = "インドネシア"; -"enum.browsing_country.name.iran" = "イラン"; -"enum.browsing_country.name.iraq" = "イラク"; -"enum.browsing_country.name.ireland" = "アイルランド"; -"enum.browsing_country.name.isle_of_man" = "マン島"; -"enum.browsing_country.name.israel" = "イスラエル"; -"enum.browsing_country.name.italy" = "イタリア"; -"enum.browsing_country.name.jamaica" = "ジャマイカ"; -"enum.browsing_country.name.japan" = "日本"; -"enum.browsing_country.name.jersey" = "ジャージー"; -"enum.browsing_country.name.jordan" = "ヨルダン"; -"enum.browsing_country.name.kazakhstan" = "カザフスタン"; -"enum.browsing_country.name.kenya" = "ケニア"; -"enum.browsing_country.name.kiribati" = "キリバス"; -"enum.browsing_country.name.kuwait" = "クウェート"; -"enum.browsing_country.name.kyrgyzstan" = "キルギス"; -"enum.browsing_country.name.lao_peoples_democratic_republic" = "ラオス"; -"enum.browsing_country.name.latvia" = "ラトビア"; -"enum.browsing_country.name.lebanon" = "レバノン"; -"enum.browsing_country.name.lesotho" = "レソト"; -"enum.browsing_country.name.liberia" = "リベリア"; -"enum.browsing_country.name.libya" = "リビア"; -"enum.browsing_country.name.liechtenstein" = "リヒテンシュタイン"; -"enum.browsing_country.name.lithuania" = "リトアニア"; -"enum.browsing_country.name.luxembourg" = "ルクセンブルク"; -"enum.browsing_country.name.macau" = "マカオ"; -"enum.browsing_country.name.macedonia" = "マケドニア"; -"enum.browsing_country.name.madagascar" = "マダガスカル"; -"enum.browsing_country.name.malawi" = "マラウイ"; -"enum.browsing_country.name.malaysia" = "マレーシア"; -"enum.browsing_country.name.maldives" = "モルディブ"; -"enum.browsing_country.name.mali" = "マリ"; -"enum.browsing_country.name.malta" = "マルタ"; -"enum.browsing_country.name.marshall_islands" = "マーシャル諸島"; -"enum.browsing_country.name.martinique" = "マルティニーク"; -"enum.browsing_country.name.mauritania" = "モーリタニア"; -"enum.browsing_country.name.mauritius" = "モーリシャス"; -"enum.browsing_country.name.mayotte" = "マヨット"; -"enum.browsing_country.name.mexico" = "メキシコ"; -"enum.browsing_country.name.micronesia" = "ミクロネシア"; -"enum.browsing_country.name.moldova" = "モルドバ"; -"enum.browsing_country.name.monaco" = "モナコ"; -"enum.browsing_country.name.mongolia" = "モンゴル"; -"enum.browsing_country.name.montenegro" = "モンテネグロ"; -"enum.browsing_country.name.montserrat" = "モントセラト"; -"enum.browsing_country.name.morocco" = "モロッコ"; -"enum.browsing_country.name.mozambique" = "モザンビーク"; -"enum.browsing_country.name.myanmar" = "ミャンマー"; -"enum.browsing_country.name.namibia" = "ナミビア"; -"enum.browsing_country.name.nauru" = "ナウル"; -"enum.browsing_country.name.nepal" = "ネパール"; -"enum.browsing_country.name.netherlands" = "オランダ"; -"enum.browsing_country.name.new_caledonia" = "ニューカレドニア"; -"enum.browsing_country.name.new_zealand" = "ニュージーランド"; -"enum.browsing_country.name.nicaragua" = "ニカラグア"; -"enum.browsing_country.name.niger" = "ニジェール"; -"enum.browsing_country.name.nigeria" = "ナイジェリア"; -"enum.browsing_country.name.niue" = "ニウエ"; -"enum.browsing_country.name.norfolk_island" = "ノーフォーク島"; -"enum.browsing_country.name.north_korea" = "朝鮮"; -"enum.browsing_country.name.northern_mariana_islands" = "北マリアナ諸島"; -"enum.browsing_country.name.norway" = "ノルウェー"; -"enum.browsing_country.name.oman" = "オマーン"; -"enum.browsing_country.name.pakistan" = "パキスタン"; -"enum.browsing_country.name.palau" = "パラオ"; -"enum.browsing_country.name.palestinian_territory" = "パレスチナ"; -"enum.browsing_country.name.panama" = "パナマ"; -"enum.browsing_country.name.papua_new_guinea" = "パプアニューギニア"; -"enum.browsing_country.name.paraguay" = "パラグアイ"; -"enum.browsing_country.name.peru" = "ペルー"; -"enum.browsing_country.name.philippines" = "フィリピン"; -"enum.browsing_country.name.pitcairn_islands" = "ピトケアン諸島"; -"enum.browsing_country.name.poland" = "ポーランド"; -"enum.browsing_country.name.portugal" = "ポルトガル"; -"enum.browsing_country.name.puerto_rico" = "プエルトリコ"; -"enum.browsing_country.name.qatar" = "カタール"; -"enum.browsing_country.name.reunion" = "ユニオン"; -"enum.browsing_country.name.romania" = "ルーマニア"; -"enum.browsing_country.name.russian_federation" = "ロシア"; -"enum.browsing_country.name.rwanda" = "ルワンダ"; -"enum.browsing_country.name.saint_barthelemy" = "サン・バルテルミー島"; -"enum.browsing_country.name.saint_helena" = "セントヘレナ"; -"enum.browsing_country.name.saint_kitts_and_nevis" = "セントクリストファー・ネービス"; -"enum.browsing_country.name.saint_lucia" = "セントルシア"; -"enum.browsing_country.name.saint_martin" = "サン・マルタン島"; -"enum.browsing_country.name.saint_pierre_and_miquelon" = "サンピエール島・ミクロン島"; -"enum.browsing_country.name.saint_vincent_and_the_grenadines" = "セントビンセントおよびグレナディーン諸島"; -"enum.browsing_country.name.samoa" = "サモア"; -"enum.browsing_country.name.san_marino" = "サンマリノ"; -"enum.browsing_country.name.sao_tome_and_principe" = "サントメ・プリンシペ"; -"enum.browsing_country.name.saudi_arabia" = "サウジアラビア"; -"enum.browsing_country.name.senegal" = "セネガル"; -"enum.browsing_country.name.serbia" = "セルビア"; -"enum.browsing_country.name.seychelles" = "セーシェル"; -"enum.browsing_country.name.sierra_leone" = "シエラレオネ"; -"enum.browsing_country.name.singapore" = "シンガポール"; -"enum.browsing_country.name.sint_maarten" = "シント・マールテン"; -"enum.browsing_country.name.slovakia" = "スロバキア"; -"enum.browsing_country.name.slovenia" = "スロベニア"; -"enum.browsing_country.name.solomon_islands" = "ソロモン諸島"; -"enum.browsing_country.name.somalia" = "ソマリア"; -"enum.browsing_country.name.south_africa" = "南アフリカ"; -"enum.browsing_country.name.south_georgia_and_the_south_sandwich_islands" = "サウスジョージア・サウスサンドウィッチ諸島"; -"enum.browsing_country.name.south_korea" = "韓国"; -"enum.browsing_country.name.south_sudan" = "南スーダン"; -"enum.browsing_country.name.spain" = "スペイン"; -"enum.browsing_country.name.sri_lanka" = "スリランカ"; -"enum.browsing_country.name.sudan" = "スーダン"; -"enum.browsing_country.name.suriname" = "スリナム"; -"enum.browsing_country.name.svalbard_and_jan_mayen" = "スヴァールバル諸島およびヤンマイエン島"; -"enum.browsing_country.name.swaziland" = "エスワティニ"; -"enum.browsing_country.name.sweden" = "スウェーデン"; -"enum.browsing_country.name.switzerland" = "スイス"; -"enum.browsing_country.name.syrian_arab_republic" = "シリア"; -"enum.browsing_country.name.taiwan" = "台湾"; -"enum.browsing_country.name.tajikistan" = "タジキスタン"; -"enum.browsing_country.name.tanzania" = "タンザニア"; -"enum.browsing_country.name.thailand" = "タイ"; -"enum.browsing_country.name.timor_leste" = "東ティモール"; -"enum.browsing_country.name.togo" = "トーゴ"; -"enum.browsing_country.name.tokelau" = "トケラウ"; -"enum.browsing_country.name.tonga" = "トンガ"; -"enum.browsing_country.name.trinidad_and_tobago" = "トリニダード・トバゴ"; -"enum.browsing_country.name.tunisia" = "チュニジア"; -"enum.browsing_country.name.turkey" = "トルコ"; -"enum.browsing_country.name.turkmenistan" = "トルクメニスタン"; -"enum.browsing_country.name.turks_and_caicos_islands" = "タークス・カイコス諸島"; -"enum.browsing_country.name.tuvalu" = "ツバル"; -"enum.browsing_country.name.uganda" = "ウガンダ"; -"enum.browsing_country.name.ukraine" = "ウクライナ"; -"enum.browsing_country.name.united_arab_emirates" = "アラブ首長国連邦"; -"enum.browsing_country.name.united_kingdom" = "イギリス"; -"enum.browsing_country.name.united_states" = "アメリカ"; -"enum.browsing_country.name.united_states_minor_outlying_islands" = "合衆国領有小離島"; -"enum.browsing_country.name.uruguay" = "ウルグアイ"; -"enum.browsing_country.name.uzbekistan" = "ウズベキスタン"; -"enum.browsing_country.name.vanuatu" = "バヌアツ"; -"enum.browsing_country.name.venezuela" = "ベネズエラ"; -"enum.browsing_country.name.vietnam" = "ベトナム"; -"enum.browsing_country.name.virgin_islands_british" = "イギリス領バージン諸島"; -"enum.browsing_country.name.virgin_islands_US" = "アメリカ領ヴァージン諸島"; -"enum.browsing_country.name.wallis_and_futuna" = "ウォリス・フツナ"; -"enum.browsing_country.name.western_sahara" = "西サハラ"; -"enum.browsing_country.name.yemen" = "イエメン"; -"enum.browsing_country.name.zambia" = "ザンビア"; -"enum.browsing_country.name.zimbabwe" = "ジンバブエ"; diff --git a/EhPanda/App/ko.lproj/InfoPlist.strings b/EhPanda/App/ko.lproj/InfoPlist.strings deleted file mode 100644 index d96b58bb8..000000000 --- a/EhPanda/App/ko.lproj/InfoPlist.strings +++ /dev/null @@ -1,7 +0,0 @@ -/* - InfoPlist.strings - EhPanda -*/ - -"NSFaceIDUsageDescription" = "이 권한을 허용해야 앱 잠금 해제할때 Face ID 옵션을 제공합니다."; -"NSPhotoLibraryAddUsageDescription" = "이미지를 사진 라이브러리에 저정하고 싶으면 이 권한을 허용해주세요."; diff --git a/EhPanda/App/ko.lproj/Localizable.strings b/EhPanda/App/ko.lproj/Localizable.strings deleted file mode 100644 index b92668981..000000000 --- a/EhPanda/App/ko.lproj/Localizable.strings +++ /dev/null @@ -1,906 +0,0 @@ -/* - Localizable.strings - EhPanda -*/ - -// MARK: BanInterval -"enum.ban_interval.description.and" = "and"; - -// MARK: ToplistsType -"enum.toplists_type.value.yesterday" = "어제"; -"enum.toplists_type.value.past_month" = "지난 달"; -"enum.toplists_type.value.past_year" = "지난 해"; -"enum.toplists_type.value.all_time" = "전체"; - -// MARK: Response -"website.response.hath_client_not_found" = "H@H 클라이언트를 아이디에 연동시킨 후 사용해주세요."; -"website.response.hath_client_not_online" = "H@H 클라이언트가 오프라인인 것 같네요. 클라이언트를 켜고 다시 시도해주세요."; -"website.response.invalid_resolution" = "이 콘텐츠는 선택한 해상도로 다운로드할 수 없어요."; - -// MARK: HUD -"hud.title.error" = "실패"; -"hud.title.success" = "성공"; -"hud.title.loading" = "로딩 중..."; -"hud.title.communicating" = "접속 중..."; -"hud.caption.copied_to_clipboard" = "클립보드에 복사되었어요"; -"hud.caption.saved_to_photo_library" = "이미지 저장"; - -// MARK: AutoLock -"local_authorization.reason" = "자동 잠금으로 앱이 잠겼어요."; - -// MARK: Common value -"common.value.stars" = "%@별"; -"common.value.pages" = "%@페이지"; -"common.value.times" = "%@번"; -"common.value.day" = "%@ day"; -"common.value.days" = "%@ days"; -"common.value.hour" = "%@ hour"; -"common.value.hours" = "%@ hours"; -"common.value.minute" = "%@ 분"; -"common.value.minutes" = "%@ 분"; -"common.value.second" = "%@ 초"; -"common.value.seconds" = "%@ 초"; -"common.value.records" = "%@ 기록수"; - -// MARK: TabItem -"tab_item.title.home" = "Home"; -"tab_item.title.favorites" = "즐겨찾기"; -"tab_item.title.search" = "검색"; -"tab_item.title.setting" = "설정"; - -// MARK: ToolbarItem -"toolbar_item.button.filters" = "필터"; -"toolbar_item.button.jump_page" = "페이지 이동"; -"toolbar_item.button.quick_search" = "빠른 검색"; - -// MARK: JumpPage -"jump_page_view.title.jump_page" = "페이지 이동"; -"jump_page_view.button.confirm" = "확인"; - -// MARK: AlertView -"loading_view.title.loading" = "로딩 중..."; -"loading_view.title.preparing_database" = "Preparing the database..."; -"not_login_view.title.need_login" = "You need to login to access this feature."; -"not_login_view.button.login" = "Login"; -"error_view.button.retry" = "재시도"; -"error_view.button.drop_database" = "Drop the database"; -"error_view.title.try_later" = "잠시 후 다시 시도해 주세요."; -"error_view.title.network" = "인터넷 접속 오류가 발생했어요."; -"error_view.title.parsing" = "구분 분석 오류가 발생했어요."; -"error_view.title.unknown" = "알 수 없는 오류가 발생했어요."; -"error_view.title.not_found" = "여기가 아무도 없는 것 같습니다."; -"error_view.title.database_corrupted" = "The database is corrupted.\nPlease submit an issue on GitHub."; -"error_view.title.ip_banned" = "자동화된 미러링/수집 소프트웨어를 사용 중임을 나타내는 과도한 페이지 로드로 인해 IP 주소가 일시적으로 금지되었습니다. 금지효과는 %@ 에서 만료되었습니다."; -"error_view.title.copyright_claim" = "%@의 저작권 요청으로 인하여 이 갤러리를 사용할 수 없어요."; -"error_view.title.gallery_unavailable" = "이 갤러리는 제거되었거나 사용할 수 없어요."; - -// MARK: ConfirmationDialog -"confirmation_dialog.title.drop_database" = "You will lose all your data in this app.\nAre you sure to drop the database?"; -"confirmation_dialog.title.remove_custom_translations" = "Are you sure to remove your custom translations?"; -"confirmation_dialog.title.logout" = "로그아웃 하시겠어요?"; -"confirmation_dialog.title.delete" = "Are you sure to delete this item?"; -"confirmation_dialog.title.clear" = "삭제하시겠어요?"; -"confirmation_dialog.title.reset" = "초기화하시겠어요?"; -"confirmation_dialog.button.drop_database" = "Drop the database"; -"confirmation_dialog.button.remove" = "Remove"; -"confirmation_dialog.button.logout" = "로그아웃"; -"confirmation_dialog.button.delete" = "삭제"; -"confirmation_dialog.button.clear" = "삭제"; -"confirmation_dialog.button.reset" = "초기화"; - -// MARK: SubSection -"sub_section.button.show_all" = "모두 보기"; - -// MARK: NewDawnView -"new_dawn_view.title.first" = "새로운 하루가 시작되었어요!"; -"new_dawn_view.title.second" = "지금까지의 여정을 돌이켜보면, 당신은 조금 더 현명해진 것 같죠?"; -// Greeting -"struct.greeting.mark.start" = ""; -"struct.greeting.mark.separator" = ", "; -"struct.greeting.mark.and" = " 과 "; -"struct.greeting.mark.end" = "획득했어요!"; - -// MARK: HomeView -"home_view.title.home" = "홈"; -"home_view.section.title.frontpage" = "프론트 페이지"; -"home_view.section.title.toplists" = "상위 목록"; -"home_view.section.title.other" = "Other"; -// HomeMiscGridType -"enum.home_misc_grid_type.title.popular" = "인기 작품"; -"enum.home_misc_grid_type.title.watched" = "주시 태그"; -"enum.home_misc_grid_type.title.history" = "읽은 목록"; - -// MARK: FrontpageView -"frontpage_view.title.frontpage" = "프론트 페이지"; - -// MARK: ToplistsView -"toplists_view.title.toplists" = "상위 목록"; - -// MARK: PopularView -"popular_view.title.popular" = "인기 작품"; - -// MARK: WatchedView -"watched_view.title.watched" = "주시 태그"; - -// MARK: HistoryView -"history_view.title.history" = "읽은 목록"; - -// MARK: FavoritesView -"favorites_view.title.favorites" = "즐겨찾기"; -// FavoriteCategory -"struct.user.favorite_category.default" = "즐겨찾기 %@"; -"struct.user.favorite_category.all" = "모두"; - -// MARK: SearchView -"search_view.title.search" = "검색"; -"search_view.section.title.recently_searched" = "Recently searched"; -"search_view.section.title.recently_seen" = "Recently seen"; -"search_view.section.title.quick_search" = "빠른 검색"; -// Searchable -"searchable.prompt.filter" = "Filter"; -"searchable.title.matches_count" = "Found %d matches."; - -// MARK: QuickSearchView -"quick_search_view.title.quick_search" = "빠른 검색"; -"quick_search_view.title.edit_word" = "Edit word"; -"quick_search_view.title.new_word" = "New word"; -"quick_search_view.title.content" = "Content"; -"quick_search_view.title.name" = "Name"; -"quick_search_view.placeholder.optional" = "Optional"; - -// MARK: SettingView -"setting_view.title.setting" = "설정"; -// SettingStateRoute -"enum.setting_state_route.value.account" = "계정"; -"enum.setting_state_route.value.general" = "일반"; -"enum.setting_state_route.value.appearance" = "외관"; -"enum.setting_state_route.value.reading" = "읽기"; -"enum.setting_state_route.value.laboratory" = "실험실"; -"enum.setting_state_route.value.about" = "EhPanda 정보"; - -// MARK: AccountSettingView -"account_setting_view.title.account" = "계정"; -"account_setting_view.title.shows_new_dawn_greeting" = "새벽 인사 구독하기"; -"account_setting_view.button.login" = "로그인"; -"account_setting_view.button.logout" = "로그아웃"; -"account_setting_view.button.account_configuration" = "계정 설정"; -"account_setting_view.button.tags_management" = "태그 구독 관리"; -"account_setting_view.button.copy_cookies" = "쿠키 복사하기"; -// CookieValue -"struct.cookie_value.localized_string.expired" = "만료됨"; -"struct.cookie_value.localized_string.mystery" = "거절됨"; -"struct.cookie_value.localized_string.none" = "None"; - -// MARK: LoginView -"login_view.title.login" = "로그인"; -"login_view.title.username" = "이름"; -"login_view.title.password" = "비밀번호"; - -// MARK: GeneralSettingView -"general_setting_view.title.general" = "일반"; -"general_setting_view.title.language" = "언어"; -"general_setting_view.title.auto_lock" = "앱 자동 잠금"; -"general_setting_view.title.enables_tags_extension" = "Enables tags extension"; -"general_setting_view.title.translates_tags" = "태그 번역하기"; -"general_setting_view.title.shows_tags_search_suggestion" = "Shows tags search suggestion"; -"general_setting_view.title.shows_images_in_tags" = "Shows images in tags"; -"general_setting_view.title.redirects_links_to_the_selected_host" = "선택한 서버로 이동하기"; -"general_setting_view.title.detects_links_from_clipboard" = "클립보드의 링크 인식하기"; -"general_setting_view.title.background_blur_radius" = "Background blur radius"; -"general_setting_view.button.logs" = "로그"; -"general_setting_view.button.import_custom_translations" = "Import custom translations"; -"general_setting_view.button.remove_custom_translations" = "Remove custom translations"; -"general_setting_view.button.clear_image_caches" = "이미지 캐시 지우기"; -"general_setting_view.value.default_language_description" = "N/A"; -"general_setting_view.section.title.tags" = "Tags"; -"general_setting_view.section.title.navigation" = "내비게이션"; -"general_setting_view.section.title.security" = "개인 정보 보호"; -"general_setting_view.section.title.caches" = "캐시"; -// AutoLockPolicy -"enum.auto_lock_policy.value.never" = "안 함"; -"enum.auto_lock_policy.value.instantly" = "즉시"; - -// MARK: LogsView -"logs_view.title.logs" = "로그"; -"logs_view.title.latest" = "마지막"; - -// MARK: AppearanceSettingView -"appearance_setting_view.title.appearance" = "외관"; -"appearance_setting_view.title.theme" = "테마"; -"appearance_setting_view.title.tint_color" = "액센트 색상"; -"appearance_setting_view.title.display_mode" = "표시방식"; -"appearance_setting_view.title.shows_tags_in_list" = "리스트에서 태그 보여주기"; -"appearance_setting_view.title.maximum_number_of_tags" = "태그 갯수"; -"appearance_setting_view.title.displays_japanese_title" = "Displays Japanese title"; -"appearance_setting_view.button.app_icon" = "앱 아이콘"; -"appearance_setting_view.menu.title.infite" = "제한 없음"; -"appearance_setting_view.section.title.list" = "리스트"; -"appearance_setting_view.section.title.gallery" = "Gallery"; -// PreferredColorScheme -"enum.preferred_color_scheme.value.automatic" = "자동"; -"enum.preferred_color_scheme.value.light" = "라이트"; -"enum.preferred_color_scheme.value.dark" = "다크"; -// AppIconType -"enum.app_icon_type.value.default" = "기본"; -"enum.app_icon_type.value.ukiyoe" = "Ukiyo-e"; -"enum.app_icon_type.value.developer" = "Developer"; -"enum.app_icon_type.value.stand_with_ukraine_2022" = "Stand With Ukraine (2022)"; -"enum.app_icon_type.value.not_my_president" = "NOT MY PRESIDENT"; -// ListDisplayMode -"enum.list_display_mode.value.detail" = "자세히"; -"enum.list_display_mode.value.thumbnail" = "썸네일"; - -// MARK: AppIconView -"app_icon_view.title.app_icon" = "앱 아이콘"; - -// MARK: reading_settingView -"reading_setting_view.title.reading" = "읽기"; -"reading_setting_view.title.direction" = "방향"; -"reading_setting_view.title.preload_limit" = "페이지 미리 로딩"; -"reading_setting_view.title.enables_landscape" = "Enables landscape"; -"reading_setting_view.title.separator_height" = "페이지 간 여백 두께"; -"reading_setting_view.title.maximum_scale_factor" = "최대 확대 비율"; -"reading_setting_view.title.double_tap_scale_factor" = "더블 탭 확대 비율"; -"reading_setting_view.section.title.appearance" = "외관"; -// ReadingDirection -"enum.reading_direction.value.vertical" = "위에서 아래로"; -"enum.reading_direction.value.right_to_left" = "오른쪽에서 왼쪽으로"; -"enum.reading_direction.value.left_to_right" = "왼쪽에서 오른쪽으로"; - -// MARK: LaboratorySettingView -"laboratory_setting_view.title.laboratory" = "실험실"; -"laboratory_setting_view.title.bypasses_SNI_filtering" = "SNI 차단 우회"; - -// MARK: AboutView -"about_view.title.ehPanda" = "EhPanda"; -"about_view.button.website" = "웹사이트"; -"about_view.button.altStore_source" = "AltStore 소스"; -"about_view.title.version" = "버전"; -"about_view.section.title.special_thanks" = "Special thanks"; -"about_view.section.title.code_level_contributors" = "Code-level contributors"; -"about_view.section.title.translation_contributors" = "Translation contributors"; -"about_view.section.title.acknowledgements" = "도움을 주신 분들"; - -// MARK: DetailView -"detail_view.button.read" = "읽기"; -"detail_view.button.post_comment" = "평가 남기기"; -"detail_view.toolbar_item.button.archives" = "아카이브"; -"detail_view.toolbar_item.button.torrents" = "토렌트"; -"detail_view.toolbar_item.button.share" = "공유"; -"detail_view.context_menu.button.detail" = "Detail"; -"detail_view.context_menu.button.withdraw_vote" = "Withdraw vote"; -"detail_view.context_menu.button.vote_up" = "Vote up"; -"detail_view.context_menu.button.vote_down" = "Vote down"; -"detail_view.description_section.title.favorited" = "즐겨찾기"; -"detail_view.description_section.title.language" = "언어"; -"detail_view.description_section.title.ratings" = "%@명의 별점"; -"detail_view.description_section.title.page_count" = "페이지 수"; -"detail_view.description_section.title.file_size" = "파일 크기"; -"detail_view.description_section.description.favorited" = "번"; -"detail_view.description_section.description.page_count" = "페이지"; -"detail_view.action_section.button.give_a_rating" = "별점 주기"; -"detail_view.action_section.button.similar_gallery" = "비슷한 작품"; -"detail_view.section.title.previews" = "미리보기"; -"detail_view.section.title.comments" = "댓글"; - -// MARK: ArchivesView -"archives_view.title.archives" = "아카이브"; -"archives_view.button.download_to_hath_client" = "H@H 클라이언트로 저장"; -// HathArchive -"struct.hath_archive.price.free" = "무료"; -"struct.hath_archive.price.not_available" = "무효"; -// ArchiveResolution -"enum.archive_resolution.value.original" = "원본"; - -// MARK: TorrentsView -"torrents_view.title.torrents" = "토렌트"; - -// MARK: GalleryInfosView -"gallery_infos_view.title.gallery_infos" = "갤러리 정보"; -"gallery_infos_view.title.id" = "ID"; -"gallery_infos_view.title.token" = "Token"; -"gallery_infos_view.title.title" = "제목"; -"gallery_infos_view.title.japanese_title" = "일본어 제목"; -"gallery_infos_view.title.gallery_URL" = "갤러리 주소"; -"gallery_infos_view.title.cover_URL" = "표지 주소"; -"gallery_infos_view.title.archive_URL" = "아카이브 주소"; -"gallery_infos_view.title.torrent_URL" = "토렌트 주소"; -"gallery_infos_view.title.parent_URL" = "부모 갤러리 링크"; -"gallery_infos_view.title.category" = "장르"; -"gallery_infos_view.title.uploader" = "업로드"; -"gallery_infos_view.title.posted_date" = "업로드된 날짜"; -"gallery_infos_view.title.visibility" = "가시성"; -"gallery_infos_view.title.language" = "언어"; -"gallery_infos_view.title.page_count" = "페이지 수"; -"gallery_infos_view.title.file_size" = "파일 크기"; -"gallery_infos_view.title.favorited_times" = "즐겨찾기된 수"; -"gallery_infos_view.title.favorited" = "즐겨찾기에 저장 됨"; -"gallery_infos_view.title.rating_count" = "별점 갯수"; -"gallery_infos_view.title.average_rating" = "평균 별점"; -"gallery_infos_view.title.my_rating" = "My rating"; -"gallery_infos_view.title.torrent_count" = "토렌트 수"; -"gallery_infos_view.value.none" = "None"; -"gallery_infos_view.value.yes" = "네"; -"gallery_infos_view.value.no" = "아니요"; -// GalleryVisibility -"enum.gallery_visibility.value.yes" = "네"; -"enum.gallery_visibility.value.no" = "아니요 (%@)"; -"enum.gallery_visibility.value.no.reason.expunged" = "삭제됨"; - -// MARK: TagDetailView -"tag_detail_view.section.title.images" = "Images"; -"tag_detail_view.section.title.links" = "Links"; - -// MARK: CommentsView -"comments_view.title.comments" = "댓글"; - -// MARK: PostCommentView -"post_comment_view.title.post_comment" = "평가 남기기"; -"post_comment_view.title.edit_comment" = "평가 수정"; - -// MARK: PreviewsView -"previews_view.title.previews" = "미리보기"; - -// MARK: ReadingView -"reading_view.context_menu.button.reload" = "재시도"; -"reading_view.context_menu.button.copy" = "복사"; -"reading_view.context_menu.button.save" = "저장"; -"reading_view.context_menu.button.save_original" = "Save original"; -"reading_view.context_menu.button.share" = "공유"; -"reading_view.toolbar_item.title.auto_play" = "자동 재생"; -"reading_view.toolbar_item.title.dual_page_mode" = "두 장을 한 화면으로 보기"; -"reading_view.toolbar_item.title.except_the_cover" = "표지 제외하기"; -"reading_view.toolbar_item.button.retry_all_failed_images" = "Retry failed images"; -"reading_view.toolbar_item.button.reload_all_images" = "Reload all images"; -"reading_view.toolbar_item.button.reading_setting" = "Reading setting"; -// AutoPlayPolicy -"enum.auto_play_policy.value.off" = "Off"; - -// MARK: FiltersView -"filters_view.title.filters" = "필터"; -"filters_view.title.advanced_settings" = "고급 설정"; -"filters_view.title.search_gallery_name" = "갤러리 이름을 찾아보기"; -"filters_view.title.search_gallery_tags" = "갤러리 태그를 찾아보기"; -"filters_view.title.search_gallery_description" = "갤러리 설명을 찾아보기"; -"filters_view.title.search_torrent_filenames" = "토렌트 파일 이름을 찾아보기"; -"filters_view.title.only_show_galleries_with_torrents" = "토렌트 있는 갤러리만 보이기"; -"filters_view.title.search_low_power_tags" = "인기가 없는 태그를 찾아보기"; -"filters_view.title.search_downvoted_tags" = "낮은 평가의 태그를 찾아보기"; -"filters_view.title.search_expunged_galleries" = "삭제된 갤러리를 보여주기"; -"filters_view.title.set_minimum_rating" = "최소 별점 설정하기"; -"filters_view.title.minimum_rating" = "최소 별점"; -"filters_view.title.set_pages_range" = "페이지 범위 설정"; -"filters_view.title.pages_range" = "페이지 범위"; -"filters_view.title.disable_language_filter" = "언어 필터 끄기"; -"filters_view.title.disable_uploader_filter" = "업로더 필터 끄기"; -"filters_view.title.disable_tags_filter" = "태그 필터 끄기"; -"filters_view.button.reset_filters" = "모든 필터 초기화"; -"filters_view.section.title.advanced" = "고급"; -"filters_view.section.title.default_filter" = "기본 옵션"; -// FilterRange -"enum.filter_range.value.search" = "검색"; -"enum.filter_range.value.global" = "전체"; -"enum.filter_range.value.watched" = "주시 태그"; - -// MARK: EhSettingView -"eh_setting_view.title.host_settings" = "%@ 설정"; -"eh_setting_view.section.title.profile_settings" = "프로필 설정"; -"eh_setting_view.title.selected_profile" = "선택한 프로필"; -"eh_setting_view.button.set_as_default" = "기본으로 설정"; -"eh_setting_view.button.delete_profile" = "프로필 삭제"; -"eh_setting_view.button.rename" = "이름 변경"; -"eh_setting_view.button.create_new" = "추가"; -"eh_setting_view.toolbar_item.button.done" = "Done"; - -"eh_setting_view.section.title.image_load_settings" = "이미지 로드 설정"; -"eh_setting_view.title.load_images_through_the_hath_network" = "Hath 네트워크를 통하여 이미지 로드"; -"eh_setting_view.title.browsing_country" = "브라우징하는 나라"; -"eh_setting_view.description.browsing_country" = "**%@**에서 사이트를 탐색하거나 이 나라에서 VPN이나 프록시를 사용하려고 하는 것 같네요. 이런 경우엔 사이트에서 이 지역의 H@H 클라이언트의 이미지를 로드하려고 시도할 거에요. 만약에 이 나라가 잘못되었거나 분할 터널링 VPN을 사용하는 경우와 같이 어떤 이유로든 다른 지역을 사용하려는 경우라면, 아래에서 다른 나라를 선택할 수 있어요."; -// EhSetting.LoadThroughHathSetting -"enum.eh_setting.load_through_hath_setting.value.any_client" = "어떤 클라이언트에서든"; -"enum.eh_setting.load_through_hath_setting.value.default_port_only" = "기본 포트 클라이언트만"; -"enum.eh_setting.load_through_hath_setting.value.modern_no" = "아닙니다 [Modern/HTTPS]"; -"enum.eh_setting.load_through_hath_setting.value.legacy_no" = "아닙니다 [Legacy/HTTP]"; -"enum.eh_setting.load_through_hath_setting.description.any_client" = "추천."; -"enum.eh_setting.load_through_hath_setting.description.default_port_only" = "더 느려질 수 있어요. 나가는 비표준 포트를 차단하는 방화벽/프록시가 있는 경우 사용하세요."; -"enum.eh_setting.load_through_hath_setting.description.modern_no" = "기부자 전용 기능이에요. 심각한 문제가 있는 경우를 제외하고는 사용하지 말아주세요."; -"enum.eh_setting.load_through_hath_setting.description.legacy_no" = "Donator only. May not work by default in modern browsers. Recommended for legacy/outdated browsers only."; - -"eh_setting_view.section.title.image_size_settings" = "이미지 사이즈 설정"; -"eh_setting_view.title.image_resolution" = "이미지 해상도"; -"eh_setting_view.description.image_resolution" = "일반적으로 이미지는 온라인 뷰어를 위해 1280 픽셀의 수평 해상도로 작아져요. 아래의 압축된 해상도 중 하나를 선택할 수 있어요. 서버 과부하를 막기 위해, 1280 이상의 해상도는 도네이션을 한 사람, hath perk를 가진 사람, 그리고 UID가 300만 이하인 사람들로 일시적으로 제한되어요."; -"eh_setting_view.title.image_size" = "이미지 사이즈"; -"eh_setting_view.description.image_size" = "사이트가 사용자의 화면 너비에 맞게 이미지를 자동으로 축소시키지만, 수동으로 크기를 정할 수도 있어요. 크기 조정은 브라우저 측에서 수행되므로 이미지가 다시 샘플링되지 않아요. (0 = no limit)"; -"eh_setting_view.title.horizontal" = "가로"; -"eh_setting_view.title.vertical" = "세로"; -// EhSetting.ImageResolution -"enum.eh_setting.image_resolution.value.auto" = "자동"; - -"eh_setting_view.section.title.gallery_name_display" = "갤러리 이름 보이기"; -"eh_setting_view.title.gallery_name" = "갤러리 이름"; -"eh_setting_view.description.gallery_name" = "영어 제목과 일본어 제목 중 기본값으로 보일 언어를 선택해주세요."; -// EhSetting.GalleryName -"enum.eh_setting.gallery_name.value.default" = "영어 제목"; -"enum.eh_setting.gallery_name.value.japanese" = "일본어 제목(가능하면)"; - -"eh_setting_view.section.title.archiver_settings" = "아카이버"; -"eh_setting_view.title.archiver_behavior" = "아카이버 동작 방법 설정"; -"eh_setting_view.description.archiver_behavior" = "아카이버의 기본 동작은 원본 또는 저화질 갤러리 저장에 대한 비용과 선택을 확인한 다음 다른 곳에서 클릭하거나 복사할 수 있는 링크를 표시하는 것입니다. 여기서 이 동작을 변경할 수 있습니다."; -// EhSetting.ArchiverBehavior -"enum.eh_setting.archiver_behavior.value.manual_select_manual_start" = "수동 선택, 수동 시작 (기본)"; -"enum.eh_setting.archiver_behavior.value.manual_select_auto_start" = "수동 선택, 자동 시작"; -"enum.eh_setting.archiver_behavior.value.auto_select_original_manual_start" = "자동으로 원본을 선택, 수동 시작"; -"enum.eh_setting.archiver_behavior.value.auto_select_original_auto_start" = "자동으로 원본을 선택, 자동 시작"; -"enum.eh_setting.archiver_behavior.value.auto_select_resample_manual_start" = "자동으로 저화질을 선택, 수동 시작"; -"enum.eh_setting.archiver_behavior.value.auto_select_resample_auto_start" = "자동으로 저화질을 선택, 자동 시작"; - -"eh_setting_view.section.title.front_page_settings" = "프론트 페이지 설정"; -"eh_setting_view.title.display_mode" = "표시방식"; -"eh_setting_view.description.display_mode" = "프론트와 검색 페이지에서 사용할 디스플레이 모드를 선택하세요."; -"eh_setting_view.section.title.show_search_range_indicator" = "Search Range Indicator"; -"eh_setting_view.title.show_search_range_indicator" = "Show search range indicator"; -"eh_setting_view.description.gallery_category" = "프론트와 검색 페이지에서 어떤 카테고리가 보여지도록 할까요?"; -// EhSetting.DisplayMode -"enum.eh_setting.display_mode.value.compact" = "Compact"; -"enum.eh_setting.display_mode.value.thumbnail" = "Thumbnail"; -"enum.eh_setting.display_mode.value.extended" = "Extended"; -"enum.eh_setting.display_mode.value.minimal" = "Minimal"; -"enum.eh_setting.display_mode.value.minimalPlus" = "Minimal+"; - -"eh_setting_view.section.title.optional_UI_elements" = "Optional UI Elements"; -"eh_setting_view.description.optional_UI_elements" = "Some historic UI elements are now disabled by default. You can enable those here."; -"eh_setting_view.title.enable_gallery_thumbnail_selector" = "Enable thumbnail selector on gallery screen"; - -"eh_setting_view.section.title.favorites" = "즐겨찾기"; -"eh_setting_view.description.favorite_categories" = "여기서 좋아하는 장르들을 선택하고 이름을 바꿀 수 있어요."; -"eh_setting_view.title.favorites_sort_order" = "관심 순서를 배열"; -"eh_setting_view.description.favorites_sort_order" = "당신의 관심 페이지의 기본 정렬 방식을 선택할 수 있어요. 2016년 3월 개정 전에 추가된 즐겨찾기는 타임스탬프가 저장되지 않아 이 설정에 관계없이 갤러리가 게시된 시간으로 정렬되어요."; -// EhSetting.FavoritesSortOrder -"enum.eh_setting.favorites_sort_order.value.last_update_time" = "마지막 업데이트 시간으로"; -"enum.eh_setting.favorites_sort_order.value.favorited_time" = "별점 시간으로"; - -"eh_setting_view.section.title.ratings" = "별점"; -"eh_setting_view.title.ratings_color" = "별점 색깔"; -"eh_setting_view.promt.ratings_color" = "RRGGB"; -"eh_setting_view.description.ratings_color" = "기본적으로 등급을 매긴 갤러리는 별 2개 이하의 등급에 대해 빨간색, 2.5~4개의 등급에 대해 녹색, 4.5~5개의 등급에 대해 파란색 별로 표시되어요. 아래에 원하는 색상 조합을 입력하여 사용자 정의할 수 있어요. 각 문자는 별 하나를 표현해요. 기본 RRGGB는 첫 번째와 두 번째 별의 경우 R(ed), 세 번째와 네 번째 별의 경우 G(reen), 다섯 번째 별의 경우 B(lue)를 의미해요. 일반 별에 (Y)ellow를 사용할 수도 있어요. 모든 5글자의 R/G/B/Y 콤보가 작동해요."; - -"eh_setting_view.section.title.tag_filtering_threshold" = "태그 필터링 임계값"; -"eh_setting_view.title.tag_filtering_threshold" = "태그 필터링 임계값"; -"eh_setting_view.description.tag_filtering_threshold" = "마이너스 가중치로 My Tags에 추가하여 태그를 소프트 필터할 수 있어요. 갤러리에 이 값 이하의 가중치를 추가하는 태그가 있으면 보기에서 필터링되어요. 이 임계값은 0과 -9999 사이에서 설정할 수 있어요."; - -"eh_setting_view.section.title.tag_watching_threshold" = "태그 보여주기 임계값"; -"eh_setting_view.title.tag_watching_threshold" = "태그 보여주기 임계값"; -"eh_setting_view.description.tag_watching_threshold" = "최근에 업로드된 갤러리는 최소 1개의 Watched 태그가 있고 Watched 태그의 가중치의 합이 이 값 이상이 될 경우 Watched 화면에 포함되어요. 이 임계값은 0과 9999 사이에서 설정할 수 있어요."; - -"eh_setting_view.section.title.filtered_removal_count" = "Show Filtered Removal Count"; -"eh_setting_view.description.filtered_removal_count" = "Show the \"Your default filters removed XX galleries from this page\" readout?"; -"eh_setting_view.title.show_filtered_removal_count" = "Show filtered removal count"; - -"eh_setting_view.section.title.excluded_languages" = "제외된 언어"; -"eh_setting_view.description.excluded_languages" = "갤러리 목록에서 특정 언어로 된 갤러리를 숨기고 검색하려면 아래 목록에서 해당 갤러리를 선택해주세요. 검색어에 관계없이 일치하는 갤러리는 나타나지 않아요."; -// EhSetting.ExcludedLanguagesCategory -"enum.eh_setting.excluded_languages_category.value.original" = "원본"; -"enum.eh_setting.excluded_languages_category.value.translated" = "번역됨"; -"enum.eh_setting.excluded_languages_category.value.rewrite" = "다시 쓰기"; - -"eh_setting_view.section.title.excluded_uploaders" = "제외된 업로드"; -"eh_setting_view.description.excluded_uploaders" = "갤러리 목록 및 검색에서 특정 업로더의 갤러리를 숨기려면 아래에 해당 갤러리를 추가해주세요. 한 줄에 하나의 사용자 이름을 입력해주세요. 이러한 업로더의 갤러리는 검색 쿼리에 관계없이 나타나지 않아요."; -"eh_setting_view.description.excluded_uploaders_count" = "**%@ / %@** 개의 슬롯을 사용하고 있어요."; - -"eh_setting_view.section.title.search_result_count" = "검색 결과 수"; -"eh_setting_view.title.result_count" = "결과 수"; -"eh_setting_view.description.result_count" = "인덱스 / 검색 / 토렌트 검색 페이지에 대해 페이지당 몇 개의 결과를 원하시나요?\n(Hath Perk: 페이징 확장 필요)"; - -"eh_setting_view.section.title.thumbnail_settings" = "썸네일 설정"; -"eh_setting_view.title.thumbnail_load_timing" = "썸네일 로드 시간"; -"eh_setting_view.description.thumbnail_load_timing" = "목록 모드를 사용할 때 앞 페이지의 마우스 오버 미리 보기를 어떻게 로드할까요?"; -"eh_setting_view.description.thumbnail_configuration" = "모든 방문한 갤러리에 대하여 기본 썸네일을 설정할 수 있어요."; -"eh_setting_view.title.thumbnail_size" = "사이즈"; -"eh_setting_view.title.thumbnail_row_count" = "줄"; -// EhSetting.ThumbnailLoadTiming -"enum.eh_setting.thumbnail_load_timing.value.on_mouse_over" = "마우스를 올릴 때"; -"enum.eh_setting.thumbnail_load_timing.value.on_page_load" = "페이지 로드될 때"; -"enum.eh_setting.thumbnail_load_timing.description.on_mouse_over" = "페이지가 더 빨리 로드되지만 엄지손가락이 나타나기 전까지 약간의 지연이 있을 수 있어요."; -"enum.eh_setting.thumbnail_load_timing.description.on_page_load" = "페이지 로드에 시간이 더 오래 걸리지만, 페이지가 로드된 후 썸네일을 로드하는데 지연이 없어요."; -// EhSetting.ThumbnailSize -"enum.eh_setting.thumbnail_size.value.normal" = "보통"; -"enum.eh_setting.thumbnail_size.value.large" = "크게"; -"enum.eh_setting.thumbnail_size.value.small" = "Small"; -"enum.eh_setting.thumbnail_size.value.auto" = "Auto"; - -"eh_setting_view.section.title.cover_scaling" = "Cover Scaling"; -"eh_setting_view.title.scale_factor" = "크기 비율"; -"eh_setting_view.description.cover_scale_factor" = "The cover size in gallery list views can be scaled to between 75%% and 150%% when using the Thumbnail or Extended display modes."; - -"eh_setting_view.section.title.viewport_override" = "뷰포트 조정"; -"eh_setting_view.title.virtual_width" = "가상 너비"; -"eh_setting_view.description.virtual_width" = "모바일 장치의 사이트 가상 너비를 설정할 수 있어요. 일반적으로 DPI에 따라 장치에 의해 자동으로 결정되어요. 100%% 썸네일 스케일의 추천 값은 640에서 1400 사이에요."; - -"eh_setting_view.section.title.gallery_comments" = "갤러리 댓글"; -"eh_setting_view.title.comments_sort_order" = "댓글 순서"; -"eh_setting_view.title.comments_votes_show_timing" = "평가의 시간을 보이기"; -// EhSetting.CommentsSortOrder -"enum.eh_setting.comments_sort_order.value.oldest" = "가장 이른 순서"; -"enum.eh_setting.comments_sort_order.value.recent" = "최신순"; -"enum.eh_setting.comments_sort_order.value.highest_score" = "평가가 가장 높은 순서"; -// EhSetting.CommentVotesShowTiming -"enum.eh_setting.comments_votes_show_timing.value.on_hover_or_click" = "점수를 가리키커나 클리하기"; -"enum.eh_setting.comments_votes_show_timing.value.always" = "항상"; - -"eh_setting_view.section.title.gallery_tags" = "갤러리 태그"; -"eh_setting_view.title.tags_sort_order" = "태그 순서를 배열"; -// EhSetting.tags_sort_order -"enum.eh_setting.tags_sort_order.value.alphabetical" = "알파벳순으로"; -"enum.eh_setting.tags_sort_order.value.tag_power" = "태크 가중치로"; - -"eh_setting_view.section.title.gallery_page_thumbnail_labeling" = "Gallery Page Thumbnail Labeling"; -"eh_setting_view.title.show_label_below_gallery_thumbnails" = "Show label below gallery thumbnails"; - -"eh_setting_view.section.title.hath_local_network_host" = "Hath 로컬 네트워크 호스트"; -"eh_setting_view.title.ip_address_port" = "IP주소:포트"; -"eh_setting_view.description.ip_address_port" = "이 설정은 사이트를 검색하는 것과 동일한 공용 IP로 로컬 네트워크에서 H@H 클라이언트를 실행하는 경우 사용할 수 있습니다. 일부 라우터는 버그가 있어 요청을 자신의 IP로 다시 라우팅할 수 없기에, 아래를 따라서 이 문제를 해결할 수 있습니다.\n찾아보는 동일한 장치에서 클라이언트를 실행하는 경우 루프백 주소(127.0.0.1:port)를 사용할 수 있습니다. 클라이언트가 네트워크의 다른 장치에서 실행 중인 경우 로컬 네트워크 IP를 사용할 수 있습니다. 일부 브라우저 구성에서는 외부 웹 사이트가 로컬 네트워크 IP가 있는 URL에 액세스할 수 없도록 합니다. 그런 다음 사이트가 작동하려면 사이트를 화이트리스트에 추가해야 합니다."; - -"eh_setting_view.section.title.original_images" = "Use original images instead of the resampled versions? Resampled images will still be used if you select a horizontal resolution different than \"Auto\" above and the image in question is wider, or if the original image is larger than 10 MiB (or 4 MiB for galleries older than one year)."; -"eh_setting_view.title.use_original_images" = "원본 뷰어 적용"; - -"eh_setting_view.section.title.multi_page_viewer" = "멀티 페이지 뷰어"; -"eh_setting_view.title.use_multi_page_viewer" = "다중 페이지 뷰어 적용"; -"eh_setting_view.title.display_style" = "보여주기 스타일"; -"eh_setting_view.title.show_thumbnail_pane" = "썸네일 창 표시"; -// EhSetting.MultiplePageViewerStyle -"enum.eh_setting.multiple_page_viewer_style.value.align_left_scale_if_over_width" = "왼쪽 정렬, 너비 초과할 때 크기 맞추기"; -"enum.eh_setting.multiple_page_viewer_style.value.align_center_scale_if_over_width" = "가운데 정렬, 너비 초과할 때 크기 맞추기"; -"enum.eh_setting.multiple_page_viewer_style.value.align_center_always_scale" = "가운데 정렬, 항상 크기 맞추기"; -// EhSetting.GalleryPageNumbering -"enum.eh_setting.gallery_page_numbering.value.none" = "None"; -"enum.eh_setting.gallery_page_numbering.value.page_number_only" = "Page Number Only"; -"enum.eh_setting.gallery_page_numbering.value.page_number_and_name" = "Page Number + Name"; - -// MARK: Category -"enum.category.value.doujinshi" = "동인지"; -"enum.category.value.manga" = "만화"; -"enum.category.value.artist_CG" = "일러스트"; -"enum.category.value.game_CG" = "게임 CG"; -"enum.category.value.western" = "서양"; -"enum.category.value.non_h" = "Non-H"; -"enum.category.value.image_set" = "포토북"; -"enum.category.value.cosplay" = "코스프레"; -"enum.category.value.asian_porn" = "Asian Porn"; -"enum.category.value.misc" = "기타"; -"enum.category.value.private" = "Private"; - -// MARK: TagNamespace -"enum.tag_namespace.value.reclass" = "재분류"; -"enum.tag_namespace.value.language" = "언어"; -"enum.tag_namespace.value.parody" = "원작"; -"enum.tag_namespace.value.character" = "캐릭터"; -"enum.tag_namespace.value.group" = "그룹"; -"enum.tag_namespace.value.artist" = "작가"; -"enum.tag_namespace.value.male" = "남성"; -"enum.tag_namespace.value.female" = "여성"; -"enum.tag_namespace.value.mixed" = "Mixed"; -"enum.tag_namespace.value.cosplayer" = "Cosplayer"; -"enum.tag_namespace.value.other" = "Other"; -"enum.tag_namespace.value.temp" = "Temp"; - -// MARK: Language -"enum.language.value.invalid" = "무효"; -"enum.language.value.other" = "Other"; -"enum.language.value.afrikaans" = "아프리칸스어"; -"enum.language.value.albanian" = "알바니아어"; -"enum.language.value.arabic" = "아랍어"; -"enum.language.value.bengali" = "벵갈어"; -"enum.language.value.bosnian" = "보스니아어"; -"enum.language.value.bulgarian" = "불가리아어"; -"enum.language.value.burmese" = "버마어"; -"enum.language.value.catalan" = "카탈루냐어"; -"enum.language.value.cebuano" = "세부어"; -"enum.language.value.chinese" = "중국어"; -"enum.language.value.croatian" = "크로아티아어"; -"enum.language.value.czech" = "체코어"; -"enum.language.value.danish" = "덴마크어"; -"enum.language.value.dutch" = "네덜란드어"; -"enum.language.value.english" = "영어"; -"enum.language.value.esperanto" = "국제어"; -"enum.language.value.estonian" = "에스토니아어"; -"enum.language.value.finnish" = "핀란드어"; -"enum.language.value.french" = "프랑스어"; -"enum.language.value.georgian" = "그루지야어"; -"enum.language.value.german" = "독일어"; -"enum.language.value.greek" = "그리스어"; -"enum.language.value.hebrew" = "히브리어"; -"enum.language.value.hindi" = "힌디어"; -"enum.language.value.hmong" = "묘어"; -"enum.language.value.hungarian" = "헝가리어"; -"enum.language.value.indonesian" = "인도네시아어"; -"enum.language.value.italian" = "이탈리아어"; -"enum.language.value.japanese" = "일본어"; -"enum.language.value.kazakh" = "카자흐어"; -"enum.language.value.khmer" = "크메르원"; -"enum.language.value.korean" = "한국어"; -"enum.language.value.kurdish" = "쿠르드어"; -"enum.language.value.lao" = "라오스어"; -"enum.language.value.latin" = "라틴어"; -"enum.language.value.mongolian" = "몽골어"; -"enum.language.value.ndebele" = "은데벨리어"; -"enum.language.value.nepali" = "네팔어"; -"enum.language.value.norwegian" = "노르웨이어로"; -"enum.language.value.oromo" = "오로모어"; -"enum.language.value.pashto" = "파슈토어"; -"enum.language.value.persian" = "페르시아어"; -"enum.language.value.polish" = "폴란드어"; -"enum.language.value.portuguese" = "포르투갈어"; -"enum.language.value.punjabi" = "펀자브어"; -"enum.language.value.romanian" = "루마니아어"; -"enum.language.value.russian" = "러시아어"; -"enum.language.value.sango" = "쌍고어"; -"enum.language.value.serbian" = "세르비아어"; -"enum.language.value.shona" = "쇼나어"; -"enum.language.value.slovak" = "슬로바키아어"; -"enum.language.value.slovenian" = "슬로베니아어"; -"enum.language.value.somali" = "소말리아어"; -"enum.language.value.spanish" = "스페인어"; -"enum.language.value.swahili" = "스와히리어로"; -"enum.language.value.swedish" = "스웨덴어"; -"enum.language.value.tagalog" = "타갈로어"; -"enum.language.value.thai" = "타이어"; -"enum.language.value.tigrinya" = "티글리니아어"; -"enum.language.value.turkish" = "터키어"; -"enum.language.value.ukrainian" = "우크라이나어"; -"enum.language.value.urdu" = "우르두어"; -"enum.language.value.vietnamese" = "베트남어"; -"enum.language.value.zulu" = "줄루어"; - -// MARK: BrowsingCountry -"enum.browsing_country.name.auto_detect" = "자동으로 설정"; -"enum.browsing_country.name.afghanistan" = "아프가니스탄"; -"enum.browsing_country.name.aland_islands" = "알란드 제도"; -"enum.browsing_country.name.albania" = "알바니아"; -"enum.browsing_country.name.algeria" = "알제리아"; -"enum.browsing_country.name.american_samoa" = "아메리칸 사모아"; -"enum.browsing_country.name.andorra" = "안도라"; -"enum.browsing_country.name.angola" = "앙골라"; -"enum.browsing_country.name.anguilla" = "안젤라"; -"enum.browsing_country.name.antarctica" = "남극"; -"enum.browsing_country.name.antigua_and_barbuda" = "앤티가 바부다"; -"enum.browsing_country.name.argentina" = "아르헨티나"; -"enum.browsing_country.name.armenia" = "아르메니아"; -"enum.browsing_country.name.aruba" = "아루바 섬"; -"enum.browsing_country.name.asia_pacific_region" = "아시아 태평양 영역"; -"enum.browsing_country.name.australia" = "호주"; -"enum.browsing_country.name.austria" = "오스트리아"; -"enum.browsing_country.name.azerbaijan" = "아제르바이잔"; -"enum.browsing_country.name.bahamas" = "바하마스"; -"enum.browsing_country.name.bahrain" = "바레인"; -"enum.browsing_country.name.bangladesh" = "방글라데시"; -"enum.browsing_country.name.barbados" = "바베이도스"; -"enum.browsing_country.name.belarus" = "벨라루스"; -"enum.browsing_country.name.belgium" = "벨기에"; -"enum.browsing_country.name.belize" = "벨리즈"; -"enum.browsing_country.name.benin" = "베냉"; -"enum.browsing_country.name.bermuda" = "버뮤다"; -"enum.browsing_country.name.bhutan" = "부탄"; -"enum.browsing_country.name.bolivia" = "볼리비아"; -"enum.browsing_country.name.bonaire_saint_eustatius_and_saba" = "보네르 성 유스타티우스와 사바"; -"enum.browsing_country.name.bosnia_and_herzegovina" = "보스니아 헤르체코비나 "; -"enum.browsing_country.name.botswana" = "보츠와나"; -"enum.browsing_country.name.bouvet_island" = "부베섬"; -"enum.browsing_country.name.brazil" = "브라질"; -"enum.browsing_country.name.british_indian_ocean_territory" = "영국령 인도양 식민지"; -"enum.browsing_country.name.brunei_darussalam" = "브루나이 다루살람"; -"enum.browsing_country.name.bulgaria" = "불가리아"; -"enum.browsing_country.name.burkina_faso" = "부르키나 파소"; -"enum.browsing_country.name.burundi" = "부룬디"; -"enum.browsing_country.name.cambodia" = "캄보디아"; -"enum.browsing_country.name.cameroon" = "카메룬"; -"enum.browsing_country.name.canada" = "캐나다"; -"enum.browsing_country.name.cape_verde" = "포르투갈어"; -"enum.browsing_country.name.cayman_islands" = "케이맨 제도"; -"enum.browsing_country.name.central_african_republic" = "중앙아프리카 공화국"; -"enum.browsing_country.name.chad" = "차드"; -"enum.browsing_country.name.chile" = "칠레"; -"enum.browsing_country.name.china" = "중국"; -"enum.browsing_country.name.christmas_island" = "크리스마스 섬"; -"enum.browsing_country.name.cocos_islands" = "코코스 제도"; -"enum.browsing_country.name.colombia" = "콜롬비아"; -"enum.browsing_country.name.comoros" = "코모로"; -"enum.browsing_country.name.congo" = "콩고"; -"enum.browsing_country.name.the_democratic_republic_of_the_congo" = "콩고민주공화국"; -"enum.browsing_country.name.cook_islands" = "쿡제도"; -"enum.browsing_country.name.costa_rica" = "코스타리카"; -"enum.browsing_country.name.cote_d_ivoire" = "코트디부아르"; -"enum.browsing_country.name.croatia" = "크로아티아"; -"enum.browsing_country.name.cuba" = "쿠바"; -"enum.browsing_country.name.curacao" = "큐라소"; -"enum.browsing_country.name.cyprus" = "키프로스"; -"enum.browsing_country.name.czech_republic" = "체코 공화국"; -"enum.browsing_country.name.denmark" = "덴마크"; -"enum.browsing_country.name.djibouti" = "지부티"; -"enum.browsing_country.name.dominica" = "도미니카"; -"enum.browsing_country.name.dominican_republic" = "도미니카 공화국"; -"enum.browsing_country.name.ecuador" = "에콰도르"; -"enum.browsing_country.name.egypt" = "이집트"; -"enum.browsing_country.name.el_salvador" = "엘살바도르"; -"enum.browsing_country.name.equatorial_guinea" = "적도 기니"; -"enum.browsing_country.name.eritrea" = "에리트레아"; -"enum.browsing_country.name.estonia" = "에스토니아"; -"enum.browsing_country.name.ethiopia" = "에티오피아"; -"enum.browsing_country.name.europe" = "유럽"; -"enum.browsing_country.name.falkland_islands" = "포클랜드 제도"; -"enum.browsing_country.name.faroe_islands" = "페로스 제도"; -"enum.browsing_country.name.fiji" = "피지"; -"enum.browsing_country.name.finland" = "핀란드"; -"enum.browsing_country.name.france" = "프랑스"; -"enum.browsing_country.name.french_guiana" = "프랑스령 기아나"; -"enum.browsing_country.name.french_polynesia" = "프랑스령 폴리네시아"; -"enum.browsing_country.name.french_southern_territories" = "프랑스령 남부와 남극지역"; -"enum.browsing_country.name.gabon" = "가봉"; -"enum.browsing_country.name.gambia" = "감비아"; -"enum.browsing_country.name.georgia" = "그루지야"; -"enum.browsing_country.name.germany" = "독일"; -"enum.browsing_country.name.ghana" = "가나"; -"enum.browsing_country.name.gibraltar" = "지브롤터"; -"enum.browsing_country.name.greece" = "희랍"; -"enum.browsing_country.name.greenland" = "그린란드"; -"enum.browsing_country.name.grenada" = "그레나다"; -"enum.browsing_country.name.guadeloupe" = "과들루프 섬"; -"enum.browsing_country.name.guam" = "괌"; -"enum.browsing_country.name.guatemala" = "과테말라"; -"enum.browsing_country.name.guernsey" = "건지종 젖소"; -"enum.browsing_country.name.guinea" = "기니"; -"enum.browsing_country.name.guinea_bissau" = "기니비사우"; -"enum.browsing_country.name.guyana" = "가이아나"; -"enum.browsing_country.name.haiti" = "아이티"; -"enum.browsing_country.name.heard_island_and_mc_donald_islands" = "허드 맥도널드 제도"; -"enum.browsing_country.name.vatican_city_state" = "바티칸 시국"; -"enum.browsing_country.name.honduras" = "온두라스"; -"enum.browsing_country.name.hong_kong" = "홍콩"; -"enum.browsing_country.name.hungary" = "헝가리"; -"enum.browsing_country.name.iceland" = "Iceland"; -"enum.browsing_country.name.india" = "인도"; -"enum.browsing_country.name.indonesia" = "인도네시아"; -"enum.browsing_country.name.iran" = "이란"; -"enum.browsing_country.name.iraq" = "이라크"; -"enum.browsing_country.name.ireland" = "아일랜드"; -"enum.browsing_country.name.isle_of_man" = "맨 섬"; -"enum.browsing_country.name.israel" = "이스라엘"; -"enum.browsing_country.name.italy" = "이탈리아"; -"enum.browsing_country.name.jamaica" = "자마이카"; -"enum.browsing_country.name.japan" = "일본"; -"enum.browsing_country.name.jersey" = "저시"; -"enum.browsing_country.name.jordan" = "요단"; -"enum.browsing_country.name.kazakhstan" = "카자흐스탄"; -"enum.browsing_country.name.kenya" = "케냐"; -"enum.browsing_country.name.kiribati" = "키리바시"; -"enum.browsing_country.name.kuwait" = "쿠웨이트"; -"enum.browsing_country.name.kyrgyzstan" = "키르기스스탄"; -"enum.browsing_country.name.lao_peoples_democratic_republic" = "라오 인민민주공화국"; -"enum.browsing_country.name.latvia" = "라트비아"; -"enum.browsing_country.name.lebanon" = "레바논"; -"enum.browsing_country.name.lesotho" = "레소토"; -"enum.browsing_country.name.liberia" = "리베리아"; -"enum.browsing_country.name.libya" = "리비아"; -"enum.browsing_country.name.liechtenstein" = "리히텐슈타인"; -"enum.browsing_country.name.lithuania" = "리투아니아"; -"enum.browsing_country.name.luxembourg" = "룩셈부르크"; -"enum.browsing_country.name.macau" = "마카오"; -"enum.browsing_country.name.macedonia" = "마케도니아"; -"enum.browsing_country.name.madagascar" = "마다스카르"; -"enum.browsing_country.name.malawi" = "말라위"; -"enum.browsing_country.name.malaysia" = "말레이시아"; -"enum.browsing_country.name.maldives" = "말디브"; -"enum.browsing_country.name.mali" = "말리"; -"enum.browsing_country.name.malta" = "말타"; -"enum.browsing_country.name.marshall_islands" = "마샬군도"; -"enum.browsing_country.name.martinique" = "마르티니크"; -"enum.browsing_country.name.mauritania" = "모리타니아"; -"enum.browsing_country.name.mauritius" = "모리셔스"; -"enum.browsing_country.name.mayotte" = "마요트 섬"; -"enum.browsing_country.name.mexico" = "맥시코"; -"enum.browsing_country.name.micronesia" = "마크로네시아"; -"enum.browsing_country.name.moldova" = "몰도바"; -"enum.browsing_country.name.monaco" = "모나코"; -"enum.browsing_country.name.mongolia" = "몽콜"; -"enum.browsing_country.name.montenegro" = "몬테네그로"; -"enum.browsing_country.name.montserrat" = "몬트세라트섬"; -"enum.browsing_country.name.morocco" = "모로코가족"; -"enum.browsing_country.name.mozambique" = "모잠비크"; -"enum.browsing_country.name.myanmar" = "미얀마"; -"enum.browsing_country.name.namibia" = "나미비아"; -"enum.browsing_country.name.nauru" = "나우루"; -"enum.browsing_country.name.nepal" = "네팔"; -"enum.browsing_country.name.netherlands" = "네덜란드"; -"enum.browsing_country.name.new_caledonia" = "뉴칼레도니아"; -"enum.browsing_country.name.new_zealand" = "뉴질랜드"; -"enum.browsing_country.name.nicaragua" = "나카라과"; -"enum.browsing_country.name.niger" = "니제르"; -"enum.browsing_country.name.nigeria" = "나이지리아"; -"enum.browsing_country.name.niue" = "니우에 섬"; -"enum.browsing_country.name.norfolk_island" = "노퍽섬"; -"enum.browsing_country.name.north_korea" = "북한"; -"enum.browsing_country.name.northern_mariana_islands" = "북마리아나제도"; -"enum.browsing_country.name.norway" = "노르웨이"; -"enum.browsing_country.name.oman" = "오만"; -"enum.browsing_country.name.pakistan" = "파키스탄"; -"enum.browsing_country.name.palau" = "팔라우"; -"enum.browsing_country.name.palestinian_territory" = "팔레스타인의 지역"; -"enum.browsing_country.name.panama" = "파나마모자"; -"enum.browsing_country.name.papua_new_guinea" = "파푸아뉴기니"; -"enum.browsing_country.name.paraguay" = "파라과이"; -"enum.browsing_country.name.peru" = "페루"; -"enum.browsing_country.name.philippines" = "필리핀"; -"enum.browsing_country.name.pitcairn_islands" = "핏케언 제도"; -"enum.browsing_country.name.poland" = "폴란드"; -"enum.browsing_country.name.portugal" = "포르투갈"; -"enum.browsing_country.name.puerto_rico" = "푸에르토리코"; -"enum.browsing_country.name.qatar" = "카타로"; -"enum.browsing_country.name.reunion" = "레워니옹"; -"enum.browsing_country.name.romania" = "루마니아"; -"enum.browsing_country.name.russian_federation" = "러시아 연방"; -"enum.browsing_country.name.rwanda" = "르완다"; -"enum.browsing_country.name.saint_barthelemy" = "생바르텔레미"; -"enum.browsing_country.name.saint_helena" = "세인츠헬레나 섬"; -"enum.browsing_country.name.saint_kitts_and_nevis" = "세인트키츠네비스"; -"enum.browsing_country.name.saint_lucia" = "세인트루시아"; -"enum.browsing_country.name.saint_martin" = "세인트 마틴"; -"enum.browsing_country.name.saint_pierre_and_miquelon" = "생피에르 미글롱"; -"enum.browsing_country.name.saint_vincent_and_the_grenadines" = "세인트빈센트 그레나딘"; -"enum.browsing_country.name.samoa" = "사모아"; -"enum.browsing_country.name.san_marino" = "산마리노"; -"enum.browsing_country.name.sao_tome_and_principe" = "상투메 프린시페 도브라"; -"enum.browsing_country.name.saudi_arabia" = "사우디 아라비아"; -"enum.browsing_country.name.senegal" = "세네갈"; -"enum.browsing_country.name.serbia" = "세르비아"; -"enum.browsing_country.name.seychelles" = "세이셸"; -"enum.browsing_country.name.sierra_leone" = "시에라리온"; -"enum.browsing_country.name.singapore" = "싱가포르"; -"enum.browsing_country.name.sint_maarten" = "신트마르턴"; -"enum.browsing_country.name.slovakia" = "슬로바키아"; -"enum.browsing_country.name.slovenia" = "슬로베니아"; -"enum.browsing_country.name.solomon_islands" = "솔로몬 제도"; -"enum.browsing_country.name.somalia" = "소말리아"; -"enum.browsing_country.name.south_africa" = "남아프리카"; -"enum.browsing_country.name.south_georgia_and_the_south_sandwich_islands" = "사우스조지아 사우스샌드위치 제도"; -"enum.browsing_country.name.south_korea" = "한국"; -"enum.browsing_country.name.south_sudan" = "남수단"; -"enum.browsing_country.name.spain" = "스페인"; -"enum.browsing_country.name.sri_lanka" = "스리랑카"; -"enum.browsing_country.name.sudan" = "수단"; -"enum.browsing_country.name.suriname" = "수리남"; -"enum.browsing_country.name.svalbard_and_jan_mayen" = "스발바르 얀마옌 제도"; -"enum.browsing_country.name.swaziland" = "스와질란드"; -"enum.browsing_country.name.sweden" = "스웨덴"; -"enum.browsing_country.name.switzerland" = "스위스"; -"enum.browsing_country.name.syrian_arab_republic" = "시리아"; -"enum.browsing_country.name.taiwan" = "대만"; -"enum.browsing_country.name.tajikistan" = "타지키스탄"; -"enum.browsing_country.name.tanzania" = "탄지니아"; -"enum.browsing_country.name.thailand" = "태국"; -"enum.browsing_country.name.timor_leste" = "동티모르"; -"enum.browsing_country.name.togo" = "토고"; -"enum.browsing_country.name.tokelau" = "토켈라우"; -"enum.browsing_country.name.tonga" = "통가"; -"enum.browsing_country.name.trinidad_and_tobago" = "트리니다드토바고"; -"enum.browsing_country.name.tunisia" = "튀니지"; -"enum.browsing_country.name.turkey" = "터키"; -"enum.browsing_country.name.turkmenistan" = "투르크메니스탄"; -"enum.browsing_country.name.turks_and_caicos_islands" = "터크스카이코스 제도"; -"enum.browsing_country.name.tuvalu" = "투발루"; -"enum.browsing_country.name.uganda" = "우간다"; -"enum.browsing_country.name.ukraine" = "우크라이나"; -"enum.browsing_country.name.united_arab_emirates" = "아랍 에미리트 연합국"; -"enum.browsing_country.name.united_kingdom" = "영국"; -"enum.browsing_country.name.united_states" = "미국"; -"enum.browsing_country.name.united_states_minor_outlying_islands" = "미국령 군소 제도"; -"enum.browsing_country.name.uruguay" = "우루과이"; -"enum.browsing_country.name.uzbekistan" = "우즈베키스탄"; -"enum.browsing_country.name.vanuatu" = "바누어투"; -"enum.browsing_country.name.venezuela" = "베네수엘라"; -"enum.browsing_country.name.vietnam" = "베트남"; -"enum.browsing_country.name.virgin_islands_british" = "영국령 버진 제도"; -"enum.browsing_country.name.virgin_islands_US" = "세인트존 섬"; -"enum.browsing_country.name.wallis_and_futuna" = "월리스 푸투나제도"; -"enum.browsing_country.name.western_sahara" = "서사하라"; -"enum.browsing_country.name.yemen" = "예멘"; -"enum.browsing_country.name.zambia" = "잠비아"; -"enum.browsing_country.name.zimbabwe" = "짐바브웨"; diff --git a/EhPanda/App/zh-Hans.lproj/InfoPlist.strings b/EhPanda/App/zh-Hans.lproj/InfoPlist.strings deleted file mode 100644 index b01d42fcb..000000000 --- a/EhPanda/App/zh-Hans.lproj/InfoPlist.strings +++ /dev/null @@ -1,7 +0,0 @@ -/* - InfoPlist.strings - EhPanda -*/ - -"NSFaceIDUsageDescription" = "需要此权限以在解锁 App 时提供 Face ID 选项"; -"NSPhotoLibraryAddUsageDescription" = "需要此权限以保存图像到相册"; diff --git a/EhPanda/App/zh-Hans.lproj/Localizable.strings b/EhPanda/App/zh-Hans.lproj/Localizable.strings deleted file mode 100644 index 19a4d6b0d..000000000 --- a/EhPanda/App/zh-Hans.lproj/Localizable.strings +++ /dev/null @@ -1,906 +0,0 @@ -/* - Localizable.strings - EhPanda -*/ - -// MARK: BanInterval -"enum.ban_interval.description.and" = ""; - -// MARK: ToplistsType -"enum.toplists_type.value.yesterday" = "昨日"; -"enum.toplists_type.value.past_month" = "上月"; -"enum.toplists_type.value.past_year" = "去年"; -"enum.toplists_type.value.all_time" = "全部"; - -// MARK: Response -"website.response.hath_client_not_found" = "你需要一个关联到账户的 H@H 客户端才能使用这个功能"; -"website.response.hath_client_not_online" = "你的 H@H 客户端似乎处于离线状态,请启动它后再试"; -"website.response.invalid_resolution" = "该画廊不能以选中的分辨率下载"; - -// MARK: HUD -"hud.title.error" = "错误"; -"hud.title.success" = "成功"; -"hud.title.loading" = "加载中..."; -"hud.title.communicating" = "通信中..."; -"hud.caption.copied_to_clipboard" = "已复制到剪切板"; -"hud.caption.saved_to_photo_library" = "已保存到图库"; - -// MARK: AutoLock -"local_authorization.reason" = "因超过设置的自动锁定期限,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.jump_page" = "页码跳转"; -"toolbar_item.button.quick_search" = "快速搜索"; - -// MARK: JumpPage -"jump_page_view.title.jump_page" = "页码跳转"; -"jump_page_view.button.confirm" = "确认"; - -// MARK: AlertView -"loading_view.title.loading" = "加载中..."; -"loading_view.title.preparing_database" = "正在准备数据库..."; -"not_login_view.title.need_login" = "你需要登录才能使用该功能"; -"not_login_view.button.login" = "登录"; -"error_view.button.retry" = "重试"; -"error_view.button.drop_database" = "丢弃数据库"; -"error_view.title.try_later" = "请稍后再试"; -"error_view.title.network" = "发生了网络故障"; -"error_view.title.parsing" = "发生了解析错误"; -"error_view.title.unknown" = "发生了未知错误"; -"error_view.title.not_found" = "这里似乎什么也没有"; -"error_view.title.database_corrupted" = "数据库已损毁。\n请到 GitHub 提起 Issue 反馈。"; -"error_view.title.ip_banned" = "当前的 IP 地址发生了过量的页面加载,因有使用爬虫程序的嫌疑已被暂时封禁。封禁将在 %@后解除。"; -"error_view.title.copyright_claim" = "抱歉,该画廊因 %@ 的版权主张已无法访问。"; -"error_view.title.gallery_unavailable" = "该画廊已被移除或不可用。"; - -// MARK: ConfirmationDialog -"confirmation_dialog.title.drop_database" = "你将失去这个 App 中所有的数据。\n确定要丢弃数据库吗?"; -"confirmation_dialog.title.remove_custom_translations" = "确定要移除自定义翻译吗?"; -"confirmation_dialog.title.logout" = "确定要退出登录吗?"; -"confirmation_dialog.title.delete" = "确定要删除吗?"; -"confirmation_dialog.title.clear" = "确定要清空吗?"; -"confirmation_dialog.title.reset" = "确定要重置吗?"; -"confirmation_dialog.button.drop_database" = "丢弃数据库"; -"confirmation_dialog.button.remove" = "移除"; -"confirmation_dialog.button.logout" = "退出登录"; -"confirmation_dialog.button.delete" = "删除"; -"confirmation_dialog.button.clear" = "清空"; -"confirmation_dialog.button.reset" = "重置"; - -// MARK: SubSection -"sub_section.button.show_all" = "显示全部"; - -// MARK: NewDawnView -"new_dawn_view.title.first" = "又是全新的一天!"; -"new_dawn_view.title.second" = "回顾至今的历程,发觉自己更睿智了一些。"; -// Greeting -"struct.greeting.mark.start" = "你获得了 "; -"struct.greeting.mark.separator" = "、"; -"struct.greeting.mark.and" = " 和 "; -"struct.greeting.mark.end" = "!"; - -// MARK: HomeView -"home_view.title.home" = "主页"; -"home_view.section.title.frontpage" = "扉页"; -"home_view.section.title.toplists" = "排行"; -"home_view.section.title.other" = "其它"; -// HomeMiscGridType -"enum.home_misc_grid_type.title.popular" = "热门"; -"enum.home_misc_grid_type.title.watched" = "标签"; -"enum.home_misc_grid_type.title.history" = "历史"; - -// MARK: FrontpageView -"frontpage_view.title.frontpage" = "扉页"; - -// MARK: ToplistsView -"toplists_view.title.toplists" = "排行"; - -// MARK: PopularView -"popular_view.title.popular" = "热门"; - -// MARK: WatchedView -"watched_view.title.watched" = "标签"; - -// MARK: HistoryView -"history_view.title.history" = "历史"; - -// MARK: FavoritesView -"favorites_view.title.favorites" = "收藏"; -// FavoriteCategory -"struct.user.favorite_category.default" = "收藏夹 %@"; -"struct.user.favorite_category.all" = "全部"; - -// MARK: SearchView -"search_view.title.search" = "搜索"; -"search_view.section.title.recently_searched" = "最近搜索"; -"search_view.section.title.recently_seen" = "最近看过"; -"search_view.section.title.quick_search" = "快速搜索"; -// Searchable -"searchable.prompt.filter" = "筛选"; -"searchable.title.matches_count" = "找到 %d 项结果"; - -// MARK: QuickSearchView -"quick_search_view.title.quick_search" = "快速搜索"; -"quick_search_view.title.edit_word" = "编辑关键词"; -"quick_search_view.title.new_word" = "添加关键词"; -"quick_search_view.title.content" = "内容"; -"quick_search_view.title.name" = "名称"; -"quick_search_view.placeholder.optional" = "可选"; - -// MARK: SettingView -"setting_view.title.setting" = "设置"; -// SettingStateRoute -"enum.setting_state_route.value.account" = "账户"; -"enum.setting_state_route.value.general" = "一般"; -"enum.setting_state_route.value.appearance" = "外观"; -"enum.setting_state_route.value.reading" = "阅读"; -"enum.setting_state_route.value.laboratory" = "实验室"; -"enum.setting_state_route.value.about" = "关于 EhPanda"; - -// MARK: AccountSettingView -"account_setting_view.title.account" = "账户"; -"account_setting_view.title.shows_new_dawn_greeting" = "显示黎明问候"; -"account_setting_view.button.login" = "登录"; -"account_setting_view.button.logout" = "退出登录"; -"account_setting_view.button.account_configuration" = "账户设置"; -"account_setting_view.button.tags_management" = "管理标签订阅"; -"account_setting_view.button.copy_cookies" = "复制 Cookies"; -// CookieValue -"struct.cookie_value.localized_string.expired" = "已过期"; -"struct.cookie_value.localized_string.mystery" = "被拒绝"; -"struct.cookie_value.localized_string.none" = "无内容"; - -// MARK: LoginView -"login_view.title.login" = "登录"; -"login_view.title.username" = "用户名"; -"login_view.title.password" = "密码"; - -// MARK: GeneralSettingView -"general_setting_view.title.general" = "一般"; -"general_setting_view.title.language" = "语言"; -"general_setting_view.title.auto_lock" = "自动锁定"; -"general_setting_view.title.enables_tags_extension" = "启用标签扩展"; -"general_setting_view.title.translates_tags" = "翻译标签"; -"general_setting_view.title.shows_tags_search_suggestion" = "显示标签搜索建议"; -"general_setting_view.title.shows_images_in_tags" = "显示标签中的图像"; -"general_setting_view.title.redirects_links_to_the_selected_host" = "重定向链接到选定的站点"; -"general_setting_view.title.detects_links_from_clipboard" = "从剪切板检测链接"; -"general_setting_view.title.background_blur_radius" = "后台模糊效果"; -"general_setting_view.button.logs" = "日志"; -"general_setting_view.button.import_custom_translations" = "导入自定义翻译"; -"general_setting_view.button.remove_custom_translations" = "移除自定义翻译"; -"general_setting_view.button.clear_image_caches" = "清空图片缓存"; -"general_setting_view.value.default_language_description" = "无效"; -"general_setting_view.section.title.tags" = "标签"; -"general_setting_view.section.title.navigation" = "导航"; -"general_setting_view.section.title.security" = "安全"; -"general_setting_view.section.title.caches" = "缓存"; -// AutoLockPolicy -"enum.auto_lock_policy.value.never" = "不锁定"; -"enum.auto_lock_policy.value.instantly" = "立即"; - -// MARK: LogsView -"logs_view.title.logs" = "日志"; -"logs_view.title.latest" = "最新"; - -// MARK: AppearanceSettingView -"appearance_setting_view.title.appearance" = "外观"; -"appearance_setting_view.title.theme" = "主题"; -"appearance_setting_view.title.tint_color" = "主题色"; -"appearance_setting_view.title.display_mode" = "显示样式"; -"appearance_setting_view.title.shows_tags_in_list" = "在列表中显示标签"; -"appearance_setting_view.title.maximum_number_of_tags" = "标签数量上限"; -"appearance_setting_view.title.displays_japanese_title" = "显示日文标题"; -"appearance_setting_view.button.app_icon" = "应用图标"; -"appearance_setting_view.menu.title.infite" = "无限"; -"appearance_setting_view.section.title.list" = "列表"; -"appearance_setting_view.section.title.gallery" = "画廊"; -// PreferredColorScheme -"enum.preferred_color_scheme.value.automatic" = "自动"; -"enum.preferred_color_scheme.value.light" = "浅色"; -"enum.preferred_color_scheme.value.dark" = "深色"; -// AppIconType -"enum.app_icon_type.value.default" = "默认"; -"enum.app_icon_type.value.ukiyoe" = "浮世绘"; -"enum.app_icon_type.value.developer" = "开发者"; -"enum.app_icon_type.value.stand_with_ukraine_2022" = "与乌克兰同在 (2022)"; -"enum.app_icon_type.value.not_my_president" = "他不是我的主席"; -// ListDisplayMode -"enum.list_display_mode.value.detail" = "详情"; -"enum.list_display_mode.value.thumbnail" = "缩略图"; - -// MARK: AppIconView -"app_icon_view.title.app_icon" = "应用图标"; - -// MARK: reading_settingView -"reading_setting_view.title.reading" = "阅读"; -"reading_setting_view.title.direction" = "方向"; -"reading_setting_view.title.preload_limit" = "预加载数量上限"; -"reading_setting_view.title.enables_landscape" = "启用横屏"; -"reading_setting_view.title.separator_height" = "分隔线高度"; -"reading_setting_view.title.maximum_scale_factor" = "最大缩放系数"; -"reading_setting_view.title.double_tap_scale_factor" = "双击缩放系数"; -"reading_setting_view.section.title.appearance" = "外观"; -// ReadingDirection -"enum.reading_direction.value.vertical" = "垂直"; -"enum.reading_direction.value.right_to_left" = "右至左"; -"enum.reading_direction.value.left_to_right" = "左至右"; - -// MARK: LaboratorySettingView -"laboratory_setting_view.title.laboratory" = "实验室"; -"laboratory_setting_view.title.bypasses_SNI_filtering" = "域前置绕过 SNI 阻断"; - -// MARK: AboutView -"about_view.title.ehPanda" = "EhPanda"; -"about_view.button.website" = "网站"; -"about_view.button.altStore_source" = "AltStore 源"; -"about_view.title.version" = "版本"; -"about_view.section.title.special_thanks" = "特别致谢"; -"about_view.section.title.code_level_contributors" = "代码级贡献者"; -"about_view.section.title.translation_contributors" = "翻译贡献者"; -"about_view.section.title.acknowledgements" = "致谢"; - -// MARK: DetailView -"detail_view.button.read" = "阅读"; -"detail_view.button.post_comment" = "发布评论"; -"detail_view.toolbar_item.button.archives" = "归档"; -"detail_view.toolbar_item.button.torrents" = "种子"; -"detail_view.toolbar_item.button.share" = "分享"; -"detail_view.context_menu.button.detail" = "详情"; -"detail_view.context_menu.button.withdraw_vote" = "撤销投票"; -"detail_view.context_menu.button.vote_up" = "投票赞成"; -"detail_view.context_menu.button.vote_down" = "投票反对"; -"detail_view.description_section.title.favorited" = "收藏"; -"detail_view.description_section.title.language" = "语言"; -"detail_view.description_section.title.ratings" = "%@ 个评分"; -"detail_view.description_section.title.page_count" = "页数"; -"detail_view.description_section.title.file_size" = "文件大小"; -"detail_view.description_section.description.favorited" = "次"; -"detail_view.description_section.description.page_count" = "页"; -"detail_view.action_section.button.give_a_rating" = "给予评分"; -"detail_view.action_section.button.similar_gallery" = "相似画廊"; -"detail_view.section.title.previews" = "预览"; -"detail_view.section.title.comments" = "评论"; - -// MARK: ArchivesView -"archives_view.title.archives" = "归档"; -"archives_view.button.download_to_hath_client" = "下载到 H@H 客户端"; -// HathArchive -"struct.hath_archive.price.free" = "免费"; -"struct.hath_archive.price.not_available" = "无效"; -// ArchiveResolution -"enum.archive_resolution.value.original" = "原始分辨率"; - -// MARK: TorrentsView -"torrents_view.title.torrents" = "种子"; - -// MARK: GalleryInfosView -"gallery_infos_view.title.gallery_infos" = "画廊信息"; -"gallery_infos_view.title.id" = "ID"; -"gallery_infos_view.title.token" = "Token"; -"gallery_infos_view.title.title" = "标题"; -"gallery_infos_view.title.japanese_title" = "日文标题"; -"gallery_infos_view.title.gallery_URL" = "画廊链接"; -"gallery_infos_view.title.cover_URL" = "封面链接"; -"gallery_infos_view.title.archive_URL" = "归档链接"; -"gallery_infos_view.title.torrent_URL" = "种子链接"; -"gallery_infos_view.title.parent_URL" = "上游画廊链接"; -"gallery_infos_view.title.category" = "分类"; -"gallery_infos_view.title.uploader" = "上传者"; -"gallery_infos_view.title.posted_date" = "发布日期"; -"gallery_infos_view.title.visibility" = "可见"; -"gallery_infos_view.title.language" = "语言"; -"gallery_infos_view.title.page_count" = "页数"; -"gallery_infos_view.title.file_size" = "文件大小"; -"gallery_infos_view.title.favorited_times" = "收藏次数"; -"gallery_infos_view.title.favorited" = "已收藏"; -"gallery_infos_view.title.rating_count" = "评分次数"; -"gallery_infos_view.title.average_rating" = "平均评分"; -"gallery_infos_view.title.my_rating" = "我的评分"; -"gallery_infos_view.title.torrent_count" = "种子个数"; -"gallery_infos_view.value.none" = "无"; -"gallery_infos_view.value.yes" = "是"; -"gallery_infos_view.value.no" = "否"; -// GalleryVisibility -"enum.gallery_visibility.value.yes" = "是"; -"enum.gallery_visibility.value.no" = "否 (%@)"; -"enum.gallery_visibility.value.no.reason.expunged" = "已删除"; - -// MARK: TagDetailView -"tag_detail_view.section.title.images" = "图片"; -"tag_detail_view.section.title.links" = "链接"; - -// MARK: CommentsView -"comments_view.title.comments" = "评论"; - -// MARK: PostCommentView -"post_comment_view.title.post_comment" = "发布评论"; -"post_comment_view.title.edit_comment" = "编辑评论"; - -// MARK: PreviewsView -"previews_view.title.previews" = "预览"; - -// MARK: ReadingView -"reading_view.context_menu.button.reload" = "重新加载"; -"reading_view.context_menu.button.copy" = "复制"; -"reading_view.context_menu.button.save" = "保存"; -"reading_view.context_menu.button.save_original" = "保存原图"; -"reading_view.context_menu.button.share" = "分享"; -"reading_view.toolbar_item.title.auto_play" = "自动播放"; -"reading_view.toolbar_item.title.dual_page_mode" = "双页模式"; -"reading_view.toolbar_item.title.except_the_cover" = "封面除外"; -"reading_view.toolbar_item.button.retry_all_failed_images" = "重试所有读取失败图片"; -"reading_view.toolbar_item.button.reload_all_images" = "重新加载所有图片"; -"reading_view.toolbar_item.button.reading_setting" = "阅读设置"; -// AutoPlayPolicy -"enum.auto_play_policy.value.off" = "不启用"; - -// MARK: FiltersView -"filters_view.title.filters" = "筛选"; -"filters_view.title.advanced_settings" = "高级选项"; -"filters_view.title.search_gallery_name" = "搜索画廊名称"; -"filters_view.title.search_gallery_tags" = "搜索画廊标签"; -"filters_view.title.search_gallery_description" = "搜索画廊描述"; -"filters_view.title.search_torrent_filenames" = "搜索种子文件名"; -"filters_view.title.only_show_galleries_with_torrents" = "只显示带有种子的画廊"; -"filters_view.title.search_low_power_tags" = "搜索低期望标签"; -"filters_view.title.search_downvoted_tags" = "搜索低评价标签"; -"filters_view.title.search_expunged_galleries" = "显示已被删除的画廊"; -"filters_view.title.set_minimum_rating" = "设置评分下限"; -"filters_view.title.minimum_rating" = "评分下限"; -"filters_view.title.set_pages_range" = "设置页数范围"; -"filters_view.title.pages_range" = "页数范围"; -"filters_view.title.disable_language_filter" = "禁用语言筛选"; -"filters_view.title.disable_uploader_filter" = "禁用上传者筛选"; -"filters_view.title.disable_tags_filter" = "禁用标签筛选"; -"filters_view.button.reset_filters" = "重置所有选项"; -"filters_view.section.title.advanced" = "高级"; -"filters_view.section.title.default_filter" = "默认筛选"; -// FilterRange -"enum.filter_range.value.search" = "搜索"; -"enum.filter_range.value.global" = "全局"; -"enum.filter_range.value.watched" = "标签"; - -// MARK: EhSettingView -"eh_setting_view.title.host_settings" = "%@ 设置"; -"eh_setting_view.section.title.profile_settings" = "档案设置"; -"eh_setting_view.title.selected_profile" = "当前选定档案"; -"eh_setting_view.button.set_as_default" = "设为默认"; -"eh_setting_view.button.delete_profile" = "删除档案"; -"eh_setting_view.button.rename" = "重命名"; -"eh_setting_view.button.create_new" = "创建新档案"; -"eh_setting_view.toolbar_item.button.done" = "完成"; - -"eh_setting_view.section.title.image_load_settings" = "图片加载设置"; -"eh_setting_view.title.load_images_through_the_hath_network" = "通过 Hath 网络加载图像"; -"eh_setting_view.title.browsing_country" = "浏览国家"; -"eh_setting_view.description.browsing_country" = "你似乎正在 **%@** 浏览此网页,或是使用了一个来自这个国家的 VPN 或代理,这意味着网站将尝试通过在此区域的 H@H 客户端加载图片。如果该结果不正确,或你想通过其它地区的 H@H 客户端加载图片(例如你正在使用分割隧道 VPN),你可以在下方选择另一个国家。"; -// EhSetting.LoadThroughHathSetting -"enum.eh_setting.load_through_hath_setting.value.any_client" = "所有客户端"; -"enum.eh_setting.load_through_hath_setting.value.default_port_only" = "仅使用默认端口的客户端"; -"enum.eh_setting.load_through_hath_setting.value.modern_no" = "不通过 [现代 / HTTPS]"; -"enum.eh_setting.load_through_hath_setting.value.legacy_no" = "不通过 [旧式 / HTTP]"; -"enum.eh_setting.load_through_hath_setting.description.any_client" = "推荐。"; -"enum.eh_setting.load_through_hath_setting.description.default_port_only" = "可能稍慢。当防火墙或代理阻止非标准接口的流量时启用此项。"; -"enum.eh_setting.load_through_hath_setting.description.modern_no" = "仅限赞助者。配额消耗会加快。只建议在遇到严重问题时使用。"; -"enum.eh_setting.load_through_hath_setting.description.legacy_no" = "仅限赞助者。在现代浏览器可能不可用。只建议在旧式 / 过时的浏览器使用。"; - -"eh_setting_view.section.title.image_size_settings" = "图像尺寸设置"; -"eh_setting_view.title.image_resolution" = "图像分辨率"; -"eh_setting_view.description.image_resolution" = "通常情况,图像将重采样到 1280 像素宽度以用于在线浏览,你也可以选择以下重新采样分辨率。但是为了避免负载过高,高于 1280 像素将只供给于赞助者、特殊贡献者,以及 UID 小于 3,000,000 的用户。"; -"eh_setting_view.title.image_size" = "图像尺寸"; -"eh_setting_view.description.image_size" = "虽然图片会自动根据窗口缩小,你也可以手动设置最大大小,图片并没有重新采样。(0 为不限制)"; -"eh_setting_view.title.horizontal" = "宽度"; -"eh_setting_view.title.vertical" = "高度"; -// EhSetting.ImageResolution -"enum.eh_setting.image_resolution.value.auto" = "自动"; - -"eh_setting_view.section.title.gallery_name_display" = "画廊名称显示"; -"eh_setting_view.title.gallery_name" = "画廊名称"; -"eh_setting_view.description.gallery_name" = "很多画廊都同时拥有英文或者日文标题,你想默认显示哪一个?"; -// EhSetting.GalleryName -"enum.eh_setting.gallery_name.value.default" = "默认标题"; -"enum.eh_setting.gallery_name.value.japanese" = "日文标题(如果有)"; - -"eh_setting_view.section.title.archiver_settings" = "归档设置"; -"eh_setting_view.title.archiver_behavior" = "归档下载方式"; -"eh_setting_view.description.archiver_behavior" = "默认归档下载方式为手动选择(原画质或压缩画质),然后手动复制或点击下载链接。你可以修改归档下载方式。"; -// EhSetting.ArchiverBehavior -"enum.eh_setting.archiver_behavior.value.manual_select_manual_start" = "手动选择,手动下载(默认)"; -"enum.eh_setting.archiver_behavior.value.manual_select_auto_start" = "手动选择,自动下载"; -"enum.eh_setting.archiver_behavior.value.auto_select_original_manual_start" = "自动选择原始画质,手动下载"; -"enum.eh_setting.archiver_behavior.value.auto_select_original_auto_start" = "自动选择原始画质,自动下载"; -"enum.eh_setting.archiver_behavior.value.auto_select_resample_manual_start" = "自动选择压缩画质,手动下载"; -"enum.eh_setting.archiver_behavior.value.auto_select_resample_auto_start" = "自动选择压缩画质,自动下载"; - -"eh_setting_view.section.title.front_page_settings" = "扉页设置"; -"eh_setting_view.title.display_mode" = "显示样式"; -"eh_setting_view.description.display_mode" = "你希望在扉页和搜索页显示哪种样式?"; -"eh_setting_view.section.title.show_search_range_indicator" = "搜索范围指示器"; -"eh_setting_view.title.show_search_range_indicator" = "显示搜索范围指示器"; -"eh_setting_view.description.gallery_category" = "你希望在扉页和搜索页看到哪些类别?"; -// EhSetting.DisplayMode -"enum.eh_setting.display_mode.value.compact" = "紧凑"; -"enum.eh_setting.display_mode.value.thumbnail" = "缩略图"; -"enum.eh_setting.display_mode.value.extended" = "扩展"; -"enum.eh_setting.display_mode.value.minimal" = "最小化"; -"enum.eh_setting.display_mode.value.minimalPlus" = "最小化 +"; - -"eh_setting_view.section.title.optional_UI_elements" = "可选的 UI 组件"; -"eh_setting_view.description.optional_UI_elements" = "一些旧版 UI 组件现已默认禁用。您可以在此启用这些组件。"; -"eh_setting_view.title.enable_gallery_thumbnail_selector" = "在画廊页面启用缩图选择器"; - -"eh_setting_view.section.title.favorites" = "收藏"; -"eh_setting_view.description.favorite_categories" = "在这里你可以重命名你的收藏夹。"; -"eh_setting_view.title.favorites_sort_order" = "收藏排序方式"; -"eh_setting_view.description.favorites_sort_order" = "你也可以选择收藏夹中默认排序。注意:2016 年 3 月改版之前加入收藏夹的画廊并未保存收藏时间,会以画廊发布时间代替。"; -// EhSetting.FavoritesSortOrder -"enum.eh_setting.favorites_sort_order.value.last_update_time" = "按更新时间"; -"enum.eh_setting.favorites_sort_order.value.favorited_time" = "按收藏时间"; - -"eh_setting_view.section.title.ratings" = "评分"; -"eh_setting_view.title.ratings_color" = "评分颜色"; -"eh_setting_view.promt.ratings_color" = "RRGGB"; -"eh_setting_view.description.ratings_color" = "默认设置下,你评为 2 星及以下的画廊显示为红星,2.5 ~ 4 星显示为绿星,4.5 ~ 5 星显示为蓝星。你可以将其设定为其它颜色组合。每一个字幕代表一颗星, 默认的 RRGGB 表示第一第二颗星显示为红色 R(ed),第三第四颗星显示是绿色 G(reen),第五颗星显示为蓝色 B(lue)。你也可以使用黄色 (Y)ellow,R/G/B/Y 任何五个组合都是有效的。"; - -"eh_setting_view.section.title.tag_filtering_threshold" = "标签筛选阈值"; -"eh_setting_view.title.tag_filtering_threshold" = "标签筛选阈值"; -"eh_setting_view.description.tag_filtering_threshold" = "你可以通过将标签加入“我的标签”并设置一个负权重来软过滤它们。如果一个作品所有的标签权重之和低于设定值,此作品将从视图中被过滤。这个值可以设定为 0 ~ -9999。"; - -"eh_setting_view.section.title.tag_watching_threshold" = "标签订阅阈值"; -"eh_setting_view.title.tag_watching_threshold" = "标签订阅阈值"; -"eh_setting_view.description.tag_watching_threshold" = "你可以通过将标签加入“我的标签”并设置一个正权重来关注它们。如果一个最近上传的作品所有标签的权重之和高于设定值,则它将会被包含在“关注”里。这个值可以设定为 0 ~ 9999。"; - -"eh_setting_view.section.title.filtered_removal_count" = "筛选器移除数"; -"eh_setting_view.description.filtered_removal_count" = "要显示“你的默认筛选器从本页移除了 XX 个画廊”提示吗?"; -"eh_setting_view.title.show_filtered_removal_count" = "显示筛选器移除数"; - -"eh_setting_view.section.title.excluded_languages" = "屏蔽的语言"; -"eh_setting_view.description.excluded_languages" = "如果你希望以从列表或搜索结果中隐藏特定语言的画廊,请从下面的列表中选择。注意:无论搜索条件为何,这些画廊都不会出现。"; -// EhSetting.ExcludedLanguagesCategory -"enum.eh_setting.excluded_languages_category.value.original" = "原始版本"; -"enum.eh_setting.excluded_languages_category.value.translated" = "翻译版本"; -"enum.eh_setting.excluded_languages_category.value.rewrite" = "改编版本"; - -"eh_setting_view.section.title.excluded_uploaders" = "屏蔽的上传者"; -"eh_setting_view.description.excluded_uploaders" = "如果你希望在画廊中和搜索中隐藏某个上传者的话,请把他们的用户名填写在下方,每行一个。注意:无论搜索条件为何,这些上传者都不会出现。"; -"eh_setting_view.description.excluded_uploaders_count" = "已使用 **%@ / %@** 个屏蔽槽位。"; - -"eh_setting_view.section.title.search_result_count" = "搜索结果数"; -"eh_setting_view.title.result_count" = "结果数"; -"eh_setting_view.description.result_count" = "搜索页面每页显示多少条数据?\n(需要“Hath Perk:页面扩大”)"; - -"eh_setting_view.section.title.thumbnail_settings" = "缩略图设置"; -"eh_setting_view.title.thumbnail_load_timing" = "缩略图加载时机"; -"eh_setting_view.description.thumbnail_load_timing" = "你希望列表中的鼠标悬停缩略图何时加载?"; -"eh_setting_view.description.thumbnail_configuration" = "你可以设定一个对所有画廊生效的默认缩略图配置。"; -"eh_setting_view.title.thumbnail_size" = "尺寸"; -"eh_setting_view.title.thumbnail_row_count" = "行数"; -// EhSetting.ThumbnailLoadTiming -"enum.eh_setting.thumbnail_load_timing.value.on_mouse_over" = "鼠标悬停时"; -"enum.eh_setting.thumbnail_load_timing.value.on_page_load" = "页面加载时"; -"enum.eh_setting.thumbnail_load_timing.description.on_mouse_over" = "页面加载快,缩略图加载有延迟。"; -"enum.eh_setting.thumbnail_load_timing.description.on_page_load" = "页面加载时间更长,显示缩略图无需等待。"; -// EhSetting.ThumbnailSize -"enum.eh_setting.thumbnail_size.value.normal" = "普通"; -"enum.eh_setting.thumbnail_size.value.large" = "较大"; -"enum.eh_setting.thumbnail_size.value.small" = "较小"; -"enum.eh_setting.thumbnail_size.value.auto" = "自动"; - -"eh_setting_view.section.title.cover_scaling" = "封面缩放"; -"eh_setting_view.title.scale_factor" = "缩放比例"; -"eh_setting_view.description.cover_scale_factor" = "缩略图和扩展模式下的画廊列表封面可以缩放为 75%% 到 150%% 之间的值。"; - -"eh_setting_view.section.title.viewport_override" = "覆写可视区域"; -"eh_setting_view.title.virtual_width" = "虚拟宽度"; -"eh_setting_view.description.virtual_width" = "允许你覆写移动设备的可视区域,默认是根据 DPI 自动计算的,100%% 缩略图比例下的合理值在 640 到 1400 之间。"; - -"eh_setting_view.section.title.gallery_comments" = "画廊评论"; -"eh_setting_view.title.comments_sort_order" = "评论排序方式"; -"eh_setting_view.title.comments_votes_show_timing" = "显示评论分数时机"; -// EhSetting.CommentsSortOrder -"enum.eh_setting.comments_sort_order.value.oldest" = "按最早的评论"; -"enum.eh_setting.comments_sort_order.value.recent" = "按最新的评论"; -"enum.eh_setting.comments_sort_order.value.highest_score" = "按最高分的评论"; -// EhSetting.CommentVotesShowTiming -"enum.eh_setting.comments_votes_show_timing.value.on_hover_or_click" = "悬停或点击时"; -"enum.eh_setting.comments_votes_show_timing.value.always" = "始终显示"; - -"eh_setting_view.section.title.gallery_tags" = "画廊标签"; -"eh_setting_view.title.tags_sort_order" = "标签排序方式"; -// EhSetting.tags_sort_order -"enum.eh_setting.tags_sort_order.value.alphabetical" = "按字母排序"; -"enum.eh_setting.tags_sort_order.value.tag_power" = "按标签权重"; - -"eh_setting_view.section.title.gallery_page_thumbnail_labeling" = "画廊页面缩略图标签"; -"eh_setting_view.title.show_label_below_gallery_thumbnails" = "在画廊缩略图下方显示标签"; - -"eh_setting_view.section.title.hath_local_network_host" = "Hath 本地网络服务器"; -"eh_setting_view.title.ip_address_port" = "IP 地址:端口"; -"eh_setting_view.description.ip_address_port" = "如果你本地安装了 H@H 客户端,本地 IP 与浏览网站的公共 IP 相同,一些路由器不支持回流导致无法访问到自己,你可以设置这里来解决。\n如果在同一台设备上访问网站和运行客户端,请使用本地回环地址 (127.0.0.1:端口号)。如果客户端在网络上的其它设备运行,请使用那台机器的内网 IP。某些浏览器的配置可能阻止外部网站访问本地网络,你必须将网站列入白名单才能工作。"; - -"eh_setting_view.section.title.original_images" = "是否使用原始图像而非重新采样的版本?如果您在上方选择的水平分辨率不是“自动”,并且所查看的图像更宽,或者原始图像大于 10 MiB(对于超过一年的图库,则为 4 MiB),那么仍将使用重新采样的图像。"; -"eh_setting_view.title.use_original_images" = "显示原图"; - -"eh_setting_view.section.title.multi_page_viewer" = "多页查看器"; -"eh_setting_view.title.use_multi_page_viewer" = "使用多页查看器"; -"eh_setting_view.title.display_style" = "显示样式"; -"eh_setting_view.title.show_thumbnail_pane" = "显示缩略图侧栏"; -// EhSetting.MultiplePageViewerStyle -"enum.eh_setting.multiple_page_viewer_style.value.align_left_scale_if_over_width" = "左对齐,图像过宽时缩放"; -"enum.eh_setting.multiple_page_viewer_style.value.align_center_scale_if_over_width" = "居中对齐,图像过宽时缩放"; -"enum.eh_setting.multiple_page_viewer_style.value.align_center_always_scale" = "居中对齐,图像始终缩放"; -// EhSetting.GalleryPageNumbering -"enum.eh_setting.gallery_page_numbering.value.none" = "不显示"; -"enum.eh_setting.gallery_page_numbering.value.page_number_only" = "仅显示页码"; -"enum.eh_setting.gallery_page_numbering.value.page_number_and_name" = "显示页码和名称"; - -// MARK: Category -"enum.category.value.doujinshi" = "同人志"; -"enum.category.value.manga" = "漫画"; -"enum.category.value.artist_CG" = "插画"; -"enum.category.value.game_CG" = "游戏 CG"; -"enum.category.value.western" = "西方"; -"enum.category.value.non_h" = "健康"; -"enum.category.value.image_set" = "照片集"; -"enum.category.value.cosplay" = "角色扮演"; -"enum.category.value.asian_porn" = "亚洲"; -"enum.category.value.misc" = "其它"; -"enum.category.value.private" = "非公开"; - -// MARK: TagNamespace -"enum.tag_namespace.value.reclass" = "重归类"; -"enum.tag_namespace.value.language" = "语言"; -"enum.tag_namespace.value.parody" = "原作"; -"enum.tag_namespace.value.character" = "角色"; -"enum.tag_namespace.value.group" = "团体"; -"enum.tag_namespace.value.artist" = "作者"; -"enum.tag_namespace.value.male" = "男性"; -"enum.tag_namespace.value.female" = "女性"; -"enum.tag_namespace.value.mixed" = "混合性别"; -"enum.tag_namespace.value.cosplayer" = "扮装者"; -"enum.tag_namespace.value.other" = "其它"; -"enum.tag_namespace.value.temp" = "临时"; - -// MARK: Language -"enum.language.value.invalid" = "无效"; -"enum.language.value.other" = "其它"; -"enum.language.value.afrikaans" = "南非语"; -"enum.language.value.albanian" = "阿尔巴尼亚语"; -"enum.language.value.arabic" = "阿拉伯语"; -"enum.language.value.bengali" = "孟加拉语"; -"enum.language.value.bosnian" = "波斯尼亚语"; -"enum.language.value.bulgarian" = "保加利亚语"; -"enum.language.value.burmese" = "缅甸语"; -"enum.language.value.catalan" = "加泰罗尼亚语"; -"enum.language.value.cebuano" = "宿雾語"; -"enum.language.value.chinese" = "汉语"; -"enum.language.value.croatian" = "克罗地亚语"; -"enum.language.value.czech" = "捷克语"; -"enum.language.value.danish" = "丹麦语"; -"enum.language.value.dutch" = "荷兰语"; -"enum.language.value.english" = "英语"; -"enum.language.value.esperanto" = "国际语"; -"enum.language.value.estonian" = "爱沙尼亚语"; -"enum.language.value.finnish" = "芬兰语"; -"enum.language.value.french" = "法语"; -"enum.language.value.georgian" = "格鲁吉亚语"; -"enum.language.value.german" = "德语"; -"enum.language.value.greek" = "希腊语"; -"enum.language.value.hebrew" = "希伯来语"; -"enum.language.value.hindi" = "印地语"; -"enum.language.value.hmong" = "苗语"; -"enum.language.value.hungarian" = "匈牙利语"; -"enum.language.value.indonesian" = "印度尼西亚语"; -"enum.language.value.italian" = "意大利语"; -"enum.language.value.japanese" = "日语"; -"enum.language.value.kazakh" = "哈萨克语"; -"enum.language.value.khmer" = "高棉文"; -"enum.language.value.korean" = "韩语"; -"enum.language.value.kurdish" = "库尔德语"; -"enum.language.value.lao" = "老挝语"; -"enum.language.value.latin" = "拉丁语"; -"enum.language.value.mongolian" = "蒙古语"; -"enum.language.value.ndebele" = "恩德贝莱语"; -"enum.language.value.nepali" = "尼泊尔语"; -"enum.language.value.norwegian" = "挪威语"; -"enum.language.value.oromo" = "奥罗莫语"; -"enum.language.value.pashto" = "普什图语"; -"enum.language.value.persian" = "波斯语"; -"enum.language.value.polish" = "波兰语"; -"enum.language.value.portuguese" = "葡萄牙语"; -"enum.language.value.punjabi" = "旁遮普语"; -"enum.language.value.romanian" = "罗马尼亚语"; -"enum.language.value.russian" = "俄语"; -"enum.language.value.sango" = "桑戈语"; -"enum.language.value.serbian" = "塞尔维亚语"; -"enum.language.value.shona" = "绍纳语"; -"enum.language.value.slovak" = "斯洛伐克语"; -"enum.language.value.slovenian" = "斯洛文尼亚语"; -"enum.language.value.somali" = "索马里语"; -"enum.language.value.spanish" = "西班牙语"; -"enum.language.value.swahili" = "斯瓦希里语"; -"enum.language.value.swedish" = "瑞典语"; -"enum.language.value.tagalog" = "他加洛语"; -"enum.language.value.thai" = "泰语"; -"enum.language.value.tigrinya" = "提格利尼亚语"; -"enum.language.value.turkish" = "土耳其语"; -"enum.language.value.ukrainian" = "乌克兰语"; -"enum.language.value.urdu" = "乌尔都语"; -"enum.language.value.vietnamese" = "越南语"; -"enum.language.value.zulu" = "祖鲁语"; - -// MARK: BrowsingCountry -"enum.browsing_country.name.auto_detect" = "自动检测"; -"enum.browsing_country.name.afghanistan" = "阿富汗"; -"enum.browsing_country.name.aland_islands" = "奥兰群岛"; -"enum.browsing_country.name.albania" = "阿尔巴尼亚"; -"enum.browsing_country.name.algeria" = "阿尔及利亚"; -"enum.browsing_country.name.american_samoa" = "美属萨摩亚"; -"enum.browsing_country.name.andorra" = "安道尔"; -"enum.browsing_country.name.angola" = "安哥拉"; -"enum.browsing_country.name.anguilla" = "安圭拉"; -"enum.browsing_country.name.antarctica" = "南极洲"; -"enum.browsing_country.name.antigua_and_barbuda" = "安提瓜和巴布达"; -"enum.browsing_country.name.argentina" = "阿根廷"; -"enum.browsing_country.name.armenia" = "亚美尼亚"; -"enum.browsing_country.name.aruba" = "阿鲁巴"; -"enum.browsing_country.name.asia_pacific_region" = "亚太地区"; -"enum.browsing_country.name.australia" = "澳大利亚"; -"enum.browsing_country.name.austria" = "奥地利"; -"enum.browsing_country.name.azerbaijan" = "阿塞拜疆"; -"enum.browsing_country.name.bahamas" = "巴哈马"; -"enum.browsing_country.name.bahrain" = "巴林"; -"enum.browsing_country.name.bangladesh" = "孟加拉国"; -"enum.browsing_country.name.barbados" = "巴巴多斯"; -"enum.browsing_country.name.belarus" = "白俄罗斯"; -"enum.browsing_country.name.belgium" = "比利时"; -"enum.browsing_country.name.belize" = "伯利兹"; -"enum.browsing_country.name.benin" = "贝宁"; -"enum.browsing_country.name.bermuda" = "百慕大"; -"enum.browsing_country.name.bhutan" = "不丹"; -"enum.browsing_country.name.bolivia" = "玻利维亚"; -"enum.browsing_country.name.bonaire_saint_eustatius_and_saba" = "博奈尔、圣尤斯特歇斯与萨巴"; -"enum.browsing_country.name.bosnia_and_herzegovina" = "波斯尼亚和黑塞哥维那"; -"enum.browsing_country.name.botswana" = "博茨瓦纳"; -"enum.browsing_country.name.bouvet_island" = "布韦岛"; -"enum.browsing_country.name.brazil" = "巴西"; -"enum.browsing_country.name.british_indian_ocean_territory" = "英属印度洋领地"; -"enum.browsing_country.name.brunei_darussalam" = "文莱"; -"enum.browsing_country.name.bulgaria" = "保加利亚"; -"enum.browsing_country.name.burkina_faso" = "布基纳法索"; -"enum.browsing_country.name.burundi" = "蒲隆地"; -"enum.browsing_country.name.cambodia" = "柬埔寨"; -"enum.browsing_country.name.cameroon" = "喀麦隆"; -"enum.browsing_country.name.canada" = "加拿大"; -"enum.browsing_country.name.cape_verde" = "佛得角"; -"enum.browsing_country.name.cayman_islands" = "开曼群岛"; -"enum.browsing_country.name.central_african_republic" = "中非"; -"enum.browsing_country.name.chad" = "乍得"; -"enum.browsing_country.name.chile" = "智利"; -"enum.browsing_country.name.china" = "中华人民共和国"; -"enum.browsing_country.name.christmas_island" = "圣诞岛"; -"enum.browsing_country.name.cocos_islands" = "科科斯岛"; -"enum.browsing_country.name.colombia" = "哥伦比亚"; -"enum.browsing_country.name.comoros" = "科摩罗"; -"enum.browsing_country.name.congo" = "刚果共和国"; -"enum.browsing_country.name.the_democratic_republic_of_the_congo" = "刚果民主共和国"; -"enum.browsing_country.name.cook_islands" = "库克群岛"; -"enum.browsing_country.name.costa_rica" = "哥斯达黎加"; -"enum.browsing_country.name.cote_d_ivoire" = "科特迪瓦"; -"enum.browsing_country.name.croatia" = "克罗地亚"; -"enum.browsing_country.name.cuba" = "古巴"; -"enum.browsing_country.name.curacao" = "库拉索"; -"enum.browsing_country.name.cyprus" = "塞浦路斯"; -"enum.browsing_country.name.czech_republic" = "捷克"; -"enum.browsing_country.name.denmark" = "丹麦"; -"enum.browsing_country.name.djibouti" = "吉布提"; -"enum.browsing_country.name.dominica" = "多米尼克"; -"enum.browsing_country.name.dominican_republic" = "多米尼加"; -"enum.browsing_country.name.ecuador" = "厄瓜多尔"; -"enum.browsing_country.name.egypt" = "埃及"; -"enum.browsing_country.name.el_salvador" = "萨尔瓦多"; -"enum.browsing_country.name.equatorial_guinea" = "赤道几内亚"; -"enum.browsing_country.name.eritrea" = "厄立特里亚"; -"enum.browsing_country.name.estonia" = "爱沙尼亚"; -"enum.browsing_country.name.ethiopia" = "埃塞俄比亚"; -"enum.browsing_country.name.europe" = "欧洲"; -"enum.browsing_country.name.falkland_islands" = "福克兰群岛"; -"enum.browsing_country.name.faroe_islands" = "法罗群岛"; -"enum.browsing_country.name.fiji" = "斐济"; -"enum.browsing_country.name.finland" = "芬兰"; -"enum.browsing_country.name.france" = "法国"; -"enum.browsing_country.name.french_guiana" = "法属圭亚那"; -"enum.browsing_country.name.french_polynesia" = "法属波利尼西亚"; -"enum.browsing_country.name.french_southern_territories" = "法属南部和南极领地"; -"enum.browsing_country.name.gabon" = "加蓬"; -"enum.browsing_country.name.gambia" = "冈比亚"; -"enum.browsing_country.name.georgia" = "格鲁吉亚"; -"enum.browsing_country.name.germany" = "德国"; -"enum.browsing_country.name.ghana" = "加纳"; -"enum.browsing_country.name.gibraltar" = "直布罗陀"; -"enum.browsing_country.name.greece" = "希腊"; -"enum.browsing_country.name.greenland" = "格陵兰"; -"enum.browsing_country.name.grenada" = "格林纳达"; -"enum.browsing_country.name.guadeloupe" = "瓜德罗普"; -"enum.browsing_country.name.guam" = "关岛"; -"enum.browsing_country.name.guatemala" = "危地马拉"; -"enum.browsing_country.name.guernsey" = "根西"; -"enum.browsing_country.name.guinea" = "几内亚"; -"enum.browsing_country.name.guinea_bissau" = "几内亚比绍"; -"enum.browsing_country.name.guyana" = "圭亚那"; -"enum.browsing_country.name.haiti" = "海地"; -"enum.browsing_country.name.heard_island_and_mc_donald_islands" = "赫德岛和麦克唐纳群岛"; -"enum.browsing_country.name.vatican_city_state" = "梵蒂冈城国"; -"enum.browsing_country.name.honduras" = "洪都拉斯"; -"enum.browsing_country.name.hong_kong" = "香港"; -"enum.browsing_country.name.hungary" = "匈牙利"; -"enum.browsing_country.name.iceland" = "冰岛"; -"enum.browsing_country.name.india" = "印度"; -"enum.browsing_country.name.indonesia" = "印度尼西亚"; -"enum.browsing_country.name.iran" = "伊朗"; -"enum.browsing_country.name.iraq" = "伊拉克"; -"enum.browsing_country.name.ireland" = "爱尔兰"; -"enum.browsing_country.name.isle_of_man" = "曼岛"; -"enum.browsing_country.name.israel" = "以色列"; -"enum.browsing_country.name.italy" = "意大利"; -"enum.browsing_country.name.jamaica" = "牙买加"; -"enum.browsing_country.name.japan" = "日本"; -"enum.browsing_country.name.jersey" = "泽西"; -"enum.browsing_country.name.jordan" = "约旦"; -"enum.browsing_country.name.kazakhstan" = "哈萨克斯坦"; -"enum.browsing_country.name.kenya" = "肯尼亚"; -"enum.browsing_country.name.kiribati" = "基里巴斯"; -"enum.browsing_country.name.kuwait" = "科威特"; -"enum.browsing_country.name.kyrgyzstan" = "吉尔吉斯斯坦"; -"enum.browsing_country.name.lao_peoples_democratic_republic" = "老挝"; -"enum.browsing_country.name.latvia" = "拉脱维亚"; -"enum.browsing_country.name.lebanon" = "黎巴嫩"; -"enum.browsing_country.name.lesotho" = "莱索托"; -"enum.browsing_country.name.liberia" = "利比里亚"; -"enum.browsing_country.name.libya" = "利比亚"; -"enum.browsing_country.name.liechtenstein" = "列支敦士登"; -"enum.browsing_country.name.lithuania" = "立陶宛"; -"enum.browsing_country.name.luxembourg" = "卢森堡"; -"enum.browsing_country.name.macau" = "澳门"; -"enum.browsing_country.name.macedonia" = "马其顿"; -"enum.browsing_country.name.madagascar" = "马达加斯加"; -"enum.browsing_country.name.malawi" = "马拉维"; -"enum.browsing_country.name.malaysia" = "马来西亚"; -"enum.browsing_country.name.maldives" = "马尔代夫"; -"enum.browsing_country.name.mali" = "马里"; -"enum.browsing_country.name.malta" = "马耳他"; -"enum.browsing_country.name.marshall_islands" = "马绍尔群岛"; -"enum.browsing_country.name.martinique" = "马提尼克"; -"enum.browsing_country.name.mauritania" = "毛里塔尼亚"; -"enum.browsing_country.name.mauritius" = "模里西斯"; -"enum.browsing_country.name.mayotte" = "马约特"; -"enum.browsing_country.name.mexico" = "墨西哥"; -"enum.browsing_country.name.micronesia" = "密克罗尼西亚"; -"enum.browsing_country.name.moldova" = "摩尔多瓦"; -"enum.browsing_country.name.monaco" = "摩纳哥"; -"enum.browsing_country.name.mongolia" = "蒙古"; -"enum.browsing_country.name.montenegro" = "黑山"; -"enum.browsing_country.name.montserrat" = "蒙塞拉特岛"; -"enum.browsing_country.name.morocco" = "摩洛哥"; -"enum.browsing_country.name.mozambique" = "莫桑比克"; -"enum.browsing_country.name.myanmar" = "缅甸"; -"enum.browsing_country.name.namibia" = "纳米比亚"; -"enum.browsing_country.name.nauru" = "诺鲁"; -"enum.browsing_country.name.nepal" = "尼泊尔"; -"enum.browsing_country.name.netherlands" = "荷兰"; -"enum.browsing_country.name.new_caledonia" = "新喀里多尼亚"; -"enum.browsing_country.name.new_zealand" = "新西兰"; -"enum.browsing_country.name.nicaragua" = "尼加拉瓜"; -"enum.browsing_country.name.niger" = "尼日尔"; -"enum.browsing_country.name.nigeria" = "尼日利亚"; -"enum.browsing_country.name.niue" = "纽埃"; -"enum.browsing_country.name.norfolk_island" = "诺福克岛"; -"enum.browsing_country.name.north_korea" = "朝鲜"; -"enum.browsing_country.name.northern_mariana_islands" = "北马里亚纳群岛"; -"enum.browsing_country.name.norway" = "挪威"; -"enum.browsing_country.name.oman" = "阿曼"; -"enum.browsing_country.name.pakistan" = "巴基斯坦"; -"enum.browsing_country.name.palau" = "帛琉"; -"enum.browsing_country.name.palestinian_territory" = "巴勒斯坦"; -"enum.browsing_country.name.panama" = "巴拿马"; -"enum.browsing_country.name.papua_new_guinea" = "巴布亚新几内亚"; -"enum.browsing_country.name.paraguay" = "巴拉圭"; -"enum.browsing_country.name.peru" = "秘鲁"; -"enum.browsing_country.name.philippines" = "菲律宾"; -"enum.browsing_country.name.pitcairn_islands" = "皮特凯恩群岛"; -"enum.browsing_country.name.poland" = "波兰"; -"enum.browsing_country.name.portugal" = "葡萄牙"; -"enum.browsing_country.name.puerto_rico" = "波多黎各"; -"enum.browsing_country.name.qatar" = "卡塔尔"; -"enum.browsing_country.name.reunion" = "留尼汪"; -"enum.browsing_country.name.romania" = ""; -"enum.browsing_country.name.russian_federation" = "俄罗斯"; -"enum.browsing_country.name.rwanda" = "卢旺达"; -"enum.browsing_country.name.saint_barthelemy" = "圣巴泰勒米"; -"enum.browsing_country.name.saint_helena" = "圣赫勒拿"; -"enum.browsing_country.name.saint_kitts_and_nevis" = "圣基茨岛"; -"enum.browsing_country.name.saint_lucia" = "圣卢西亚"; -"enum.browsing_country.name.saint_martin" = "圣马丁岛"; -"enum.browsing_country.name.saint_pierre_and_miquelon" = "圣皮埃尔和密克隆"; -"enum.browsing_country.name.saint_vincent_and_the_grenadines" = "圣文森特和格林纳丁斯"; -"enum.browsing_country.name.samoa" = "萨摩亚"; -"enum.browsing_country.name.san_marino" = "圣马力诺"; -"enum.browsing_country.name.sao_tome_and_principe" = "圣多美和普林西比"; -"enum.browsing_country.name.saudi_arabia" = "沙地阿拉伯"; -"enum.browsing_country.name.senegal" = "塞内加尔"; -"enum.browsing_country.name.serbia" = "塞尔维亚"; -"enum.browsing_country.name.seychelles" = "塞舌尔"; -"enum.browsing_country.name.sierra_leone" = "塞拉利昂"; -"enum.browsing_country.name.singapore" = "新加坡"; -"enum.browsing_country.name.sint_maarten" = "圣马丁岛"; -"enum.browsing_country.name.slovakia" = "斯洛伐克"; -"enum.browsing_country.name.slovenia" = "斯洛文尼亚"; -"enum.browsing_country.name.solomon_islands" = "所罗门群岛"; -"enum.browsing_country.name.somalia" = "索马里"; -"enum.browsing_country.name.south_africa" = "南非"; -"enum.browsing_country.name.south_georgia_and_the_south_sandwich_islands" = "南乔治亚和南桑威奇群岛"; -"enum.browsing_country.name.south_korea" = "韩国"; -"enum.browsing_country.name.south_sudan" = "南苏丹"; -"enum.browsing_country.name.spain" = "西班牙"; -"enum.browsing_country.name.sri_lanka" = "斯里兰卡"; -"enum.browsing_country.name.sudan" = "苏丹"; -"enum.browsing_country.name.suriname" = "苏里南"; -"enum.browsing_country.name.svalbard_and_jan_mayen" = "斯瓦尔巴和扬马延"; -"enum.browsing_country.name.swaziland" = "史瓦帝尼"; -"enum.browsing_country.name.sweden" = "瑞典"; -"enum.browsing_country.name.switzerland" = "瑞士"; -"enum.browsing_country.name.syrian_arab_republic" = "叙利亚"; -"enum.browsing_country.name.taiwan" = "台湾"; -"enum.browsing_country.name.tajikistan" = "塔吉克斯坦"; -"enum.browsing_country.name.tanzania" = "坦桑尼亚"; -"enum.browsing_country.name.thailand" = "泰国"; -"enum.browsing_country.name.timor_leste" = "东帝汶"; -"enum.browsing_country.name.togo" = "多哥"; -"enum.browsing_country.name.tokelau" = "托克劳"; -"enum.browsing_country.name.tonga" = "汤加"; -"enum.browsing_country.name.trinidad_and_tobago" = "特立尼达和多巴哥"; -"enum.browsing_country.name.tunisia" = "突尼斯"; -"enum.browsing_country.name.turkey" = "土耳其"; -"enum.browsing_country.name.turkmenistan" = "土库曼斯坦"; -"enum.browsing_country.name.turks_and_caicos_islands" = "特克斯和凯科斯群岛"; -"enum.browsing_country.name.tuvalu" = "图瓦卢"; -"enum.browsing_country.name.uganda" = "乌干达"; -"enum.browsing_country.name.ukraine" = "乌克兰"; -"enum.browsing_country.name.united_arab_emirates" = "阿拉伯联合酋长国"; -"enum.browsing_country.name.united_kingdom" = "英国"; -"enum.browsing_country.name.united_states" = "美国"; -"enum.browsing_country.name.united_states_minor_outlying_islands" = "美国本土外小岛屿"; -"enum.browsing_country.name.uruguay" = "乌拉圭"; -"enum.browsing_country.name.uzbekistan" = "乌兹别克斯坦"; -"enum.browsing_country.name.vanuatu" = "瓦努阿图"; -"enum.browsing_country.name.venezuela" = "委內瑞拉"; -"enum.browsing_country.name.vietnam" = "越南"; -"enum.browsing_country.name.virgin_islands_british" = "英属维尔京群岛"; -"enum.browsing_country.name.virgin_islands_US" = "美属维尔京群岛"; -"enum.browsing_country.name.wallis_and_futuna" = "瓦利斯和富图纳"; -"enum.browsing_country.name.western_sahara" = "西撒哈拉"; -"enum.browsing_country.name.yemen" = "也门"; -"enum.browsing_country.name.zambia" = "赞比亚"; -"enum.browsing_country.name.zimbabwe" = "津巴布韦"; diff --git a/EhPanda/App/zh-Hant-HK.lproj/InfoPlist.strings b/EhPanda/App/zh-Hant-HK.lproj/InfoPlist.strings deleted file mode 100644 index c19da4373..000000000 --- a/EhPanda/App/zh-Hant-HK.lproj/InfoPlist.strings +++ /dev/null @@ -1,7 +0,0 @@ -/* - InfoPlist.strings - EhPanda -*/ - -"NSFaceIDUsageDescription" = "我們需要您提供 Face ID 權限來使用 Face ID 解鎖"; -"NSPhotoLibraryAddUsageDescription" = "我們需要您提供照片權限來保存圖片到照片"; diff --git a/EhPanda/App/zh-Hant-HK.lproj/Localizable.strings b/EhPanda/App/zh-Hant-HK.lproj/Localizable.strings deleted file mode 100644 index 948d5eb2f..000000000 --- a/EhPanda/App/zh-Hant-HK.lproj/Localizable.strings +++ /dev/null @@ -1,905 +0,0 @@ -/* - Localizable.strings - EhPanda -*/ - -// MARK: BanInterval -"enum.ban_interval.description.and" = ""; - -// MARK: ToplistsType -"enum.toplists_type.value.yesterday" = "昨天"; -"enum.toplists_type.value.past_month" = "上個月"; -"enum.toplists_type.value.past_year" = "去年"; -"enum.toplists_type.value.all_time" = "所有時間"; - -// MARK: Response -"website.response.hath_client_not_found" = "你需要一個連結到帳號的 H@H 用戶端才能使用這個功能"; -"website.response.hath_client_not_online" = "你的 H@H 用戶端為離線狀態,請啟動後再試"; -"website.response.invalid_resolution" = "該畫廊不能以目前選擇的解像度下載"; - -// MARK: HUD -"hud.title.error" = "錯誤"; -"hud.title.success" = "成功"; -"hud.title.loading" = "載入中..."; -"hud.title.communicating" = "連線中..."; -"hud.caption.copied_to_clipboard" = "已複製到剪貼簿"; -"hud.caption.saved_to_photo_library" = "已儲存到照片"; - -// MARK: AutoLock -"local_authorization.reason" = "由於超過 APP 自動鎖定期限,APP 已被鎖定,請重新解鎖"; - -// MARK: Common value -"common.value.stars" = "%@ 星"; -"common.value.pages" = "%@ 頁"; -"common.value.times" = "%@ 次"; -"common.value.day" = "%@ 天"; -"common.value.days" = "%@ 天"; -"common.value.hour" = "%@ 小時"; -"common.value.hours" = "%@ 小時"; -"common.value.minute" = "%@ 分"; -"common.value.minutes" = "%@ 分"; -"common.value.second" = "%@ 秒"; -"common.value.seconds" = "%@ 秒"; -"common.value.records" = "%@ 筆紀錄"; - -// MARK: 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.jump_page" = "跳到..."; -"toolbar_item.button.quick_search" = "快速搜尋"; - -// MARK: JumpPage -"jump_page_view.title.jump_page" = "跳到..."; -"jump_page_view.button.confirm" = "確定"; - -// MARK: AlertView -"loading_view.title.loading" = "載入中..."; -"loading_view.title.preparing_database" = "正在準備..."; -"not_login_view.title.need_login" = "你需要登入才能完成這個動作"; -"not_login_view.button.login" = "登入"; -"error_view.button.retry" = "重試"; -"error_view.button.drop_database" = "刪除資料庫"; -"error_view.title.try_later" = "請稍後再試"; -"error_view.title.network" = "網絡發生故障,請檢查網絡狀態"; -"error_view.title.parsing" = "網頁解析器發生故障"; -"error_view.title.unknown" = "發生不明錯誤"; -"error_view.title.not_found" = "這邊看起來空空如也"; -"error_view.title.database_corrupted" = "資料庫已經損壞\n請提交 issue 至 GitHub."; -"error_view.title.ip_banned" = "你的 IP 因為頁面載入次數過於頻繁而被暫時禁止存取,這可能是因為你正在使用爬蟲/鏡像軟件,禁止存取將在 %@ 後解除"; -"error_view.title.copyright_claim" = "非常抱歉,這個畫廊已因為 %@ 提出的版權聲索而不再提供存取"; -"error_view.title.gallery_unavailable" = "此畫廊已被刪除或你沒有權限存取"; - -// MARK: ConfirmationDialog -"confirmation_dialog.title.drop_database" = "繼續此操作將會清除 APP 中的所有資料\n確定要刪除資料庫?"; -"confirmation_dialog.title.remove_custom_translations" = "是否確定要刪除所有自訂翻譯?"; -"confirmation_dialog.title.logout" = "確定要登出嗎?"; -"confirmation_dialog.title.delete" = "確定要刪除?"; -"confirmation_dialog.title.clear" = "確定要清空嗎?"; -"confirmation_dialog.title.reset" = "確定要重設嗎?"; -"confirmation_dialog.button.drop_database" = "刪除資料庫"; -"confirmation_dialog.button.remove" = "移除"; -"confirmation_dialog.button.logout" = "登出"; -"confirmation_dialog.button.delete" = "刪除"; -"confirmation_dialog.button.clear" = "清空"; -"confirmation_dialog.button.reset" = "重設"; - -// MARK: SubSection -"sub_section.button.show_all" = "顯示全部"; - -// MARK: NewDawnView -"new_dawn_view.title.first" = "現在是嶄新的一天!"; -"new_dawn_view.title.second" = "回顧到目前為止的旅程,你發現自己睿智了一點。"; -// Greeting -"struct.greeting.mark.start" = "你獲得了 "; -"struct.greeting.mark.separator" = "、"; -"struct.greeting.mark.and" = " 和 "; -"struct.greeting.mark.end" = "!"; - -// MARK: HomeView -"home_view.title.home" = "總覽"; -"home_view.section.title.frontpage" = "首頁"; -"home_view.section.title.toplists" = "排行"; -"home_view.section.title.other" = "其他"; -// HomeMiscGridType -"enum.home_misc_grid_type.title.popular" = "熱門"; -"enum.home_misc_grid_type.title.watched" = "關注"; -"enum.home_misc_grid_type.title.history" = "歷程"; - -// MARK: FrontpageView -"frontpage_view.title.frontpage" = "首頁"; - -// MARK: ToplistsView -"toplists_view.title.toplists" = "排行"; - -// MARK: PopularView -"popular_view.title.popular" = "熱門"; - -// MARK: WatchedView -"watched_view.title.watched" = "關注"; - -// MARK: HistoryView -"history_view.title.history" = "歷程"; - -// MARK: FavoritesView -"favorites_view.title.favorites" = "收藏"; -// FavoriteCategory -"struct.user.favorite_category.default" = "收藏匣 %@"; -"struct.user.favorite_category.all" = "全部"; - -// MARK: SearchView -"search_view.title.search" = "搜尋"; -"search_view.section.title.recently_searched" = "最近搜尋"; -"search_view.section.title.recently_seen" = "最近閱讀"; -"search_view.section.title.quick_search" = "快速搜尋"; -// Searchable -"searchable.prompt.filter" = "過濾"; -"searchable.title.matches_count" = "找到 %d 項結果"; - -// MARK: QuickSearchView -"quick_search_view.title.quick_search" = "快速搜尋"; -"quick_search_view.title.edit_word" = "編輯關鍵字"; -"quick_search_view.title.new_word" = "新關鍵字"; -"quick_search_view.title.content" = "搜尋內容"; -"quick_search_view.title.name" = "名稱"; -"quick_search_view.placeholder.optional" = "(可選)"; - -// MARK: SettingView -"setting_view.title.setting" = "設定"; -// SettingStateRoute -"enum.setting_state_route.value.account" = "帳號"; -"enum.setting_state_route.value.general" = "一般"; -"enum.setting_state_route.value.appearance" = "外觀"; -"enum.setting_state_route.value.reading" = "閱讀"; -"enum.setting_state_route.value.laboratory" = "實驗性功能"; -"enum.setting_state_route.value.about" = "關於 EhPanda"; - -// MARK: AccountSettingView -"account_setting_view.title.account" = "帳號設定"; -"account_setting_view.title.shows_new_dawn_greeting" = "顯示黎明問候"; -"account_setting_view.button.login" = "登入"; -"account_setting_view.button.logout" = "登出"; -"account_setting_view.button.account_configuration" = "帳號設定"; -"account_setting_view.button.tags_management" = "管理訂閱標籤"; -"account_setting_view.button.copy_cookies" = "複製 Cookies"; -// CookieValue -"struct.cookie_value.localized_string.expired" = "已過期"; -"struct.cookie_value.localized_string.mystery" = "被拒絕"; -"struct.cookie_value.localized_string.none" = "None"; - -// MARK: LoginView -"login_view.title.login" = "登入"; -"login_view.title.username" = "Username"; -"login_view.title.password" = "Password"; - -// MARK: GeneralSettingView -"general_setting_view.title.general" = "一般設定"; -"general_setting_view.title.language" = "語言"; -"general_setting_view.title.auto_lock" = "自動鎖定"; -"general_setting_view.title.enables_tags_extension" = "啟用自訂標籤擴充功能"; -"general_setting_view.title.translates_tags" = "標籤翻譯"; -"general_setting_view.title.shows_tags_search_suggestion" = "搜尋時顯示標籤建議"; -"general_setting_view.title.shows_images_in_tags" = "在標籤顯示圖片"; -"general_setting_view.title.redirects_links_to_the_selected_host" = "將連結重新導向至選擇的網站"; -"general_setting_view.title.detects_links_from_clipboard" = "偵測剪貼簿中的連結"; -"general_setting_view.title.background_blur_radius" = "後台背景模糊"; -"general_setting_view.button.logs" = "日誌"; -"general_setting_view.button.import_custom_translations" = "匯入自訂標籤翻譯"; -"general_setting_view.button.remove_custom_translations" = "刪除自訂標籤翻譯"; -"general_setting_view.button.clear_image_caches" = "清理圖片快取"; -"general_setting_view.value.default_language_description" = "N/A"; -"general_setting_view.section.title.tags" = "標籤"; -"general_setting_view.section.title.navigation" = "導覽"; -"general_setting_view.section.title.security" = "安全"; -"general_setting_view.section.title.caches" = "快取"; -// AutoLockPolicy -"enum.auto_lock_policy.value.never" = "永不自動鎖定"; -"enum.auto_lock_policy.value.instantly" = "立刻"; - -// MARK: LogsView -"logs_view.title.logs" = "日誌"; -"logs_view.title.latest" = "最新"; - -// MARK: AppearanceSettingView -"appearance_setting_view.title.appearance" = "外觀設定"; -"appearance_setting_view.title.theme" = "主題"; -"appearance_setting_view.title.tint_color" = "強調色"; -"appearance_setting_view.title.display_mode" = "顯示模式"; -"appearance_setting_view.title.shows_tags_in_list" = "在列表中顯示標籤"; -"appearance_setting_view.title.maximum_number_of_tags" = "標籤最大顯示數量"; -"appearance_setting_view.title.displays_japanese_title" = "以日文顯示標籤"; -"appearance_setting_view.button.app_icon" = "App 圖案"; -"appearance_setting_view.menu.title.infite" = "無限"; -"appearance_setting_view.section.title.list" = "列表"; -"appearance_setting_view.section.title.gallery" = "畫廊"; -// PreferredColorScheme -"enum.preferred_color_scheme.value.automatic" = "自動"; -"enum.preferred_color_scheme.value.light" = "淺色"; -"enum.preferred_color_scheme.value.dark" = "深色"; -// AppIconType -"enum.app_icon_type.value.default" = "預設"; -"enum.app_icon_type.value.ukiyoe" = "Ukiyo-e"; -"enum.app_icon_type.value.developer" = "Developer"; -"enum.app_icon_type.value.stand_with_ukraine_2022" = "Stand With Ukraine (2022)"; -"enum.app_icon_type.value.not_my_president" = "NOT MY PRESIDENT"; -// ListDisplayMode -"enum.list_display_mode.value.detail" = "詳細"; -"enum.list_display_mode.value.thumbnail" = "縮圖"; - -// MARK: AppIconView -"app_icon_view.title.app_icon" = "App 圖案"; - -// MARK: reading_settingView -"reading_setting_view.title.reading" = "閱讀設定"; -"reading_setting_view.title.direction" = "翻頁方向"; -"reading_setting_view.title.preload_limit" = "預先載入頁數"; -"reading_setting_view.title.enables_landscape" = "啟用橫向顯示"; -"reading_setting_view.title.separator_height" = "頁間分隔高度"; -"reading_setting_view.title.maximum_scale_factor" = "縮放上限"; -"reading_setting_view.title.double_tap_scale_factor" = "雙擊縮放值"; -"reading_setting_view.section.title.appearance" = "外觀"; -// ReadingDirection -"enum.reading_direction.value.vertical" = "垂直"; -"enum.reading_direction.value.right_to_left" = "由右至左滑"; -"enum.reading_direction.value.left_to_right" = "由左至右滑"; - -// MARK: LaboratorySettingView -"laboratory_setting_view.title.laboratory" = "實驗性功能"; -"laboratory_setting_view.title.bypasses_SNI_filtering" = "繞過 SNI 過濾"; - -// MARK: AboutView -"about_view.title.ehPanda" = "EhPanda"; -"about_view.button.website" = "官方網站"; -"about_view.button.altStore_source" = "AltStore source"; -"about_view.title.version" = "版本"; -"about_view.section.title.special_thanks" = "特別銘謝"; -"about_view.section.title.code_level_contributors" = "程式碼貢獻者"; -"about_view.section.title.translation_contributors" = "翻譯貢獻者"; -"about_view.section.title.acknowledgements" = "致謝"; - -// MARK: DetailView -"detail_view.button.read" = "閱讀"; -"detail_view.button.post_comment" = "發表留言"; -"detail_view.toolbar_item.button.archives" = "存檔至 H@H 用戶端"; -"detail_view.toolbar_item.button.torrents" = "種子"; -"detail_view.toolbar_item.button.share" = "分享"; -"detail_view.context_menu.button.detail" = "Detail"; -"detail_view.context_menu.button.withdraw_vote" = "收回評分"; -"detail_view.context_menu.button.vote_up" = "Vote up"; -"detail_view.context_menu.button.vote_down" = "Vote down"; -"detail_view.description_section.title.favorited" = "收藏"; -"detail_view.description_section.title.language" = "語言"; -"detail_view.description_section.title.ratings" = "%@ 個評分"; -"detail_view.description_section.title.page_count" = "頁數"; -"detail_view.description_section.title.file_size" = "檔案大小"; -"detail_view.description_section.description.favorited" = "次"; -"detail_view.description_section.description.page_count" = "頁"; -"detail_view.action_section.button.give_a_rating" = "給予評分"; -"detail_view.action_section.button.similar_gallery" = "類似畫廊"; -"detail_view.section.title.previews" = "預覽"; -"detail_view.section.title.comments" = "留言"; - -// MARK: ArchivesView -"archives_view.title.archives" = "存檔"; -"archives_view.button.download_to_hath_client" = "下載至 H@H 用戶端"; -// HathArchive -"struct.hath_archive.price.free" = "免費"; -"struct.hath_archive.price.not_available" = "N/A"; -// ArchiveResolution -"enum.archive_resolution.value.original" = "原始畫質"; - -// MARK: TorrentsView -"torrents_view.title.torrents" = "種子"; - -// MARK: GalleryInfosView -"gallery_infos_view.title.gallery_infos" = "畫廊資訊"; -"gallery_infos_view.title.id" = "畫廊 ID"; -"gallery_infos_view.title.token" = "Token"; -"gallery_infos_view.title.title" = "標題"; -"gallery_infos_view.title.japanese_title" = "日文標題"; -"gallery_infos_view.title.gallery_URL" = "畫廊 URL"; -"gallery_infos_view.title.cover_URL" = "封面 URL"; -"gallery_infos_view.title.archive_URL" = "存檔 URL"; -"gallery_infos_view.title.torrent_URL" = "種子 URL"; -"gallery_infos_view.title.parent_URL" = "Parent URL"; -"gallery_infos_view.title.category" = "類別"; -"gallery_infos_view.title.uploader" = "上傳者"; -"gallery_infos_view.title.posted_date" = "發佈日期"; -"gallery_infos_view.title.visibility" = "能見度"; -"gallery_infos_view.title.language" = "語言"; -"gallery_infos_view.title.page_count" = "頁數"; -"gallery_infos_view.title.file_size" = "檔案大小"; -"gallery_infos_view.title.favorited_times" = "被收藏次數"; -"gallery_infos_view.title.favorited" = "已收藏"; -"gallery_infos_view.title.rating_count" = "被評分次數"; -"gallery_infos_view.title.average_rating" = "平均評分"; -"gallery_infos_view.title.my_rating" = "我的評分"; -"gallery_infos_view.title.torrent_count" = "種子數量"; -"gallery_infos_view.value.none" = "None"; -"gallery_infos_view.value.yes" = "Yes"; -"gallery_infos_view.value.no" = "No"; -// GalleryVisibility -"enum.gallery_visibility.value.yes" = "Yes"; -"enum.gallery_visibility.value.no" = "No (%@)"; -"enum.gallery_visibility.value.no.reason.expunged" = "已被刪除"; - -// MARK: TagDetailView -"tag_detail_view.section.title.images" = "圖片"; -"tag_detail_view.section.title.links" = "連結"; - -// MARK: CommentsView -"comments_view.title.comments" = "留言"; - -// MARK: PostCommentView -"post_comment_view.title.post_comment" = "發表留言"; -"post_comment_view.title.edit_comment" = "編輯留言"; -// MARK: PreviewsView -"previews_view.title.previews" = "預覽"; - -// MARK: ReadingView -"reading_view.context_menu.button.reload" = "重新整理"; -"reading_view.context_menu.button.copy" = "複製"; -"reading_view.context_menu.button.save" = "儲存圖片"; -"reading_view.context_menu.button.save_original" = "儲存原始圖片"; -"reading_view.context_menu.button.share" = "分享"; -"reading_view.toolbar_item.title.auto_play" = "自動播放"; -"reading_view.toolbar_item.title.dual_page_mode" = "雙頁模式"; -"reading_view.toolbar_item.title.except_the_cover" = "封面除外"; -"reading_view.toolbar_item.button.retry_all_failed_images" = "重新載入失敗圖片"; -"reading_view.toolbar_item.button.reload_all_images" = "重新載入所有圖片"; -"reading_view.toolbar_item.button.reading_setting" = "閱讀設定"; -// AutoPlayPolicy -"enum.auto_play_policy.value.off" = "關閉"; - -// MARK: FiltersView -"filters_view.title.filters" = "過濾"; -"filters_view.title.advanced_settings" = "進階選項"; -"filters_view.title.search_gallery_name" = "搜尋畫廊名稱"; -"filters_view.title.search_gallery_tags" = "搜尋畫廊標籤"; -"filters_view.title.search_gallery_description" = "搜尋畫廊描述"; -"filters_view.title.search_torrent_filenames" = "搜尋種子檔案名"; -"filters_view.title.only_show_galleries_with_torrents" = "只顯示有種子的畫廊"; -"filters_view.title.search_low_power_tags" = "搜尋低期望標籤"; -"filters_view.title.search_downvoted_tags" = "搜尋低評價標籤"; -"filters_view.title.search_expunged_galleries" = "顯示被刪除的畫廊"; -"filters_view.title.set_minimum_rating" = "設定評分下限"; -"filters_view.title.minimum_rating" = "評分下限"; -"filters_view.title.set_pages_range" = "設定頁數範圍"; -"filters_view.title.pages_range" = "頁數範圍"; -"filters_view.title.disable_language_filter" = "停用語言篩選"; -"filters_view.title.disable_uploader_filter" = "停用上傳者篩選"; -"filters_view.title.disable_tags_filter" = "停用標籤篩選"; -"filters_view.button.reset_filters" = "重設所有選項"; -"filters_view.section.title.advanced" = "進階"; -"filters_view.section.title.default_filter" = "預設篩選"; -// FilterRange -"enum.filter_range.value.search" = "搜尋"; -"enum.filter_range.value.global" = "全域"; -"enum.filter_range.value.watched" = "標籤"; - -// MARK: EhSettingView -"eh_setting_view.title.host_settings" = "%@ 設定"; -"eh_setting_view.section.title.profile_settings" = "設定檔設定"; -"eh_setting_view.title.selected_profile" = "選擇設定檔"; -"eh_setting_view.button.set_as_default" = "設為預設"; -"eh_setting_view.button.delete_profile" = "刪除設定檔"; -"eh_setting_view.button.rename" = "重新命名"; -"eh_setting_view.button.create_new" = "新增設定檔"; -"eh_setting_view.toolbar_item.button.done" = "完成"; - -"eh_setting_view.section.title.image_load_settings" = "圖片來源設定"; -"eh_setting_view.title.load_images_through_the_hath_network" = "透過 Hath Network 載入圖片"; -"eh_setting_view.title.browsing_country" = "所在國家"; -"eh_setting_view.description.browsing_country" = "你似乎是從 **%@** 瀏覽這個網站或在使用當地的 VPN,這意味着網站將嘗試從這個地理區域的 H@H 用戶端載入圖片。如果這不正確或你出於任何原因想要使用不同的區域(例如你正在透過 VPN 連線),您可以在下面選擇不同的國家/地區。"; -// EhSetting.LoadThroughHathSetting -"enum.eh_setting.load_through_hath_setting.value.any_client" = "任何用戶端"; -"enum.eh_setting.load_through_hath_setting.value.default_port_only" = "只使用預設連接埠的用戶端"; -"enum.eh_setting.load_through_hath_setting.value.modern_no" = "No [現代/Modern/HTTPS]"; -"enum.eh_setting.load_through_hath_setting.value.legacy_no" = "No [傳統/Legacy/HTTP]"; -"enum.eh_setting.load_through_hath_setting.description.any_client" = "建議選項(預設)"; -"enum.eh_setting.load_through_hath_setting.description.default_port_only" = "如果網絡防火牆會阻擋任何非預設傳出連接埠則使用這個選項(可能較慢)"; -"enum.eh_setting.load_through_hath_setting.description.modern_no" = "E-Hentai 贊助者功能: 你將無法同時瀏覽多個頁面,僅在出現重大錯誤時才啟用這個選項"; -"enum.eh_setting.load_through_hath_setting.description.legacy_no" = "E-Hentai 贊助者功能: 在現代瀏覽器上預設設定可能無法正常工作,僅推薦用於傳統瀏覽器"; - -"eh_setting_view.section.title.image_size_settings" = "圖片尺寸設定"; -"eh_setting_view.title.image_resolution" = "圖片解像度"; -"eh_setting_view.description.image_resolution" = "一般情況下圖片會被縮放成 1280 px 水平解像度以供線上瀏覽,你也可以選擇下列解像度之一。為了避免破壞伺服器正常運作,高於 1280x 的解像度暫時僅提供下列使用者使用: 贊助者、具有任何 Hath Perk 的使用者以及 UID 低於 3,000,000 的使用者"; -"eh_setting_view.title.image_size" = "圖片尺寸"; -"eh_setting_view.description.image_size" = "雖然網站會自動縮小圖片以適應瀏覽裝置的螢幕寬度,但您也可以手動限製圖片的最大顯示尺寸。 像自動縮放一樣,這不會重新採樣圖像,因為調整大小是在瀏覽器完成的 (0 = 沒有限制)"; -"eh_setting_view.title.horizontal" = "水平尺寸(寬)"; -"eh_setting_view.title.vertical" = "垂直尺寸(長)"; -// EhSetting.ImageResolution -"enum.eh_setting.image_resolution.value.auto" = "自動"; - -"eh_setting_view.section.title.gallery_name_display" = "畫廊顯示名稱"; -"eh_setting_view.title.gallery_name" = "畫廊名稱"; -"eh_setting_view.description.gallery_name" = "許多畫廊同時提供了英文/預設標題與日文標題,你想優先顯示哪種畫廊名稱?"; -// EhSetting.GalleryName -"enum.eh_setting.gallery_name.value.default" = "預設標題"; -"enum.eh_setting.gallery_name.value.japanese" = "日文標題(若該畫廊支援)"; - -"eh_setting_view.section.title.archiver_settings" = "存檔設定"; -"eh_setting_view.title.archiver_behavior" = "存檔邏輯設定"; -"eh_setting_view.description.archiver_behavior" = "存檔的預設邏輯是確認原始圖片或重新採樣後進行存檔的成本差異與選擇,然後顯示一個可以在其他地方點擊、複製的連結,你可以在此處更改他的運作方式。"; -// EhSetting.ArchiverBehavior -"enum.eh_setting.archiver_behavior.value.manual_select_manual_start" = "手動選擇,手動開始下載(預設)"; -"enum.eh_setting.archiver_behavior.value.manual_select_auto_start" = "手動選擇,自動開始下載"; -"enum.eh_setting.archiver_behavior.value.auto_select_original_manual_start" = "自動選擇原始畫質,,手動開始下載"; -"enum.eh_setting.archiver_behavior.value.auto_select_original_auto_start" = "自動選擇原始畫質並開始下載"; -"enum.eh_setting.archiver_behavior.value.auto_select_resample_manual_start" = "自動選擇重新採樣,手動開始下載"; -"enum.eh_setting.archiver_behavior.value.auto_select_resample_auto_start" = "自動選擇重新採樣並開始下載"; - -"eh_setting_view.section.title.front_page_settings" = "首頁設定"; -"eh_setting_view.title.display_mode" = "顯示模式"; -"eh_setting_view.description.display_mode" = "你想在首頁和搜尋結果中使用哪一種顯示方式?"; -"eh_setting_view.section.title.show_search_range_indicator" = "搜尋範圍指示器"; -"eh_setting_view.title.show_search_range_indicator" = "顯示搜尋範圍指示器"; -"eh_setting_view.description.gallery_category" = "預設情況下你希望在首頁和搜尋結果中顯示哪些類別的結果?"; -// EhSetting.DisplayMode -"enum.eh_setting.display_mode.value.compact" = "緊湊(Compact)"; -"enum.eh_setting.display_mode.value.thumbnail" = "縮圖(Thumbnail)"; -"enum.eh_setting.display_mode.value.extended" = "放大(Extended)"; -"enum.eh_setting.display_mode.value.minimal" = "最小(Minimal)"; -"enum.eh_setting.display_mode.value.minimalPlus" = "Minimal+"; - -"eh_setting_view.section.title.optional_UI_elements" = "可選用的 UI 元件"; -"eh_setting_view.description.optional_UI_elements" = "一些舊版 UI 元件現已預設停用。您可以在此啟用這些元件。"; -"eh_setting_view.title.enable_gallery_thumbnail_selector" = "在畫廊頁面啟用縮圖選擇器"; - -"eh_setting_view.section.title.favorites" = "收藏匣"; -"eh_setting_view.description.favorite_categories" = "在這裏你可以選擇並重新命名收藏匣"; -"eh_setting_view.title.favorites_sort_order" = "收藏匣排序"; -"eh_setting_view.description.favorites_sort_order" = "你還可以在收藏匣頁面上為畫廊選擇預設排列順序。 請注意,在 2016 年 3 月網站改版之前新增的畫廊並不儲存時間印記,並且無論使用何種設定,都將使用畫廊發佈時間作為排序參考。"; -// EhSetting.FavoritesSortOrder -"enum.eh_setting.favorites_sort_order.value.last_update_time" = "透過最後更新時間排序"; -"enum.eh_setting.favorites_sort_order.value.favorited_time" = "透過收藏順序排序"; - -"eh_setting_view.section.title.ratings" = "評分"; -"eh_setting_view.title.ratings_color" = "評分顏色"; -"eh_setting_view.promt.ratings_color" = "RRGGB"; -"eh_setting_view.description.ratings_color" = "預設情況下,你評分的畫廊將 2 星及以下的評分顯示為紅色星,2.5 ~ 4 顆星的評分為綠色,4.5 ~ 5 顆星的評分為藍色。 透過在下面輸入顏色組合你可以自訂想顯示的顏色。 每個字母各代表一顆星(1~5),預設的 RRGGB 表示第一顆和第二顆星的 R(ed),第三顆和第四顆的 G(reen),第五顆的 B(lue)。 你也可以將 (Y)ellow 用於普通星星。 任何五個字母的 R/G/B/Y 組合都有效"; - -"eh_setting_view.section.title.tag_filtering_threshold" = "過濾標籤閾值"; -"eh_setting_view.title.tag_filtering_threshold" = "閾值"; -"eh_setting_view.description.tag_filtering_threshold" = "你可以透過將標籤新增到具有負數權重的“我的標籤”清單中來過濾標籤。 如果畫廊的標籤加起來的權重低於此值,則會被從列表中過濾掉,此閾值可以設定在 0 ~ -9999 之間"; - -"eh_setting_view.section.title.tag_watching_threshold" = "關注標籤閾值"; -"eh_setting_view.title.tag_watching_threshold" = "關注標籤生效的閾值"; -"eh_setting_view.description.tag_watching_threshold" = "如果最近上傳的畫廊中至少有一個具有正權重的你正在關注/追蹤的標籤,並且這些標籤所具有的權重高於此設定中的數值,那這個畫廊將會出現在「追蹤標籤」頁面中,此閾值可以設定在 0 ~ -9999 之間"; - -"eh_setting_view.section.title.filtered_removal_count" = "顯示過濾結果計數器"; -"eh_setting_view.description.filtered_removal_count" = "顯示 \"Your default filters removed XX galleries from this page\" ?"; -"eh_setting_view.title.show_filtered_removal_count" = "是否顯示過濾結果計數器"; - -"eh_setting_view.section.title.excluded_languages" = "排除語言"; -"eh_setting_view.description.excluded_languages" = "如果你希望從畫廊列表和搜尋中隱藏掉某些語言的畫廊,請從下面的清單中選取它們。請注意,無論你的搜尋查詢如何,相符於篩除規則的畫廊都不會出現。"; -// EhSetting.ExcludedLanguagesCategory -"enum.eh_setting.excluded_languages_category.value.original" = "原始語言"; -"enum.eh_setting.excluded_languages_category.value.translated" = "翻譯語言"; -"enum.eh_setting.excluded_languages_category.value.rewrite" = "覆寫"; - -"eh_setting_view.section.title.excluded_uploaders" = "排除的上傳者"; -"eh_setting_view.description.excluded_uploaders" = "如果你希望從畫廊列表和搜尋結果中隱藏某些上傳者的畫廊,請將它們新增到下方,每行輸入一個使用者名稱。請注意,無論你的搜尋結果如何,這些上傳者的畫廊都不會出現。"; -"eh_setting_view.description.excluded_uploaders_count" = "你正在使用 **%@ / %@** 排除欄位"; - -"eh_setting_view.section.title.search_result_count" = "搜尋結果數量上限"; -"eh_setting_view.title.result_count" = "數量上限"; -"eh_setting_view.description.result_count" = "你希望每頁顯示幾個搜尋結果?\n(該功能需要有 Hath Perk: Paging Enlargement)"; - -"eh_setting_view.section.title.thumbnail_settings" = "縮圖設定"; -"eh_setting_view.title.thumbnail_load_timing" = "縮圖載入時機設定"; -"eh_setting_view.description.thumbnail_load_timing" = "使用列表模式時,你希望如何載入以滑鼠位置顯示的縮圖?"; -"eh_setting_view.description.thumbnail_configuration" = "你可以為存取的所有畫廊設定預設的縮圖配置。"; -"eh_setting_view.title.thumbnail_size" = "尺寸"; -"eh_setting_view.title.thumbnail_row_count" = "行數"; -// EhSetting.ThumbnailLoadTiming -"enum.eh_setting.thumbnail_load_timing.value.on_mouse_over" = "滑鼠位置"; -"enum.eh_setting.thumbnail_load_timing.value.on_page_load" = "網頁載入位置"; -"enum.eh_setting.thumbnail_load_timing.description.on_mouse_over" = "網頁的載入速度更快,但縮圖出現的時間可能稍有延遲"; -"enum.eh_setting.thumbnail_load_timing.description.on_page_load" = "網頁需要更多的載入時間,但是在網頁完全載入後縮圖顯示不會有任何延遲"; -// EhSetting.ThumbnailSize -"enum.eh_setting.thumbnail_size.value.normal" = "正常"; -"enum.eh_setting.thumbnail_size.value.large" = "大型"; -"enum.eh_setting.thumbnail_size.value.small" = "小型"; -"enum.eh_setting.thumbnail_size.value.auto" = "自動"; - -"eh_setting_view.section.title.cover_scaling" = "封面縮放"; -"eh_setting_view.title.scale_factor" = "縮放比例"; -"eh_setting_view.description.cover_scale_factor" = "在縮圖與放大檢視這兩種檢視模式下,封面的重新採樣比率介於 75%% 至 150%%."; - -"eh_setting_view.section.title.viewport_override" = "視窗覆蓋"; -"eh_setting_view.title.virtual_width" = "虛擬寬度"; -"eh_setting_view.description.virtual_width" = "允許你覆蓋流動裝置網站的虛擬寬度。 這通常由你的裝置根據其 DPI 自動確定。 100%% 縮圖比例的合理值介於 640 和 1400 之間。"; - -"eh_setting_view.section.title.gallery_comments" = "畫廊留言"; -"eh_setting_view.title.comments_sort_order" = "留言排序方式"; -"eh_setting_view.title.comments_votes_show_timing" = "留言投票數顯示時機"; -// EhSetting.CommentsSortOrder -"enum.eh_setting.comments_sort_order.value.oldest" = "最舊留言優先"; -"enum.eh_setting.comments_sort_order.value.recent" = "最新留言優先"; -"enum.eh_setting.comments_sort_order.value.highest_score" = "最相關留言優先"; -// EhSetting.CommentVotesShowTiming -"enum.eh_setting.comments_votes_show_timing.value.on_hover_or_click" = "滑鼠在分數上停留或點擊時"; -"enum.eh_setting.comments_votes_show_timing.value.always" = "總是顯示"; - -"eh_setting_view.section.title.gallery_tags" = "畫廊標籤"; -"eh_setting_view.title.tags_sort_order" = "標籤顯示順序"; -// EhSetting.tags_sort_order -"enum.eh_setting.tags_sort_order.value.alphabetical" = "字母順序"; -"enum.eh_setting.tags_sort_order.value.tag_power" = "標籤權重"; - -"eh_setting_view.section.title.gallery_page_thumbnail_labeling" = "畫廊頁面縮圖標籤"; -"eh_setting_view.title.show_label_below_gallery_thumbnails" = "在畫廊縮圖下方顯示標籤"; - -"eh_setting_view.section.title.hath_local_network_host" = "內網 H@H 服務 (Hath Local Network Host)"; -"eh_setting_view.title.ip_address_port" = "IP 位址:連接埠號"; -"eh_setting_view.description.ip_address_port" = "如果你在內網絡上使用與瀏覽站點相同的公共 IP 架設 H@H 用戶端,有些路由器會因此發生問題,無法將請求傳回自己的 IP,透過啟用這項設定可以解決此問題。\n如果您在瀏覽的同一裝置上執行用戶端,請使用回環地址 (127.0.0.1:port)。 如果用戶端在您網絡上的另一台裝置上執行,請使用其本機網絡 IP。 某些瀏覽器配置會阻止外部網站存取具有本機網絡 IP 的 URL,你必須將站點列入白名單才能使其正常工作。"; - -"eh_setting_view.section.title.original_images" = "要使用原始圖片而非重新取樣的版本嗎? 若您在上方選擇「自動」以外的水準解像度且圖片較寬,或原始圖片大於 10 MiB(一年以上的圖庫則為 4 MiB),系統仍會使用重新取樣的圖片。"; -"eh_setting_view.title.use_original_images" = "使用原始圖片(原解像度)"; - -"eh_setting_view.section.title.multi_page_viewer" = "多頁瀏覽"; -"eh_setting_view.title.use_multi_page_viewer" = "使用多頁瀏覽"; -"eh_setting_view.title.display_style" = "顯示方式"; -"eh_setting_view.title.show_thumbnail_pane" = "顯示縮圖窗格"; -// EhSetting.MultiplePageViewerStyle -"enum.eh_setting.multiple_page_viewer_style.value.align_left_scale_if_over_width" = "向左對齊,若寬度超出頁面則進行縮放"; -"enum.eh_setting.multiple_page_viewer_style.value.align_center_scale_if_over_width" = "置中對齊,若寬度超出頁面則進行縮放"; -"enum.eh_setting.multiple_page_viewer_style.value.align_center_always_scale" = "置中對齊且總是縮放"; -// EhSetting.GalleryPageNumbering -"enum.eh_setting.gallery_page_numbering.value.none" = "不顯示"; -"enum.eh_setting.gallery_page_numbering.value.page_number_only" = "只顯示頁碼"; -"enum.eh_setting.gallery_page_numbering.value.page_number_and_name" = "顯示頁碼和名稱"; - -// MARK: Category -"enum.category.value.doujinshi" = "同人誌"; -"enum.category.value.manga" = "漫畫"; -"enum.category.value.artist_CG" = "插畫"; -"enum.category.value.game_CG" = "遊戲 CG"; -"enum.category.value.western" = "西方"; -"enum.category.value.non_h" = "健康"; -"enum.category.value.image_set" = "圖片集"; -"enum.category.value.cosplay" = "角色扮演"; -"enum.category.value.asian_porn" = "亞洲"; -"enum.category.value.misc" = "其他"; -"enum.category.value.private" = "私人"; - -// MARK: TagNamespace -"enum.tag_namespace.value.reclass" = "重新分類"; -"enum.tag_namespace.value.language" = "語言"; -"enum.tag_namespace.value.parody" = "原作"; -"enum.tag_namespace.value.character" = "角色"; -"enum.tag_namespace.value.group" = "團體"; -"enum.tag_namespace.value.artist" = "作者"; -"enum.tag_namespace.value.male" = "男性"; -"enum.tag_namespace.value.female" = "女性"; -"enum.tag_namespace.value.mixed" = "Mixed"; -"enum.tag_namespace.value.cosplayer" = "Cosplayer"; -"enum.tag_namespace.value.other" = "其他"; -"enum.tag_namespace.value.temp" = "Temp"; - -// MARK: Language -"enum.language.value.invalid" = "無效"; -"enum.language.value.other" = "其他"; -"enum.language.value.afrikaans" = "南非語"; -"enum.language.value.albanian" = "阿爾巴尼亞語"; -"enum.language.value.arabic" = "阿拉伯語"; -"enum.language.value.bengali" = "孟加拉語"; -"enum.language.value.bosnian" = "波斯尼亞語"; -"enum.language.value.bulgarian" = "保加利亞語"; -"enum.language.value.burmese" = "緬甸語"; -"enum.language.value.catalan" = "加泰隆尼亞語"; -"enum.language.value.cebuano" = "宿霧語"; -"enum.language.value.chinese" = "漢語"; -"enum.language.value.croatian" = "克羅地亞語"; -"enum.language.value.czech" = "捷克語"; -"enum.language.value.danish" = "丹麥語"; -"enum.language.value.dutch" = "荷蘭語"; -"enum.language.value.english" = "英語"; -"enum.language.value.esperanto" = "國際語"; -"enum.language.value.estonian" = "愛沙尼亞語"; -"enum.language.value.finnish" = "芬蘭語"; -"enum.language.value.french" = "法語"; -"enum.language.value.georgian" = "格魯吉亞語"; -"enum.language.value.german" = "德語"; -"enum.language.value.greek" = "希臘語"; -"enum.language.value.hebrew" = "希伯來語"; -"enum.language.value.hindi" = "印度語"; -"enum.language.value.hmong" = "苗語"; -"enum.language.value.hungarian" = "匈牙利語"; -"enum.language.value.indonesian" = "印尼語"; -"enum.language.value.italian" = "意大利語"; -"enum.language.value.japanese" = "日語"; -"enum.language.value.kazakh" = "哈薩克語"; -"enum.language.value.khmer" = "高棉語"; -"enum.language.value.korean" = "韓語"; -"enum.language.value.kurdish" = "庫爾德語"; -"enum.language.value.lao" = "老撾語"; -"enum.language.value.latin" = "拉丁語"; -"enum.language.value.mongolian" = "蒙古語"; -"enum.language.value.ndebele" = "恩德貝萊語"; -"enum.language.value.nepali" = "尼泊爾語"; -"enum.language.value.norwegian" = "挪威語"; -"enum.language.value.oromo" = "奧羅莫語"; -"enum.language.value.pashto" = "普什圖語"; -"enum.language.value.persian" = "波斯語"; -"enum.language.value.polish" = "波蘭語"; -"enum.language.value.portuguese" = "葡萄牙語"; -"enum.language.value.punjabi" = "旁遮普語"; -"enum.language.value.romanian" = "羅馬尼亞語"; -"enum.language.value.russian" = "俄語"; -"enum.language.value.sango" = "桑戈語"; -"enum.language.value.serbian" = "塞爾維亞語"; -"enum.language.value.shona" = "紹納語"; -"enum.language.value.slovak" = "斯洛伐克語"; -"enum.language.value.slovenian" = "斯洛文尼亞語"; -"enum.language.value.somali" = "索馬利語"; -"enum.language.value.spanish" = "西班牙語"; -"enum.language.value.swahili" = "斯瓦希里語"; -"enum.language.value.swedish" = "瑞典語"; -"enum.language.value.tagalog" = "他加祿語"; -"enum.language.value.thai" = "泰語"; -"enum.language.value.tigrinya" = "提格利尼亞語"; -"enum.language.value.turkish" = "土耳其語"; -"enum.language.value.ukrainian" = "烏克蘭語"; -"enum.language.value.urdu" = "烏爾都語"; -"enum.language.value.vietnamese" = "越南語"; -"enum.language.value.zulu" = "祖魯語"; - -// MARK: BrowsingCountry -"enum.browsing_country.name.auto_detect" = "自動偵測"; -"enum.browsing_country.name.afghanistan" = "阿富汗"; -"enum.browsing_country.name.aland_islands" = "奧蘭群島"; -"enum.browsing_country.name.albania" = "阿爾巴尼亞"; -"enum.browsing_country.name.algeria" = "阿爾及利亞"; -"enum.browsing_country.name.american_samoa" = "美屬薩摩亞"; -"enum.browsing_country.name.andorra" = "安道爾"; -"enum.browsing_country.name.angola" = "安哥拉"; -"enum.browsing_country.name.anguilla" = "安圭拉"; -"enum.browsing_country.name.antarctica" = "南極洲"; -"enum.browsing_country.name.antigua_and_barbuda" = "安提瓜和巴布達"; -"enum.browsing_country.name.argentina" = "阿根廷"; -"enum.browsing_country.name.armenia" = "亞美尼亞"; -"enum.browsing_country.name.aruba" = "阿魯巴"; -"enum.browsing_country.name.asia_pacific_region" = "亞太地區"; -"enum.browsing_country.name.australia" = "澳洲"; -"enum.browsing_country.name.austria" = "奧地利"; -"enum.browsing_country.name.azerbaijan" = "阿塞拜疆"; -"enum.browsing_country.name.bahamas" = "巴哈馬"; -"enum.browsing_country.name.bahrain" = "巴林"; -"enum.browsing_country.name.bangladesh" = "孟加拉"; -"enum.browsing_country.name.barbados" = "巴巴多斯"; -"enum.browsing_country.name.belarus" = "白俄羅斯"; -"enum.browsing_country.name.belgium" = "比利時"; -"enum.browsing_country.name.belize" = "伯利茲"; -"enum.browsing_country.name.benin" = "貝南"; -"enum.browsing_country.name.bermuda" = "百慕達"; -"enum.browsing_country.name.bhutan" = "不丹"; -"enum.browsing_country.name.bolivia" = "玻利維亞"; -"enum.browsing_country.name.bonaire_saint_eustatius_and_saba" = "博奈爾、聖尤斯特歇斯和薩巴"; -"enum.browsing_country.name.bosnia_and_herzegovina" = "波斯尼亞"; -"enum.browsing_country.name.botswana" = "博茨瓦納"; -"enum.browsing_country.name.bouvet_island" = "布威島"; -"enum.browsing_country.name.brazil" = "巴西"; -"enum.browsing_country.name.british_indian_ocean_territory" = "英屬印度洋領地"; -"enum.browsing_country.name.brunei_darussalam" = "汶萊"; -"enum.browsing_country.name.bulgaria" = "保加利亞"; -"enum.browsing_country.name.burkina_faso" = "布基納法索"; -"enum.browsing_country.name.burundi" = "布隆迪"; -"enum.browsing_country.name.cambodia" = "柬埔寨"; -"enum.browsing_country.name.cameroon" = "喀麥隆"; -"enum.browsing_country.name.canada" = "加拿大"; -"enum.browsing_country.name.cape_verde" = "佛得角"; -"enum.browsing_country.name.cayman_islands" = "開曼群島"; -"enum.browsing_country.name.central_african_republic" = "中非"; -"enum.browsing_country.name.chad" = "乍德"; -"enum.browsing_country.name.chile" = "智利"; -"enum.browsing_country.name.china" = "中國"; -"enum.browsing_country.name.christmas_island" = "聖誕島"; -"enum.browsing_country.name.cocos_islands" = "科科斯(基林)群島"; -"enum.browsing_country.name.colombia" = "哥倫比亞"; -"enum.browsing_country.name.comoros" = "科摩羅"; -"enum.browsing_country.name.congo" = "剛果共和國"; -"enum.browsing_country.name.the_democratic_republic_of_the_congo" = "剛果民主共和國"; -"enum.browsing_country.name.cook_islands" = "庫克群島"; -"enum.browsing_country.name.costa_rica" = "哥斯達黎加"; -"enum.browsing_country.name.cote_d_ivoire" = "象牙海岸"; -"enum.browsing_country.name.croatia" = "克羅地亞"; -"enum.browsing_country.name.cuba" = "古巴"; -"enum.browsing_country.name.curacao" = "古拉索"; -"enum.browsing_country.name.cyprus" = "塞浦路斯"; -"enum.browsing_country.name.czech_republic" = "捷克共和國"; -"enum.browsing_country.name.denmark" = "丹麥"; -"enum.browsing_country.name.djibouti" = "吉布堤"; -"enum.browsing_country.name.dominica" = "多米尼克"; -"enum.browsing_country.name.dominican_republic" = "多明尼加"; -"enum.browsing_country.name.ecuador" = "厄瓜多爾"; -"enum.browsing_country.name.egypt" = "埃及"; -"enum.browsing_country.name.el_salvador" = "薩爾瓦多"; -"enum.browsing_country.name.equatorial_guinea" = "赤道幾內亞"; -"enum.browsing_country.name.eritrea" = "厄立特里亞"; -"enum.browsing_country.name.estonia" = "愛沙尼亞"; -"enum.browsing_country.name.ethiopia" = "埃塞俄比亞"; -"enum.browsing_country.name.europe" = "歐洲"; -"enum.browsing_country.name.falkland_islands" = "福克蘭群島"; -"enum.browsing_country.name.faroe_islands" = "法羅群島"; -"enum.browsing_country.name.fiji" = "斐濟"; -"enum.browsing_country.name.finland" = "芬蘭"; -"enum.browsing_country.name.france" = "法國"; -"enum.browsing_country.name.french_guiana" = "法屬圭亞那"; -"enum.browsing_country.name.french_polynesia" = "法屬波利尼西亞"; -"enum.browsing_country.name.french_southern_territories" = "法屬南部領土"; -"enum.browsing_country.name.gabon" = "加蓬"; -"enum.browsing_country.name.gambia" = "岡比亞"; -"enum.browsing_country.name.georgia" = "格魯吉亞"; -"enum.browsing_country.name.germany" = "德國"; -"enum.browsing_country.name.ghana" = "加納"; -"enum.browsing_country.name.gibraltar" = "直布羅陀"; -"enum.browsing_country.name.greece" = "希臘"; -"enum.browsing_country.name.greenland" = "格陵蘭"; -"enum.browsing_country.name.grenada" = "格林納達"; -"enum.browsing_country.name.guadeloupe" = "瓜地洛普"; -"enum.browsing_country.name.guam" = "關島"; -"enum.browsing_country.name.guatemala" = "危地馬拉"; -"enum.browsing_country.name.guernsey" = "耿西"; -"enum.browsing_country.name.guinea" = "幾內亞"; -"enum.browsing_country.name.guinea_bissau" = "幾內亞比紹"; -"enum.browsing_country.name.guyana" = "圭亞那"; -"enum.browsing_country.name.haiti" = "海地"; -"enum.browsing_country.name.heard_island_and_mc_donald_islands" = "赫德島和麥克唐納群島"; -"enum.browsing_country.name.vatican_city_state" = "梵蒂岡"; -"enum.browsing_country.name.honduras" = "洪都拉斯"; -"enum.browsing_country.name.hong_kong" = "香港"; -"enum.browsing_country.name.hungary" = "匈牙利"; -"enum.browsing_country.name.iceland" = "冰島"; -"enum.browsing_country.name.india" = "印度"; -"enum.browsing_country.name.indonesia" = "印度尼西亞(印尼)"; -"enum.browsing_country.name.iran" = "伊朗"; -"enum.browsing_country.name.iraq" = "伊拉克"; -"enum.browsing_country.name.ireland" = "愛爾蘭"; -"enum.browsing_country.name.isle_of_man" = "曼島"; -"enum.browsing_country.name.israel" = "以色列"; -"enum.browsing_country.name.italy" = "意大利"; -"enum.browsing_country.name.jamaica" = "牙買加"; -"enum.browsing_country.name.japan" = "日本"; -"enum.browsing_country.name.jersey" = "澤西島"; -"enum.browsing_country.name.jordan" = "約旦"; -"enum.browsing_country.name.kazakhstan" = "哈薩克共和國"; -"enum.browsing_country.name.kenya" = "肯亞"; -"enum.browsing_country.name.kiribati" = "基里巴斯"; -"enum.browsing_country.name.kuwait" = "科威特"; -"enum.browsing_country.name.kyrgyzstan" = "吉爾吉斯"; -"enum.browsing_country.name.lao_peoples_democratic_republic" = "老撾"; -"enum.browsing_country.name.latvia" = "拉脫維亞"; -"enum.browsing_country.name.lebanon" = "黎巴嫩"; -"enum.browsing_country.name.lesotho" = "萊索托"; -"enum.browsing_country.name.liberia" = "利比里亞"; -"enum.browsing_country.name.libya" = "利比亞"; -"enum.browsing_country.name.liechtenstein" = "列支敦士登"; -"enum.browsing_country.name.lithuania" = "立陶宛"; -"enum.browsing_country.name.luxembourg" = "盧森堡"; -"enum.browsing_country.name.macau" = "澳門"; -"enum.browsing_country.name.macedonia" = "北馬其頓"; -"enum.browsing_country.name.madagascar" = "馬達加斯加"; -"enum.browsing_country.name.malawi" = "馬拉維"; -"enum.browsing_country.name.malaysia" = "馬來西亞"; -"enum.browsing_country.name.maldives" = "馬爾代夫"; -"enum.browsing_country.name.mali" = "馬利"; -"enum.browsing_country.name.malta" = "馬爾他"; -"enum.browsing_country.name.marshall_islands" = "馬紹爾群島"; -"enum.browsing_country.name.martinique" = "馬丁尼克"; -"enum.browsing_country.name.mauritania" = "毛里塔尼亞"; -"enum.browsing_country.name.mauritius" = "毛里裘斯"; -"enum.browsing_country.name.mayotte" = "馬約特"; -"enum.browsing_country.name.mexico" = "墨西哥"; -"enum.browsing_country.name.micronesia" = "密克羅尼西亞"; -"enum.browsing_country.name.moldova" = "摩爾多瓦"; -"enum.browsing_country.name.monaco" = "摩納哥"; -"enum.browsing_country.name.mongolia" = "蒙古"; -"enum.browsing_country.name.montenegro" = "黑山"; -"enum.browsing_country.name.montserrat" = "蒙特塞拉特"; -"enum.browsing_country.name.morocco" = "摩洛哥"; -"enum.browsing_country.name.mozambique" = "莫桑比克"; -"enum.browsing_country.name.myanmar" = "緬甸"; -"enum.browsing_country.name.namibia" = "納米比亞"; -"enum.browsing_country.name.nauru" = "諾魯"; -"enum.browsing_country.name.nepal" = "尼泊爾"; -"enum.browsing_country.name.netherlands" = "荷蘭"; -"enum.browsing_country.name.new_caledonia" = "新喀里多尼亞"; -"enum.browsing_country.name.new_zealand" = "新西蘭"; -"enum.browsing_country.name.nicaragua" = "尼加拉瓜"; -"enum.browsing_country.name.niger" = "尼日爾"; -"enum.browsing_country.name.nigeria" = "尼日利亞"; -"enum.browsing_country.name.niue" = "紐埃"; -"enum.browsing_country.name.norfolk_island" = "諾福克島"; -"enum.browsing_country.name.north_korea" = "朝鮮"; -"enum.browsing_country.name.northern_mariana_islands" = "北馬里亞納群島"; -"enum.browsing_country.name.norway" = "挪威"; -"enum.browsing_country.name.oman" = "阿曼"; -"enum.browsing_country.name.pakistan" = "巴基斯坦"; -"enum.browsing_country.name.palau" = "帕勞"; -"enum.browsing_country.name.palestinian_territory" = "巴勒斯坦領土"; -"enum.browsing_country.name.panama" = "巴拿馬"; -"enum.browsing_country.name.papua_new_guinea" = "巴布亞新幾內亞"; -"enum.browsing_country.name.paraguay" = "巴拉圭"; -"enum.browsing_country.name.peru" = "秘魯"; -"enum.browsing_country.name.philippines" = "菲律賓"; -"enum.browsing_country.name.pitcairn_islands" = "皮特凱恩群島"; -"enum.browsing_country.name.poland" = "波蘭"; -"enum.browsing_country.name.portugal" = "葡萄牙"; -"enum.browsing_country.name.puerto_rico" = "波多黎各"; -"enum.browsing_country.name.qatar" = "卡達"; -"enum.browsing_country.name.reunion" = "留尼旺"; -"enum.browsing_country.name.romania" = "羅馬尼亞"; -"enum.browsing_country.name.russian_federation" = "俄羅斯"; -"enum.browsing_country.name.rwanda" = "盧旺達"; -"enum.browsing_country.name.saint_barthelemy" = "聖巴瑟米"; -"enum.browsing_country.name.saint_helena" = "聖赫勒拿"; -"enum.browsing_country.name.saint_kitts_and_nevis" = "聖吉斯納域斯"; -"enum.browsing_country.name.saint_lucia" = "聖盧西亞"; -"enum.browsing_country.name.saint_martin" = "聖馬丁"; -"enum.browsing_country.name.saint_pierre_and_miquelon" = "聖皮耶與密克隆"; -"enum.browsing_country.name.saint_vincent_and_the_grenadines" = "聖文森特和格林納丁斯"; -"enum.browsing_country.name.samoa" = "薩摩亞"; -"enum.browsing_country.name.san_marino" = "聖馬力諾"; -"enum.browsing_country.name.sao_tome_and_principe" = "聖多美和普林西比"; -"enum.browsing_country.name.saudi_arabia" = "沙特阿拉伯"; -"enum.browsing_country.name.senegal" = "塞內加爾"; -"enum.browsing_country.name.serbia" = "塞爾維亞"; -"enum.browsing_country.name.seychelles" = "塞舌爾"; -"enum.browsing_country.name.sierra_leone" = "獅子山"; -"enum.browsing_country.name.singapore" = "新加坡"; -"enum.browsing_country.name.sint_maarten" = "聖馬丁"; -"enum.browsing_country.name.slovakia" = "斯洛伐克"; -"enum.browsing_country.name.slovenia" = "斯洛文尼亞"; -"enum.browsing_country.name.solomon_islands" = "所羅門群島"; -"enum.browsing_country.name.somalia" = "索馬里"; -"enum.browsing_country.name.south_africa" = "南非"; -"enum.browsing_country.name.south_georgia_and_the_south_sandwich_islands" = "南格魯吉亞和南桑威奇群島"; -"enum.browsing_country.name.south_korea" = "韓國"; -"enum.browsing_country.name.south_sudan" = "南蘇丹"; -"enum.browsing_country.name.spain" = "西班牙"; -"enum.browsing_country.name.sri_lanka" = "斯里蘭卡"; -"enum.browsing_country.name.sudan" = "蘇丹"; -"enum.browsing_country.name.suriname" = "蘇里南"; -"enum.browsing_country.name.svalbard_and_jan_mayen" = "斯瓦巴和揚馬延"; -"enum.browsing_country.name.swaziland" = "斯威士蘭"; -"enum.browsing_country.name.sweden" = "瑞典"; -"enum.browsing_country.name.switzerland" = "瑞士"; -"enum.browsing_country.name.syrian_arab_republic" = "敘利亞"; -"enum.browsing_country.name.taiwan" = "臺灣"; -"enum.browsing_country.name.tajikistan" = "塔吉克"; -"enum.browsing_country.name.tanzania" = "坦桑尼亞"; -"enum.browsing_country.name.thailand" = "泰國"; -"enum.browsing_country.name.timor_leste" = "東帝汶"; -"enum.browsing_country.name.togo" = "多哥"; -"enum.browsing_country.name.tokelau" = "托克勞"; -"enum.browsing_country.name.tonga" = "湯加"; -"enum.browsing_country.name.trinidad_and_tobago" = "千里達和托巴哥"; -"enum.browsing_country.name.tunisia" = "突尼斯"; -"enum.browsing_country.name.turkey" = "土耳其"; -"enum.browsing_country.name.turkmenistan" = "土庫曼"; -"enum.browsing_country.name.turks_and_caicos_islands" = "土克斯及開科斯群島"; -"enum.browsing_country.name.tuvalu" = "圖瓦盧"; -"enum.browsing_country.name.uganda" = "烏干達"; -"enum.browsing_country.name.ukraine" = "烏克蘭"; -"enum.browsing_country.name.united_arab_emirates" = "阿拉伯聯合酋長國"; -"enum.browsing_country.name.united_kingdom" = "英國"; -"enum.browsing_country.name.united_states" = "美國"; -"enum.browsing_country.name.united_states_minor_outlying_islands" = "美國外圍小島嶼"; -"enum.browsing_country.name.uruguay" = "烏拉圭"; -"enum.browsing_country.name.uzbekistan" = "烏茲別克"; -"enum.browsing_country.name.vanuatu" = "瓦努阿圖"; -"enum.browsing_country.name.venezuela" = "委內瑞拉"; -"enum.browsing_country.name.vietnam" = "越南"; -"enum.browsing_country.name.virgin_islands_british" = "英屬維京群島"; -"enum.browsing_country.name.virgin_islands_US" = "美屬維京群島"; -"enum.browsing_country.name.wallis_and_futuna" = "瓦利斯和富圖納"; -"enum.browsing_country.name.western_sahara" = "西撒哈拉"; -"enum.browsing_country.name.yemen" = "也門"; -"enum.browsing_country.name.zambia" = "贊比亞"; -"enum.browsing_country.name.zimbabwe" = "津巴布韋"; diff --git a/EhPanda/App/zh-Hant-TW.lproj/InfoPlist.strings b/EhPanda/App/zh-Hant-TW.lproj/InfoPlist.strings deleted file mode 100644 index af7729065..000000000 --- a/EhPanda/App/zh-Hant-TW.lproj/InfoPlist.strings +++ /dev/null @@ -1,7 +0,0 @@ -/* - InfoPlist.strings - EhPanda -*/ - -"NSFaceIDUsageDescription" = "我們需要您提供 Face ID 權限來使用 Face ID 解鎖"; -"NSPhotoLibraryAddUsageDescription" = "我們需要您提供照片權限來儲存圖片到照片"; diff --git a/EhPanda/App/zh-Hant-TW.lproj/Localizable.strings b/EhPanda/App/zh-Hant-TW.lproj/Localizable.strings deleted file mode 100644 index 0c4f150d2..000000000 --- a/EhPanda/App/zh-Hant-TW.lproj/Localizable.strings +++ /dev/null @@ -1,906 +0,0 @@ -/* - Localizable.strings - EhPanda -*/ - -// MARK: BanInterval -"enum.ban_interval.description.and" = ""; - -// MARK: ToplistsType -"enum.toplists_type.value.yesterday" = "昨天"; -"enum.toplists_type.value.past_month" = "上個月"; -"enum.toplists_type.value.past_year" = "去年"; -"enum.toplists_type.value.all_time" = "所有時間"; - -// MARK: Response -"website.response.hath_client_not_found" = "你需要一個連結到帳號的 H@H 用戶端才能使用這個功能"; -"website.response.hath_client_not_online" = "你的 H@H 用戶端為離線狀態,請啟動後再試"; -"website.response.invalid_resolution" = "該畫廊不能以目前選擇的解析度下載"; - -// MARK: HUD -"hud.title.error" = "錯誤"; -"hud.title.success" = "成功"; -"hud.title.loading" = "載入中..."; -"hud.title.communicating" = "連線中..."; -"hud.caption.copied_to_clipboard" = "已複製到剪貼簿"; -"hud.caption.saved_to_photo_library" = "已儲存到照片"; - -// MARK: AutoLock -"local_authorization.reason" = "由於超過 APP 自動鎖定期限,APP 已被鎖定,請重新解鎖"; - -// MARK: Common value -"common.value.stars" = "%@ 星"; -"common.value.pages" = "%@ 頁"; -"common.value.times" = "%@ 次"; -"common.value.day" = "%@ 天"; -"common.value.days" = "%@ 天"; -"common.value.hour" = "%@ 小時"; -"common.value.hours" = "%@ 小時"; -"common.value.minute" = "%@ 分"; -"common.value.minutes" = "%@ 分"; -"common.value.second" = "%@ 秒"; -"common.value.seconds" = "%@ 秒"; -"common.value.records" = "%@ 筆紀錄"; - -// MARK: 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.jump_page" = "跳到..."; -"toolbar_item.button.quick_search" = "快速搜尋"; - -// MARK: JumpPage -"jump_page_view.title.jump_page" = "跳到..."; -"jump_page_view.button.confirm" = "確定"; - -// MARK: AlertView -"loading_view.title.loading" = "載入中..."; -"loading_view.title.preparing_database" = "正在準備..."; -"not_login_view.title.need_login" = "你需要登入才能完成這個動作"; -"not_login_view.button.login" = "登入"; -"error_view.button.retry" = "重試"; -"error_view.button.drop_database" = "刪除資料庫"; -"error_view.title.try_later" = "請稍後再試"; -"error_view.title.network" = "網路發生故障,請檢查網路狀態"; -"error_view.title.parsing" = "網頁解析器發生故障"; -"error_view.title.unknown" = "發生不明錯誤"; -"error_view.title.not_found" = "這邊看起來空空如也"; -"error_view.title.database_corrupted" = "資料庫已經損壞\n請提交 issue 至 GitHub."; -"error_view.title.ip_banned" = "你的 IP 因為頁面載入次數過於頻繁而被暫時禁止存取,這可能是因為你正在使用爬蟲/鏡像軟體,禁止存取將在 %@ 後解除"; -"error_view.title.copyright_claim" = "非常抱歉,這個畫廊已因為 %@ 提出的版權聲索而不再提供存取"; -"error_view.title.gallery_unavailable" = "此畫廊已被刪除或你沒有權限存取"; - -// MARK: ConfirmationDialog -"confirmation_dialog.title.drop_database" = "繼續此操作將會清除 APP 中的所有資料\n確定要刪除資料庫?"; -"confirmation_dialog.title.remove_custom_translations" = "是否確定要刪除所有自訂翻譯?"; -"confirmation_dialog.title.logout" = "確定要登出嗎?"; -"confirmation_dialog.title.delete" = "確定要刪除?"; -"confirmation_dialog.title.clear" = "確定要清空嗎?"; -"confirmation_dialog.title.reset" = "確定要重設嗎?"; -"confirmation_dialog.button.drop_database" = "刪除資料庫"; -"confirmation_dialog.button.remove" = "移除"; -"confirmation_dialog.button.logout" = "登出"; -"confirmation_dialog.button.delete" = "刪除"; -"confirmation_dialog.button.clear" = "清空"; -"confirmation_dialog.button.reset" = "重設"; - -// MARK: SubSection -"sub_section.button.show_all" = "顯示全部"; - -// MARK: NewDawnView -"new_dawn_view.title.first" = "現在是嶄新的一天!"; -"new_dawn_view.title.second" = "回顧到目前為止的旅程,你發現自己睿智了一點。"; -// Greeting -"struct.greeting.mark.start" = "你獲得了 "; -"struct.greeting.mark.separator" = "、"; -"struct.greeting.mark.and" = " 和 "; -"struct.greeting.mark.end" = "!"; - -// MARK: HomeView -"home_view.title.home" = "總覽"; -"home_view.section.title.frontpage" = "首頁"; -"home_view.section.title.toplists" = "排行"; -"home_view.section.title.other" = "其他"; -// HomeMiscGridType -"enum.home_misc_grid_type.title.popular" = "熱門"; -"enum.home_misc_grid_type.title.watched" = "關注"; -"enum.home_misc_grid_type.title.history" = "歷程"; - -// MARK: FrontpageView -"frontpage_view.title.frontpage" = "首頁"; - -// MARK: ToplistsView -"toplists_view.title.toplists" = "排行"; - -// MARK: PopularView -"popular_view.title.popular" = "熱門"; - -// MARK: WatchedView -"watched_view.title.watched" = "關注"; - -// MARK: HistoryView -"history_view.title.history" = "歷程"; - -// MARK: FavoritesView -"favorites_view.title.favorites" = "收藏"; -// FavoriteCategory -"struct.user.favorite_category.default" = "收藏匣 %@"; -"struct.user.favorite_category.all" = "全部"; - -// MARK: SearchView -"search_view.title.search" = "搜尋"; -"search_view.section.title.recently_searched" = "最近搜尋"; -"search_view.section.title.recently_seen" = "最近閱讀"; -"search_view.section.title.quick_search" = "快速搜尋"; -// Searchable -"searchable.prompt.filter" = "過濾"; -"searchable.title.matches_count" = "找到 %d 項結果"; - -// MARK: QuickSearchView -"quick_search_view.title.quick_search" = "快速搜尋"; -"quick_search_view.title.edit_word" = "編輯關鍵字"; -"quick_search_view.title.new_word" = "新關鍵字"; -"quick_search_view.title.content" = "搜尋內容"; -"quick_search_view.title.name" = "名稱"; -"quick_search_view.placeholder.optional" = "(可選)"; - -// MARK: SettingView -"setting_view.title.setting" = "設定"; -// SettingStateRoute -"enum.setting_state_route.value.account" = "帳號"; -"enum.setting_state_route.value.general" = "一般"; -"enum.setting_state_route.value.appearance" = "外觀"; -"enum.setting_state_route.value.reading" = "閱讀"; -"enum.setting_state_route.value.laboratory" = "實驗性功能"; -"enum.setting_state_route.value.about" = "關於 EhPanda"; - -// MARK: AccountSettingView -"account_setting_view.title.account" = "帳號設定"; -"account_setting_view.title.shows_new_dawn_greeting" = "顯示黎明問候"; -"account_setting_view.button.login" = "登入"; -"account_setting_view.button.logout" = "登出"; -"account_setting_view.button.account_configuration" = "帳號設定"; -"account_setting_view.button.tags_management" = "管理訂閱標籤"; -"account_setting_view.button.copy_cookies" = "複製 Cookies"; -// CookieValue -"struct.cookie_value.localized_string.expired" = "已過期"; -"struct.cookie_value.localized_string.mystery" = "被拒絕"; -"struct.cookie_value.localized_string.none" = "None"; - -// MARK: LoginView -"login_view.title.login" = "登入"; -"login_view.title.username" = "Username"; -"login_view.title.password" = "Password"; - -// MARK: GeneralSettingView -"general_setting_view.title.general" = "一般設定"; -"general_setting_view.title.language" = "語言"; -"general_setting_view.title.auto_lock" = "自動鎖定"; -"general_setting_view.title.enables_tags_extension" = "啟用自訂標籤擴充功能"; -"general_setting_view.title.translates_tags" = "標籤翻譯"; -"general_setting_view.title.shows_tags_search_suggestion" = "搜尋時顯示標籤建議"; -"general_setting_view.title.shows_images_in_tags" = "在標籤顯示圖片"; -"general_setting_view.title.redirects_links_to_the_selected_host" = "將連結重新導向至選擇的網站"; -"general_setting_view.title.detects_links_from_clipboard" = "偵測剪貼簿中的連結"; -"general_setting_view.title.background_blur_radius" = "後台背景模糊"; -"general_setting_view.button.logs" = "日誌"; -"general_setting_view.button.import_custom_translations" = "匯入自訂標籤翻譯"; -"general_setting_view.button.remove_custom_translations" = "刪除自訂標籤翻譯"; -"general_setting_view.button.clear_image_caches" = "清理圖片快取"; -"general_setting_view.value.default_language_description" = "N/A"; -"general_setting_view.section.title.tags" = "標籤"; -"general_setting_view.section.title.navigation" = "導覽"; -"general_setting_view.section.title.security" = "安全"; -"general_setting_view.section.title.caches" = "快取"; -// AutoLockPolicy -"enum.auto_lock_policy.value.never" = "永不自動鎖定"; -"enum.auto_lock_policy.value.instantly" = "立刻"; - -// MARK: LogsView -"logs_view.title.logs" = "日誌"; -"logs_view.title.latest" = "最新"; - -// MARK: AppearanceSettingView -"appearance_setting_view.title.appearance" = "外觀設定"; -"appearance_setting_view.title.theme" = "主題"; -"appearance_setting_view.title.tint_color" = "強調色"; -"appearance_setting_view.title.display_mode" = "顯示模式"; -"appearance_setting_view.title.shows_tags_in_list" = "在列表中顯示標籤"; -"appearance_setting_view.title.maximum_number_of_tags" = "標籤最大顯示數量"; -"appearance_setting_view.title.displays_japanese_title" = "以日文顯示標籤"; -"appearance_setting_view.button.app_icon" = "App 圖案"; -"appearance_setting_view.menu.title.infite" = "無限"; -"appearance_setting_view.section.title.list" = "列表"; -"appearance_setting_view.section.title.gallery" = "畫廊"; -// PreferredColorScheme -"enum.preferred_color_scheme.value.automatic" = "自動"; -"enum.preferred_color_scheme.value.light" = "淺色"; -"enum.preferred_color_scheme.value.dark" = "深色"; -// AppIconType -"enum.app_icon_type.value.default" = "預設"; -"enum.app_icon_type.value.ukiyoe" = "Ukiyo-e"; -"enum.app_icon_type.value.developer" = "Developer"; -"enum.app_icon_type.value.stand_with_ukraine_2022" = "Stand With Ukraine (2022)"; -"enum.app_icon_type.value.not_my_president" = "NOT MY PRESIDENT"; -// ListDisplayMode -"enum.list_display_mode.value.detail" = "詳細"; -"enum.list_display_mode.value.thumbnail" = "縮圖"; - -// MARK: AppIconView -"app_icon_view.title.app_icon" = "App 圖案"; - -// MARK: reading_settingView -"reading_setting_view.title.reading" = "閱讀設定"; -"reading_setting_view.title.direction" = "翻頁方向"; -"reading_setting_view.title.preload_limit" = "預先載入頁數"; -"reading_setting_view.title.enables_landscape" = "啟用橫向顯示"; -"reading_setting_view.title.separator_height" = "頁間分隔高度"; -"reading_setting_view.title.maximum_scale_factor" = "縮放上限"; -"reading_setting_view.title.double_tap_scale_factor" = "雙擊縮放值"; -"reading_setting_view.section.title.appearance" = "外觀"; -// ReadingDirection -"enum.reading_direction.value.vertical" = "垂直"; -"enum.reading_direction.value.right_to_left" = "由右至左滑"; -"enum.reading_direction.value.left_to_right" = "由左至右滑"; - -// MARK: LaboratorySettingView -"laboratory_setting_view.title.laboratory" = "實驗性功能"; -"laboratory_setting_view.title.bypasses_SNI_filtering" = "繞過 SNI 過濾"; - -// MARK: AboutView -"about_view.title.ehPanda" = "EhPanda"; -"about_view.button.website" = "官方網站"; -"about_view.button.altStore_source" = "AltStore source"; -"about_view.title.version" = "版本"; -"about_view.section.title.special_thanks" = "特別銘謝"; -"about_view.section.title.code_level_contributors" = "程式碼貢獻者"; -"about_view.section.title.translation_contributors" = "翻譯貢獻者"; -"about_view.section.title.acknowledgements" = "致謝"; - -// MARK: DetailView -"detail_view.button.read" = "閱讀"; -"detail_view.button.post_comment" = "發表留言"; -"detail_view.toolbar_item.button.archives" = "存檔至 H@H 用戶端"; -"detail_view.toolbar_item.button.torrents" = "種子"; -"detail_view.toolbar_item.button.share" = "分享"; -"detail_view.context_menu.button.detail" = "Detail"; -"detail_view.context_menu.button.withdraw_vote" = "收回評分"; -"detail_view.context_menu.button.vote_up" = "Vote up"; -"detail_view.context_menu.button.vote_down" = "Vote down"; -"detail_view.description_section.title.favorited" = "收藏"; -"detail_view.description_section.title.language" = "語言"; -"detail_view.description_section.title.ratings" = "%@ 個評分"; -"detail_view.description_section.title.page_count" = "頁數"; -"detail_view.description_section.title.file_size" = "檔案大小"; -"detail_view.description_section.description.favorited" = "次"; -"detail_view.description_section.description.page_count" = "頁"; -"detail_view.action_section.button.give_a_rating" = "給予評分"; -"detail_view.action_section.button.similar_gallery" = "類似畫廊"; -"detail_view.section.title.previews" = "預覽"; -"detail_view.section.title.comments" = "留言"; - -// MARK: ArchivesView -"archives_view.title.archives" = "存檔"; -"archives_view.button.download_to_hath_client" = "下載至 H@H 用戶端"; -// HathArchive -"struct.hath_archive.price.free" = "免費"; -"struct.hath_archive.price.not_available" = "N/A"; -// ArchiveResolution -"enum.archive_resolution.value.original" = "原始畫質"; - -// MARK: TorrentsView -"torrents_view.title.torrents" = "種子"; - -// MARK: GalleryInfosView -"gallery_infos_view.title.gallery_infos" = "畫廊資訊"; -"gallery_infos_view.title.id" = "畫廊 ID"; -"gallery_infos_view.title.token" = "Token"; -"gallery_infos_view.title.title" = "標題"; -"gallery_infos_view.title.japanese_title" = "日文標題"; -"gallery_infos_view.title.gallery_URL" = "畫廊 URL"; -"gallery_infos_view.title.cover_URL" = "封面 URL"; -"gallery_infos_view.title.archive_URL" = "存檔 URL"; -"gallery_infos_view.title.torrent_URL" = "種子 URL"; -"gallery_infos_view.title.parent_URL" = "Parent URL"; -"gallery_infos_view.title.category" = "類別"; -"gallery_infos_view.title.uploader" = "上傳者"; -"gallery_infos_view.title.posted_date" = "發布日期"; -"gallery_infos_view.title.visibility" = "能見度"; -"gallery_infos_view.title.language" = "語言"; -"gallery_infos_view.title.page_count" = "頁數"; -"gallery_infos_view.title.file_size" = "檔案大小"; -"gallery_infos_view.title.favorited_times" = "被收藏次數"; -"gallery_infos_view.title.favorited" = "已收藏"; -"gallery_infos_view.title.rating_count" = "被評分次數"; -"gallery_infos_view.title.average_rating" = "平均評分"; -"gallery_infos_view.title.my_rating" = "我的評分"; -"gallery_infos_view.title.torrent_count" = "種子數量"; -"gallery_infos_view.value.none" = "None"; -"gallery_infos_view.value.yes" = "Yes"; -"gallery_infos_view.value.no" = "No"; -// GalleryVisibility -"enum.gallery_visibility.value.yes" = "Yes"; -"enum.gallery_visibility.value.no" = "No (%@)"; -"enum.gallery_visibility.value.no.reason.expunged" = "已被刪除"; - -// MARK: TagDetailView -"tag_detail_view.section.title.images" = "圖片"; -"tag_detail_view.section.title.links" = "連結"; - -// MARK: CommentsView -"comments_view.title.comments" = "留言"; - -// MARK: PostCommentView -"post_comment_view.title.post_comment" = "發表留言"; -"post_comment_view.title.edit_comment" = "編輯留言"; - -// MARK: PreviewsView -"previews_view.title.previews" = "預覽"; - -// MARK: ReadingView -"reading_view.context_menu.button.reload" = "重新整理"; -"reading_view.context_menu.button.copy" = "複製"; -"reading_view.context_menu.button.save" = "儲存圖片"; -"reading_view.context_menu.button.save_original" = "儲存原始圖片"; -"reading_view.context_menu.button.share" = "分享"; -"reading_view.toolbar_item.title.auto_play" = "自動播放"; -"reading_view.toolbar_item.title.dual_page_mode" = "雙頁模式"; -"reading_view.toolbar_item.title.except_the_cover" = "封面除外"; -"reading_view.toolbar_item.button.retry_all_failed_images" = "重新載入失敗圖片"; -"reading_view.toolbar_item.button.reload_all_images" = "重新載入所有圖片"; -"reading_view.toolbar_item.button.reading_setting" = "閱讀設定"; -// AutoPlayPolicy -"enum.auto_play_policy.value.off" = "關閉"; - -// MARK: FiltersView -"filters_view.title.filters" = "過濾"; -"filters_view.title.advanced_settings" = "進階選項"; -"filters_view.title.search_gallery_name" = "搜尋畫廊名稱"; -"filters_view.title.search_gallery_tags" = "搜尋畫廊標籤"; -"filters_view.title.search_gallery_description" = "搜尋畫廊描述"; -"filters_view.title.search_torrent_filenames" = "搜尋種子檔案名"; -"filters_view.title.only_show_galleries_with_torrents" = "只顯示有種子的畫廊"; -"filters_view.title.search_low_power_tags" = "搜尋低期望標籤"; -"filters_view.title.search_downvoted_tags" = "搜尋低評價標籤"; -"filters_view.title.search_expunged_galleries" = "顯示被刪除的畫廊"; -"filters_view.title.set_minimum_rating" = "設定評分下限"; -"filters_view.title.minimum_rating" = "評分下限"; -"filters_view.title.set_pages_range" = "設定頁數範圍"; -"filters_view.title.pages_range" = "頁數範圍"; -"filters_view.title.disable_language_filter" = "停用語言篩選"; -"filters_view.title.disable_uploader_filter" = "停用上傳者篩選"; -"filters_view.title.disable_tags_filter" = "停用標籤篩選"; -"filters_view.button.reset_filters" = "重設所有選項"; -"filters_view.section.title.advanced" = "進階"; -"filters_view.section.title.default_filter" = "預設篩選"; -// FilterRange -"enum.filter_range.value.search" = "搜尋"; -"enum.filter_range.value.global" = "全域"; -"enum.filter_range.value.watched" = "標籤"; - -// MARK: EhSettingView -"eh_setting_view.title.host_settings" = "%@ 設定"; -"eh_setting_view.section.title.profile_settings" = "設定檔設定"; -"eh_setting_view.title.selected_profile" = "選擇設定檔"; -"eh_setting_view.button.set_as_default" = "設為預設"; -"eh_setting_view.button.delete_profile" = "刪除設定檔"; -"eh_setting_view.button.rename" = "重新命名"; -"eh_setting_view.button.create_new" = "新增設定檔"; -"eh_setting_view.toolbar_item.button.done" = "完成"; - -"eh_setting_view.section.title.image_load_settings" = "圖片來源設定"; -"eh_setting_view.title.load_images_through_the_hath_network" = "透過 Hath Network 載入圖片"; -"eh_setting_view.title.browsing_country" = "所在國家"; -"eh_setting_view.description.browsing_country" = "你似乎是從 **%@** 瀏覽這個網站或在使用當地的 VPN,這意味著網站將嘗試從這個地理區域的 H@H 用戶端載入圖片。如果這不正確或你出於任何原因想要使用不同的區域(例如你正在透過 VPN 連線),您可以在下面選擇不同的國家/地區。"; -// EhSetting.LoadThroughHathSetting -"enum.eh_setting.load_through_hath_setting.value.any_client" = "任何用戶端"; -"enum.eh_setting.load_through_hath_setting.value.default_port_only" = "只使用預設連接埠的用戶端"; -"enum.eh_setting.load_through_hath_setting.value.modern_no" = "No [現代/Modern/HTTPS]"; -"enum.eh_setting.load_through_hath_setting.value.legacy_no" = "No [傳統/Legacy/HTTP]"; -"enum.eh_setting.load_through_hath_setting.description.any_client" = "建議選項(預設)"; -"enum.eh_setting.load_through_hath_setting.description.default_port_only" = "如果網路防火牆會阻擋任何非預設傳出連接埠則使用這個選項(可能較慢)"; -"enum.eh_setting.load_through_hath_setting.description.modern_no" = "E-Hentai 贊助者功能: 你將無法同時瀏覽多個頁面,僅在出現重大錯誤時才啟用這個選項"; -"enum.eh_setting.load_through_hath_setting.description.legacy_no" = "E-Hentai 贊助者功能: 在現代瀏覽器上預設設定可能無法正常工作,僅推薦用於傳統瀏覽器"; - -"eh_setting_view.section.title.image_size_settings" = "圖片尺寸設定"; -"eh_setting_view.title.image_resolution" = "圖片解析度"; -"eh_setting_view.description.image_resolution" = "一般情況下圖片會被縮放成 1280 px 水平解析度以供線上瀏覽,你也可以選擇下列解析度之一。為了避免破壞伺服器正常運作,高於 1280x 的解析度暫時僅提供下列使用者使用: 贊助者、具有任何 Hath Perk 的使用者以及 UID 低於 3,000,000 的使用者"; -"eh_setting_view.title.image_size" = "圖片尺寸"; -"eh_setting_view.description.image_size" = "雖然網站會自動縮小圖片以適應瀏覽裝置的螢幕寬度,但您也可以手動限製圖片的最大顯示尺寸。 像自動縮放一樣,這不會重新採樣圖像,因為調整大小是在瀏覽器完成的 (0 = 沒有限制)"; -"eh_setting_view.title.horizontal" = "水平尺寸(寬)"; -"eh_setting_view.title.vertical" = "垂直尺寸(長)"; -// EhSetting.ImageResolution -"enum.eh_setting.image_resolution.value.auto" = "自動"; - -"eh_setting_view.section.title.gallery_name_display" = "畫廊顯示名稱"; -"eh_setting_view.title.gallery_name" = "畫廊名稱"; -"eh_setting_view.description.gallery_name" = "許多畫廊同時提供了英文/預設標題與日文標題,你想優先顯示哪種畫廊名稱?"; -// EhSetting.GalleryName -"enum.eh_setting.gallery_name.value.default" = "預設標題"; -"enum.eh_setting.gallery_name.value.japanese" = "日文標題(若該畫廊支援)"; - -"eh_setting_view.section.title.archiver_settings" = "存檔設定"; -"eh_setting_view.title.archiver_behavior" = "存檔邏輯設定"; -"eh_setting_view.description.archiver_behavior" = "存檔的預設邏輯是確認原始圖片或重新採樣後進行存檔的成本差異與選擇,然後顯示一個可以在其他地方點擊、複製的連結,你可以在此處更改他的運作方式。"; -// EhSetting.ArchiverBehavior -"enum.eh_setting.archiver_behavior.value.manual_select_manual_start" = "手動選擇,手動開始下載(預設)"; -"enum.eh_setting.archiver_behavior.value.manual_select_auto_start" = "手動選擇,自動開始下載"; -"enum.eh_setting.archiver_behavior.value.auto_select_original_manual_start" = "自動選擇原始畫質,,手動開始下載"; -"enum.eh_setting.archiver_behavior.value.auto_select_original_auto_start" = "自動選擇原始畫質並開始下載"; -"enum.eh_setting.archiver_behavior.value.auto_select_resample_manual_start" = "自動選擇重新採樣,手動開始下載"; -"enum.eh_setting.archiver_behavior.value.auto_select_resample_auto_start" = "自動選擇重新採樣並開始下載"; - -"eh_setting_view.section.title.front_page_settings" = "首頁設定"; -"eh_setting_view.title.display_mode" = "顯示模式"; -"eh_setting_view.description.display_mode" = "你想在首頁和搜尋結果中使用哪一種顯示方式?"; -"eh_setting_view.section.title.show_search_range_indicator" = "搜尋範圍指示器"; -"eh_setting_view.title.show_search_range_indicator" = "顯示搜尋範圍指示器"; -"eh_setting_view.description.gallery_category" = "預設情況下你希望在首頁和搜尋結果中顯示哪些類別的結果?"; -// EhSetting.DisplayMode -"enum.eh_setting.display_mode.value.compact" = "緊湊(Compact)"; -"enum.eh_setting.display_mode.value.thumbnail" = "縮圖(Thumbnail)"; -"enum.eh_setting.display_mode.value.extended" = "放大(Extended)"; -"enum.eh_setting.display_mode.value.minimal" = "最小(Minimal)"; -"enum.eh_setting.display_mode.value.minimalPlus" = "Minimal+"; - -"eh_setting_view.section.title.optional_UI_elements" = "可選用的 UI 元件"; -"eh_setting_view.description.optional_UI_elements" = "一些舊版 UI 元件現已預設停用。您可以在此啟用這些元件。"; -"eh_setting_view.title.enable_gallery_thumbnail_selector" = "在畫廊頁面啟用縮圖選擇器"; - -"eh_setting_view.section.title.favorites" = "收藏匣"; -"eh_setting_view.description.favorite_categories" = "在這裡你可以選擇並重新命名收藏匣"; -"eh_setting_view.title.favorites_sort_order" = "收藏匣排序"; -"eh_setting_view.description.favorites_sort_order" = "你還可以在收藏匣頁面上為畫廊選擇預設排列順序。 請注意,在 2016 年 3 月網站改版之前新增的畫廊並不儲存時間印記,並且無論使用何種設定,都將使用畫廊發佈時間作為排序參考。"; -// EhSetting.FavoritesSortOrder -"enum.eh_setting.favorites_sort_order.value.last_update_time" = "透過最後更新時間排序"; -"enum.eh_setting.favorites_sort_order.value.favorited_time" = "透過收藏順序排序"; - -"eh_setting_view.section.title.ratings" = "評分"; -"eh_setting_view.title.ratings_color" = "評分顏色"; -"eh_setting_view.promt.ratings_color" = "RRGGB"; -"eh_setting_view.description.ratings_color" = "預設情況下,你評分的畫廊將 2 星及以下的評分顯示為紅色星,2.5 ~ 4 顆星的評分為綠色,4.5 ~ 5 顆星的評分為藍色。 透過在下面輸入顏色組合你可以自訂想顯示的顏色。 每個字母各代表一顆星(1~5),預設的 RRGGB 表示第一顆和第二顆星的 R(ed),第三顆和第四顆的 G(reen),第五顆的 B(lue)。 你也可以將 (Y)ellow 用於普通星星。 任何五個字母的 R/G/B/Y 組合都有效"; - -"eh_setting_view.section.title.tag_filtering_threshold" = "過濾標籤閾值"; -"eh_setting_view.title.tag_filtering_threshold" = "閾值"; -"eh_setting_view.description.tag_filtering_threshold" = "你可以透過將標籤新增到具有負數權重的“我的標籤”清單中來過濾標籤。 如果畫廊的標籤加起來的權重低於此值,則會被從列表中過濾掉,此閾值可以設定在 0 ~ -9999 之間"; - -"eh_setting_view.section.title.tag_watching_threshold" = "關注標籤閾值"; -"eh_setting_view.title.tag_watching_threshold" = "關注標籤生效的閾值"; -"eh_setting_view.description.tag_watching_threshold" = "如果最近上傳的畫廊中至少有一個具有正權重的你正在關注/追蹤的標籤,並且這些標籤所具有的權重高於此設定中的數值,那這個畫廊將會出現在「追蹤標籤」頁面中,此閾值可以設定在 0 ~ -9999 之間"; - -"eh_setting_view.section.title.filtered_removal_count" = "顯示過濾結果計數器"; -"eh_setting_view.description.filtered_removal_count" = "顯示 \"Your default filters removed XX galleries from this page\" ?"; -"eh_setting_view.title.show_filtered_removal_count" = "是否顯示過濾結果計數器"; - -"eh_setting_view.section.title.excluded_languages" = "排除語言"; -"eh_setting_view.description.excluded_languages" = "如果你希望從畫廊列表和搜尋中隱藏掉某些語言的畫廊,請從下面的清單中選取它們。請注意,無論你的搜尋查詢如何,相符於篩除規則的畫廊都不會出現。"; -// EhSetting.ExcludedLanguagesCategory -"enum.eh_setting.excluded_languages_category.value.original" = "原始語言"; -"enum.eh_setting.excluded_languages_category.value.translated" = "翻譯語言"; -"enum.eh_setting.excluded_languages_category.value.rewrite" = "覆寫"; - -"eh_setting_view.section.title.excluded_uploaders" = "排除的上傳者"; -"eh_setting_view.description.excluded_uploaders" = "如果你希望從畫廊列表和搜尋結果中隱藏某些上傳者的畫廊,請將它們新增到下方,每行輸入一個使用者名稱。請注意,無論你的搜尋結果如何,這些上傳者的畫廊都不會出現。"; -"eh_setting_view.description.excluded_uploaders_count" = "你正在使用 **%@ / %@** 排除欄位"; - -"eh_setting_view.section.title.search_result_count" = "搜尋結果數量上限"; -"eh_setting_view.title.result_count" = "數量上限"; -"eh_setting_view.description.result_count" = "你希望每頁顯示幾個搜尋結果?\n(該功能需要有 Hath Perk: Paging Enlargement)"; - -"eh_setting_view.section.title.thumbnail_settings" = "縮圖設定"; -"eh_setting_view.title.thumbnail_load_timing" = "縮圖載入時機設定"; -"eh_setting_view.description.thumbnail_load_timing" = "使用列表模式時,你希望如何載入以滑鼠位置顯示的縮圖?"; -"eh_setting_view.description.thumbnail_configuration" = "你可以為存取的所有畫廊設定預設的縮圖配置。"; -"eh_setting_view.title.thumbnail_size" = "尺寸"; -"eh_setting_view.title.thumbnail_row_count" = "行數"; -// EhSetting.ThumbnailLoadTiming -"enum.eh_setting.thumbnail_load_timing.value.on_mouse_over" = "滑鼠位置"; -"enum.eh_setting.thumbnail_load_timing.value.on_page_load" = "網頁載入位置"; -"enum.eh_setting.thumbnail_load_timing.description.on_mouse_over" = "網頁的載入速度更快,但縮圖出現的時間可能稍有延遲"; -"enum.eh_setting.thumbnail_load_timing.description.on_page_load" = "網頁需要更多的載入時間,但是在網頁完全載入後縮圖顯示不會有任何延遲"; -// EhSetting.ThumbnailSize -"enum.eh_setting.thumbnail_size.value.normal" = "正常"; -"enum.eh_setting.thumbnail_size.value.large" = "大型"; -"enum.eh_setting.thumbnail_size.value.small" = "小型"; -"enum.eh_setting.thumbnail_size.value.auto" = "自動"; - -"eh_setting_view.section.title.cover_scaling" = "封面縮放"; -"eh_setting_view.title.scale_factor" = "縮放比例"; -"eh_setting_view.description.cover_scale_factor" = "在縮圖與放大檢視這兩種檢視模式下,封面的重新採樣比率介於 75%% 至 150%%."; - -"eh_setting_view.section.title.viewport_override" = "視窗覆蓋"; -"eh_setting_view.title.virtual_width" = "虛擬寬度"; -"eh_setting_view.description.virtual_width" = "允許你覆蓋行動裝置網站的虛擬寬度。 這通常由你的裝置根據其 DPI 自動確定。 100%% 縮圖比例的合理值介於 640 和 1400 之間。"; - -"eh_setting_view.section.title.gallery_comments" = "畫廊留言"; -"eh_setting_view.title.comments_sort_order" = "留言排序方式"; -"eh_setting_view.title.comments_votes_show_timing" = "留言投票數顯示時機"; -// EhSetting.CommentsSortOrder -"enum.eh_setting.comments_sort_order.value.oldest" = "最舊留言優先"; -"enum.eh_setting.comments_sort_order.value.recent" = "最新留言優先"; -"enum.eh_setting.comments_sort_order.value.highest_score" = "最相關留言優先"; -// EhSetting.CommentVotesShowTiming -"enum.eh_setting.comments_votes_show_timing.value.on_hover_or_click" = "滑鼠在分數上停留或點擊時"; -"enum.eh_setting.comments_votes_show_timing.value.always" = "總是顯示"; - -"eh_setting_view.section.title.gallery_tags" = "畫廊標籤"; -"eh_setting_view.title.tags_sort_order" = "標籤顯示順序"; -// EhSetting.tags_sort_order -"enum.eh_setting.tags_sort_order.value.alphabetical" = "字母順序"; -"enum.eh_setting.tags_sort_order.value.tag_power" = "標籤權重"; - -"eh_setting_view.section.title.gallery_page_thumbnail_labeling" = "畫廊頁面縮圖標籤"; -"eh_setting_view.title.show_label_below_gallery_thumbnails" = "在畫廊縮圖下方顯示標籤"; - -"eh_setting_view.section.title.hath_local_network_host" = "內網 H@H 服務 (Hath Local Network Host)"; -"eh_setting_view.title.ip_address_port" = "IP 位址:連接埠號"; -"eh_setting_view.description.ip_address_port" = "如果你在內網路上使用與瀏覽站點相同的公共 IP 架設 H@H 用戶端,有些路由器會因此發生問題,無法將請求傳回自己的 IP,透過啟用這項設定可以解決此問題。\n如果您在瀏覽的同一裝置上執行用戶端,請使用回環地址 (127.0.0.1:port)。 如果用戶端在您網路上的另一台裝置上執行,請使用其本機網路 IP。 某些瀏覽器配置會阻止外部網站存取具有本機網路 IP 的 URL,你必須將站點列入白名單才能使其正常工作。"; - -"eh_setting_view.section.title.original_images" = "要使用原始圖片而非重新取樣的版本嗎? 若您在上方選擇「自動」以外的水準解析度且圖片較寬,或原始圖片大於 10 MiB(一年以上的圖庫則為 4 MiB),系統仍會使用重新取樣的圖片。"; -"eh_setting_view.title.use_original_images" = "使用原始圖片(原解析度)"; - -"eh_setting_view.section.title.multi_page_viewer" = "多頁瀏覽"; -"eh_setting_view.title.use_multi_page_viewer" = "使用多頁瀏覽"; -"eh_setting_view.title.display_style" = "顯示方式"; -"eh_setting_view.title.show_thumbnail_pane" = "顯示縮圖窗格"; -// EhSetting.MultiplePageViewerStyle -"enum.eh_setting.multiple_page_viewer_style.value.align_left_scale_if_over_width" = "向左對齊,若寬度超出頁面則進行縮放"; -"enum.eh_setting.multiple_page_viewer_style.value.align_center_scale_if_over_width" = "置中對齊,若寬度超出頁面則進行縮放"; -"enum.eh_setting.multiple_page_viewer_style.value.align_center_always_scale" = "置中對齊且總是縮放"; -// EhSetting.GalleryPageNumbering -"enum.eh_setting.gallery_page_numbering.value.none" = "不顯示"; -"enum.eh_setting.gallery_page_numbering.value.page_number_only" = "只顯示頁碼"; -"enum.eh_setting.gallery_page_numbering.value.page_number_and_name" = "顯示頁碼和名稱"; - -// MARK: Category -"enum.category.value.doujinshi" = "同人誌"; -"enum.category.value.manga" = "漫畫"; -"enum.category.value.artist_CG" = "插畫"; -"enum.category.value.game_CG" = "遊戲 CG"; -"enum.category.value.western" = "西方"; -"enum.category.value.non_h" = "健康"; -"enum.category.value.image_set" = "圖片集"; -"enum.category.value.cosplay" = "角色扮演"; -"enum.category.value.asian_porn" = "亞洲"; -"enum.category.value.misc" = "其它"; -"enum.category.value.private" = "私人"; - -// MARK: TagNamespace -"enum.tag_namespace.value.reclass" = "重新分類"; -"enum.tag_namespace.value.language" = "語言"; -"enum.tag_namespace.value.parody" = "原作"; -"enum.tag_namespace.value.character" = "角色"; -"enum.tag_namespace.value.group" = "團體"; -"enum.tag_namespace.value.artist" = "作者"; -"enum.tag_namespace.value.male" = "男性"; -"enum.tag_namespace.value.female" = "女性"; -"enum.tag_namespace.value.mixed" = "Mixed"; -"enum.tag_namespace.value.cosplayer" = "Cosplayer"; -"enum.tag_namespace.value.other" = "其他"; -"enum.tag_namespace.value.temp" = "Temp"; - -// MARK: Language -"enum.language.value.invalid" = "無效"; -"enum.language.value.other" = "其它"; -"enum.language.value.afrikaans" = "南非語"; -"enum.language.value.albanian" = "阿爾巴尼亞語"; -"enum.language.value.arabic" = "阿拉伯語"; -"enum.language.value.bengali" = "孟加拉語"; -"enum.language.value.bosnian" = "波士尼亞語"; -"enum.language.value.bulgarian" = "保加利亞語"; -"enum.language.value.burmese" = "緬甸語"; -"enum.language.value.catalan" = "加泰隆尼亞語"; -"enum.language.value.cebuano" = "宿霧語"; -"enum.language.value.chinese" = "漢語"; -"enum.language.value.croatian" = "克羅埃西亞語"; -"enum.language.value.czech" = "捷克語"; -"enum.language.value.danish" = "丹麥語"; -"enum.language.value.dutch" = "荷蘭語"; -"enum.language.value.english" = "英語"; -"enum.language.value.esperanto" = "國際語"; -"enum.language.value.estonian" = "愛沙尼亞語"; -"enum.language.value.finnish" = "芬蘭語"; -"enum.language.value.french" = "法語"; -"enum.language.value.georgian" = "喬治亞語"; -"enum.language.value.german" = "德語"; -"enum.language.value.greek" = "希臘語"; -"enum.language.value.hebrew" = "希伯來語"; -"enum.language.value.hindi" = "印度語"; -"enum.language.value.hmong" = "苗語"; -"enum.language.value.hungarian" = "匈牙利語"; -"enum.language.value.indonesian" = "印尼語"; -"enum.language.value.italian" = "義大利語"; -"enum.language.value.japanese" = "日語"; -"enum.language.value.kazakh" = "哈薩克語"; -"enum.language.value.khmer" = "高棉語"; -"enum.language.value.korean" = "韓語"; -"enum.language.value.kurdish" = "庫德語"; -"enum.language.value.lao" = "寮語"; -"enum.language.value.latin" = "拉丁語"; -"enum.language.value.mongolian" = "蒙古語"; -"enum.language.value.ndebele" = "恩德貝萊語"; -"enum.language.value.nepali" = "尼泊爾語"; -"enum.language.value.norwegian" = "挪威語"; -"enum.language.value.oromo" = "奧羅莫語"; -"enum.language.value.pashto" = "普什圖語"; -"enum.language.value.persian" = "波斯語"; -"enum.language.value.polish" = "波蘭語"; -"enum.language.value.portuguese" = "葡萄牙語"; -"enum.language.value.punjabi" = "旁遮普語"; -"enum.language.value.romanian" = "羅馬尼亞語"; -"enum.language.value.russian" = "俄語"; -"enum.language.value.sango" = "桑戈語"; -"enum.language.value.serbian" = "塞爾維亞語"; -"enum.language.value.shona" = "紹納語"; -"enum.language.value.slovak" = "斯洛伐克語"; -"enum.language.value.slovenian" = "斯洛維尼亞語"; -"enum.language.value.somali" = "索馬利語"; -"enum.language.value.spanish" = "西班牙語"; -"enum.language.value.swahili" = "斯瓦希里語"; -"enum.language.value.swedish" = "瑞典語"; -"enum.language.value.tagalog" = "他加祿語"; -"enum.language.value.thai" = "泰語"; -"enum.language.value.tigrinya" = "提格利尼亞語"; -"enum.language.value.turkish" = "土耳其語"; -"enum.language.value.ukrainian" = "烏克蘭語"; -"enum.language.value.urdu" = "烏爾都語"; -"enum.language.value.vietnamese" = "越南語"; -"enum.language.value.zulu" = "祖魯語"; - -// MARK: BrowsingCountry -"enum.browsing_country.name.auto_detect" = "自動偵測"; -"enum.browsing_country.name.afghanistan" = "阿富汗"; -"enum.browsing_country.name.aland_islands" = "奧蘭群島"; -"enum.browsing_country.name.albania" = "阿爾巴尼亞"; -"enum.browsing_country.name.algeria" = "阿爾及利亞"; -"enum.browsing_country.name.american_samoa" = "美屬薩摩亞"; -"enum.browsing_country.name.andorra" = "安道爾"; -"enum.browsing_country.name.angola" = "安哥拉"; -"enum.browsing_country.name.anguilla" = "安圭拉"; -"enum.browsing_country.name.antarctica" = "南極洲"; -"enum.browsing_country.name.antigua_and_barbuda" = "安地卡及巴布達"; -"enum.browsing_country.name.argentina" = "阿根廷"; -"enum.browsing_country.name.armenia" = "亞美尼亞"; -"enum.browsing_country.name.aruba" = "阿魯巴"; -"enum.browsing_country.name.asia_pacific_region" = "亞太地區"; -"enum.browsing_country.name.australia" = "澳洲"; -"enum.browsing_country.name.austria" = "奧地利"; -"enum.browsing_country.name.azerbaijan" = "亞塞拜然"; -"enum.browsing_country.name.bahamas" = "巴哈馬"; -"enum.browsing_country.name.bahrain" = "巴林"; -"enum.browsing_country.name.bangladesh" = "孟加拉"; -"enum.browsing_country.name.barbados" = "巴貝多"; -"enum.browsing_country.name.belarus" = "白俄羅斯"; -"enum.browsing_country.name.belgium" = "比利時"; -"enum.browsing_country.name.belize" = "貝里斯"; -"enum.browsing_country.name.benin" = "貝南"; -"enum.browsing_country.name.bermuda" = "百慕達"; -"enum.browsing_country.name.bhutan" = "不丹"; -"enum.browsing_country.name.bolivia" = "玻利維亞"; -"enum.browsing_country.name.bonaire_saint_eustatius_and_saba" = "博奈爾、聖尤斯特歇斯和薩巴"; -"enum.browsing_country.name.bosnia_and_herzegovina" = "波士尼亞"; -"enum.browsing_country.name.botswana" = "波札那"; -"enum.browsing_country.name.bouvet_island" = "布威島"; -"enum.browsing_country.name.brazil" = "巴西"; -"enum.browsing_country.name.british_indian_ocean_territory" = "英屬印度洋領地"; -"enum.browsing_country.name.brunei_darussalam" = "汶萊"; -"enum.browsing_country.name.bulgaria" = "保加利亞"; -"enum.browsing_country.name.burkina_faso" = "布吉納法索"; -"enum.browsing_country.name.burundi" = "蒲隆地"; -"enum.browsing_country.name.cambodia" = "柬埔寨"; -"enum.browsing_country.name.cameroon" = "喀麥隆"; -"enum.browsing_country.name.canada" = "加拿大"; -"enum.browsing_country.name.cape_verde" = "維德角"; -"enum.browsing_country.name.cayman_islands" = "開曼群島"; -"enum.browsing_country.name.central_african_republic" = "中非"; -"enum.browsing_country.name.chad" = "查德"; -"enum.browsing_country.name.chile" = "智利"; -"enum.browsing_country.name.china" = "中國"; -"enum.browsing_country.name.christmas_island" = "聖誕島"; -"enum.browsing_country.name.cocos_islands" = "科科斯(基林)群島"; -"enum.browsing_country.name.colombia" = "哥倫比亞"; -"enum.browsing_country.name.comoros" = "葛摩"; -"enum.browsing_country.name.congo" = "剛果共和國"; -"enum.browsing_country.name.the_democratic_republic_of_the_congo" = "剛果民主共和國"; -"enum.browsing_country.name.cook_islands" = "庫克群島"; -"enum.browsing_country.name.costa_rica" = "哥斯大黎加"; -"enum.browsing_country.name.cote_d_ivoire" = "象牙海岸"; -"enum.browsing_country.name.croatia" = "克羅埃西亞"; -"enum.browsing_country.name.cuba" = "古巴"; -"enum.browsing_country.name.curacao" = "古拉索"; -"enum.browsing_country.name.cyprus" = "賽普勒斯"; -"enum.browsing_country.name.czech_republic" = "捷克共和國"; -"enum.browsing_country.name.denmark" = "丹麥"; -"enum.browsing_country.name.djibouti" = "吉布地"; -"enum.browsing_country.name.dominica" = "多米尼克"; -"enum.browsing_country.name.dominican_republic" = "多明尼加"; -"enum.browsing_country.name.ecuador" = "厄瓜多"; -"enum.browsing_country.name.egypt" = "埃及"; -"enum.browsing_country.name.el_salvador" = "薩爾瓦多"; -"enum.browsing_country.name.equatorial_guinea" = "赤道幾內亞"; -"enum.browsing_country.name.eritrea" = "厄利垂亞"; -"enum.browsing_country.name.estonia" = "愛沙尼亞"; -"enum.browsing_country.name.ethiopia" = "衣索比亞"; -"enum.browsing_country.name.europe" = "歐洲"; -"enum.browsing_country.name.falkland_islands" = "福克蘭群島"; -"enum.browsing_country.name.faroe_islands" = "法羅群島"; -"enum.browsing_country.name.fiji" = "斐濟"; -"enum.browsing_country.name.finland" = "芬蘭"; -"enum.browsing_country.name.france" = "法國"; -"enum.browsing_country.name.french_guiana" = "法屬圭亞那"; -"enum.browsing_country.name.french_polynesia" = "法屬玻里尼西亞"; -"enum.browsing_country.name.french_southern_territories" = "法屬南部領土"; -"enum.browsing_country.name.gabon" = "加彭"; -"enum.browsing_country.name.gambia" = "甘比亞"; -"enum.browsing_country.name.georgia" = "喬治亞"; -"enum.browsing_country.name.germany" = "德國"; -"enum.browsing_country.name.ghana" = "迦納"; -"enum.browsing_country.name.gibraltar" = "直布羅陀"; -"enum.browsing_country.name.greece" = "希臘"; -"enum.browsing_country.name.greenland" = "格陵蘭"; -"enum.browsing_country.name.grenada" = "格瑞那達"; -"enum.browsing_country.name.guadeloupe" = "瓜地洛普"; -"enum.browsing_country.name.guam" = "關島"; -"enum.browsing_country.name.guatemala" = "瓜地馬拉"; -"enum.browsing_country.name.guernsey" = "耿西"; -"enum.browsing_country.name.guinea" = "幾內亞"; -"enum.browsing_country.name.guinea_bissau" = "幾內亞比索"; -"enum.browsing_country.name.guyana" = "蓋亞那"; -"enum.browsing_country.name.haiti" = "海地"; -"enum.browsing_country.name.heard_island_and_mc_donald_islands" = "赫德島和麥克唐納群島"; -"enum.browsing_country.name.vatican_city_state" = "梵蒂岡"; -"enum.browsing_country.name.honduras" = "宏都拉斯"; -"enum.browsing_country.name.hong_kong" = "香港"; -"enum.browsing_country.name.hungary" = "匈牙利"; -"enum.browsing_country.name.iceland" = "冰島"; -"enum.browsing_country.name.india" = "印度"; -"enum.browsing_country.name.indonesia" = "印度尼西亞(印尼)"; -"enum.browsing_country.name.iran" = "伊朗"; -"enum.browsing_country.name.iraq" = "伊拉克"; -"enum.browsing_country.name.ireland" = "愛爾蘭"; -"enum.browsing_country.name.isle_of_man" = "曼島"; -"enum.browsing_country.name.israel" = "以色列"; -"enum.browsing_country.name.italy" = "義大利"; -"enum.browsing_country.name.jamaica" = "牙買加"; -"enum.browsing_country.name.japan" = "日本"; -"enum.browsing_country.name.jersey" = "澤西島"; -"enum.browsing_country.name.jordan" = "約旦"; -"enum.browsing_country.name.kazakhstan" = "哈薩克共和國"; -"enum.browsing_country.name.kenya" = "肯亞"; -"enum.browsing_country.name.kiribati" = "吉里巴斯"; -"enum.browsing_country.name.kuwait" = "科威特"; -"enum.browsing_country.name.kyrgyzstan" = "吉爾吉斯"; -"enum.browsing_country.name.lao_peoples_democratic_republic" = "寮國"; -"enum.browsing_country.name.latvia" = "拉脫維亞"; -"enum.browsing_country.name.lebanon" = "黎巴嫩"; -"enum.browsing_country.name.lesotho" = "賴索托"; -"enum.browsing_country.name.liberia" = "賴比瑞亞"; -"enum.browsing_country.name.libya" = "利比亞"; -"enum.browsing_country.name.liechtenstein" = "列支敦斯登"; -"enum.browsing_country.name.lithuania" = "立陶宛"; -"enum.browsing_country.name.luxembourg" = "盧森堡"; -"enum.browsing_country.name.macau" = "澳門"; -"enum.browsing_country.name.macedonia" = "北馬其頓"; -"enum.browsing_country.name.madagascar" = "馬達加斯加"; -"enum.browsing_country.name.malawi" = "馬拉威"; -"enum.browsing_country.name.malaysia" = "馬來西亞"; -"enum.browsing_country.name.maldives" = "馬爾地夫"; -"enum.browsing_country.name.mali" = "馬利"; -"enum.browsing_country.name.malta" = "馬爾他"; -"enum.browsing_country.name.marshall_islands" = "馬紹爾群島"; -"enum.browsing_country.name.martinique" = "馬丁尼克"; -"enum.browsing_country.name.mauritania" = "茅利塔尼亞"; -"enum.browsing_country.name.mauritius" = "模里西斯"; -"enum.browsing_country.name.mayotte" = "馬約特"; -"enum.browsing_country.name.mexico" = "墨西哥"; -"enum.browsing_country.name.micronesia" = "密克羅尼西亞"; -"enum.browsing_country.name.moldova" = "摩爾多瓦"; -"enum.browsing_country.name.monaco" = "摩納哥"; -"enum.browsing_country.name.mongolia" = "蒙古"; -"enum.browsing_country.name.montenegro" = "蒙特內哥羅"; -"enum.browsing_country.name.montserrat" = "蒙特塞拉特"; -"enum.browsing_country.name.morocco" = "摩洛哥"; -"enum.browsing_country.name.mozambique" = "莫三比克"; -"enum.browsing_country.name.myanmar" = "緬甸"; -"enum.browsing_country.name.namibia" = "納米比亞"; -"enum.browsing_country.name.nauru" = "諾魯"; -"enum.browsing_country.name.nepal" = "尼泊爾"; -"enum.browsing_country.name.netherlands" = "荷蘭"; -"enum.browsing_country.name.new_caledonia" = "新喀里多尼亞"; -"enum.browsing_country.name.new_zealand" = "紐西蘭"; -"enum.browsing_country.name.nicaragua" = "尼加拉瓜"; -"enum.browsing_country.name.niger" = "尼日"; -"enum.browsing_country.name.nigeria" = "奈及利亞"; -"enum.browsing_country.name.niue" = "紐埃"; -"enum.browsing_country.name.norfolk_island" = "諾福克島"; -"enum.browsing_country.name.north_korea" = "朝鮮"; -"enum.browsing_country.name.northern_mariana_islands" = "北馬里亞納群島"; -"enum.browsing_country.name.norway" = "挪威"; -"enum.browsing_country.name.oman" = "阿曼"; -"enum.browsing_country.name.pakistan" = "巴基斯坦"; -"enum.browsing_country.name.palau" = "帛琉"; -"enum.browsing_country.name.palestinian_territory" = "巴勒斯坦領土"; -"enum.browsing_country.name.panama" = "巴拿馬"; -"enum.browsing_country.name.papua_new_guinea" = "巴布亞紐幾內亞"; -"enum.browsing_country.name.paraguay" = "巴拉圭"; -"enum.browsing_country.name.peru" = "秘魯"; -"enum.browsing_country.name.philippines" = "菲律賓"; -"enum.browsing_country.name.pitcairn_islands" = "皮特凱恩群島"; -"enum.browsing_country.name.poland" = "波蘭"; -"enum.browsing_country.name.portugal" = "葡萄牙"; -"enum.browsing_country.name.puerto_rico" = "波多黎各"; -"enum.browsing_country.name.qatar" = "卡達"; -"enum.browsing_country.name.reunion" = "留尼旺"; -"enum.browsing_country.name.romania" = "羅馬尼亞"; -"enum.browsing_country.name.russian_federation" = "俄羅斯"; -"enum.browsing_country.name.rwanda" = "盧安達"; -"enum.browsing_country.name.saint_barthelemy" = "聖巴瑟米"; -"enum.browsing_country.name.saint_helena" = "聖赫勒拿"; -"enum.browsing_country.name.saint_kitts_and_nevis" = "聖克里斯多福及尼維斯"; -"enum.browsing_country.name.saint_lucia" = "聖露西亞"; -"enum.browsing_country.name.saint_martin" = "聖馬丁"; -"enum.browsing_country.name.saint_pierre_and_miquelon" = "聖皮耶與密克隆"; -"enum.browsing_country.name.saint_vincent_and_the_grenadines" = "聖文森及格瑞那丁"; -"enum.browsing_country.name.samoa" = "薩摩亞"; -"enum.browsing_country.name.san_marino" = "聖馬利諾"; -"enum.browsing_country.name.sao_tome_and_principe" = "聖多美普林西比"; -"enum.browsing_country.name.saudi_arabia" = "沙烏地阿拉伯"; -"enum.browsing_country.name.senegal" = "塞內加爾"; -"enum.browsing_country.name.serbia" = "塞爾維亞"; -"enum.browsing_country.name.seychelles" = "塞席爾"; -"enum.browsing_country.name.sierra_leone" = "獅子山"; -"enum.browsing_country.name.singapore" = "新加坡"; -"enum.browsing_country.name.sint_maarten" = "聖馬丁"; -"enum.browsing_country.name.slovakia" = "斯洛伐克"; -"enum.browsing_country.name.slovenia" = "斯洛維尼亞"; -"enum.browsing_country.name.solomon_islands" = "索羅門群島"; -"enum.browsing_country.name.somalia" = "索馬利亞"; -"enum.browsing_country.name.south_africa" = "南非"; -"enum.browsing_country.name.south_georgia_and_the_south_sandwich_islands" = "南喬治亞和南桑威奇群島"; -"enum.browsing_country.name.south_korea" = "韓國"; -"enum.browsing_country.name.south_sudan" = "南蘇丹"; -"enum.browsing_country.name.spain" = "西班牙"; -"enum.browsing_country.name.sri_lanka" = "斯里蘭卡"; -"enum.browsing_country.name.sudan" = "蘇丹"; -"enum.browsing_country.name.suriname" = "蘇利南"; -"enum.browsing_country.name.svalbard_and_jan_mayen" = "斯瓦巴和揚馬延"; -"enum.browsing_country.name.swaziland" = "史瓦帝尼"; -"enum.browsing_country.name.sweden" = "瑞典"; -"enum.browsing_country.name.switzerland" = "瑞士"; -"enum.browsing_country.name.syrian_arab_republic" = "敘利亞"; -"enum.browsing_country.name.taiwan" = "臺灣"; -"enum.browsing_country.name.tajikistan" = "塔吉克"; -"enum.browsing_country.name.tanzania" = "坦尚尼亞"; -"enum.browsing_country.name.thailand" = "泰國"; -"enum.browsing_country.name.timor_leste" = "東帝汶"; -"enum.browsing_country.name.togo" = "多哥"; -"enum.browsing_country.name.tokelau" = "托克勞"; -"enum.browsing_country.name.tonga" = "東加"; -"enum.browsing_country.name.trinidad_and_tobago" = "千里達和托巴哥"; -"enum.browsing_country.name.tunisia" = "突尼西亞"; -"enum.browsing_country.name.turkey" = "土耳其"; -"enum.browsing_country.name.turkmenistan" = "土庫曼"; -"enum.browsing_country.name.turks_and_caicos_islands" = "土克斯及開科斯群島"; -"enum.browsing_country.name.tuvalu" = "吐瓦魯"; -"enum.browsing_country.name.uganda" = "烏干達"; -"enum.browsing_country.name.ukraine" = "烏克蘭"; -"enum.browsing_country.name.united_arab_emirates" = "阿拉伯聯合大公國"; -"enum.browsing_country.name.united_kingdom" = "英國"; -"enum.browsing_country.name.united_states" = "美國"; -"enum.browsing_country.name.united_states_minor_outlying_islands" = "美國外圍小島嶼"; -"enum.browsing_country.name.uruguay" = "烏拉圭"; -"enum.browsing_country.name.uzbekistan" = "烏茲別克"; -"enum.browsing_country.name.vanuatu" = "萬那杜"; -"enum.browsing_country.name.venezuela" = "委內瑞拉"; -"enum.browsing_country.name.vietnam" = "越南"; -"enum.browsing_country.name.virgin_islands_british" = "英屬維京群島"; -"enum.browsing_country.name.virgin_islands_US" = "美屬維京群島"; -"enum.browsing_country.name.wallis_and_futuna" = "瓦利斯和富圖納"; -"enum.browsing_country.name.western_sahara" = "西撒哈拉"; -"enum.browsing_country.name.yemen" = "葉門"; -"enum.browsing_country.name.zambia" = "尚比亞"; -"enum.browsing_country.name.zimbabwe" = "辛巴威"; diff --git a/EhPanda/App/zh-Hant.lproj/InfoPlist.strings b/EhPanda/App/zh-Hant.lproj/InfoPlist.strings deleted file mode 100644 index c19da4373..000000000 --- a/EhPanda/App/zh-Hant.lproj/InfoPlist.strings +++ /dev/null @@ -1,7 +0,0 @@ -/* - InfoPlist.strings - EhPanda -*/ - -"NSFaceIDUsageDescription" = "我們需要您提供 Face ID 權限來使用 Face ID 解鎖"; -"NSPhotoLibraryAddUsageDescription" = "我們需要您提供照片權限來保存圖片到照片"; diff --git a/EhPanda/App/zh-Hant.lproj/Localizable.strings b/EhPanda/App/zh-Hant.lproj/Localizable.strings deleted file mode 100644 index ad77e1673..000000000 --- a/EhPanda/App/zh-Hant.lproj/Localizable.strings +++ /dev/null @@ -1,906 +0,0 @@ -/* - Localizable.strings - EhPanda -*/ - -// MARK: BanInterval -"enum.ban_interval.description.and" = ""; - -// MARK: ToplistsType -"enum.toplists_type.value.yesterday" = "昨天"; -"enum.toplists_type.value.past_month" = "上個月"; -"enum.toplists_type.value.past_year" = "去年"; -"enum.toplists_type.value.all_time" = "所有時間"; - -// MARK: Response -"website.response.hath_client_not_found" = "你需要一個連結到帳號的 H@H 用戶端才能使用這個功能"; -"website.response.hath_client_not_online" = "你的 H@H 用戶端為離線狀態,請啟動後再試"; -"website.response.invalid_resolution" = "該畫廊不能以目前選擇的解析度下載"; - -// MARK: HUD -"hud.title.error" = "錯誤"; -"hud.title.success" = "成功"; -"hud.title.loading" = "載入中..."; -"hud.title.communicating" = "連線中..."; -"hud.caption.copied_to_clipboard" = "已複製到剪貼簿"; -"hud.caption.saved_to_photo_library" = "已儲存到照片"; - -// MARK: AutoLock -"local_authorization.reason" = "由於超過 APP 自動鎖定期限,APP 已被鎖定,請重新解鎖"; - -// MARK: Common value -"common.value.stars" = "%@ 星"; -"common.value.pages" = "%@ 頁"; -"common.value.times" = "%@ 次"; -"common.value.day" = "%@ 天"; -"common.value.days" = "%@ 天"; -"common.value.hour" = "%@ 小時"; -"common.value.hours" = "%@ 小時"; -"common.value.minute" = "%@ 分"; -"common.value.minutes" = "%@ 分"; -"common.value.second" = "%@ 秒"; -"common.value.seconds" = "%@ 秒"; -"common.value.records" = "%@ 筆紀錄"; - -// MARK: 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.jump_page" = "跳到..."; -"toolbar_item.button.quick_search" = "快速搜尋"; - -// MARK: JumpPage -"jump_page_view.title.jump_page" = "跳到..."; -"jump_page_view.button.confirm" = "確定"; - -// MARK: AlertView -"loading_view.title.loading" = "載入中..."; -"loading_view.title.preparing_database" = "正在準備..."; -"not_login_view.title.need_login" = "你需要登入才能完成這個動作"; -"not_login_view.button.login" = "登入"; -"error_view.button.retry" = "重試"; -"error_view.button.drop_database" = "刪除資料庫"; -"error_view.title.try_later" = "請稍後再試"; -"error_view.title.network" = "網路發生故障,請檢查網路狀態"; -"error_view.title.parsing" = "網頁解析器發生故障"; -"error_view.title.unknown" = "發生不明錯誤"; -"error_view.title.not_found" = "這邊看起來空空如也"; -"error_view.title.database_corrupted" = "資料庫已經損壞\n請提交 issue 至 GitHub."; -"error_view.title.ip_banned" = "你的 IP 因為頁面載入次數過於頻繁而被暫時禁止存取,這可能是因為你正在使用爬蟲/鏡像軟體,禁止存取將在 %@ 後解除"; -"error_view.title.copyright_claim" = "非常抱歉,這個畫廊已因為 %@ 提出的版權聲索而不再提供存取"; -"error_view.title.gallery_unavailable" = "此畫廊已被刪除或你沒有權限存取"; - -// MARK: ConfirmationDialog -"confirmation_dialog.title.drop_database" = "繼續此操作將會清除 APP 中的所有資料\n確定要刪除資料庫?"; -"confirmation_dialog.title.remove_custom_translations" = "是否確定要刪除所有自訂翻譯?"; -"confirmation_dialog.title.logout" = "確定要登出嗎?"; -"confirmation_dialog.title.delete" = "確定要刪除?"; -"confirmation_dialog.title.clear" = "確定要清空嗎?"; -"confirmation_dialog.title.reset" = "確定要重設嗎?"; -"confirmation_dialog.button.drop_database" = "刪除資料庫"; -"confirmation_dialog.button.remove" = "移除"; -"confirmation_dialog.button.logout" = "登出"; -"confirmation_dialog.button.delete" = "刪除"; -"confirmation_dialog.button.clear" = "清空"; -"confirmation_dialog.button.reset" = "重設"; - -// MARK: SubSection -"sub_section.button.show_all" = "顯示全部"; - -// MARK: NewDawnView -"new_dawn_view.title.first" = "現在是嶄新的一天!"; -"new_dawn_view.title.second" = "回顧到目前為止的旅程,你發現自己睿智了一點。"; -// Greeting -"struct.greeting.mark.start" = "你獲得了 "; -"struct.greeting.mark.separator" = "、"; -"struct.greeting.mark.and" = " 和 "; -"struct.greeting.mark.end" = "!"; - -// MARK: HomeView -"home_view.title.home" = "總覽"; -"home_view.section.title.frontpage" = "首頁"; -"home_view.section.title.toplists" = "排行"; -"home_view.section.title.other" = "其他"; -// HomeMiscGridType -"enum.home_misc_grid_type.title.popular" = "熱門"; -"enum.home_misc_grid_type.title.watched" = "關注"; -"enum.home_misc_grid_type.title.history" = "歷程"; - -// MARK: FrontpageView -"frontpage_view.title.frontpage" = "首頁"; - -// MARK: ToplistsView -"toplists_view.title.toplists" = "排行"; - -// MARK: PopularView -"popular_view.title.popular" = "熱門"; - -// MARK: WatchedView -"watched_view.title.watched" = "關注"; - -// MARK: HistoryView -"history_view.title.history" = "歷程"; - -// MARK: FavoritesView -"favorites_view.title.favorites" = "收藏"; -// FavoriteCategory -"struct.user.favorite_category.default" = "收藏匣 %@"; -"struct.user.favorite_category.all" = "全部"; - -// MARK: SearchView -"search_view.title.search" = "搜尋"; -"search_view.section.title.recently_searched" = "最近搜尋"; -"search_view.section.title.recently_seen" = "最近閱讀"; -"search_view.section.title.quick_search" = "快速搜尋"; -// Searchable -"searchable.prompt.filter" = "過濾"; -"searchable.title.matches_count" = "找到 %d 項結果"; - -// MARK: QuickSearchView -"quick_search_view.title.quick_search" = "快速搜尋"; -"quick_search_view.title.edit_word" = "編輯關鍵字"; -"quick_search_view.title.new_word" = "新關鍵字"; -"quick_search_view.title.content" = "搜尋內容"; -"quick_search_view.title.name" = "名稱"; -"quick_search_view.placeholder.optional" = "(可選)"; - -// MARK: SettingView -"setting_view.title.setting" = "設定"; -// SettingStateRoute -"enum.setting_state_route.value.account" = "帳號"; -"enum.setting_state_route.value.general" = "一般"; -"enum.setting_state_route.value.appearance" = "外觀"; -"enum.setting_state_route.value.reading" = "閱讀"; -"enum.setting_state_route.value.laboratory" = "實驗性功能"; -"enum.setting_state_route.value.about" = "關於 EhPanda"; - -// MARK: AccountSettingView -"account_setting_view.title.account" = "帳號設定"; -"account_setting_view.title.shows_new_dawn_greeting" = "顯示黎明問候"; -"account_setting_view.button.login" = "登入"; -"account_setting_view.button.logout" = "登出"; -"account_setting_view.button.account_configuration" = "帳號設定"; -"account_setting_view.button.tags_management" = "管理訂閱標籤"; -"account_setting_view.button.copy_cookies" = "複製 Cookies"; -// CookieValue -"struct.cookie_value.localized_string.expired" = "已過期"; -"struct.cookie_value.localized_string.mystery" = "被拒絕"; -"struct.cookie_value.localized_string.none" = "None"; - -// MARK: LoginView -"login_view.title.login" = "登入"; -"login_view.title.username" = "Username"; -"login_view.title.password" = "Password"; - -// MARK: GeneralSettingView -"general_setting_view.title.general" = "一般設定"; -"general_setting_view.title.language" = "語言"; -"general_setting_view.title.auto_lock" = "自動鎖定"; -"general_setting_view.title.enables_tags_extension" = "啟用自訂標籤擴充功能"; -"general_setting_view.title.translates_tags" = "標籤翻譯"; -"general_setting_view.title.shows_tags_search_suggestion" = "搜尋時顯示標籤建議"; -"general_setting_view.title.shows_images_in_tags" = "在標籤顯示圖片"; -"general_setting_view.title.redirects_links_to_the_selected_host" = "將連結重新導向至選擇的網站"; -"general_setting_view.title.detects_links_from_clipboard" = "偵測剪貼簿中的連結"; -"general_setting_view.title.background_blur_radius" = "後台背景模糊"; -"general_setting_view.button.logs" = "日誌"; -"general_setting_view.button.import_custom_translations" = "匯入自訂標籤翻譯"; -"general_setting_view.button.remove_custom_translations" = "刪除自訂標籤翻譯"; -"general_setting_view.button.clear_image_caches" = "清理圖片快取"; -"general_setting_view.value.default_language_description" = "N/A"; -"general_setting_view.section.title.tags" = "標籤"; -"general_setting_view.section.title.navigation" = "導覽"; -"general_setting_view.section.title.security" = "安全"; -"general_setting_view.section.title.caches" = "快取"; -// AutoLockPolicy -"enum.auto_lock_policy.value.never" = "永不自動鎖定"; -"enum.auto_lock_policy.value.instantly" = "立刻"; - -// MARK: LogsView -"logs_view.title.logs" = "日誌"; -"logs_view.title.latest" = "最新"; - -// MARK: AppearanceSettingView -"appearance_setting_view.title.appearance" = "外觀設定"; -"appearance_setting_view.title.theme" = "主題"; -"appearance_setting_view.title.tint_color" = "強調色"; -"appearance_setting_view.title.display_mode" = "顯示模式"; -"appearance_setting_view.title.shows_tags_in_list" = "在列表中顯示標籤"; -"appearance_setting_view.title.maximum_number_of_tags" = "標籤最大顯示數量"; -"appearance_setting_view.title.displays_japanese_title" = "以日文顯示標籤"; -"appearance_setting_view.button.app_icon" = "App 圖案"; -"appearance_setting_view.menu.title.infite" = "無限"; -"appearance_setting_view.section.title.list" = "列表"; -"appearance_setting_view.section.title.gallery" = "畫廊"; -// PreferredColorScheme -"enum.preferred_color_scheme.value.automatic" = "自動"; -"enum.preferred_color_scheme.value.light" = "淺色"; -"enum.preferred_color_scheme.value.dark" = "深色"; -// AppIconType -"enum.app_icon_type.value.default" = "預設"; -"enum.app_icon_type.value.ukiyoe" = "Ukiyo-e"; -"enum.app_icon_type.value.developer" = "Developer"; -"enum.app_icon_type.value.stand_with_ukraine_2022" = "Stand With Ukraine (2022)"; -"enum.app_icon_type.value.not_my_president" = "NOT MY PRESIDENT"; -// ListDisplayMode -"enum.list_display_mode.value.detail" = "詳細"; -"enum.list_display_mode.value.thumbnail" = "縮圖"; - -// MARK: AppIconView -"app_icon_view.title.app_icon" = "App 圖案"; - -// MARK: reading_settingView -"reading_setting_view.title.reading" = "閱讀設定"; -"reading_setting_view.title.direction" = "翻頁方向"; -"reading_setting_view.title.preload_limit" = "預先載入頁數"; -"reading_setting_view.title.enables_landscape" = "啟用橫向顯示"; -"reading_setting_view.title.separator_height" = "頁間分隔高度"; -"reading_setting_view.title.maximum_scale_factor" = "縮放上限"; -"reading_setting_view.title.double_tap_scale_factor" = "雙擊縮放值"; -"reading_setting_view.section.title.appearance" = "外觀"; -// ReadingDirection -"enum.reading_direction.value.vertical" = "垂直"; -"enum.reading_direction.value.right_to_left" = "由右至左滑"; -"enum.reading_direction.value.left_to_right" = "由左至右滑"; - -// MARK: LaboratorySettingView -"laboratory_setting_view.title.laboratory" = "實驗性功能"; -"laboratory_setting_view.title.bypasses_SNI_filtering" = "繞過 SNI 過濾"; - -// MARK: AboutView -"about_view.title.ehPanda" = "EhPanda"; -"about_view.button.website" = "官方網站"; -"about_view.button.altStore_source" = "AltStore source"; -"about_view.title.version" = "版本"; -"about_view.section.title.special_thanks" = "特別銘謝"; -"about_view.section.title.code_level_contributors" = "程式碼貢獻者"; -"about_view.section.title.translation_contributors" = "翻譯貢獻者"; -"about_view.section.title.acknowledgements" = "致謝"; - -// MARK: DetailView -"detail_view.button.read" = "閱讀"; -"detail_view.button.post_comment" = "發表留言"; -"detail_view.toolbar_item.button.archives" = "存檔至 H@H 用戶端"; -"detail_view.toolbar_item.button.torrents" = "種子"; -"detail_view.toolbar_item.button.share" = "分享"; -"detail_view.context_menu.button.detail" = "Detail"; -"detail_view.context_menu.button.withdraw_vote" = "收回評分"; -"detail_view.context_menu.button.vote_up" = "Vote up"; -"detail_view.context_menu.button.vote_down" = "Vote down"; -"detail_view.description_section.title.favorited" = "收藏"; -"detail_view.description_section.title.language" = "語言"; -"detail_view.description_section.title.ratings" = "%@ 個評分"; -"detail_view.description_section.title.page_count" = "頁數"; -"detail_view.description_section.title.file_size" = "檔案大小"; -"detail_view.description_section.description.favorited" = "次"; -"detail_view.description_section.description.page_count" = "頁"; -"detail_view.action_section.button.give_a_rating" = "給予評分"; -"detail_view.action_section.button.similar_gallery" = "類似畫廊"; -"detail_view.section.title.previews" = "預覽"; -"detail_view.section.title.comments" = "留言"; - -// MARK: ArchivesView -"archives_view.title.archives" = "存檔"; -"archives_view.button.download_to_hath_client" = "下載至 H@H 用戶端"; -// HathArchive -"struct.hath_archive.price.free" = "免費"; -"struct.hath_archive.price.not_available" = "N/A"; -// ArchiveResolution -"enum.archive_resolution.value.original" = "原始畫質"; - -// MARK: TorrentsView -"torrents_view.title.torrents" = "種子"; - -// MARK: GalleryInfosView -"gallery_infos_view.title.gallery_infos" = "畫廊資訊"; -"gallery_infos_view.title.id" = "畫廊 ID"; -"gallery_infos_view.title.token" = "Token"; -"gallery_infos_view.title.title" = "標題"; -"gallery_infos_view.title.japanese_title" = "日文標題"; -"gallery_infos_view.title.gallery_URL" = "畫廊 URL"; -"gallery_infos_view.title.cover_URL" = "封面 URL"; -"gallery_infos_view.title.archive_URL" = "存檔 URL"; -"gallery_infos_view.title.torrent_URL" = "種子 URL"; -"gallery_infos_view.title.parent_URL" = "Parent URL"; -"gallery_infos_view.title.category" = "類別"; -"gallery_infos_view.title.uploader" = "上傳者"; -"gallery_infos_view.title.posted_date" = "發布日期"; -"gallery_infos_view.title.visibility" = "能見度"; -"gallery_infos_view.title.language" = "語言"; -"gallery_infos_view.title.page_count" = "頁數"; -"gallery_infos_view.title.file_size" = "檔案大小"; -"gallery_infos_view.title.favorited_times" = "被收藏次數"; -"gallery_infos_view.title.favorited" = "已收藏"; -"gallery_infos_view.title.rating_count" = "被評分次數"; -"gallery_infos_view.title.average_rating" = "平均評分"; -"gallery_infos_view.title.my_rating" = "我的評分"; -"gallery_infos_view.title.torrent_count" = "種子數量"; -"gallery_infos_view.value.none" = "None"; -"gallery_infos_view.value.yes" = "Yes"; -"gallery_infos_view.value.no" = "No"; -// GalleryVisibility -"enum.gallery_visibility.value.yes" = "Yes"; -"enum.gallery_visibility.value.no" = "No (%@)"; -"enum.gallery_visibility.value.no.reason.expunged" = "已被刪除"; - -// MARK: TagDetailView -"tag_detail_view.section.title.images" = "圖片"; -"tag_detail_view.section.title.links" = "連結"; - -// MARK: CommentsView -"comments_view.title.comments" = "留言"; - -// MARK: PostCommentView -"post_comment_view.title.post_comment" = "發表留言"; -"post_comment_view.title.edit_comment" = "編輯留言"; - -// MARK: PreviewsView -"previews_view.title.previews" = "預覽"; - -// MARK: ReadingView -"reading_view.context_menu.button.reload" = "重新整理"; -"reading_view.context_menu.button.copy" = "複製"; -"reading_view.context_menu.button.save" = "儲存圖片"; -"reading_view.context_menu.button.save_original" = "儲存原始圖片"; -"reading_view.context_menu.button.share" = "分享"; -"reading_view.toolbar_item.title.auto_play" = "自動播放"; -"reading_view.toolbar_item.title.dual_page_mode" = "雙頁模式"; -"reading_view.toolbar_item.title.except_the_cover" = "封面除外"; -"reading_view.toolbar_item.button.retry_all_failed_images" = "重新載入失敗圖片"; -"reading_view.toolbar_item.button.reload_all_images" = "重新載入所有圖片"; -"reading_view.toolbar_item.button.reading_setting" = "閱讀設定"; -// AutoPlayPolicy -"enum.auto_play_policy.value.off" = "關閉"; - -// MARK: FiltersView -"filters_view.title.filters" = "過濾"; -"filters_view.title.advanced_settings" = "進階選項"; -"filters_view.title.search_gallery_name" = "搜尋畫廊名稱"; -"filters_view.title.search_gallery_tags" = "搜尋畫廊標籤"; -"filters_view.title.search_gallery_description" = "搜尋畫廊描述"; -"filters_view.title.search_torrent_filenames" = "搜尋種子檔案名"; -"filters_view.title.only_show_galleries_with_torrents" = "只顯示有種子的畫廊"; -"filters_view.title.search_low_power_tags" = "搜尋低期望標籤"; -"filters_view.title.search_downvoted_tags" = "搜尋低評價標籤"; -"filters_view.title.search_expunged_galleries" = "顯示被刪除的畫廊"; -"filters_view.title.set_minimum_rating" = "設定評分下限"; -"filters_view.title.minimum_rating" = "評分下限"; -"filters_view.title.set_pages_range" = "設定頁數範圍"; -"filters_view.title.pages_range" = "頁數範圍"; -"filters_view.title.disable_language_filter" = "停用語言篩選"; -"filters_view.title.disable_uploader_filter" = "停用上傳者篩選"; -"filters_view.title.disable_tags_filter" = "停用標籤篩選"; -"filters_view.button.reset_filters" = "重設所有選項"; -"filters_view.section.title.advanced" = "進階"; -"filters_view.section.title.default_filter" = "預設篩選"; -// FilterRange -"enum.filter_range.value.search" = "搜尋"; -"enum.filter_range.value.global" = "全域"; -"enum.filter_range.value.watched" = "標籤"; - -// MARK: EhSettingView -"eh_setting_view.title.host_settings" = "%@ 設定"; -"eh_setting_view.section.title.profile_settings" = "設定檔設定"; -"eh_setting_view.title.selected_profile" = "選擇設定檔"; -"eh_setting_view.button.set_as_default" = "設為預設"; -"eh_setting_view.button.delete_profile" = "刪除設定檔"; -"eh_setting_view.button.rename" = "重新命名"; -"eh_setting_view.button.create_new" = "新增設定檔"; -"eh_setting_view.toolbar_item.button.done" = "完成"; - -"eh_setting_view.section.title.image_load_settings" = "圖片來源設定"; -"eh_setting_view.title.load_images_through_the_hath_network" = "透過 Hath Network 載入圖片"; -"eh_setting_view.title.browsing_country" = "所在國家"; -"eh_setting_view.description.browsing_country" = "你似乎是從 **%@** 瀏覽這個網站或在使用當地的 VPN,這意味著網站將嘗試從這個地理區域的 H@H 用戶端載入圖片。如果這不正確或你出於任何原因想要使用不同的區域(例如你正在透過 VPN 連線),您可以在下面選擇不同的國家/地區。"; -// EhSetting.LoadThroughHathSetting -"enum.eh_setting.load_through_hath_setting.value.any_client" = "任何用戶端"; -"enum.eh_setting.load_through_hath_setting.value.default_port_only" = "只使用預設連接埠的用戶端"; -"enum.eh_setting.load_through_hath_setting.value.modern_no" = "No [現代/Modern/HTTPS]"; -"enum.eh_setting.load_through_hath_setting.value.legacy_no" = "No [傳統/Legacy/HTTP]"; -"enum.eh_setting.load_through_hath_setting.description.any_client" = "建議選項(預設)"; -"enum.eh_setting.load_through_hath_setting.description.default_port_only" = "如果網路防火牆會阻擋任何非預設傳出連接埠則使用這個選項(可能較慢)"; -"enum.eh_setting.load_through_hath_setting.description.modern_no" = "E-Hentai 贊助者功能: 你將無法同時瀏覽多個頁面,僅在出現重大錯誤時才啟用這個選項"; -"enum.eh_setting.load_through_hath_setting.description.legacy_no" = "E-Hentai 贊助者功能: 在現代瀏覽器上預設設定可能無法正常工作,僅推薦用於傳統瀏覽器"; - -"eh_setting_view.section.title.image_size_settings" = "圖片尺寸設定"; -"eh_setting_view.title.image_resolution" = "圖片解析度"; -"eh_setting_view.description.image_resolution" = "一般情況下圖片會被縮放成 1280 px 水平解析度以供線上瀏覽,你也可以選擇下列解析度之一。為了避免破壞伺服器正常運作,高於 1280x 的解析度暫時僅提供下列使用者使用: 贊助者、具有任何 Hath Perk 的使用者以及 UID 低於 3,000,000 的使用者"; -"eh_setting_view.title.image_size" = "圖片尺寸"; -"eh_setting_view.description.image_size" = "雖然網站會自動縮小圖片以適應瀏覽裝置的螢幕寬度,但您也可以手動限製圖片的最大顯示尺寸。 像自動縮放一樣,這不會重新採樣圖像,因為調整大小是在瀏覽器完成的 (0 = 沒有限制)"; -"eh_setting_view.title.horizontal" = "水平尺寸(寬)"; -"eh_setting_view.title.vertical" = "垂直尺寸(長)"; -// EhSetting.ImageResolution -"enum.eh_setting.image_resolution.value.auto" = "自動"; - -"eh_setting_view.section.title.gallery_name_display" = "畫廊顯示名稱"; -"eh_setting_view.title.gallery_name" = "畫廊名稱"; -"eh_setting_view.description.gallery_name" = "許多畫廊同時提供了英文/預設標題與日文標題,你想優先顯示哪種畫廊名稱?"; -// EhSetting.GalleryName -"enum.eh_setting.gallery_name.value.default" = "預設標題"; -"enum.eh_setting.gallery_name.value.japanese" = "日文標題(若該畫廊支援)"; - -"eh_setting_view.section.title.archiver_settings" = "存檔設定"; -"eh_setting_view.title.archiver_behavior" = "存檔邏輯設定"; -"eh_setting_view.description.archiver_behavior" = "存檔的預設邏輯是確認原始圖片或重新採樣後進行存檔的成本差異與選擇,然後顯示一個可以在其他地方點擊、複製的連結,你可以在此處更改他的運作方式。"; -// EhSetting.ArchiverBehavior -"enum.eh_setting.archiver_behavior.value.manual_select_manual_start" = "手動選擇,手動開始下載(預設)"; -"enum.eh_setting.archiver_behavior.value.manual_select_auto_start" = "手動選擇,自動開始下載"; -"enum.eh_setting.archiver_behavior.value.auto_select_original_manual_start" = "自動選擇原始畫質,,手動開始下載"; -"enum.eh_setting.archiver_behavior.value.auto_select_original_auto_start" = "自動選擇原始畫質並開始下載"; -"enum.eh_setting.archiver_behavior.value.auto_select_resample_manual_start" = "自動選擇重新採樣,手動開始下載"; -"enum.eh_setting.archiver_behavior.value.auto_select_resample_auto_start" = "自動選擇重新採樣並開始下載"; - -"eh_setting_view.section.title.front_page_settings" = "首頁設定"; -"eh_setting_view.title.display_mode" = "顯示模式"; -"eh_setting_view.description.display_mode" = "你想在首頁和搜尋結果中使用哪一種顯示方式?"; -"eh_setting_view.section.title.show_search_range_indicator" = "搜尋範圍指示器"; -"eh_setting_view.title.show_search_range_indicator" = "顯示搜尋範圍指示器"; -"eh_setting_view.description.gallery_category" = "預設情況下你希望在首頁和搜尋結果中顯示哪些類別的結果?"; -// EhSetting.DisplayMode -"enum.eh_setting.display_mode.value.compact" = "緊湊(Compact)"; -"enum.eh_setting.display_mode.value.thumbnail" = "縮圖(Thumbnail)"; -"enum.eh_setting.display_mode.value.extended" = "放大(Extended)"; -"enum.eh_setting.display_mode.value.minimal" = "最小(Minimal)"; -"enum.eh_setting.display_mode.value.minimalPlus" = "Minimal+"; - -"eh_setting_view.section.title.optional_UI_elements" = "可選用的 UI 元件"; -"eh_setting_view.description.optional_UI_elements" = "一些舊版 UI 元件現已預設停用。您可以在此啟用這些元件。"; -"eh_setting_view.title.enable_gallery_thumbnail_selector" = "在畫廊頁面啟用縮圖選擇器"; - -"eh_setting_view.section.title.favorites" = "收藏匣"; -"eh_setting_view.description.favorite_categories" = "在這裡你可以選擇並重新命名收藏匣"; -"eh_setting_view.title.favorites_sort_order" = "收藏匣排序"; -"eh_setting_view.description.favorites_sort_order" = "你還可以在收藏匣頁面上為畫廊選擇預設排列順序。 請注意,在 2016 年 3 月網站改版之前新增的畫廊並不儲存時間印記,並且無論使用何種設定,都將使用畫廊發佈時間作為排序參考。"; -// EhSetting.FavoritesSortOrder -"enum.eh_setting.favorites_sort_order.value.last_update_time" = "透過最後更新時間排序"; -"enum.eh_setting.favorites_sort_order.value.favorited_time" = "透過收藏順序排序"; - -"eh_setting_view.section.title.ratings" = "評分"; -"eh_setting_view.title.ratings_color" = "評分顏色"; -"eh_setting_view.promt.ratings_color" = "RRGGB"; -"eh_setting_view.description.ratings_color" = "預設情況下,你評分的畫廊將 2 星及以下的評分顯示為紅色星,2.5 ~ 4 顆星的評分為綠色,4.5 ~ 5 顆星的評分為藍色。 透過在下面輸入顏色組合你可以自訂想顯示的顏色。 每個字母各代表一顆星(1~5),預設的 RRGGB 表示第一顆和第二顆星的 R(ed),第三顆和第四顆的 G(reen),第五顆的 B(lue)。 你也可以將 (Y)ellow 用於普通星星。 任何五個字母的 R/G/B/Y 組合都有效"; - -"eh_setting_view.section.title.tag_filtering_threshold" = "過濾標籤閾值"; -"eh_setting_view.title.tag_filtering_threshold" = "閾值"; -"eh_setting_view.description.tag_filtering_threshold" = "你可以透過將標籤新增到具有負數權重的“我的標籤”清單中來過濾標籤。 如果畫廊的標籤加起來的權重低於此值,則會被從列表中過濾掉,此閾值可以設定在 0 ~ -9999 之間"; - -"eh_setting_view.section.title.tag_watching_threshold" = "關注標籤閾值"; -"eh_setting_view.title.tag_watching_threshold" = "關注標籤生效的閾值"; -"eh_setting_view.description.tag_watching_threshold" = "如果最近上傳的畫廊中至少有一個具有正權重的你正在關注/追蹤的標籤,並且這些標籤所具有的權重高於此設定中的數值,那這個畫廊將會出現在「追蹤標籤」頁面中,此閾值可以設定在 0 ~ -9999 之間"; - -"eh_setting_view.section.title.filtered_removal_count" = "顯示過濾結果計數器"; -"eh_setting_view.description.filtered_removal_count" = "顯示 \"Your default filters removed XX galleries from this page\" ?"; -"eh_setting_view.title.show_filtered_removal_count" = "是否顯示過濾結果計數器"; - -"eh_setting_view.section.title.excluded_languages" = "排除語言"; -"eh_setting_view.description.excluded_languages" = "如果你希望從畫廊列表和搜尋中隱藏掉某些語言的畫廊,請從下面的清單中選取它們。請注意,無論你的搜尋查詢如何,相符於篩除規則的畫廊都不會出現。"; -// EhSetting.ExcludedLanguagesCategory -"enum.eh_setting.excluded_languages_category.value.original" = "原始語言"; -"enum.eh_setting.excluded_languages_category.value.translated" = "翻譯語言"; -"enum.eh_setting.excluded_languages_category.value.rewrite" = "覆寫"; - -"eh_setting_view.section.title.excluded_uploaders" = "排除的上傳者"; -"eh_setting_view.description.excluded_uploaders" = "如果你希望從畫廊列表和搜尋結果中隱藏某些上傳者的畫廊,請將它們新增到下方,每行輸入一個使用者名稱。請注意,無論你的搜尋結果如何,這些上傳者的畫廊都不會出現。"; -"eh_setting_view.description.excluded_uploaders_count" = "你正在使用 **%@ / %@** 排除欄位"; - -"eh_setting_view.section.title.search_result_count" = "搜尋結果數量上限"; -"eh_setting_view.title.result_count" = "數量上限"; -"eh_setting_view.description.result_count" = "你希望每頁顯示幾個搜尋結果?\n(該功能需要有 Hath Perk: Paging Enlargement)"; - -"eh_setting_view.section.title.thumbnail_settings" = "縮圖設定"; -"eh_setting_view.title.thumbnail_load_timing" = "縮圖載入時機設定"; -"eh_setting_view.description.thumbnail_load_timing" = "使用列表模式時,你希望如何載入以滑鼠位置顯示的縮圖?"; -"eh_setting_view.description.thumbnail_configuration" = "你可以為存取的所有畫廊設定預設的縮圖配置。"; -"eh_setting_view.title.thumbnail_size" = "尺寸"; -"eh_setting_view.title.thumbnail_row_count" = "行數"; -// EhSetting.ThumbnailLoadTiming -"enum.eh_setting.thumbnail_load_timing.value.on_mouse_over" = "滑鼠位置"; -"enum.eh_setting.thumbnail_load_timing.value.on_page_load" = "網頁載入位置"; -"enum.eh_setting.thumbnail_load_timing.description.on_mouse_over" = "網頁的載入速度更快,但縮圖出現的時間可能稍有延遲"; -"enum.eh_setting.thumbnail_load_timing.description.on_page_load" = "網頁需要更多的載入時間,但是在網頁完全載入後縮圖顯示不會有任何延遲"; -// EhSetting.ThumbnailSize -"enum.eh_setting.thumbnail_size.value.normal" = "正常"; -"enum.eh_setting.thumbnail_size.value.large" = "大型"; -"enum.eh_setting.thumbnail_size.value.small" = "小型"; -"enum.eh_setting.thumbnail_size.value.auto" = "自動"; - -"eh_setting_view.section.title.cover_scaling" = "封面縮放"; -"eh_setting_view.title.scale_factor" = "縮放比例"; -"eh_setting_view.description.cover_scale_factor" = "在縮圖與放大檢視這兩種檢視模式下,封面的重新採樣比率介於 75%% 至 150%%."; - -"eh_setting_view.section.title.viewport_override" = "視窗覆蓋"; -"eh_setting_view.title.virtual_width" = "虛擬寬度"; -"eh_setting_view.description.virtual_width" = "允許你覆蓋行動裝置網站的虛擬寬度。 這通常由你的裝置根據其 DPI 自動確定。 100%% 縮圖比例的合理值介於 640 和 1400 之間。"; - -"eh_setting_view.section.title.gallery_comments" = "畫廊留言"; -"eh_setting_view.title.comments_sort_order" = "留言排序方式"; -"eh_setting_view.title.comments_votes_show_timing" = "留言投票數顯示時機"; -// EhSetting.CommentsSortOrder -"enum.eh_setting.comments_sort_order.value.oldest" = "最舊留言優先"; -"enum.eh_setting.comments_sort_order.value.recent" = "最新留言優先"; -"enum.eh_setting.comments_sort_order.value.highest_score" = "最相關留言優先"; -// EhSetting.CommentVotesShowTiming -"enum.eh_setting.comments_votes_show_timing.value.on_hover_or_click" = "滑鼠在分數上停留或點擊時"; -"enum.eh_setting.comments_votes_show_timing.value.always" = "總是顯示"; - -"eh_setting_view.section.title.gallery_tags" = "畫廊標籤"; -"eh_setting_view.title.tags_sort_order" = "標籤顯示順序"; -// EhSetting.tags_sort_order -"enum.eh_setting.tags_sort_order.value.alphabetical" = "字母順序"; -"enum.eh_setting.tags_sort_order.value.tag_power" = "標籤權重"; - -"eh_setting_view.section.title.gallery_page_thumbnail_labeling" = "畫廊頁面縮圖標籤"; -"eh_setting_view.title.show_label_below_gallery_thumbnails" = "在畫廊縮圖下方顯示標籤"; - -"eh_setting_view.section.title.hath_local_network_host" = "內網 H@H 服務 (Hath Local Network Host)"; -"eh_setting_view.title.ip_address_port" = "IP 位址:連接埠號"; -"eh_setting_view.description.ip_address_port" = "如果你在內網路上使用與瀏覽站點相同的公共 IP 架設 H@H 用戶端,有些路由器會因此發生問題,無法將請求傳回自己的 IP,透過啟用這項設定可以解決此問題。\n如果您在瀏覽的同一裝置上執行用戶端,請使用回環地址 (127.0.0.1:port)。 如果用戶端在您網路上的另一台裝置上執行,請使用其本機網路 IP。 某些瀏覽器配置會阻止外部網站存取具有本機網路 IP 的 URL,你必須將站點列入白名單才能使其正常工作。"; - -"eh_setting_view.section.title.original_images" = "要使用原始圖片而非重新取樣的版本嗎? 若您在上方選擇「自動」以外的水準解析度且圖片較寬,或原始圖片大於 10 MiB(一年以上的圖庫則為 4 MiB),系統仍會使用重新取樣的圖片。"; -"eh_setting_view.title.use_original_images" = "使用原始圖片(原解析度)"; - -"eh_setting_view.section.title.multi_page_viewer" = "多頁瀏覽"; -"eh_setting_view.title.use_multi_page_viewer" = "使用多頁瀏覽"; -"eh_setting_view.title.display_style" = "顯示方式"; -"eh_setting_view.title.show_thumbnail_pane" = "顯示縮圖窗格"; -// EhSetting.MultiplePageViewerStyle -"enum.eh_setting.multiple_page_viewer_style.value.align_left_scale_if_over_width" = "向左對齊,若寬度超出頁面則進行縮放"; -"enum.eh_setting.multiple_page_viewer_style.value.align_center_scale_if_over_width" = "置中對齊,若寬度超出頁面則進行縮放"; -"enum.eh_setting.multiple_page_viewer_style.value.align_center_always_scale" = "置中對齊且總是縮放"; -// EhSetting.GalleryPageNumbering -"enum.eh_setting.gallery_page_numbering.value.none" = "不顯示"; -"enum.eh_setting.gallery_page_numbering.value.page_number_only" = "只顯示頁碼"; -"enum.eh_setting.gallery_page_numbering.value.page_number_and_name" = "顯示頁碼和名稱"; - -// MARK: Category -"enum.category.value.doujinshi" = "同人誌"; -"enum.category.value.manga" = "漫畫"; -"enum.category.value.artist_CG" = "插畫"; -"enum.category.value.game_CG" = "遊戲 CG"; -"enum.category.value.western" = "西方"; -"enum.category.value.non_h" = "健康"; -"enum.category.value.image_set" = "圖片集"; -"enum.category.value.cosplay" = "角色扮演"; -"enum.category.value.asian_porn" = "亞洲"; -"enum.category.value.misc" = "其它"; -"enum.category.value.private" = "私人"; - -// MARK: TagNamespace -"enum.tag_namespace.value.reclass" = "重新分類"; -"enum.tag_namespace.value.language" = "語言"; -"enum.tag_namespace.value.parody" = "原作"; -"enum.tag_namespace.value.character" = "角色"; -"enum.tag_namespace.value.group" = "團體"; -"enum.tag_namespace.value.artist" = "作者"; -"enum.tag_namespace.value.male" = "男性"; -"enum.tag_namespace.value.female" = "女性"; -"enum.tag_namespace.value.mixed" = "Mixed"; -"enum.tag_namespace.value.cosplayer" = "Cosplayer"; -"enum.tag_namespace.value.other" = "其他"; -"enum.tag_namespace.value.temp" = "Temp"; - -// MARK: Language -"enum.language.value.invalid" = "無效"; -"enum.language.value.other" = "其它"; -"enum.language.value.afrikaans" = "南非語"; -"enum.language.value.albanian" = "阿爾巴尼亞語"; -"enum.language.value.arabic" = "阿拉伯語"; -"enum.language.value.bengali" = "孟加拉語"; -"enum.language.value.bosnian" = "波士尼亞語"; -"enum.language.value.bulgarian" = "保加利亞語"; -"enum.language.value.burmese" = "緬甸語"; -"enum.language.value.catalan" = "加泰隆尼亞語"; -"enum.language.value.cebuano" = "宿霧語"; -"enum.language.value.chinese" = "漢語"; -"enum.language.value.croatian" = "克羅埃西亞語"; -"enum.language.value.czech" = "捷克語"; -"enum.language.value.danish" = "丹麥語"; -"enum.language.value.dutch" = "荷蘭語"; -"enum.language.value.english" = "英語"; -"enum.language.value.esperanto" = "國際語"; -"enum.language.value.estonian" = "愛沙尼亞語"; -"enum.language.value.finnish" = "芬蘭語"; -"enum.language.value.french" = "法語"; -"enum.language.value.georgian" = "喬治亞語"; -"enum.language.value.german" = "德語"; -"enum.language.value.greek" = "希臘語"; -"enum.language.value.hebrew" = "希伯來語"; -"enum.language.value.hindi" = "印度語"; -"enum.language.value.hmong" = "苗語"; -"enum.language.value.hungarian" = "匈牙利語"; -"enum.language.value.indonesian" = "印尼語"; -"enum.language.value.italian" = "義大利語"; -"enum.language.value.japanese" = "日語"; -"enum.language.value.kazakh" = "哈薩克語"; -"enum.language.value.khmer" = "高棉語"; -"enum.language.value.korean" = "韓語"; -"enum.language.value.kurdish" = "庫德語"; -"enum.language.value.lao" = "寮語"; -"enum.language.value.latin" = "拉丁語"; -"enum.language.value.mongolian" = "蒙古語"; -"enum.language.value.ndebele" = "恩德貝萊語"; -"enum.language.value.nepali" = "尼泊爾語"; -"enum.language.value.norwegian" = "挪威語"; -"enum.language.value.oromo" = "奧羅莫語"; -"enum.language.value.pashto" = "普什圖語"; -"enum.language.value.persian" = "波斯語"; -"enum.language.value.polish" = "波蘭語"; -"enum.language.value.portuguese" = "葡萄牙語"; -"enum.language.value.punjabi" = "旁遮普語"; -"enum.language.value.romanian" = "羅馬尼亞語"; -"enum.language.value.russian" = "俄語"; -"enum.language.value.sango" = "桑戈語"; -"enum.language.value.serbian" = "塞爾維亞語"; -"enum.language.value.shona" = "紹納語"; -"enum.language.value.slovak" = "斯洛伐克語"; -"enum.language.value.slovenian" = "斯洛維尼亞語"; -"enum.language.value.somali" = "索馬利語"; -"enum.language.value.spanish" = "西班牙語"; -"enum.language.value.swahili" = "斯瓦希里語"; -"enum.language.value.swedish" = "瑞典語"; -"enum.language.value.tagalog" = "他加祿語"; -"enum.language.value.thai" = "泰語"; -"enum.language.value.tigrinya" = "提格利尼亞語"; -"enum.language.value.turkish" = "土耳其語"; -"enum.language.value.ukrainian" = "烏克蘭語"; -"enum.language.value.urdu" = "烏爾都語"; -"enum.language.value.vietnamese" = "越南語"; -"enum.language.value.zulu" = "祖魯語"; - -// MARK: BrowsingCountry -"enum.browsing_country.name.auto_detect" = "自動偵測"; -"enum.browsing_country.name.afghanistan" = "阿富汗"; -"enum.browsing_country.name.aland_islands" = "奧蘭群島"; -"enum.browsing_country.name.albania" = "阿爾巴尼亞"; -"enum.browsing_country.name.algeria" = "阿爾及利亞"; -"enum.browsing_country.name.american_samoa" = "美屬薩摩亞"; -"enum.browsing_country.name.andorra" = "安道爾"; -"enum.browsing_country.name.angola" = "安哥拉"; -"enum.browsing_country.name.anguilla" = "安圭拉"; -"enum.browsing_country.name.antarctica" = "南極洲"; -"enum.browsing_country.name.antigua_and_barbuda" = "安地卡及巴布達"; -"enum.browsing_country.name.argentina" = "阿根廷"; -"enum.browsing_country.name.armenia" = "亞美尼亞"; -"enum.browsing_country.name.aruba" = "阿魯巴"; -"enum.browsing_country.name.asia_pacific_region" = "亞太地區"; -"enum.browsing_country.name.australia" = "澳洲"; -"enum.browsing_country.name.austria" = "奧地利"; -"enum.browsing_country.name.azerbaijan" = "亞塞拜然"; -"enum.browsing_country.name.bahamas" = "巴哈馬"; -"enum.browsing_country.name.bahrain" = "巴林"; -"enum.browsing_country.name.bangladesh" = "孟加拉"; -"enum.browsing_country.name.barbados" = "巴貝多"; -"enum.browsing_country.name.belarus" = "白俄羅斯"; -"enum.browsing_country.name.belgium" = "比利時"; -"enum.browsing_country.name.belize" = "貝里斯"; -"enum.browsing_country.name.benin" = "貝南"; -"enum.browsing_country.name.bermuda" = "百慕達"; -"enum.browsing_country.name.bhutan" = "不丹"; -"enum.browsing_country.name.bolivia" = "玻利維亞"; -"enum.browsing_country.name.bonaire_saint_eustatius_and_saba" = "博奈爾、聖尤斯特歇斯和薩巴"; -"enum.browsing_country.name.bosnia_and_herzegovina" = "波士尼亞"; -"enum.browsing_country.name.botswana" = "波札那"; -"enum.browsing_country.name.bouvet_island" = "布威島"; -"enum.browsing_country.name.brazil" = "巴西"; -"enum.browsing_country.name.british_indian_ocean_territory" = "英屬印度洋領地"; -"enum.browsing_country.name.brunei_darussalam" = "汶萊"; -"enum.browsing_country.name.bulgaria" = "保加利亞"; -"enum.browsing_country.name.burkina_faso" = "布吉納法索"; -"enum.browsing_country.name.burundi" = "蒲隆地"; -"enum.browsing_country.name.cambodia" = "柬埔寨"; -"enum.browsing_country.name.cameroon" = "喀麥隆"; -"enum.browsing_country.name.canada" = "加拿大"; -"enum.browsing_country.name.cape_verde" = "維德角"; -"enum.browsing_country.name.cayman_islands" = "開曼群島"; -"enum.browsing_country.name.central_african_republic" = "中非"; -"enum.browsing_country.name.chad" = "查德"; -"enum.browsing_country.name.chile" = "智利"; -"enum.browsing_country.name.china" = "中國"; -"enum.browsing_country.name.christmas_island" = "聖誕島"; -"enum.browsing_country.name.cocos_islands" = "科科斯(基林)群島"; -"enum.browsing_country.name.colombia" = "哥倫比亞"; -"enum.browsing_country.name.comoros" = "葛摩"; -"enum.browsing_country.name.congo" = "剛果共和國"; -"enum.browsing_country.name.the_democratic_republic_of_the_congo" = "剛果民主共和國"; -"enum.browsing_country.name.cook_islands" = "庫克群島"; -"enum.browsing_country.name.costa_rica" = "哥斯大黎加"; -"enum.browsing_country.name.cote_d_ivoire" = "象牙海岸"; -"enum.browsing_country.name.croatia" = "克羅埃西亞"; -"enum.browsing_country.name.cuba" = "古巴"; -"enum.browsing_country.name.curacao" = "古拉索"; -"enum.browsing_country.name.cyprus" = "賽普勒斯"; -"enum.browsing_country.name.czech_republic" = "捷克共和國"; -"enum.browsing_country.name.denmark" = "丹麥"; -"enum.browsing_country.name.djibouti" = "吉布地"; -"enum.browsing_country.name.dominica" = "多米尼克"; -"enum.browsing_country.name.dominican_republic" = "多明尼加"; -"enum.browsing_country.name.ecuador" = "厄瓜多"; -"enum.browsing_country.name.egypt" = "埃及"; -"enum.browsing_country.name.el_salvador" = "薩爾瓦多"; -"enum.browsing_country.name.equatorial_guinea" = "赤道幾內亞"; -"enum.browsing_country.name.eritrea" = "厄利垂亞"; -"enum.browsing_country.name.estonia" = "愛沙尼亞"; -"enum.browsing_country.name.ethiopia" = "衣索比亞"; -"enum.browsing_country.name.europe" = "歐洲"; -"enum.browsing_country.name.falkland_islands" = "福克蘭群島"; -"enum.browsing_country.name.faroe_islands" = "法羅群島"; -"enum.browsing_country.name.fiji" = "斐濟"; -"enum.browsing_country.name.finland" = "芬蘭"; -"enum.browsing_country.name.france" = "法國"; -"enum.browsing_country.name.french_guiana" = "法屬圭亞那"; -"enum.browsing_country.name.french_polynesia" = "法屬玻里尼西亞"; -"enum.browsing_country.name.french_southern_territories" = "法屬南部領土"; -"enum.browsing_country.name.gabon" = "加彭"; -"enum.browsing_country.name.gambia" = "甘比亞"; -"enum.browsing_country.name.georgia" = "喬治亞"; -"enum.browsing_country.name.germany" = "德國"; -"enum.browsing_country.name.ghana" = "迦納"; -"enum.browsing_country.name.gibraltar" = "直布羅陀"; -"enum.browsing_country.name.greece" = "希臘"; -"enum.browsing_country.name.greenland" = "格陵蘭"; -"enum.browsing_country.name.grenada" = "格瑞那達"; -"enum.browsing_country.name.guadeloupe" = "瓜地洛普"; -"enum.browsing_country.name.guam" = "關島"; -"enum.browsing_country.name.guatemala" = "瓜地馬拉"; -"enum.browsing_country.name.guernsey" = "耿西"; -"enum.browsing_country.name.guinea" = "幾內亞"; -"enum.browsing_country.name.guinea_bissau" = "幾內亞比索"; -"enum.browsing_country.name.guyana" = "蓋亞那"; -"enum.browsing_country.name.haiti" = "海地"; -"enum.browsing_country.name.heard_island_and_mc_donald_islands" = "赫德島和麥克唐納群島"; -"enum.browsing_country.name.vatican_city_state" = "梵蒂岡"; -"enum.browsing_country.name.honduras" = "宏都拉斯"; -"enum.browsing_country.name.hong_kong" = "香港"; -"enum.browsing_country.name.hungary" = "匈牙利"; -"enum.browsing_country.name.iceland" = "冰島"; -"enum.browsing_country.name.india" = "印度"; -"enum.browsing_country.name.indonesia" = "印度尼西亞(印尼)"; -"enum.browsing_country.name.iran" = "伊朗"; -"enum.browsing_country.name.iraq" = "伊拉克"; -"enum.browsing_country.name.ireland" = "愛爾蘭"; -"enum.browsing_country.name.isle_of_man" = "曼島"; -"enum.browsing_country.name.israel" = "以色列"; -"enum.browsing_country.name.italy" = "義大利"; -"enum.browsing_country.name.jamaica" = "牙買加"; -"enum.browsing_country.name.japan" = "日本"; -"enum.browsing_country.name.jersey" = "澤西島"; -"enum.browsing_country.name.jordan" = "約旦"; -"enum.browsing_country.name.kazakhstan" = "哈薩克共和國"; -"enum.browsing_country.name.kenya" = "肯亞"; -"enum.browsing_country.name.kiribati" = "吉里巴斯"; -"enum.browsing_country.name.kuwait" = "科威特"; -"enum.browsing_country.name.kyrgyzstan" = "吉爾吉斯"; -"enum.browsing_country.name.lao_peoples_democratic_republic" = "寮國"; -"enum.browsing_country.name.latvia" = "拉脫維亞"; -"enum.browsing_country.name.lebanon" = "黎巴嫩"; -"enum.browsing_country.name.lesotho" = "賴索托"; -"enum.browsing_country.name.liberia" = "賴比瑞亞"; -"enum.browsing_country.name.libya" = "利比亞"; -"enum.browsing_country.name.liechtenstein" = "列支敦斯登"; -"enum.browsing_country.name.lithuania" = "立陶宛"; -"enum.browsing_country.name.luxembourg" = "盧森堡"; -"enum.browsing_country.name.macau" = "澳門"; -"enum.browsing_country.name.macedonia" = "北馬其頓"; -"enum.browsing_country.name.madagascar" = "馬達加斯加"; -"enum.browsing_country.name.malawi" = "馬拉威"; -"enum.browsing_country.name.malaysia" = "馬來西亞"; -"enum.browsing_country.name.maldives" = "馬爾地夫"; -"enum.browsing_country.name.mali" = "馬利"; -"enum.browsing_country.name.malta" = "馬爾他"; -"enum.browsing_country.name.marshall_islands" = "馬紹爾群島"; -"enum.browsing_country.name.martinique" = "馬丁尼克"; -"enum.browsing_country.name.mauritania" = "茅利塔尼亞"; -"enum.browsing_country.name.mauritius" = "模里西斯"; -"enum.browsing_country.name.mayotte" = "馬約特"; -"enum.browsing_country.name.mexico" = "墨西哥"; -"enum.browsing_country.name.micronesia" = "密克羅尼西亞"; -"enum.browsing_country.name.moldova" = "摩爾多瓦"; -"enum.browsing_country.name.monaco" = "摩納哥"; -"enum.browsing_country.name.mongolia" = "蒙古"; -"enum.browsing_country.name.montenegro" = "蒙特內哥羅"; -"enum.browsing_country.name.montserrat" = "蒙特塞拉特"; -"enum.browsing_country.name.morocco" = "摩洛哥"; -"enum.browsing_country.name.mozambique" = "莫三比克"; -"enum.browsing_country.name.myanmar" = "緬甸"; -"enum.browsing_country.name.namibia" = "納米比亞"; -"enum.browsing_country.name.nauru" = "諾魯"; -"enum.browsing_country.name.nepal" = "尼泊爾"; -"enum.browsing_country.name.netherlands" = "荷蘭"; -"enum.browsing_country.name.new_caledonia" = "新喀里多尼亞"; -"enum.browsing_country.name.new_zealand" = "紐西蘭"; -"enum.browsing_country.name.nicaragua" = "尼加拉瓜"; -"enum.browsing_country.name.niger" = "尼日"; -"enum.browsing_country.name.nigeria" = "奈及利亞"; -"enum.browsing_country.name.niue" = "紐埃"; -"enum.browsing_country.name.norfolk_island" = "諾福克島"; -"enum.browsing_country.name.north_korea" = "朝鮮"; -"enum.browsing_country.name.northern_mariana_islands" = "北馬里亞納群島"; -"enum.browsing_country.name.norway" = "挪威"; -"enum.browsing_country.name.oman" = "阿曼"; -"enum.browsing_country.name.pakistan" = "巴基斯坦"; -"enum.browsing_country.name.palau" = "帛琉"; -"enum.browsing_country.name.palestinian_territory" = "巴勒斯坦領土"; -"enum.browsing_country.name.panama" = "巴拿馬"; -"enum.browsing_country.name.papua_new_guinea" = "巴布亞紐幾內亞"; -"enum.browsing_country.name.paraguay" = "巴拉圭"; -"enum.browsing_country.name.peru" = "秘魯"; -"enum.browsing_country.name.philippines" = "菲律賓"; -"enum.browsing_country.name.pitcairn_islands" = "皮特凱恩群島"; -"enum.browsing_country.name.poland" = "波蘭"; -"enum.browsing_country.name.portugal" = "葡萄牙"; -"enum.browsing_country.name.puerto_rico" = "波多黎各"; -"enum.browsing_country.name.qatar" = "卡達"; -"enum.browsing_country.name.reunion" = "留尼旺"; -"enum.browsing_country.name.romania" = "羅馬尼亞"; -"enum.browsing_country.name.russian_federation" = "俄羅斯"; -"enum.browsing_country.name.rwanda" = "盧安達"; -"enum.browsing_country.name.saint_barthelemy" = "聖巴瑟米"; -"enum.browsing_country.name.saint_helena" = "聖赫勒拿"; -"enum.browsing_country.name.saint_kitts_and_nevis" = "聖克里斯多福及尼維斯"; -"enum.browsing_country.name.saint_lucia" = "聖露西亞"; -"enum.browsing_country.name.saint_martin" = "聖馬丁"; -"enum.browsing_country.name.saint_pierre_and_miquelon" = "聖皮耶與密克隆"; -"enum.browsing_country.name.saint_vincent_and_the_grenadines" = "聖文森及格瑞那丁"; -"enum.browsing_country.name.samoa" = "薩摩亞"; -"enum.browsing_country.name.san_marino" = "聖馬利諾"; -"enum.browsing_country.name.sao_tome_and_principe" = "聖多美普林西比"; -"enum.browsing_country.name.saudi_arabia" = "沙烏地阿拉伯"; -"enum.browsing_country.name.senegal" = "塞內加爾"; -"enum.browsing_country.name.serbia" = "塞爾維亞"; -"enum.browsing_country.name.seychelles" = "塞席爾"; -"enum.browsing_country.name.sierra_leone" = "獅子山"; -"enum.browsing_country.name.singapore" = "新加坡"; -"enum.browsing_country.name.sint_maarten" = "聖馬丁"; -"enum.browsing_country.name.slovakia" = "斯洛伐克"; -"enum.browsing_country.name.slovenia" = "斯洛維尼亞"; -"enum.browsing_country.name.solomon_islands" = "索羅門群島"; -"enum.browsing_country.name.somalia" = "索馬利亞"; -"enum.browsing_country.name.south_africa" = "南非"; -"enum.browsing_country.name.south_georgia_and_the_south_sandwich_islands" = "南喬治亞和南桑威奇群島"; -"enum.browsing_country.name.south_korea" = "韓國"; -"enum.browsing_country.name.south_sudan" = "南蘇丹"; -"enum.browsing_country.name.spain" = "西班牙"; -"enum.browsing_country.name.sri_lanka" = "斯里蘭卡"; -"enum.browsing_country.name.sudan" = "蘇丹"; -"enum.browsing_country.name.suriname" = "蘇利南"; -"enum.browsing_country.name.svalbard_and_jan_mayen" = "斯瓦巴和揚馬延"; -"enum.browsing_country.name.swaziland" = "史瓦帝尼"; -"enum.browsing_country.name.sweden" = "瑞典"; -"enum.browsing_country.name.switzerland" = "瑞士"; -"enum.browsing_country.name.syrian_arab_republic" = "敘利亞"; -"enum.browsing_country.name.taiwan" = "臺灣"; -"enum.browsing_country.name.tajikistan" = "塔吉克"; -"enum.browsing_country.name.tanzania" = "坦尚尼亞"; -"enum.browsing_country.name.thailand" = "泰國"; -"enum.browsing_country.name.timor_leste" = "東帝汶"; -"enum.browsing_country.name.togo" = "多哥"; -"enum.browsing_country.name.tokelau" = "托克勞"; -"enum.browsing_country.name.tonga" = "東加"; -"enum.browsing_country.name.trinidad_and_tobago" = "千里達和托巴哥"; -"enum.browsing_country.name.tunisia" = "突尼西亞"; -"enum.browsing_country.name.turkey" = "土耳其"; -"enum.browsing_country.name.turkmenistan" = "土庫曼"; -"enum.browsing_country.name.turks_and_caicos_islands" = "土克斯及開科斯群島"; -"enum.browsing_country.name.tuvalu" = "吐瓦魯"; -"enum.browsing_country.name.uganda" = "烏干達"; -"enum.browsing_country.name.ukraine" = "烏克蘭"; -"enum.browsing_country.name.united_arab_emirates" = "阿拉伯聯合大公國"; -"enum.browsing_country.name.united_kingdom" = "英國"; -"enum.browsing_country.name.united_states" = "美國"; -"enum.browsing_country.name.united_states_minor_outlying_islands" = "美國外圍小島嶼"; -"enum.browsing_country.name.uruguay" = "烏拉圭"; -"enum.browsing_country.name.uzbekistan" = "烏茲別克"; -"enum.browsing_country.name.vanuatu" = "萬那杜"; -"enum.browsing_country.name.venezuela" = "委內瑞拉"; -"enum.browsing_country.name.vietnam" = "越南"; -"enum.browsing_country.name.virgin_islands_british" = "英屬維京群島"; -"enum.browsing_country.name.virgin_islands_US" = "美屬維京群島"; -"enum.browsing_country.name.wallis_and_futuna" = "瓦利斯和富圖納"; -"enum.browsing_country.name.western_sahara" = "西撒哈拉"; -"enum.browsing_country.name.yemen" = "葉門"; -"enum.browsing_country.name.zambia" = "尚比亞"; -"enum.browsing_country.name.zimbabwe" = "辛巴威"; diff --git a/EhPanda/DataFlow/AppDelegateReducer.swift b/EhPanda/DataFlow/AppDelegateReducer.swift deleted file mode 100644 index 4b413e104..000000000 --- a/EhPanda/DataFlow/AppDelegateReducer.swift +++ /dev/null @@ -1,75 +0,0 @@ -// -// AppDelegateReducer.swift -// EhPanda -// - -import SwiftUI -import SwiftyBeaver -import ComposableArchitecture - -@Reducer -struct AppDelegateReducer { - @ObservableState - struct State: Equatable { - var migrationState = MigrationReducer.State() - } - - enum Action: Equatable { - case onLaunchFinish - case removeExpiredImageURLs - - case migration(MigrationReducer.Action) - } - - @Dependency(\.databaseClient) private var databaseClient - @Dependency(\.libraryClient) private var libraryClient - @Dependency(\.cookieClient) private var cookieClient - - var body: some Reducer { - Reduce { _, action in - switch action { - case .onLaunchFinish: - return .merge( - .run(operation: { _ in libraryClient.initializeLogger() }), - .run(operation: { _ in libraryClient.initializeWebImage() }), - .run(operation: { _ in cookieClient.removeYay() }), - .run(operation: { _ in cookieClient.syncExCookies() }), - .run(operation: { _ in cookieClient.ignoreOffensive() }), - .run(operation: { _ in cookieClient.fulfillAnotherHostField() }), - .send(.migration(.prepareDatabase)) - ) - - case .removeExpiredImageURLs: - return .run(operation: { _ in await databaseClient.removeExpiredImageURLs() }) - - case .migration: - return .none - } - } - - Scope(state: \.migrationState, action: \.migration, child: MigrationReducer.init) - } -} - -// MARK: AppDelegate -class AppDelegate: UIResponder, UIApplicationDelegate { - let store = Store(initialState: .init()) { - AppReducer() - } - - 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 { - if !AppUtil.isTesting { - store.send(.appDelegate(.onLaunchFinish)) - } - return true - } -} diff --git a/EhPanda/DataFlow/AppReducer.swift b/EhPanda/DataFlow/AppReducer.swift deleted file mode 100644 index 5333c8a2a..000000000 --- a/EhPanda/DataFlow/AppReducer.swift +++ /dev/null @@ -1,207 +0,0 @@ -// -// AppReducer.swift -// EhPanda -// - -import SwiftUI -import ComposableArchitecture - -@Reducer -struct AppReducer { - @ObservableState - struct State: Equatable { - var appDelegateState = AppDelegateReducer.State() - var appRouteState = AppRouteReducer.State() - var appLockState = AppLockReducer.State() - var tabBarState = TabBarReducer.State() - var homeState = HomeReducer.State() - var favoritesState = FavoritesReducer.State() - var searchRootState = SearchRootReducer.State() - var settingState = SettingReducer.State() - } - - enum Action: BindableAction { - case binding(BindingAction) - case onScenePhaseChange(ScenePhase) - - case appDelegate(AppDelegateReducer.Action) - case appRoute(AppRouteReducer.Action) - case appLock(AppLockReducer.Action) - - case tabBar(TabBarReducer.Action) - - case home(HomeReducer.Action) - case favorites(FavoritesReducer.Action) - case searchRoot(SearchRootReducer.Action) - case setting(SettingReducer.Action) - } - - @Dependency(\.hapticsClient) private var hapticsClient - @Dependency(\.cookieClient) private var cookieClient - @Dependency(\.deviceClient) private var deviceClient - - var body: some Reducer { - LoggingReducer { - BindingReducer() - .onChange(of: \.appRouteState.route) { _, newValue in - Reduce({ _, _ in newValue == nil ? .send(.appRoute(.clearSubStates)) : .none }) - } - .onChange(of: \.settingState.setting) { _, _ in - Reduce({ _, _ in .send(.setting(.syncSetting)) }) - } - - Reduce { state, action in - switch action { - case .binding: - return .none - - case .onScenePhaseChange(let scenePhase): - guard state.settingState.hasLoadedInitialSetting else { return .none } - - switch scenePhase { - case .active: - let threshold = state.settingState.setting.autoLockPolicy.rawValue - let blurRadius = state.settingState.setting.backgroundBlurRadius - return .send(.appLock(.onBecomeActive(threshold, blurRadius))) - - case .inactive: - let blurRadius = state.settingState.setting.backgroundBlurRadius - return .send(.appLock(.onBecomeInactive(blurRadius))) - - default: - return .none - } - - case .appDelegate(.migration(.onDatabasePreparationSuccess)): - return .merge( - .send(.appDelegate(.removeExpiredImageURLs)), - .send(.setting(.loadUserSettings)) - ) - - case .appDelegate: - return .none - - case .appRoute(.clearSubStates): - var effects = [Effect]() - if deviceClient.isPad() { - state.settingState.route = nil - effects.append(.send(.setting(.clearSubStates))) - } - return effects.isEmpty ? .none : .merge(effects) - - case .appRoute: - return .none - - case .appLock(.unlockApp): - var effects: [Effect] = [ - .send(.setting(.fetchGreeting)) - ] - if state.settingState.setting.detectsLinksFromClipboard { - effects.append(.send(.appRoute(.detectClipboardURL))) - } - return .merge(effects) - - case .appLock: - return .none - - case .tabBar(.setTabBarItemType(let type)): - var effects = [Effect]() - let hapticEffect: Effect = .run(operation: { _ in hapticsClient.generateFeedback(.soft) }) - if type == state.tabBarState.tabBarItemType { - switch type { - case .home: - if state.homeState.route != nil { - effects.append(.send(.home(.setNavigation(nil)))) - } else { - effects.append(.send(.home(.fetchAllGalleries))) - } - case .favorites: - if state.favoritesState.route != nil { - effects.append(.send(.favorites(.setNavigation(nil)))) - effects.append(hapticEffect) - } else if cookieClient.didLogin { - effects.append(.send(.favorites(.fetchGalleries()))) - effects.append(hapticEffect) - } - case .search: - if state.searchRootState.route != nil { - effects.append(.send(.searchRoot(.setNavigation(nil)))) - } else { - effects.append(.send(.searchRoot(.fetchDatabaseInfos))) - } - case .setting: - if state.settingState.route != nil { - effects.append(.send(.setting(.setNavigation(nil)))) - effects.append(hapticEffect) - } - } - if [.home, .search].contains(type) { - effects.append(hapticEffect) - } - } - if type == .setting && deviceClient.isPad() { - effects.append(.send(.appRoute(.setNavigation(.setting())))) - } - return effects.isEmpty ? .none : .merge(effects) - - case .tabBar: - return .none - - case .home(.watched(.onNotLoginViewButtonTapped)), .favorites(.onNotLoginViewButtonTapped): - var effects: [Effect] = [ - .run(operation: { _ in hapticsClient.generateFeedback(.soft) }), - .send(.tabBar(.setTabBarItemType(.setting))) - ] - effects.append(.send(.setting(.setNavigation(.account)))) - if !cookieClient.didLogin { - effects.append( - .run { send in - let delay = UInt64(deviceClient.isPad() ? 1200 : 200) - try await Task.sleep(for: .milliseconds(delay)) - await send(.setting(.account(.setNavigation(.login)))) - } - ) - } - return .merge(effects) - - case .home: - return .none - - case .favorites: - return .none - - case .searchRoot: - return .none - - case .setting(.loadUserSettingsDone): - var effects = [Effect]() - let threshold = state.settingState.setting.autoLockPolicy.rawValue - let blurRadius = state.settingState.setting.backgroundBlurRadius - if threshold >= 0 { - state.appLockState.becameInactiveDate = .distantPast - effects.append(.send(.appLock(.onBecomeActive(threshold, blurRadius)))) - } - if state.settingState.setting.detectsLinksFromClipboard { - effects.append(.send(.appRoute(.detectClipboardURL))) - } - return effects.isEmpty ? .none : .merge(effects) - - case .setting(.fetchGreetingDone(let result)): - return .send(.appRoute(.fetchGreetingDone(result))) - - case .setting: - return .none - } - } - - Scope(state: \.appRouteState, action: \.appRoute, child: AppRouteReducer.init) - Scope(state: \.appLockState, action: \.appLock, child: AppLockReducer.init) - Scope(state: \.appDelegateState, action: \.appDelegate, child: AppDelegateReducer.init) - Scope(state: \.tabBarState, action: \.tabBar, child: TabBarReducer.init) - Scope(state: \.homeState, action: \.home, child: HomeReducer.init) - Scope(state: \.favoritesState, action: \.favorites, child: FavoritesReducer.init) - Scope(state: \.searchRootState, action: \.searchRoot, child: SearchRootReducer.init) - Scope(state: \.settingState, action: \.setting, child: SettingReducer.init) - } - } -} diff --git a/EhPanda/DataFlow/AppRouteReducer.swift b/EhPanda/DataFlow/AppRouteReducer.swift deleted file mode 100644 index 94c04e573..000000000 --- a/EhPanda/DataFlow/AppRouteReducer.swift +++ /dev/null @@ -1,194 +0,0 @@ -// -// AppRouteReducer.swift -// EhPanda -// - -import SwiftUI -import TTProgressHUD -import ComposableArchitecture - -@Reducer -struct AppRouteReducer { - @CasePathable - enum Route: Equatable, Hashable { - case hud - case setting(EquatableVoid = .init()) - case detail(String) - case newDawn(Greeting) - } - - @ObservableState - struct State: Equatable { - var route: Route? - var hudConfig: TTProgressHUDConfig = .loading - - var detailState: Heap - - init() { - detailState = .init(.init()) - } - } - - enum Action: BindableAction { - case binding(BindingAction) - case setNavigation(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(DetailReducer.Action) - } - - @Dependency(\.userDefaultsClient) private var userDefaultsClient - @Dependency(\.clipboardClient) private var clipboardClient - @Dependency(\.databaseClient) private var databaseClient - @Dependency(\.hapticsClient) private var hapticsClient - @Dependency(\.urlClient) private var urlClient - - var body: some Reducer { - BindingReducer() - .onChange(of: \.route) { _, newValue in - Reduce({ _, _ in newValue == nil ? .send(.clearSubStates) : .none }) - } - - Reduce { state, action in - switch action { - case .binding: - return .none - - case .setNavigation(let route): - state.route = route - return route == nil ? .send(.clearSubStates) : .none - - case .setHUDConfig(let config): - state.hudConfig = config - return .none - - case .clearSubStates: - state.detailState.wrappedValue = .init() - return .send(.detail(.teardown)) - - case .detectClipboardURL: - let currentChangeCount = clipboardClient.changeCount() - guard currentChangeCount != userDefaultsClient - .getValue(.clipboardChangeCount) else { return .none } - var effects: [Effect] = [ - .run(operation: { _ in userDefaultsClient.setValue(currentChangeCount, .clipboardChangeCount) }) - ] - if let url = clipboardClient.url() { - effects.append(.send(.handleDeepLink(url))) - } - return .merge(effects) - - case .handleDeepLink(let url): - let url = urlClient.resolveAppSchemeURL(url) ?? url - guard urlClient.checkIfHandleable(url) else { return .none } - var delay = 0 - if case .detail = state.route { - delay = 1000 - state.route = nil - state.detailState.wrappedValue = .init() - } - let (isGalleryImageURL, _, _) = urlClient.analyzeURL(url) - let gid = urlClient.parseGalleryID(url) - guard databaseClient.fetchGallery(gid: gid) == nil else { - return .run { [delay] send in - try await Task.sleep(for: .milliseconds(delay + 250)) - await send(.handleGalleryLink(url)) - } - } - return .run { [delay] send in - try await Task.sleep(for: .milliseconds(delay)) - await send(.fetchGallery(url, isGalleryImageURL)) - } - - case .handleGalleryLink(let url): - let (_, pageIndex, commentID) = urlClient.analyzeURL(url) - let gid = urlClient.parseGalleryID(url) - var effects = [Effect]() - state.detailState.wrappedValue = .init() - effects.append(.send(.detail(.fetchDatabaseInfos(gid)))) - if let pageIndex = pageIndex { - effects.append(.send(.updateReadingProgress(gid, pageIndex))) - effects.append( - .run { send in - try await Task.sleep(for: .milliseconds(500)) - await send(.detail(.setNavigation(.reading()))) - } - ) - } else if let commentID = commentID { - state.detailState.wrappedValue?.commentsState.wrappedValue?.scrollCommentID = commentID - effects.append( - .run { send in - try await Task.sleep(for: .milliseconds(500)) - await send(.detail(.setNavigation(.comments(url)))) - } - ) - } - effects.append(.send(.setNavigation(.detail(gid)))) - return .merge(effects) - - case .updateReadingProgress(let gid, let progress): - guard !gid.isEmpty else { return .none } - return .run { _ in - await databaseClient.updateReadingProgress(gid: gid, progress: progress) - } - - case .fetchGallery(let url, let isGalleryImageURL): - state.route = .hud - return .run { send in - let response = await GalleryReverseRequest( - url: url, isGalleryImageURL: isGalleryImageURL - ) - .response() - await send(.fetchGalleryDone(url, response)) - } - - case .fetchGalleryDone(let url, let result): - state.route = nil - switch result { - case .success(let gallery): - return .merge( - .run(operation: { _ in await databaseClient.cacheGalleries([gallery]) }), - .send(.handleGalleryLink(url)) - ) - case .failure: - return .run { send in - try await Task.sleep(for: .milliseconds(500)) - await send(.setHUDConfig(.error)) - } - } - - case .fetchGreetingDone(let result): - if case .success(let greeting) = result, !greeting.gainedNothing { - return .send(.setNavigation(.newDawn(greeting))) - } - return .none - - case .detail: - return .none - } - } - .haptics( - unwrapping: \.route, - case: \.newDawn, - hapticsClient: hapticsClient - ) - .haptics( - unwrapping: \.route, - case: \.detail, - hapticsClient: hapticsClient - ) - - Scope(state: \.detailState.wrappedValue!, action: \.detail, child: DetailReducer.init) - } -} diff --git a/EhPanda/DataFlow/Heap.swift b/EhPanda/DataFlow/Heap.swift deleted file mode 100755 index 21fff54f8..000000000 --- a/EhPanda/DataFlow/Heap.swift +++ /dev/null @@ -1,36 +0,0 @@ -// -// Heap.swift -// EhPanda -// - -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/Database/Extensions/FileManager/FileManager+ApplicationSupport.swift b/EhPanda/Database/Extensions/FileManager/FileManager+ApplicationSupport.swift deleted file mode 100755 index 13afa9a0e..000000000 --- a/EhPanda/Database/Extensions/FileManager/FileManager+ApplicationSupport.swift +++ /dev/null @@ -1,20 +0,0 @@ -// -// FileManager+ApplicationSupport.swift -// CoreDataMigration-Example -// - -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 deleted file mode 100755 index 4e6d00b9a..000000000 --- a/EhPanda/Database/Extensions/NSManagedObjectModel/NSManagedObjectModel+Compatible.swift +++ /dev/null @@ -1,13 +0,0 @@ -// -// NSManagedObjectModel+Compatible.swift -// CoreDataMigration-Example -// - -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 deleted file mode 100755 index 683efe310..000000000 --- a/EhPanda/Database/Extensions/NSManagedObjectModel/NSManagedObjectModel+Resource.swift +++ /dev/null @@ -1,24 +0,0 @@ -// -// NSManagedObjectModel+Resource.swift -// CoreDataMigration-Example -// - -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 deleted file mode 100755 index 10820f454..000000000 --- a/EhPanda/Database/Extensions/NSPersistentStoreCoordinator/NSPersistentStoreCoordinator+SQLite.swift +++ /dev/null @@ -1,48 +0,0 @@ -// -// NSPersistentStoreCoordinator+SQLite.swift -// CoreDataMigration-Example -// - -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 deleted file mode 100644 index 43a3f32c6..000000000 --- a/EhPanda/Database/MODefinition/AppEnvMO+CoreDataClass.swift +++ /dev/null @@ -1,40 +0,0 @@ -// -// AppEnvMO+CoreDataClass.swift -// EhPanda -// - -import CoreData - -public class AppEnvMO: NSManagedObject {} - -extension AppEnvMO: ManagedObjectProtocol { - func toEntity() -> AppEnv { - AppEnv( - user: user?.toObject() ?? User(), - setting: setting?.toObject() ?? Setting(), - searchFilter: searchFilter?.toObject() ?? Filter(), - globalFilter: globalFilter?.toObject() ?? Filter(), - watchedFilter: watchedFilter?.toObject() ?? Filter(), - tagTranslator: tagTranslator?.toObject() ?? TagTranslator(), - historyKeywords: historyKeywords?.toObject() ?? [String](), - quickSearchWords: quickSearchWords?.toObject() ?? [QuickSearchWord]() - ) - } -} - -extension AppEnv: ManagedObjectConvertible { - @discardableResult func toManagedObject(in context: NSManagedObjectContext) -> AppEnvMO { - let appEnvMO = AppEnvMO(context: context) - - appEnvMO.user = user.toData() - appEnvMO.setting = setting.toData() - appEnvMO.searchFilter = searchFilter.toData() - appEnvMO.globalFilter = globalFilter.toData() - appEnvMO.watchedFilter = watchedFilter.toData() - appEnvMO.tagTranslator = tagTranslator.toData() - appEnvMO.historyKeywords = historyKeywords.toData() - appEnvMO.quickSearchWords = quickSearchWords.toData() - - return appEnvMO - } -} diff --git a/EhPanda/Database/MODefinition/AppEnvMO+CoreDataProperties.swift b/EhPanda/Database/MODefinition/AppEnvMO+CoreDataProperties.swift deleted file mode 100644 index 63f3c000a..000000000 --- a/EhPanda/Database/MODefinition/AppEnvMO+CoreDataProperties.swift +++ /dev/null @@ -1,21 +0,0 @@ -// -// AppEnvMO+CoreDataProperties.swift -// EhPanda -// - -import CoreData - -extension AppEnvMO { - @nonobjc public class func fetchRequest() -> NSFetchRequest { - NSFetchRequest(entityName: "AppEnvMO") - } - - @NSManaged public var user: Data? - @NSManaged public var setting: Data? - @NSManaged public var searchFilter: Data? - @NSManaged public var globalFilter: Data? - @NSManaged public var watchedFilter: Data? - @NSManaged public var tagTranslator: Data? - @NSManaged public var historyKeywords: Data? - @NSManaged public var quickSearchWords: Data? -} diff --git a/EhPanda/Database/MODefinition/GalleryDetailMO+CoreDataClass.swift b/EhPanda/Database/MODefinition/GalleryDetailMO+CoreDataClass.swift deleted file mode 100644 index 8b108ac10..000000000 --- a/EhPanda/Database/MODefinition/GalleryDetailMO+CoreDataClass.swift +++ /dev/null @@ -1,53 +0,0 @@ -// -// GalleryDetailMO+CoreDataClass.swift -// EhPanda -// - -import CoreData - -public class GalleryDetailMO: NSManagedObject {} - -extension GalleryDetailMO: ManagedObjectProtocol { - func toEntity() -> GalleryDetail { - GalleryDetail( - gid: gid, title: title, jpnTitle: jpnTitle, isFavorited: isFavorited, - visibility: visibility?.toObject() ?? GalleryVisibility.yes, - rating: rating, userRating: userRating, ratingCount: Int(ratingCount), - category: Category(rawValue: category).forceUnwrapped, - language: Language(rawValue: language).forceUnwrapped, - uploader: uploader, postedDate: postedDate, - coverURL: coverURL, archiveURL: archiveURL, parentURL: parentURL, - favoritedCount: Int(favoritedCount), pageCount: Int(pageCount), - sizeCount: sizeCount, sizeType: sizeType, - torrentCount: Int(torrentCount) - ) - } -} -extension GalleryDetail: ManagedObjectConvertible { - @discardableResult func toManagedObject(in context: NSManagedObjectContext) -> GalleryDetailMO { - let galleryDetailMO = GalleryDetailMO(context: context) - - galleryDetailMO.gid = gid - galleryDetailMO.archiveURL = archiveURL - galleryDetailMO.category = category.rawValue - galleryDetailMO.coverURL = coverURL - galleryDetailMO.isFavorited = isFavorited - galleryDetailMO.visibility = visibility.toData() - galleryDetailMO.jpnTitle = jpnTitle - galleryDetailMO.language = language.rawValue - galleryDetailMO.favoritedCount = Int64(favoritedCount) - galleryDetailMO.pageCount = Int64(pageCount) - galleryDetailMO.parentURL = parentURL - galleryDetailMO.postedDate = postedDate - galleryDetailMO.rating = rating - galleryDetailMO.userRating = userRating - galleryDetailMO.ratingCount = Int64(ratingCount) - galleryDetailMO.sizeCount = sizeCount - galleryDetailMO.sizeType = sizeType - galleryDetailMO.title = title - galleryDetailMO.torrentCount = Int64(torrentCount) - galleryDetailMO.uploader = uploader - - return galleryDetailMO - } -} diff --git a/EhPanda/Database/MODefinition/GalleryDetailMO+CoreDataProperties.swift b/EhPanda/Database/MODefinition/GalleryDetailMO+CoreDataProperties.swift deleted file mode 100644 index b4b1ee26d..000000000 --- a/EhPanda/Database/MODefinition/GalleryDetailMO+CoreDataProperties.swift +++ /dev/null @@ -1,33 +0,0 @@ -// -// GalleryDetailMO+CoreDataProperties.swift -// EhPanda -// - -import CoreData - -extension GalleryDetailMO: GalleryIdentifiable { - @nonobjc public class func fetchRequest() -> NSFetchRequest { - NSFetchRequest(entityName: "GalleryDetailMO") - } - - @NSManaged public var archiveURL: URL? - @NSManaged public var category: String - @NSManaged public var coverURL: URL? - @NSManaged public var gid: String - @NSManaged public var isFavorited: Bool - @NSManaged public var jpnTitle: String? - @NSManaged public var language: String - @NSManaged public var favoritedCount: Int64 - @NSManaged public var pageCount: Int64 - @NSManaged public var parentURL: URL? - @NSManaged public var postedDate: Date - @NSManaged public var rating: Float - @NSManaged public var userRating: Float - @NSManaged public var ratingCount: Int64 - @NSManaged public var sizeCount: Float - @NSManaged public var sizeType: String - @NSManaged public var title: String - @NSManaged public var torrentCount: Int64 - @NSManaged public var uploader: String - @NSManaged public var visibility: Data? -} diff --git a/EhPanda/Database/MODefinition/GalleryMO+CoreDataClass.swift b/EhPanda/Database/MODefinition/GalleryMO+CoreDataClass.swift deleted file mode 100644 index 01fb5db5b..000000000 --- a/EhPanda/Database/MODefinition/GalleryMO+CoreDataClass.swift +++ /dev/null @@ -1,43 +0,0 @@ -// -// GalleryMO+CoreDataClass.swift -// EhPanda -// - -import CoreData - -public class GalleryMO: NSManagedObject {} - -extension GalleryMO: ManagedObjectProtocol { - func toEntity() -> Gallery { - Gallery( - gid: gid, token: token, - title: title, rating: rating, - tags: tags?.toObject() ?? [GalleryTag](), - category: Category(rawValue: category) ?? .private, - uploader: uploader, pageCount: Int(pageCount), - postedDate: postedDate, - coverURL: coverURL, galleryURL: galleryURL, - lastOpenDate: lastOpenDate - ) - } -} -extension Gallery: ManagedObjectConvertible { - @discardableResult func toManagedObject(in context: NSManagedObjectContext) -> GalleryMO { - let galleryMO = GalleryMO(context: context) - - galleryMO.gid = gid - galleryMO.category = category.rawValue - galleryMO.coverURL = coverURL - galleryMO.galleryURL = galleryURL - galleryMO.lastOpenDate = lastOpenDate - galleryMO.pageCount = Int64(pageCount) - galleryMO.postedDate = postedDate - galleryMO.rating = rating - galleryMO.tags = tags.toData() - galleryMO.title = title - galleryMO.token = token - galleryMO.uploader = uploader - - return galleryMO - } -} diff --git a/EhPanda/Database/MODefinition/GalleryMO+CoreDataProperties.swift b/EhPanda/Database/MODefinition/GalleryMO+CoreDataProperties.swift deleted file mode 100644 index 11167c46d..000000000 --- a/EhPanda/Database/MODefinition/GalleryMO+CoreDataProperties.swift +++ /dev/null @@ -1,25 +0,0 @@ -// -// GalleryMO+CoreDataProperties.swift -// EhPanda -// - -import CoreData - -extension GalleryMO: GalleryIdentifiable { - @nonobjc public class func fetchRequest() -> NSFetchRequest { - NSFetchRequest(entityName: "GalleryMO") - } - - @NSManaged public var category: String - @NSManaged public var coverURL: URL? - @NSManaged public var galleryURL: URL? - @NSManaged public var gid: String - @NSManaged public var lastOpenDate: Date? - @NSManaged public var pageCount: Int64 - @NSManaged public var postedDate: Date - @NSManaged public var rating: Float - @NSManaged public var tags: Data? - @NSManaged public var title: String - @NSManaged public var token: String - @NSManaged public var uploader: String? -} diff --git a/EhPanda/Database/MODefinition/GalleryStateMO+CoreDataClass.swift b/EhPanda/Database/MODefinition/GalleryStateMO+CoreDataClass.swift deleted file mode 100644 index f3bec3872..000000000 --- a/EhPanda/Database/MODefinition/GalleryStateMO+CoreDataClass.swift +++ /dev/null @@ -1,42 +0,0 @@ -// -// GalleryStateMO+CoreDataClass.swift -// EhPanda -// - -import SwiftUI -import CoreData - -public class GalleryStateMO: NSManagedObject {} - -extension GalleryStateMO: ManagedObjectProtocol { - func toEntity() -> GalleryState { - GalleryState( - gid: gid, tags: tags?.toObject() ?? [GalleryTag](), - readingProgress: Int(readingProgress), - previewURLs: previewURLs?.toObject() ?? [Int: URL](), - previewConfig: previewConfig?.toObject() ?? PreviewConfig.normal(rows: 4), - comments: comments?.toObject() ?? [GalleryComment](), - imageURLs: imageURLs?.toObject() ?? [Int: URL](), - originalImageURLs: originalImageURLs?.toObject() ?? [Int: URL](), - thumbnailURLs: thumbnailURLs?.toObject() ?? [Int: URL]() - ) - } -} - -extension GalleryState: ManagedObjectConvertible { - @discardableResult func toManagedObject(in context: NSManagedObjectContext) -> GalleryStateMO { - let galleryStateMO = GalleryStateMO(context: context) - - galleryStateMO.gid = gid - galleryStateMO.tags = tags.toData() - galleryStateMO.readingProgress = Int64(readingProgress) - galleryStateMO.previewConfig = previewConfig?.toData() - galleryStateMO.previewURLs = previewURLs.toData() - galleryStateMO.comments = comments.toData() - galleryStateMO.imageURLs = imageURLs.toData() - galleryStateMO.originalImageURLs = originalImageURLs.toData() - galleryStateMO.thumbnailURLs = thumbnailURLs.toData() - - return galleryStateMO - } -} diff --git a/EhPanda/Database/MODefinition/GalleryStateMO+CoreDataProperties.swift b/EhPanda/Database/MODefinition/GalleryStateMO+CoreDataProperties.swift deleted file mode 100644 index 06b5e8e1c..000000000 --- a/EhPanda/Database/MODefinition/GalleryStateMO+CoreDataProperties.swift +++ /dev/null @@ -1,22 +0,0 @@ -// -// GalleryStateMO+CoreDataProperties.swift -// EhPanda -// - -import CoreData - -extension GalleryStateMO: GalleryIdentifiable { - @nonobjc public class func fetchRequest() -> NSFetchRequest { - NSFetchRequest(entityName: "GalleryStateMO") - } - - @NSManaged public var comments: Data? - @NSManaged public var imageURLs: Data? - @NSManaged public var originalImageURLs: Data? - @NSManaged public var gid: String - @NSManaged public var previewConfig: Data? - @NSManaged public var previewURLs: Data? - @NSManaged public var readingProgress: Int64 - @NSManaged public var tags: Data? - @NSManaged public var thumbnailURLs: Data? -} diff --git a/EhPanda/Database/Migration/CoreDataMigrationStep.swift b/EhPanda/Database/Migration/CoreDataMigrationStep.swift deleted file mode 100755 index 9842587eb..000000000 --- a/EhPanda/Database/Migration/CoreDataMigrationStep.swift +++ /dev/null @@ -1,51 +0,0 @@ -// -// CoreDataMigrationStep.swift -// CoreDataMigration-Example -// - -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 deleted file mode 100755 index b34968416..000000000 --- a/EhPanda/Database/Migration/CoreDataMigrationVersion.swift +++ /dev/null @@ -1,36 +0,0 @@ -// -// CoreDataVersion.swift -// CoreDataMigration-Example -// - -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" - case version7 = "Model 7" - - 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 .version7 - case .version7: return nil - } - } -} diff --git a/EhPanda/Database/Migration/CoreDataMigrator.swift b/EhPanda/Database/Migration/CoreDataMigrator.swift deleted file mode 100755 index 5694e6bee..000000000 --- a/EhPanda/Database/Migration/CoreDataMigrator.swift +++ /dev/null @@ -1,109 +0,0 @@ -// -// CoreDataMigrator.swift -// CoreDataMigration-Example -// - -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 = FileUtil.temporaryDirectory.appendingPathComponent(UUID().uuidString) - - do { - try manager.migrateStore( - from: currentURL, sourceType: NSSQLiteStoreType, options: nil, - with: migrationStep.mappingModel, toDestinationURL: destinationURL, - destinationType: NSSQLiteStoreType, destinationOptions: nil - ) - } catch { - let message = "Failed attempting to migrate from \(migrationStep.sourceModel) " - + "to \(migrationStep.destinationModel), error: \(error)." - throw AppError.databaseCorrupted(message) - } - - if currentURL != storeURL { - try NSPersistentStoreCoordinator.destroyStore(at: currentURL) - } - - currentURL = destinationURL - } - - try NSPersistentStoreCoordinator.replaceStore(at: storeURL, withStoreAt: currentURL) - - if currentURL != storeURL { - try NSPersistentStoreCoordinator.destroyStore(at: currentURL) - } - } - - private func migrationStepsForStore( - at storeURL: URL, toVersion destinationVersion: CoreDataMigrationVersion - ) throws -> [CoreDataMigrationStep] { - guard let metadata = NSPersistentStoreCoordinator.metadata(at: storeURL), - let sourceVersion = try CoreDataMigrationVersion.compatibleVersionForStoreMetadata(metadata) - else { - throw AppError.databaseCorrupted("Unknown store version at URL \(storeURL).") - } - return try migrationSteps(fromSourceVersion: sourceVersion, toDestinationVersion: destinationVersion) - } - - private func migrationSteps( - fromSourceVersion sourceVersion: CoreDataMigrationVersion, - toDestinationVersion destinationVersion: CoreDataMigrationVersion - ) throws -> [CoreDataMigrationStep] { - var sourceVersion = sourceVersion - var migrationSteps = [CoreDataMigrationStep]() - - while sourceVersion != destinationVersion, let nextVersion = sourceVersion.nextVersion() { - let migrationStep = try CoreDataMigrationStep(sourceVersion: sourceVersion, destinationVersion: nextVersion) - migrationSteps.append(migrationStep) - - sourceVersion = nextVersion - } - - return migrationSteps - } - - 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 deleted file mode 100644 index 95d12a760..000000000 --- a/EhPanda/Database/Migration/Mappings/Model5toModel6.xcmappingmodel/xcmapping.xml +++ /dev/null @@ -1,343 +0,0 @@ - - - - - - 134481920 - 521A9C87-4856-47D3-B239-6780806CD202 - 157 - - - - NSPersistenceFrameworkVersion - 1145 - NSStoreModelVersionHashes - - XDDevAttributeMapping - - 0plcXXRN7XHKl5CcF+fwriFmUpON3ZtcI/AfK748aWc= - - XDDevEntityMapping - - qeN1Ym3TkWN1G6dU9RfX6Kd2ccEvcDVWHpd3LpLgboI= - - XDDevMappingModel - - EqtMzvRnVZWkXwBHu4VeVGy8UyoOe+bi67KC79kphlQ= - - XDDevPropertyMapping - - XN33V44TTGY4JETlMoOB5yyTKxB+u4slvDIinv0rtGA= - - XDDevRelationshipMapping - - akYY9LhehVA/mCb4ATLWuI9XGLcjpm14wWL1oEBtIcs= - - - NSStoreModelVersionHashesDigest - +Hmc2uYZK6og+Pvx5GUJ7oW75UG4V/ksQanTjfTKUnxyGWJRMtB5tIRgVwGsrd7lz/QR57++wbvWsr6nxwyS0A== - NSStoreModelVersionHashesVersion - 3 - NSStoreModelVersionIdentifiers - - - - - - - - - EhPanda.GalleryStateMO5toGalleryStateMO6MigrationPolicy - GalleryStateMO - Undefined - 3 - GalleryStateMO - 1 - - - - - - sizeType - - - - EhPanda.GalleryDetailMO5toGalleryDetailMO6MigrationPolicy - GalleryDetailMO - Undefined - 1 - GalleryDetailMO - 1 - - - - - - language - - - - YnBsaXN0MDDUAQIDBAUGBwpYJHZlcnNpb25ZJGFyY2hpdmVyVCR0b3BYJG9iamVjdHMSAAGGoF8Q -D05TS2V5ZWRBcmNoaXZlctEICVRyb290gAGsCwwXGB0eJiswMTQ4VSRudWxs1Q0ODxAREhMUFRZZTlNPcGVyYW5kXk5TU2VsZWN0b3JOYW1lXxAQTlNFeHByZXNzaW9uVHlwZVtOU0FyZ3VtZW50c1YkY2xhc3OAA4ACEASABoALXxAQdmFsdWVGb3JLZXlQYXRoOtMZDxEaGxxaTlNWYXJpYWJsZYAEEAKABVZzb3VyY2XSHyAhIlokY2xhc3NuYW1lWCRjbGFzc2VzXxAUTlNWYXJpYWJsZUV4cHJlc3Npb26jIyQlXxAUTlNWYXJpYWJsZUV4cHJlc3Npb25cTlNFeHByZXNzaW9uWE5TT2JqZWN00icRKCpaTlMub2JqZWN0c6EpgAeACtMRDywtLi9ZTlNLZXlQYXRogAkQCoAIXxAQb3JpZ2luYWxDb250ZW50c9IfIDIzXxAcTlNLZXlQYXRoU3BlY2lmaWVyRXhwcmVzc2lvbqMyJCXSHyA1Nl5OU011dGFibGVBcnJheaM1NyVXTlNBcnJhedIfIDk6XxATTlNLZXlQYXRoRXhwcmVzc2lvbqQ5OyQlXxAUTlNGdW5jdGlvbkV4cHJlc3Npb24ACAARABoAJAApADIANwBJAEwAUQBTAGAAZgBxAHsAigCdAKkAsACyALQAtgC4ALoAzQDUAN8A4QDjAOUA7ADxAPwBBQEcASABNwFEAU0BUgFdAV8BYQFjAWoBdAF2AXgBegGNAZIBsQG1AboByQHNAdUB2gHwAfUAAAAAAAACAQAAAAAAAAA8AAAAAAAAAAAAAAAAAAACDA== - - originalImageURLs - - - - visibility - - - - postedDate - - - - category - - - - previewConfig - - - - setting - - - - gid - - - - jpnTitle - - - - postedDate - - - - EhPanda.GalleryMO5toGalleryMO6MigrationPolicy - GalleryMO - Undefined - 4 - GalleryMO - 1 - - - - - - YnBsaXN0MDDUAQIDBAUGBwpYJHZlcnNpb25ZJGFyY2hpdmVyVCR0b3BYJG9iamVjdHMSAAGGoF8Q -D05TS2V5ZWRBcmNoaXZlctEICVRyb290gAGkCwwTFFUkbnVsbNMNDg8QERJfEA9OU0NvbnN0YW50VmFsdWVfEBBOU0V4cHJlc3Npb25UeXBlViRjbGFzc4ACEACAA1DSFRYXGFokY2xhc3NuYW1lWCRjbGFzc2VzXxAZTlNDb25zdGFudFZhbHVlRXhwcmVzc2lvbqMXGRpcTlNFeHByZXNzaW9uWE5TT2JqZWN0CBEaJCkyN0lMUVNYXmV3ipGTlZeYnaixzdHeAAAAAAAAAQEAAAAAAAAAGwAAAAAAAAAAAAAAAAAAAOc= - - coverURL - - - - YnBsaXN0MDDUAQIDBAUGBwpYJHZlcnNpb25ZJGFyY2hpdmVyVCR0b3BYJG9iamVjdHMSAAGGoF8Q -D05TS2V5ZWRBcmNoaXZlctEICVRyb290gAGkCwwTFFUkbnVsbNMNDg8QERJfEA9OU0NvbnN0YW50VmFsdWVfEBBOU0V4cHJlc3Npb25UeXBlViRjbGFzc4ACEACAA1DSFRYXGFokY2xhc3NuYW1lWCRjbGFzc2VzXxAZTlNDb25zdGFudFZhbHVlRXhwcmVzc2lvbqMXGRpcTlNFeHByZXNzaW9uWE5TT2JqZWN0CBEaJCkyN0lMUVNYXmV3ipGTlZeYnaixzdHeAAAAAAAAAQEAAAAAAAAAGwAAAAAAAAAAAAAAAAAAAOc= - - parentURL - - - - rating - - - - YnBsaXN0MDDUAQIDBAUGBwpYJHZlcnNpb25ZJGFyY2hpdmVyVCR0b3BYJG9iamVjdHMSAAGGoF8Q -D05TS2V5ZWRBcmNoaXZlctEICVRyb290gAGsCwwXGB0eJiswMTQ4VSRudWxs1Q0ODxAREhMUFRZZTlNPcGVyYW5kXk5TU2VsZWN0b3JOYW1lXxAQTlNFeHByZXNzaW9uVHlwZVtOU0FyZ3VtZW50c1YkY2xhc3OAA4ACEASABoALXxAQdmFsdWVGb3JLZXlQYXRoOtMZDxEaGxxaTlNWYXJpYWJsZYAEEAKABVZzb3VyY2XSHyAhIlokY2xhc3NuYW1lWCRjbGFzc2VzXxAUTlNWYXJpYWJsZUV4cHJlc3Npb26jIyQlXxAUTlNWYXJpYWJsZUV4cHJlc3Npb25cTlNFeHByZXNzaW9uWE5TT2JqZWN00icRKCpaTlMub2JqZWN0c6EpgAeACtMRDywtLi9ZTlNLZXlQYXRogAkQCoAIXGdsb2JhbEZpbHRlctIfIDIzXxAcTlNLZXlQYXRoU3BlY2lmaWVyRXhwcmVzc2lvbqMyJCXSHyA1Nl5OU011dGFibGVBcnJheaM1NyVXTlNBcnJhedIfIDk6XxATTlNLZXlQYXRoRXhwcmVzc2lvbqQ5OyQlXxAUTlNGdW5jdGlvbkV4cHJlc3Npb24ACAARABoAJAApADIANwBJAEwAUQBTAGAAZgBxAHsAigCdAKkAsACyALQAtgC4ALoAzQDUAN8A4QDjAOUA7ADxAPwBBQEcASABNwFEAU0BUgFdAV8BYQFjAWoBdAF2AXgBegGHAYwBqwGvAbQBwwHHAc8B1AHqAe8AAAAAAAACAQAAAAAAAAA8AAAAAAAAAAAAAAAAAAACBg== - - watchedFilter - - - - YnBsaXN0MDDUAQIDBAUGBwpYJHZlcnNpb25ZJGFyY2hpdmVyVCR0b3BYJG9iamVjdHMSAAGGoF8Q -D05TS2V5ZWRBcmNoaXZlctEICVRyb290gAGsCwwXGB0eJiswMTQ4VSRudWxs1Q0ODxAREhMUFRZZTlNPcGVyYW5kXk5TU2VsZWN0b3JOYW1lXxAQTlNFeHByZXNzaW9uVHlwZVtOU0FyZ3VtZW50c1YkY2xhc3OAA4ACEASABoALXxAQdmFsdWVGb3JLZXlQYXRoOtMZDxEaGxxaTlNWYXJpYWJsZYAEEAKABVZzb3VyY2XSHyAhIlokY2xhc3NuYW1lWCRjbGFzc2VzXxAUTlNWYXJpYWJsZUV4cHJlc3Npb26jIyQlXxAUTlNWYXJpYWJsZUV4cHJlc3Npb25cTlNFeHByZXNzaW9uWE5TT2JqZWN00icRKCpaTlMub2JqZWN0c6EpgAeACtMRDywtLi9ZTlNLZXlQYXRogAkQCoAIWnRodW1ibmFpbHPSHyAyM18QHE5TS2V5UGF0aFNwZWNpZmllckV4cHJlc3Npb26jMiQl0h8gNTZeTlNNdXRhYmxlQXJyYXmjNTclV05TQXJyYXnSHyA5Ol8QE05TS2V5UGF0aEV4cHJlc3Npb26kOTskJV8QFE5TRnVuY3Rpb25FeHByZXNzaW9uAAgAEQAaACQAKQAyADcASQBMAFEAUwBgAGYAcQB7AIoAnQCpALAAsgC0ALYAuAC6AM0A1ADfAOEA4wDlAOwA8QD8AQUBHAEgATcBRAFNAVIBXQFfAWEBYwFqAXQBdgF4AXoBhQGKAakBrQGyAcEBxQHNAdIB6AHtAAAAAAAAAgEAAAAAAAAAPAAAAAAAAAAAAAAAAAAAAgQ= - - thumbnailURLs - - - - YnBsaXN0MDDUAQIDBAUGBwpYJHZlcnNpb25ZJGFyY2hpdmVyVCR0b3BYJG9iamVjdHMSAAGGoF8Q -D05TS2V5ZWRBcmNoaXZlctEICVRyb290gAGsCwwXGB0eJiswMTQ4VSRudWxs1Q0ODxAREhMUFRZZTlNPcGVyYW5kXk5TU2VsZWN0b3JOYW1lXxAQTlNFeHByZXNzaW9uVHlwZVtOU0FyZ3VtZW50c1YkY2xhc3OAA4ACEASABoALXxAQdmFsdWVGb3JLZXlQYXRoOtMZDxEaGxxaTlNWYXJpYWJsZYAEEAKABVZzb3VyY2XSHyAhIlokY2xhc3NuYW1lWCRjbGFzc2VzXxAUTlNWYXJpYWJsZUV4cHJlc3Npb26jIyQlXxAUTlNWYXJpYWJsZUV4cHJlc3Npb25cTlNFeHByZXNzaW9uWE5TT2JqZWN00icRKCpaTlMub2JqZWN0c6EpgAeACtMRDywtLi9ZTlNLZXlQYXRogAkQCoAIXGZhdm9yZWRDb3VudNIfIDIzXxAcTlNLZXlQYXRoU3BlY2lmaWVyRXhwcmVzc2lvbqMyJCXSHyA1Nl5OU011dGFibGVBcnJheaM1NyVXTlNBcnJhedIfIDk6XxATTlNLZXlQYXRoRXhwcmVzc2lvbqQ5OyQlXxAUTlNGdW5jdGlvbkV4cHJlc3Npb24ACAARABoAJAApADIANwBJAEwAUQBTAGAAZgBxAHsAigCdAKkAsACyALQAtgC4ALoAzQDUAN8A4QDjAOUA7ADxAPwBBQEcASABNwFEAU0BUgFdAV8BYQFjAWoBdAF2AXgBegGHAYwBqwGvAbQBwwHHAc8B1AHqAe8AAAAAAAACAQAAAAAAAAA8AAAAAAAAAAAAAAAAAAACBg== - - favoritedCount - - - - torrentCount - - - - YnBsaXN0MDDUAQIDBAUGBwpYJHZlcnNpb25ZJGFyY2hpdmVyVCR0b3BYJG9iamVjdHMSAAGGoF8Q -D05TS2V5ZWRBcmNoaXZlctEICVRyb290gAGkCwwTFFUkbnVsbNMNDg8QERJfEA9OU0NvbnN0YW50VmFsdWVfEBBOU0V4cHJlc3Npb25UeXBlViRjbGFzc4ACEACAA1DSFRYXGFokY2xhc3NuYW1lWCRjbGFzc2VzXxAZTlNDb25zdGFudFZhbHVlRXhwcmVzc2lvbqMXGRpcTlNFeHByZXNzaW9uWE5TT2JqZWN0CBEaJCkyN0lMUVNYXmV3ipGTlZeYnaixzdHeAAAAAAAAAQEAAAAAAAAAGwAAAAAAAAAAAAAAAAAAAOc= - - archiveURL - - - - category - - - - readingProgress - - - - ratingCount - - - - tags - - - - pageCount - - - - token - - - - YnBsaXN0MDDUAQIDBAUGBwpYJHZlcnNpb25ZJGFyY2hpdmVyVCR0b3BYJG9iamVjdHMSAAGGoF8Q -D05TS2V5ZWRBcmNoaXZlctEICVRyb290gAGsCwwXGB0eJiswMTQ4VSRudWxs1Q0ODxAREhMUFRZZTlNPcGVyYW5kXk5TU2VsZWN0b3JOYW1lXxAQTlNFeHByZXNzaW9uVHlwZVtOU0FyZ3VtZW50c1YkY2xhc3OAA4ACEASABoALXxAQdmFsdWVGb3JLZXlQYXRoOtMZDxEaGxxaTlNWYXJpYWJsZYAEEAKABVZzb3VyY2XSHyAhIlokY2xhc3NuYW1lWCRjbGFzc2VzXxAUTlNWYXJpYWJsZUV4cHJlc3Npb26jIyQlXxAUTlNWYXJpYWJsZUV4cHJlc3Npb25cTlNFeHByZXNzaW9uWE5TT2JqZWN00icRKCpaTlMub2JqZWN0c6EpgAeACtMRDywtLi9ZTlNLZXlQYXRogAkQCoAIWWlzRmF2b3JlZNIfIDIzXxAcTlNLZXlQYXRoU3BlY2lmaWVyRXhwcmVzc2lvbqMyJCXSHyA1Nl5OU011dGFibGVBcnJheaM1NyVXTlNBcnJhedIfIDk6XxATTlNLZXlQYXRoRXhwcmVzc2lvbqQ5OyQlXxAUTlNGdW5jdGlvbkV4cHJlc3Npb24ACAARABoAJAApADIANwBJAEwAUQBTAGAAZgBxAHsAigCdAKkAsACyALQAtgC4ALoAzQDUAN8A4QDjAOUA7ADxAPwBBQEcASABNwFEAU0BUgFdAV8BYQFjAWoBdAF2AXgBegGEAYkBqAGsAbEBwAHEAcwB0QHnAewAAAAAAAACAQAAAAAAAAA8AAAAAAAAAAAAAAAAAAACAw== - - isFavorited - - - - comments - - - - gid - - - - YnBsaXN0MDDUAQIDBAUGBwpYJHZlcnNpb25ZJGFyY2hpdmVyVCR0b3BYJG9iamVjdHMSAAGGoF8Q -D05TS2V5ZWRBcmNoaXZlctEICVRyb290gAGkCwwTFFUkbnVsbNMNDg8QERJfEA9OU0NvbnN0YW50VmFsdWVfEBBOU0V4cHJlc3Npb25UeXBlViRjbGFzc4ACEACAA1DSFRYXGFokY2xhc3NuYW1lWCRjbGFzc2VzXxAZTlNDb25zdGFudFZhbHVlRXhwcmVzc2lvbqMXGRpcTlNFeHByZXNzaW9uWE5TT2JqZWN0CBEaJCkyN0lMUVNYXmV3ipGTlZeYnaixzdHeAAAAAAAAAQEAAAAAAAAAGwAAAAAAAAAAAAAAAAAAAOc= - - coverURL - - - - uploader - - - - EhPanda/Database/Model.xcdatamodeld/Model 5.xcdatamodel - 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XV94ZF9tb2RlbE5hbWVfEBdfbW9kZWxWZXJzaW9uSWRlbnRpZmllcoACgQT3gQT1gACBBPaAAIEBAd4AHAAdAB4AHwAgACEAIgAOACMAJAAlACYAJwAoACkAKgArAAkAKQAXAC8AMAAxADIAMwApACkAF18QHFhEQnVja2V0Rm9yQ2xhc3Nlc3dhc0VuY29kZWRfEBpYREJ1Y2tldEZvclBhY2thZ2Vzc3RvcmFnZV8QHFhEQnVja2V0Rm9ySW50ZXJmYWNlc3N0b3JhZ2VfEA9feGRfb3duaW5nTW9kZWxfEB1YREJ1Y2tldEZvclBhY2thZ2Vzd2FzRW5jb2RlZFZfb3duZXJfEBtYREJ1Y2tldEZvckRhdGFUeXBlc3N0b3JhZ2VbX3Zpc2liaWxpdHlfEBlYREJ1Y2tldEZvckNsYXNzZXNzdG9yYWdlVV9uYW1lXxAfWERCdWNrZXRGb3JJbnRlcmZhY2Vzd2FzRW5jb2RlZF8QHlhEQnVja2V0Rm9yRGF0YVR5cGVzd2FzRW5jb2RlZF8QEF91bmlxdWVFbGVtZW50SUSABIEE84EE8YABgASAAIEE8oEE9BAAgAWAA4AEgASAAFBTWUVT0wA6ADsADgA8AEEARldOUy5rZXlzWk5TLm9iamVjdHOkAD0APgA/AECABoAHgAiACaQAQgBDAEQARYAKgQEMgQMGgQQ0gCheR2FsbGVyeVN0YXRlTU9fEA9HYWxsZXJ5RGV0YWlsTU9ZR2FsbGVyeU1PWEFwcEVudk1P3xAQAEwATQBOAE8AIQBQAFEAIwBSAFMADgAlAFQAVQAoAFYAVwBYACkAKQAUAFwAXQAxACkAVwBgAD0AVwBjAGQAZV8QIVhEQnVja2V0Rm9yT3duZWRBdHRyaWJ1dGVzb3JkZXJlZF8QIVhEQnVja2V0Rm9yT3duZWRBdHRyaWJ1dGVzc3RvcmFnZV8QIFhEQnVja2V0Rm9yU3RlcmVvdHlwZXN3YXNFbmNvZGVkXxAkWERCdWNrZXRGb3JPd25lZEF0dHJpYnV0ZXN3YXNFbmNvZGVkXxAdWERCdWNrZXRGb3JTdGVyZW90eXBlc3N0b3JhZ2VfECRYREJ1Y2tldEZvckdlbmVyYWxpemF0aW9uc2R1cGxpY2F0ZXNfECRYREJ1Y2tldEZvckdlbmVyYWxpemF0aW9uc3dhc0VuY29kZWRfEB1YREJ1Y2tldEZvclN0ZXJlb3R5cGVzb3JkZXJlZF8QIVhEQnVja2V0Rm9yR2VuZXJhbGl6YXRpb25zb3JkZXJlZF8QIVhEQnVja2V0Rm9yR2VuZXJhbGl6YXRpb25zc3RvcmFnZVtfaXNBYnN0cmFjdIAMgDCABIAEgAKADYEBCYAEgAyBAQuABoAMgQEKgAsIEi7IzU9Xb3JkZXJlZNMAOgA7AA4AaQBrAEahAGqADqEAbIAPgCheWERfUFN0ZXJlb3R5cGXZACEAJQBwAA4AKABxACMAVgByAEIAagBXAHYAFwApADEAZQB6XxAhWERCdWNrZXRGb3JPd25lZEF0dHJpYnV0ZXNvcmRlcmVkXxAkWERCdWNrZXRGb3JPd25lZEF0dHJpYnV0ZXN3YXNFbmNvZGVkXxAhWERCdWNrZXRGb3JPd25lZEF0dHJpYnV0ZXNzdG9yYWdlgAqADoAMgC+AAIAECIAQ0wA6ADsADgB8AIYARqkAfQB+AH8AgACBAIIAgwCEAIWAEYASgBOAFIAVgBaAF4AYgBmpAIcAiACJAIoAiwCMAI0AjgCPgBqAHoAfgCGAIoAkgCaAKYAtgChfEBNYRFBNQ29tcG91bmRJbmRleGVzXxAQWERfUFNLX2VsZW1lbnRJRF8QGVhEUE1VbmlxdWVuZXNzQ29uc3RyYWludHNfEBpYRF9QU0tfdmVyc2lvbkhhc2hNb2RpZmllcl8QGVhEX1BTS19mZXRjaFJlcXVlc3RzQXJyYXlfEBFYRF9QU0tfaXNBYnN0cmFjdF8QD1hEX1BTS191c2VySW5mb18QE1hEX1BTS19jbGFzc01hcHBpbmdfEBZYRF9QU0tfZW50aXR5Q2xhc3NOYW1l3xAPAJsAnACdACEAngCfAKAAIwChAA4AJQCiAKMAKACkABcApgAXAGwAZQBlAGUAMQBlAK0AfQBlAGUAFwBlVV90eXBlWF9kZWZhdWx0XF9hc3NvY2lhdGlvbltfaXNSZWFkT25seVlfaXNTdGF0aWNZX2lzVW5pcXVlWl9pc0Rlcml2ZWRaX2lzT3JkZXJlZFxfaXNDb21wb3NpdGVXX2lzTGVhZoAAgBuAAIAPCAgICIAdgBEICIAACNIAOwAOALQAtaCAHNIAtwC4ALkAulokY2xhc3NuYW1lWCRjbGFzc2VzXk5TTXV0YWJsZUFycmF5owC5ALsAvFdOU0FycmF5WE5TT2JqZWN00gC3ALgAvgC/XxAQWERVTUxQcm9wZXJ0eUltcKQAwADBAMIAvF8QEFhEVU1MUHJvcGVydHlJbXBfEBRYRFVNTE5hbWVkRWxlbWVudEltcF8QD1hEVU1MRWxlbWVudEltcN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXABcAFwBsAGUAZQBlADEAZQCtAH4AZQBlABcAZYAAgACAAIAPCAgICIAdgBIICIAACN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXANQAFwBsAGUAZQBlADEAZQCtAH8AZQBlABcAZYAAgCCAAIAPCAgICIAdgBMICIAACNIAOwAOAOIAtaCAHN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXABcAFwBsAGUAZQBlADEAZQCtAIAAZQBlABcAZYAAgACAAIAPCAgICIAdgBQICIAACN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXAPUAFwBsAGUAZQBlADEAZQCtAIEAZQBlABcAZYAAgCOAAIAPCAgICIAdgBUICIAACNIAOwAOAQMAtaCAHN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXAQcAFwBsAGUAZQBlADEAZQCtAIIAZQBlABcAZYAAgCWAAIAPCAgICIAdgBYICIAACAjfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwEXABcAbABlAGUAZQAxAGUArQCDAGUAZQAXAGWAAIAngACADwgICAiAHYAXCAiAAAjTADoAOwAOASUBJgBGoKCAKNIAtwC4ASkBKl8QE05TTXV0YWJsZURpY3Rpb25hcnmjASkBKwC8XE5TRGljdGlvbmFyed8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXAS4AFwBsAGUAZQBlADEAZQCtAIQAZQBlABcAZYAAgCqAAIAPCAgICIAdgBgICIAACNYAJQAOACgAVgAhACMBPAE9ABcAZQAXADGAK4AsgAAIgABfEBRYREdlbmVyaWNSZWNvcmRDbGFzc9IAtwC4AUMBRF1YRFVNTENsYXNzSW1wpgFFAUYBRwFIAUkAvF1YRFVNTENsYXNzSW1wXxASWERVTUxDbGFzc2lmaWVySW1wXxARWERVTUxOYW1lc3BhY2VJbXBfEBRYRFVNTE5hbWVkRWxlbWVudEltcF8QD1hEVU1MRWxlbWVudEltcN8QDwCbAJwAnQAhAJ4AnwCgACMAoQAOACUAogCjACgApAAXAUwAFwBsAGUAZQBlADEAZQCtAIUAZQBlABcAZYAAgC6AAIAPCAgICIAdgBkICIAACF8QDy5HYWxsZXJ5U3RhdGVNT9IAtwC4AVsBXF8QElhEVU1MU3RlcmVvdHlwZUltcKcBXQFeAV8BYAFhAWIAvF8QElhEVU1MU3RlcmVvdHlwZUltcF1YRFVNTENsYXNzSW1wXxASWERVTUxDbGFzc2lmaWVySW1wXxARWERVTUxOYW1lc3BhY2VJbXBfEBRYRFVNTE5hbWVkRWxlbWVudEltcF8QD1hEVU1MRWxlbWVudEltcNMAOgA7AA4BZAFtAEaoAWUBZgFnAWgBaQFqAWsBbIAxgDKAM4A0gDWANoA3gDioAW4BbwFwAXEBcgFzAXQBdYA5gGSAe4CUgKuAwoDZgPCAKFhjb21tZW50c1p0aHVtYm5haWxzXxAPcmVhZGluZ1Byb2dyZXNzXxAQb3JpZ2luYWxDb250ZW50c1hjb250ZW50c1hwcmV2aWV3c1R0YWdzU2dpZN8QEgCbAJwAnQGAACEAnwCgAYEAIwCeAYIAoQAOACUAogCjACgApAAXABcAFwApAEIAZQBlAYoAMQBlAFcAZQGOAWUAZQBlAZIAZV8QIFhEQnVja2V0Rm9yU3RlcmVvdHlwZXN3YXNFbmNvZGVkXxAdWERCdWNrZXRGb3JTdGVyZW90eXBlc3N0b3JhZ2VfEB1YREJ1Y2tldEZvclN0ZXJlb3R5cGVzb3JkZXJlZIAAgACAAIAEgAoICIA7CIAMCIBjgDEICIA6CBL1LwGf0wA6ADsADgGWAZkARqIBlwGYgDyAPaIBmgGbgD6AUoAoXxASWERfUFByb3BTdGVyZW90eXBlXxASWERfUEF0dF9TdGVyZW90eXBl2QAhACUBoAAOACgBoQAjAFYBogFuAZcAVwB2ABcAKQAxAGUBql8QIVhEQnVja2V0Rm9yT3duZWRBdHRyaWJ1dGVzb3JkZXJlZF8QJFhEQnVja2V0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WmR1cGxpY2F0ZXPSADsADguoALWggBzSALcAuAurC6xaWERQTUVudGl0eacLrQuuC68LsAuxC7IAvFpYRFBNRW50aXR5XVhEVU1MQ2xhc3NJbXBfEBJYRFVNTENsYXNzaWZpZXJJbXBfEBFYRFVNTE5hbWVzcGFjZUltcF8QFFhEVU1MTmFtZWRFbGVtZW50SW1wXxAPWERVTUxFbGVtZW50SW1w3xAQC7QLtQu2C7cAIQu4C7kAIwu6C7sADgAlC7wLvQAoAFYAVwu/ACkAKQAUC8MAXQAxACkAVwBgAD4AVwvKC8sAZV8QIVhEQnVja2V0Rm9yT3duZWRBdHRyaWJ1dGVzb3JkZXJlZF8QIVhEQnVja2V0Rm9yT3duZWRBdHRyaWJ1dGVzc3RvcmFnZV8QIFhEQnVja2V0Rm9yU3RlcmVvdHlwZXN3YXNFbmNvZGVkXxAkWERCdWNrZXRGb3JPd25lZEF0dHJpYnV0ZXN3YXNFbmNvZGVkXxAdWERCdWNrZXRGb3JTdGVyZW90eXBlc3N0b3JhZ2VfECRYREJ1Y2tldEZvckdlbmVyYWxpemF0aW9uc2R1cGxpY2F0ZXNfECRYREJ1Y2tldEZvckdlbmVyYWxpemF0aW9uc3dhc0VuY29kZWRfEB1YREJ1Y2tldEZvclN0ZXJlb3R5cGVzb3JkZXJlZF8QIVhEQnVja2V0Rm9yR2VuZXJhbGl6YXRpb25zb3JkZXJlZF8QIVhEQnVja2V0Rm9yR2VuZXJhbGl6YXRpb25zc3RvcmFnZYAMgQEfgASABIACgQEOgQEJgASADIEBC4AHgAyBAwWBAQ0IEqlMFM3TADoAOwAOC88L0QBGoQBqgA6hC9KBAQ+AKNkAIQAlC9UADgAoC9YAIwBWC9cAQwBqAFcAdgAXACkAMQBlC99fECFYREJ1Y2tldEZvck93bmVkQXR0cmlidXRlc29yZGVyZWRfECRYREJ1Y2tldEZvck93bmVkQXR0cmlidXRlc3dhc0VuY29kZWRfECFYREJ1Y2tldEZvck93bmVkQXR0cmlidXRlc3N0b3JhZ2WBAQyADoAMgC+AAIAECIEBENMAOgA7AA4L4QvrAEapAH0AfgB/AIAAgQCCAIMAhACFgBGAEoATgBSAFYAWgBeAGIAZqQvsC+0L7gvvC/AL8QvyC/ML9IEBEYEBE4EB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JWMAACWiAAAlpAAAJaYAACWoAAAlqgAAJasAACWsAAAlrQAAJa4AACWwAAAlsgAAJbMAACW0AAAltgAAJbcAACX2AAAl+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+AABRAAAAUQEAAFEDAABRBAAAUQYAAFEIAABRCQAAUQoAAFEMAABRDQAAURIAAFEfAABRJAAAUSYAAFEoAABRLQAA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aIUAAGiGAABohwAAaIkAAGiLAABojAAAaI0AAGiPAABokAAAaNsAAGj+AABpHgAAaT4AAGlAAABpQgAAaUQAAGlGAABpSQAAaUoAAGlLAABpTgAAaU8AAGlRAABpUgAAaVQAAGlXAABpWAAAaVkAAGlcAABpXQAAaWIAAGlvAABpdAAAaXYAAGl4AABpfQAAaYAAAGmDAABphQAAaaoAAGnOAABp9QAAahkAAGocAABqHgAAaiAAAGoiAABqJAAAaiYAAGonAABqKgAAajcAAGpIAABqSgAAakwAAGpOAABqUAAAalIAAGpUAABqVgAAalgAAGppAABqbAAAam8AAGpyAABqdQAAangAAGp7AABqfgAAaoEAAGqDAABqwgAAasQAAGrGAABqyAAAassAAGrMAABqzQAAas4AAGrPAABq0QAAatMAAGrUAABq1QAAatcAAGrYAABrFwAAaxkAAGsbAABrHQAAayAAAGshAABrIgAAayMAAGskAABrJgAAaygAAGspAABrKgAAaywAAGstAABrbAAAa24AAGtxAABrcwAAa3YAAGt3AABreAAAa3kAAGt6AABrfAAAa34AAGt/AABrgAAAa4IAAGuDAABrkAAAa5EAAGuSAABrlAAAa9MAAGvVAABr1wAAa9kAAGvcAABr3QAAa94AAGvfAABr4AAAa+IAAGvkAABr5QAAa+YAAGvoAABr6QAAbCgAAGwqAABsLAAAbC4AAGwxAABsMgAAbDMAAGw0AABsNQAAbDcAAGw5AABsOgAAbDsAAGw9AABsPgAAbH0AAGx/AABsgQAAbIMAAGyGAABshwAAbIgAAGyJAABsigAAbIwAAGyOAABsjwAAbJAAAGySAABskwAAbNIAAGzUAABs1gAAbNgAAGzbAABs3AAAbN0AAGzeAABs3wAAbOEAAGzjAABs5AAAbOUAAGznAABs6AAAbScAAG0pAABtKwAAbS0AAG0wAABtMQAAbTIAAG0zAABtNAAAbTYAAG04AABtOQAAbToAAG08AABtPQAAbWIAAG2GAABtrQAAbdEAAG3UAABt1gAAbdgAAG3aAABt3AAAbd4AAG3fAABt4gAAbe8AAG3+AABuAAAAbgIAAG4EAABuBgAAbggAAG4KAABuDAAAbhsAAG4eAABuIQAAbiQAAG4nAABuKgAAbi0AAG4wAABuMgAA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oFMAAKBUAACgVgAAoFgAAKBZAACgWgAAoFwAAKBdAACgqAAAoMsAAKDrAAChCwAAoQ0AAKEPAAChEQAAoRMAAKEWAAChFwAAoRgAAKEbAAChHAAAoR4AAKEfAAChIQAAoSQAAKElAAChJgAAoSkAAKEqAAChLwAAoTwAAKFBAAChQwAAoUUAAKFKAAChTQAAoVAAAKFSAAChdwAAoZsAAKHCAACh5gAAoekAAKHrAACh7QAAoe8AAKHxAACh8wAAofQAAKH3AACiBAAAohUAAKIXAACiGQAAohsAAKIdAACiHwAAoiEAAKIjAACiJQAAojYAAKI5AACiPAAAoj8AAKJCAACiRQAAokgAAKJLAACiTgAAolAAAKKPAACikQAAopMAAKKVAACimAAAopkAAKKaAACimwAAopwAAKKeAACioAAAoqEAAKKiAACipAAAoqUAAKLkAACi5gAAougAAKLqAACi7QAAou4AAKLvAACi8AAAovEAAKLzAACi9QAAovYAAKL3AACi+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/MAALwyAAC8NAAAvDYAALw4AAC8OwAAvDwAALw9AAC8PgAAvD8AALxBAAC8QwAA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zN4AAM0DAADNJwAAzU4AAM1yAADNdQAAzXcAAM15AADNewAAzX0AAM1/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/AADzwAAA88IAAPPEAADzxQAA88YAAPPIAADzyQAA9AgAAPQKAAD0DAAA9A4AAPQRAAD0EgAA9BMAAPQUAAD0FQAA9BcAAPQZAAD0GgAA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ESkAARErAAERLgABES8AAREwAAERMQABETIAARE0AAERNgABETcAARE4AAEROgABETsAARF6AAERfAABEX4AARGAAAERgwABEYQAARGFAAERhgABEYcAARGJAAERiwABEYwAARGNAAERjwABEZAAARHPAAER0QABEdQAARHWAAER2QABEdoAARHbAAER3AABEd0AARHfAAER4QABEeIAARHjAAER5QABEeYAARHzAAER9AABEfUAARH3AAESNgABEjgAARI6AAESPAABEj8AARJAAAESQQABEkIAARJDAAESRQABEkcAARJIAAESSQABEksAARJMAAESiwABEo0AARKPAAESkQABEpQAARKVAAESlgABEpcAARKYAAESmgABEpwAARKdAAESngABEqAAARKhAAES4AABEuIAARLkAAES5gABEukAARLqAAES6wABEuwAARLtAAES7wABEvEAARLyAAES8wABEvUAARL2AAETNQABEzcAARM5AAETOwABEz4AARM/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+AAE7fwABO4AAATuCAAE7gwABO6gAATvMAAE78wABPBcAATwaAAE8HAABPB4AATwgAAE8IgABPCQAATwlAAE8KAABPDUAATxEAAE8RgABPEgAATxKAAE8TAABPE4AATxQAAE8UgABPGEAATxkAAE8ZwABPGoAATxtAAE8cAABPHMAATx2AAE8eAABPLcAATy5AAE8vAABPL4AATzBAAE8wgABPMMAATzEAAE8xQABPMcAATzJAAE8ygABPMsAATzNAAE8zgABPQ0AAT0PAAE9EQABPRMAAT0WAAE9FwABPRgAAT0ZAAE9GgABPRwAAT0eAAE9HwABPSAAAT0iAAE9IwABPWIAAT1kAAE9ZgABPWgAAT1rAAE9bAABPW0AAT1uAAE9bwABPXEAAT1zAAE9dAAB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dlAAAXZRAAF2VAABdlUAAXZaAAF2ZwABdmwAAXZuAAF2cAABdnUAAXZ4AAF2ewABdn0AAXaiAAF2xgABdu0AAXcRAAF3FAABdxYAAXcYAAF3GgABdxwAAXceAAF3HwABdyIAAXcvAAF3QAABd0IAAXdEAAF3RgABd0gAAXdKAAF3TAABd04AAXdQAAF3YQABd2QAAXdnAAF3agABd20AAXdwAAF3cwABd3YAAXd5AAF3ewABd7oAAXe8AAF3vgABd8AAAXfDAAF3xAABd8UAAXfGAAF3xwABd8kAAXfLAAF3zAABd80AAXfPAAF30AABeA8AAXgRAAF4EwABeBUAAXgYAAF4GQABeBoAAXgbAAF4HAABeB4AAXggAAF4IQABeCIAAXgkAAF4JQABeGQAAXhmAAF4aQABeGsAAXhuAAF4bwABeHAAAXhxAAF4cgABeHQAAXh2AAF4dwABeHgAAXh6AAF4ewABeIgAAXiJAAF4igABeIwAAXjLAAF4zQABeM8AAXjRAAF41AABeNUAAXjWAAF41wABeNgAAXjaAAF43AABeN0AAXjeAAF44AABeOEAAXkgAAF5IgABeSQAAXkmAAF5KQABeSoAAXkrAAF5LAABeS0AAXkvAAF5MQABeTIAAXkzAAF5NQABeTYAAXl1AAF5dwABeXkAAXl7AAF5fgABeX8AAXmAAAF5gQABeYIAAXmEAAF5hgABeYcAAXmIAAF5igABeYsAAXnKAAF5zAABec4AAXnQAAF50wABedQAAXnVAAF51gABedcAAXnZAAF52wABedwAAXndAAF53wABeeAAAXofAAF6IQABeiMAAXolAAF6KAABeikAAXoqAAF6KwABeiwAAXouAAF6MAABejEAAXoyAAF6NAABejUAAXpaAAF6fgABeqUAAXrJAAF6zAABes4AAXrQAAF60gABetQAAXrWAAF61wABetoAAXrnAAF69gABevgAAXr6AAF6/AABev4AAXsAAAF7AgABewQAAXsTAAF7FgABexkAAXscAAF7HwABeyIAAXslAAF7KAABeyoAAXtpAAF7awABe20AAXtvAAF7cgABe3MAAXt0AAF7dQABe3YAAXt4AAF7egABe3sAAXt8AAF7fgABe38AAXu+AAF7wAABe8IAAXvEAAF7xwABe8gAAXvJAAF7ygABe8sAAXvNAAF7zwAB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 - - EhPanda/Database/Model.xcdatamodeld/Model 6.xcdatamodel - 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QwtDGkMpQzhDR0NWQ2VDdEN3Q3tDf0ODQ4tDjkOSVSRudWxs1wANAA4ADwAQABEAEgATABQAFQAWABcAGAAXABpfEA9feGRfcm9vdFBhY2thZ2VWJGNsYXNzXF94ZF9jb21tZW50c18QEF94ZF9tb2RlbE1hbmFnZXJfEBVfY29uZmlndXJhdGlvbnNCeU5hbWVdX3hkX21vZGVsTmFtZV8QF19tb2RlbFZlcnNpb25JZGVudGlmaWVygAKBBUCBBT6AAIEFP4AAgQEC3gAcAB0AHgAfACAAIQAiAA4AIwAkACUAJgAnACgAKQAqACsACQApABcALwAwADEAMgAzACkAKQAXXxAcWERCdWNrZXRGb3JDbGFzc2Vzd2FzRW5jb2RlZF8QGlhEQnVja2V0Rm9yUGFja2FnZXNzdG9yYWdlXxAcWERCdWNrZXRGb3JJbnRlcmZhY2Vzc3RvcmFnZV8QD194ZF9vd25pbmdNb2RlbF8QHVhEQnVja2V0Rm9yUGFja2FnZXN3YXNFbmNvZGVkVl9vd25lcl8QG1hEQnVja2V0Rm9yRGF0YVR5cGVzc3RvcmFnZVtfdmlzaWJpbGl0eV8QGVhEQnVja2V0Rm9yQ2xhc3Nlc3N0b3JhZ2VVX25hbWVfEB9YREJ1Y2tldEZvckludGVyZmFjZXN3YXNFbmNvZGVkXxAeWERCdWNrZXRGb3JEYXRhVHlwZXN3YXNFbmNvZGVkXxAQX3VuaXF1ZUVsZW1lbnRJRIAEgQU8gQU6gAGABIAAgQU7gQU9EACABYADgASABIAAUFNZRVPTADoAOwAOADwAQQBGV05TLmtleXNaTlMub2JqZWN0c6QAPQA+AD8AQIAGgAeACIAJpABCAEMARABFgAqBASSBAx+BBGWAKF5HYWxsZXJ5U3RhdGVNT18QD0dhbGxlcnlEZXRhaWxNT1lHYWxsZXJ5TU9YQXBwRW52TU/fEBAATABNAE4ATwAhAFAAUQAjAFIAUwAOACUAVABVACgAVgBXAFgAKQApABQAXABdADEAKQBXAGAAPQBXAGMAZABlXxAhWERCdWNrZXRGb3JPd25lZEF0dHJpYnV0ZXNvcmRlcmVkXxAhWERCdWNrZXRGb3JPd25lZEF0dHJpYnV0ZXNzdG9yYWdlXxAgWERCdWNrZXRGb3JTdGVyZW90eXBlc3dhc0VuY29kZWRfECRYREJ1Y2tldEZvck93bmVkQXR0cmlidXRlc3dhc0VuY29kZWRfEB1YREJ1Y2tldEZvclN0ZXJlb3R5cGVzc3RvcmFnZV8QJFhEQnVja2V0Rm9yR2VuZXJhbGl6YXRpb25zZHVwbGljYXRlc18QJFhEQnVja2V0Rm9yR2VuZXJhbGl6YXRpb25zd2FzRW5jb2RlZF8QHVhEQnVja2V0Rm9yU3RlcmVvdHlwZXNvcmRlcmVkXxAhWERCdWNrZXRGb3JHZW5lcmFsaXphdGlvbnNvcmRlcmVkXxAhWERCdWNrZXRGb3JHZW5lcmFsaXphdGlvbnNzdG9yYWdlW19pc0Fic3RyYWN0gAyAMIAEgASAAoANgQEhgASADIEBI4AGgAyBASKACwgSq62Cw1dvcmRlcmVk0wA6ADsADgBpAGsARqEAaoAOoQBsgA+AKF5YRF9QU3RlcmVvdHlwZdkAIQAlAHAADgAoAHEAIwBWAHIAQgBqAFcAdgAXACkAMQBlAHpfECFYREJ1Y2tldEZvck93bmVkQXR0cmlidXRlc29yZGVyZWRfECRYREJ1Y2tldEZvck93bmVkQXR0cmlidXRlc3dhc0VuY29kZWRfECFYREJ1Y2tldEZvck93bmVkQXR0cmlidXRlc3N0b3JhZ2WACoAOgAyAL4AAgAQIgBDTADoAOwAOAHwAhgBGqQB9AH4AfwCAAIEAggCDAIQAhYARgBKAE4AUgBWAFoAXgBiAGakAhwCIAIkAigCLAIwAjQCOAI+AGoAegB+AIYAigCSAJoApgC2AKF8QE1hEUE1Db21wb3VuZEluZGV4ZXNfEBBYRF9QU0tfZWxlbWVudElEXxAZWERQTVVuaXF1ZW5lc3NDb25zdHJhaW50c18QGlhEX1BTS192ZXJzaW9uSGFzaE1vZGlmaWVyXxAZWERfUFNLX2ZldGNoUmVxdWVzdHNBcnJheV8QEVhEX1BTS19pc0Fic3RyYWN0XxAPWERfUFNLX3VzZXJJbmZvXxATWERfUFNLX2NsYXNzTWFwcGluZ18QFlhEX1BTS19lbnRpdHlDbGFzc05hbWXfEA8AmwCcAJ0AIQCeAJ8AoAAjAKEADgAlAKIAowAoAKQAFwCmABcAbABlAGUAZQAxAGUArQB9AGUAZQAXAGVVX3R5cGVYX2RlZmF1bHRcX2Fz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LwAAEzAAABMxAAATMgAAEzQAABM2AAATNwAAEzgAABM6AAATOwAAE3oAABN8AAATfgAAE4AAABOCAAATgwAAE4QAABOFAAAThgAAE4gAABOKAAATiwAAE4wAABOOAAATjwAAE5gAABOZAAATmwAAE9oAABPcAAAT3gAAE+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+AACIfwAAiL4AAIjAAACIwgAAiMQAAIjHAACIyAAAiMkAAIjKAACIywAAiM0AAIjPAACI0AAAiNEAAIjTAACI1AAAiRMAAIkVAACJFwAAiRkAAIkcAACJHQAAiR4AAIkfAACJIAAAiSIAAIkkAACJJQAAiSYAAIkoAACJKQAAiXQAAImXAACJtwAAidcAAInZAACJ2wAAid0AAInfAACJ4gAAieMAAInkAACJ5wAAiegAAInqAACJ6wAAie0AAInwAACJ8QAAifIAAIn1AACJ9gAAifsAAIoIAACKDQAAig8AAIoRAACKFgAAihkAAIocAACKHgAAikMAAIpnAACKjgAAirIAAIq1AACKtwAAirkAAIq7AACKvQAAir8AAIrAAACK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fAAAlbsAAJW9AACVvwAAlcEAAJXEAACVxQAAlcYAAJXHAACVyAAAlcoAAJXMAACVzQAAlc4AAJXQAACV0QAAlfYAAJYaAACWQQAAlmUAAJZoAACWagAAlmwAAJZuAACWcAAAlnIAAJZzAACWdgAAloMAAJaSAACWlAAAlpYAAJaYAACWmgAAlpwAAJaeAACWoAAAlq8AAJayAACWtQAAlrgAAJa7AACWvgAAlsEAAJbEAACWxgAAlwUAAJcHAACXCgAAlwwAAJcPAACXEAAAlxEAAJcSAACXEwAAlxUAAJcXAACXGAAAlxkAAJcbAACXHAAAlyAAAJdfAACXYQAAl2MAAJdlAACXaAAAl2kAAJdqAACXawAAl2wAAJduAACXcAAAl3EAAJdyAACXdAAAl3UAAJe0AACXtgAAl7gAAJe6AACXvQAAl74AAJe/AACXwAAAl8EAAJfDAACXxQAAl8YAAJfHAACXyQAAl8oAAJgJAACYCwAAmA4AAJgQAACYEwAAmBQAAJgVAACYFgAAmBcAAJgZAACYGwAAmBwAAJgdAACYHwAAmCAAAJhfAACYYQAAmGMAAJhlAACYaAAAmGkAAJhqAACYawAAmGwAAJhuAACYcAAAmHEAAJhyAACYdAAAmHUAAJi0AACYtgAAmLgAAJi6AACYvQAAmL4AAJi/AACYwAAAmMEAAJjDAACYxQAAmMYAAJjHAACYyQAAmMoAAJkJAACZCwAAmQ0AAJkPAACZEgAAmRMAAJkUAACZFQAAmRYAAJkYAACZGgAAmRsAAJkcAACZHgAAmR8AAJlqAACZjQAAma0AAJnNAACZzwAAmdEAAJnTAACZ1QAAmdgAAJnZAACZ2gAAmd0AAJneAACZ4AAAmeEAAJnjAACZ5gAAmecAAJnoAACZ6wAAmewAAJnxAACZ/gAAmgMAAJoFAACaBwAAmgwAAJoPAACaEgAAmhQAAJo5AACaXQAAmoQAAJqoAACaqwAAmq0AAJqvAACasQAAmrMAAJq1AACatgAAmrkAAJrGAACa1wAAmtkAAJrbAACa3QAAmt8AAJrhAACa4wAAmuUAAJrnAACa+AAAmvsAAJr+AACbAQAAmwQAAJsHAACbCgAAmw0AAJsQAACbEgAAm1EAAJtTAACbVQAAm1cAAJtaAACbWwAAm1wAAJtdAACbXgAAm2AAAJtiAACbYwAAm2QAAJtmAACbZwAAm6YAAJuoAACbqgAAm6wAAJuvAACbsAAAm7EAAJuyAACbswAAm7UAAJu3AACbuAAAm7kAAJu7AACbvAAAm/sAAJv9AACcAAAAnAIAAJwFAACcBgAAnAcAAJwIAACcCQAAnAsAAJwNAACcDgAAnA8AAJwRAACcEgAAnB8AAJwgAACcIQAAnCMAAJxiAACcZAAAnGYAAJxoAACcawAAnGwAAJxtAACcbgAAnG8AAJxxAACccwAAnHQAAJx1AACcdwAAnHgAAJy3AACcuQAAnLsAAJy9AACcwAAAnMEAAJzCAACcwwAAnMQAAJzGAACcyAAAnMkAAJzKAACczAAAnM0AAJ0MAACdDgAAnRAAAJ0SAACdFQAAnRYAAJ0XAACdGAAAnRkAAJ0bAACdHQAAnR4AAJ0fAACdIQAAnSIAAJ1hAACdYwAAnWUAAJ1nAACdagAAnWsAAJ1sAACdbQAAnW4AAJ1wAACdcgAAnXMAAJ10AACddgAAnXcAAJ22AACduAAAnboAAJ28AACdvwAAncAAAJ3BAACdwgAAncMAAJ3FAACdxwAAncgAAJ3JAACdywAAncwAAJ3xAACeFQAAnjwAAJ5gAACeYwAAnmUAAJ5nAACeaQAAnmsAAJ5tAACebgAAnnEAAJ5+AACejQAAno8AAJ6RAACekwAAnpUAAJ6XAACemQAAnpsAAJ6qAACerQAAnrAAAJ6zAACetgAAnrkAAJ68AACevwAAnsEAAJ8AAACfAgAAnwQAAJ8GAACfCQAAnwoAAJ8LAACfDAAAnw0AAJ8PAACfEQAAnxIAAJ8TAACfFQAAnxYAAJ9VAACfVwAAn1kAAJ9bAACfXgAAn18AAJ9gAACfYQAAn2IAAJ9kAACfZgAAn2cAAJ9oAACfagAAn2sAAJ+qAACfrAAAn64AAJ+wAACfswAAn7QAAJ+1AACftgAAn7cAAJ+5AACfuwAAn7wAAJ+9AACfvwAAn8AAAJ//AACgAQAAoAMAAKAFAACgCAAAoAkAAKAKAACgCwAAoAwAAKAOAACgEAAAoBEAAKASAACgFAAAoBUAAKBUAACgVgAAoFgAAKBaAACgXQAAoF4AAKBfAACgYAAAoGEAAKBjAACgZQAAoGYAAKBnAACgaQAAoGoAAKCpAACgqwAAoK0AAKCvAACgsgAAoLMAAKC0AACgtQAAoLYAAKC4AACgugAAoLsAAKC8AACgvgAAoL8AAKD+AAChAAAAoQIAAKEEAAChBwAAoQgAAKEJAAChCgAAoQsAAKENAAChDwAAoRAAAKERAAChEwAAoRQAAKFfAAChggAAoaIAAKHCAAChxAAAocYAAKHIAAChygAAoc0AAKHOAAChzwAAodIAAKHTAACh1QAAodYAAKHYAACh2wAAodwAAKHdAACh4AAAoeEAAKHmAACh8wAAofgAAKH6AACh/AAAogEAAKIEAACiBwAAogkAAKIuAACiUgAAonkAAKKdAACioAAAoqIAAKKkAACipgAAoqgAAKKqAACiqwAAoq4AAKK7AACizAAAos4AAKLQAACi0gAAotQAAKLWAACi2AAAotoAAKLcAACi7QAAovAAAKLzAACi9gAAovkAAKL8AACi/wAAowIAAKMFAACjBwAAo0YAAKNIAACjSgAAo0wAAKNPAACjUAAAo1EAAKNSAACjUwAAo1UAAKNXAACjWAAAo1kAAKNbAACjXAAAo5sAAKOdAACjnwAAo6EAAKOkAACjpQAAo6YAAKOnAACjqAAAo6oAAKOsAACjrQAAo64AAKOwAACjsQAAo/AAAKPyAACj9QAAo/cAAKP6AACj+wAAo/wAAKP9AACj/gAApAAAAKQCAACkAwAApAQAAKQGAACkBwAApBQAAKQVAACkFgAApBgAAKRXAACkWQAApFsAAKRdAACkYAAApGEAAKRiAACkYwAApGQAAKRmAACkaAAApGkAAKRqAACkbAAApG0AAKSsAACkrgAApLAAAKSyAACktQAApLYAAKS3AACkuAAApLkAAKS7AACkvQAApL4AAKS/AACkwQAApMIAAKUBAAClAwAApQUAAKUHAAClCgAApQsAAKUMAAClDQAApQ4AAKUQAAClEgAApRMAAKUUAAClFgAApRcAAKVWAAClWAAApVoAAKVcAAClXwAApWAAAKVhAAClYgAApWMAAKVlAAClZwAApWgAAKVpAAClawAApWwAAKWrAAClrQAApa8AAKWxAACltAAApbUAAKW2AACltwAApbgAAKW6AAClvAAApb0AAKW+AAClwAAApcEAAKXmAACmCgAApjEAAKZVAACmWAAAploAAKZcAACmXgAApmAAAKZiAACmYwAApmYAAKZzAACmggAApoQAAKaGAACmiAAApooAAKaMAACmjgAAppAAAKafAACmogAApqUAAKaoAACmqwAApq4AAKaxAACmtAAAprYAAKb1AACm9wAApvkAAKb7AACm/gAApv8AAKcAAACnAQAApwIAAKcEAACnBgAApwcAAKcIAACnCgAApwsAAKdKAACnTAAAp04AAKdQAACnUwAAp1QAAKdVAACnVgAAp1cAAKdZAACnWwAAp1wAAKddAACnXwAAp2AAAKefAACnoQAAp6MAAKelAACnqAAAp6kAAKeqAACnqwAAp6wAAKeuAACnsAAAp7EAAKeyAACntAAAp7UAAKf0AACn9gAAp/gAAKf6AACn/QAAp/4AAKf/AACoAAAAqAEAAKgDAACoBQAAqAYAAKgHAACoCQAAqAoAAKhJAACoSwAAqE0AAKhPAACoUgAAqFMAAKhUAACoVQAAqFYAAKhYAACoWgAAqFsAAKhcAACoXgAAqF8AAKieAACooAAAqKIAAKikAACopwAAqKgAAKipAACoqgAAqKsAAKitAACorwAAqLAAAKixAACoswAAqLQAAKjzAACo9QAAqPcAAKj5AACo/AAAqP0AAKj+AACo/wAAqQAAAKkCAACpBAAAqQUAAKkGAACpCAAAqQkAAKlUAACpdwAAqZcAAKm3AACpuQAAqbsAAKm9AACpvwAAqcIAAKnDAACpxAAAqccAAKnIAACpygAAqcsAAKnNAACp0AAAqdEAAKnSAACp1QAAqdYAAKnbAACp6AAAqe0AAKnvAACp8QAAqfYAAKn5AACp/AAAqf4AAKojAACqRwAAqm4AAKqSAACqlQAAqpcAAKqZAACqmwAAqp0AAKqfAACqoAAAqqMAAKqwAACqwQAAqsMAAKrFAACqxwAAqskAAKrLAACqzQAAqs8AAKrRAACq4gAAquUAAKroAACq6wAAqu4AAKrxAACq9AAAqvcAAKr6AACq/AAAqzsAAKs9AACrPwAAq0EAAKtEAACrRQAAq0YAAKtHAACrSAAAq0oAAKtMAACrTQAAq04AAKtQAACrUQAAq5AAAKuSAACrlAAAq5YAAKuZAACrmgAAq5sAAKucAACrnQAAq58AAKuhAACrogAAq6MAAKulAACrpgAAq+UAAKvnAACr6gAAq+wAAKvvAACr8AAAq/EAAKvyAACr8wAAq/UAAKv3AACr+AAAq/kAAKv7AACr/AAArAkAAKwKAACsCwAArA0AAKxMAACsTgAArFAAAKxSAACsVQAArFYAAKxXAACsWAAArFkAAKxbAACsXQAArF4AAKxfAACsYQAArGIAAKyhAACsowAArKUAAKynAACsqgAArKsAAKysAACsrQAArK4AAKywAACssgAArLMAAKy0AACstgAArLcAAKz2AACs+AAArPoAAKz8AACs/wAArQAAAK0BAACtAgAArQMAAK0FAACtBwAArQgAAK0JAACtCwAArQwAAK1LAACtTQAArU8AAK1RAACtVAAArVUAAK1WAACtVwAArVgAAK1aAACtXAAArV0AAK1eAACtYAAArWEAAK2gAACtogAAraQAAK2mAACtqQAAraoAAK2rAACtrAAAra0AAK2vAACtsQAArbIAAK2zAACttQAArbYAAK3bAACt/wAAriYAAK5KAACuTQAArk8AAK5RAACuUwAArlUAAK5XAACuWAAArlsAAK5oAACudwAArnkAAK57AACufQAArn8AAK6BAACugwAAroUAAK6UAACulwAArpoAAK6dAACuoAAArqMAAK6mAACuqQAArqsAAK7qAACu7AAAru4AAK7wAACu8wAArvQAAK71AACu9gAArvcAAK75AACu+wAArvwAAK79AACu/wAArwAAAK8/AACvQQAAr0MAAK9FAACvSAAAr0kAAK9KAACvSwAAr0wAAK9OAACvUAAAr1EAAK9SAACvVAAAr1UAAK+UAACvlgAAr5gAAK+aAACvnQAAr54AAK+fAACvoAAAr6EAAK+jAACvpQAAr6YAAK+nAACvqQAAr6oAAK/pAACv6wAAr+4AAK/wAACv8wAAr/QAAK/1AACv9gAAr/cAAK/5AACv+wAAr/wAAK/9AACv/wAAsAAAALA/AACwQQAAsEMAALBFAACwSAAAsEkAALBKAACwSwAAsEwAALBOAACwUAAAsFEAALBSAACwVAAAsFUAALCUAACwlgAAsJgAALCaAACwnQAAsJ4AALCfAACwoAAAsKEAALCjAACwpQAAsKYAALCnAACwqQAAsKoAALDpAACw6wAAsO0AALDvAACw8gAAsPMAALD0AACw9QAAsPYAALD4AACw+gAAsPsAALD8AACw/gAAsP8AALFKAACxbQAAsY0AALGtAACxrwAAsbEAALGzAACxtQAAsbgAALG5AACxugAAsb0AALG+AACxwAAAscEAALHDAACxxgAAsccAALHIAACxywAAscwAALHRAACx3gAAseMAALHlAACx5wAAsewAALHvAACx8gAAsfQAALIZAACyPQAAsmQAALKIAACyiwAAso0AALKPAACykQAAspMAALKVAACylgAAspkAALKmAACytwAAsrkAALK7AACyvQAAsr8AALLBAACywwAAssUAALLHAACy2AAAstsAALLeAACy4QAAsuQAALLnAACy6gAAsu0AALLwAACy8gAAszEAALMzAACzNQAAszcAALM6AACzOwAAszwAALM9AACzPgAAs0AAALNCAACzQwAAs0QAALNGAACzRwAAs4YAALOIAACzigAAs4wAALOPAACzkAAAs5EAALOSAACzkwAAs5UAALOXAACzmAAAs5kAALObAACznAAAs9sAALPdAACz4AAAs+IAALPlAACz5gAAs+cAALPoAACz6QAAs+sAALPtAACz7gAAs+8AALPxAACz8gAAs/8AALQAAAC0AQAAtAMAALRCAAC0RAAAtEYAALRIAAC0SwAAtEwAALRNAAC0TgAAtE8AALRRAAC0UwAAtFQAALRVAAC0VwAAtFgAALSXAAC0mQAAtJsAALSdAAC0oAAAtKEAALSiAAC0owAAtKQAALSmAAC0qAAAtKkAALSqAAC0rAAAtK0AALTsAAC07gAAtPAAALTyAAC09QAAtPYAALT3AAC0+AAAtPkAALT7AAC0/QAAtP4AALT/AAC1AQAAtQIAALVBAAC1QwAAtUUAALVHAAC1SgAAtUsAALVMAAC1TQAAtU4AALVQAAC1UgAAtVMAALVUAAC1VgAAtVcAALWWAAC1mAAAtZoAALWcAAC1nwAAtaAAALWhAAC1ogAAtaMAALWlAAC1pwAAtagAALWpAAC1qwAAtawAALXRAAC19QAAthwAALZAAAC2QwAAtkUAALZHAAC2SQAAtksAALZNAAC2TgAAtlEAALZeAAC2bQAAtm8AALZxAAC2cwAAtnUAALZ3AAC2eQAAtnsAALaKAAC2jQAAtpAAALaTAAC2lgAAtpkAALacAAC2nwAAtqEAALbgAAC24gAAtuQAALbmAAC26QAAtuoAALbrAAC27AAAtu0AALbvAAC28QAAtvIAALbzAAC29QAAtvYAALc1AAC3NwAAtzkAALc7AAC3PgAAtz8AALdAAAC3QQAAt0IAALdEAAC3RgAAt0cAALdIAAC3SgAAt0sAALeKAAC3jAAAt44AALeQAAC3kwAAt5QAALeVAAC3lgAAt5cAALeZAAC3mwAAt5wAALedAAC3nwAAt6AAALffAAC34QAAt+MAALflAAC36AAAt+kAALfqAAC36wAAt+wAALfuAAC38AAAt/EAALfyAAC39AAAt/UAALg0AAC4NgAAuDgAALg6AAC4PQAAuD4AALg/AAC4QAAAuEEAALhDAAC4RQAAuEYAALhHAAC4SQAAuEoAALiJAAC4iwAAuI0AALiPAAC4kgAAuJMAALiUAAC4lQAAuJYAALiYAAC4mgAAuJsAALicAAC4ngAAuJ8AALjeAAC44AAAuOIAALjkAAC45wAAuOgAALjpAAC46gAAuOsAALjtAAC47wAAuPAAALjxAAC48wAAuPQAALk/AAC5YgAAuYIAALmiAAC5pAAAuaYAALmoAAC5qgAAua0AALmuAAC5rwAAubIAALmzAAC5tQAAubYAALm4AAC5uwAAubwAALm9AAC5wAAAucEAALnGAAC50wAAudgAALnaAAC53AAAueEAALnkAAC55wAAuekAALoOAAC6MgAAulkAALp9AAC6gAAAuoIAALqEAAC6hgAAuogAALqKAAC6iwAAuo4AALqbAAC6rAAAuq4AALqwAAC6sgAAurQAALq2AAC6uAAAuroAALq8AAC6zQAAutAAALrTAAC61gAAutkAALrcAAC63wAAuuIAALrlAAC65wAAuyYAALsoAAC7KgAAuywAALsvAAC7MAAAuzEAALsyAAC7MwAAuzUAALs3AAC7OAAAuzkAALs7AAC7PAAAu3sAALt9AAC7fwAAu4EAALuEAAC7hQAAu4YAALuHAAC7iAAAu4oAALuMAAC7jQAAu44AALuQAAC7kQAAu9AAALvSAAC71QAAu9cAALvaAAC72wAAu9wAALvdAAC73gAAu+AAALviAAC74wAAu+QAALvmAAC75wAAu/QAALv1AAC79gAAu/gAALw3AAC8OQAAvDsAALw9AAC8QAAAvEEAALxCAAC8QwAAvEQAALxGAAC8SAAAvEkAALxKAAC8TAAAvE0AALyMAAC8jgAAvJAAALySAAC8lQAAvJYAALyXAAC8mAAAvJkAALybAAC8nQAAvJ4AALyfAAC8oQAAvKIAALzhAAC84wAAvOUAALznAAC86gAAvOsAALzsAAC87QAAvO4AALzwAAC88gAAvPMAALz0AAC89gAAvPcAAL02AAC9OAAAvToAAL08AAC9PwAAvUAAAL1BAAC9QgAAvUMAAL1FAAC9RwAAvUgAAL1JAAC9SwAAvUwAAL2LAAC9jQAAvY8AAL2RAAC9lAAAvZUAAL2WAAC9lwAAvZgAAL2aAAC9nAAAvZ0AAL2eAAC9oAAAvaEAAL3GAAC96gAAvhEAAL41AAC+OAAAvjoAAL48AAC+PgAAvkAAAL5CAAC+QwAAvkYAAL5TAAC+YgAAvmQAAL5mAAC+aAAAvmoAAL5sAAC+bgAAvnAAAL5/AAC+ggAAvoUAAL6IAAC+iwAAvo4AAL6RAAC+lAAAvpYAAL7VAAC+1wAAvtoAAL7cAAC+3wAAvuAAAL7hAAC+4gAAvuMAAL7lAAC+5wAAvugAAL7pAAC+6wAAvuwAAL8rAAC/LQAAvy8AAL8xAAC/NAAAvzUAAL82AAC/NwAAvzgAAL86AAC/PAAAvz0AAL8+AAC/QAAAv0EAAL+AAAC/ggAAv4QAAL+GAAC/iQAAv4oAAL+LAAC/jAAAv40AAL+PAAC/kQAAv5IAAL+TAAC/lQAAv5YAAL/VAAC/1wAAv9oAAL/cAAC/3wAAv+AAAL/hAAC/4gAAv+MAAL/lAAC/5wAAv+gAAL/pAAC/6wAAv+wAAMArAADALQAAwC8AAMAxAADANAAAwDUAAMA2AADANwAAwDgAAMA6AADAPAAAwD0AAMA+AADAQAAAwEEAAMCAAADAggAAwIQAAMCGAADAiQAAwIoAAMCLAADAjAAAwI0AAMCPAADAkQAAwJIAAMCTAADAlQAAwJYAAMDVAADA1wAAwNkAAMDbAADA3gAAwN8AAMDgAADA4QAAwOIAAMDkAADA5gAAwOcAAMDoAADA6gAAwOsAAME2AADBWQAAwXkAAMGZAADBmwAAwZ0AAMGfAADBoQAAwaQAAMGlAADBpgAAwakAAMGqAADBrAAAwa0AAMGvAADBsgAAwbMAAMG0AADBtwAAwbgAAMG9AADBygAAwc8AAMHRAADB0wAAwdgAAMHbAADB3gAAweAAAMIFAADCKQAAwlAAAMJ0AADCdwAAwnkAAMJ7AADCfQAAwn8AAMKBAADCggAAwoUAAMKSAADCowAAwqUAAMKnAADCqQAAwqsAAMKtAADCrwAAwrEAAMKzAADCxAAAwscAAMLKAADCzQAAwtAAAMLTAADC1gAAwtkAAMLcAADC3gAAwx0AAMMfAADDIQAAwyMAAMMmAADDJwAAwygAAMMpAADDKgAAwywAAMMuAADDLwAAwzAAAMMyAADDMwAAw3IAAMN0AADDdgAAw3gAAMN7AADDfAAAw30AAMN+AADDfwAAw4EAAMODAADDhAAAw4UAAMOHAADDiAAAw8cAAMPJAADDzAAAw84AAMPRAADD0gAAw9MAAMPUAADD1QAAw9cAAMPZAADD2gAAw9sAAMPdAADD3gAAw+sAAMPsAADD7QAAw+8AAMQuAADEMAAAxDIAAMQ0AADENwAAxDgAAMQ5AADEOgAAxDsAAMQ9AADEPwAAxEAAAMRBAADEQwAAxEQAAMSDAADEhQAAxIcAAMSJAADEjAAAxI0AAMSOAADEjwAAxJAAAMSSAADElAAAxJUAAMSWAADEmAAAxJkAAMTYAADE2gAAxNwAAMTeAADE4QAAxOIAAMTjAADE5AAAxOUAAMTnAADE6QAAxOoAAMTrAADE7QAAxO4AAMUtAADFLwAAxTEAAMUzAADFNgAAxTcAAMU4AADFOQAAxToAAMU8AADFPgAAxT8AAMVAAADFQgAAxUMAAMWCAADFhAAAxYYAAMWIAADFiwAAxYwAAMWNAADFjgAAxY8AAMWRAADFkwAAxZQAAMWVAADFlwAAxZgAAMW9AADF4QAAxggAAMYsAADGLwAAxjEAAMYzAADGNQAAxjcAAMY5AADGOgAAxj0AAMZKAADGWQAAxlsAAMZdAADGXwAAxmEAAMZjAADGZQAAxmcAAMZ2AADGeQAAxnwAAMZ/AADGggAAxoUAAMaIAADGiwAAxo0AAMbMAADGzgAAxtAAAMbSAADG1QAAxtYAAMbXAADG2AAAxtkAAMbbAADG3QAAxt4AAMbfAADG4QAAxuIAAMchAADHIwAAxyUAAMcnAADHKgAAxysAAMcsAADHLQAAxy4AAMcwAADHMgAAxzMAAMc0AADHNgAAxzcAAMd2AADHeAAAx3oAAMd8AADHfwAAx4AAAMeBAADHggAAx4MAAMeFAADHhwAAx4gAAMeJAADHiwAAx4wAAMfLAADHzQAAx88AAMfRAADH1AAAx9UAAMfWAADH1wAAx9gAAMfaAADH3AAAx90AAMfeAADH4AAAx+EAAMggAADIIgAAyCQAAMgmAADIKQAAyCoAAMgrAADILAAAyC0AAMgvAADIMQAAyDIAAMgzAADINQAAyDYAAMh1AADIdwAAyHkAAMh7AADIfgAAyH8AAMiAAADIgQAAyIIAAMiEAADIhgAAyIcAAMiIAADIigAAyIsAAMjKAADIzAAAyM4AAMjQAADI0wAAyNQAAMjVAADI1gAAyNcAAMjZAADI2wAAyNwAAMjdAADI3wAAyOAAAMkrAADJTgAAyW4AAMmOAADJkAAAyZIAAMmUAADJlgAAyZkAAMmaAADJmwAAyZ4AAMmfAADJoQAAyaIAAMmkAADJpwAAyagAAMmpAADJrAAAya0AAMmyAADJvwAAycQAAMnGAADJyAAAyc0AAMnQAADJ0wAAydUAAMn6AADKHgAAykUAAMppAADKbAAAym4AAMpwAADKcgAAynQAAMp2AADKdwAAynoAAMqHAADKmAAAypoAAMqcAADKngAAyqAAAMqiAADKpAAAyqYAAMqoAADKuQAAyrwAAMq/AADKwgAAysUAAMrIAADKywAAys4AAMrRAADK0wAAyxIAAMsUAADLFgAAyxgAAMsbAADLHAAAyx0AAMseAADLHwAAyyEAAMsjAADLJAAAyyUAAMsnAADLKAAAy2cAAMtpAADLawAAy20AAMtwAADLcQAAy3IAAMtzAADLdAAAy3YAAMt4AADLeQAAy3oAAMt8AADLfQAAy7wAAMu+AADLwQAAy8MAAMvGAADLxwAAy8gAAMvJAADLygAAy8wAAMvOAADLzwAAy9AAAMvSAADL0wAAy+AAAMvhAADL4gAAy+QAAMwjAADMJQAAzCcAAMwpAADMLAAAzC0AAMwuAADMLwAAzDAAAMwyAADMNAAAzDUAAMw2AADMOAAAzDkAAMx4AADMegAAzHwAAMx+AADMgQAAzIIAAMyDAADMhAAAzIUAAMyHAADMiQAAzIoAAMyLAADMjQAAzI4AAMzNAADMzwAAzNEAAMzTAADM1gAAzNcAAMzYAADM2QAAzNoAAMzcAADM3gAAzN8AAMzgAADM4gAAzOMAAM0iAADNJAAAzSYAAM0oAADNKwAAzSwAAM0tAADNLgAAzS8AAM0xAADNMwAAzTQAAM01AADNNwAAzTgAAM13AADNeQAAzXsAAM19AADNgAAAzYEAAM2CAADNgwAAzYQAAM2GAADNiAAAzYkAAM2KAADNjAAAzY0AAM2yAADN1gAAzf0AAM4hAADOJAAAziYAAM4oAADOKgAAziwAAM4uAADOLwAAzjIAAM4/AADOTgAAzlAAAM5SAADOVAAAzlYAAM5YAADOWgAAzlwAAM5rAADObgAAznEAAM50AADOdwAAznoAAM59AADOgAAAzoIAAM7BAADOwwAAzsYAAM7IAADOywAAzswAAM7NAADOzgAAzs8AAM7RAADO0wAAztQAAM7VAADO1wAAztgAAM7bAADPGgAAzxwAAM8eAADPIAAAzyMAAM8kAADPJQAAzyYAAM8nAADPKQAAzysAAM8sAADPLQAAzy8AAM8wAADPbwAAz3EAAM9zAADPdQAAz3gAAM95AADPegAAz3sAAM98AADPfgAAz4AAAM+BAADPggAAz4QAAM+FAADPxAAAz8YAAM/JAADPywAAz84AAM/PAADP0AAAz9EAAM/SAADP1AAAz9YAAM/XAADP2AAAz9oAAM/bAADP3gAA0B0AANAfAADQIQAA0CMAANAmAADQJwAA0CgAANApAADQKgAA0CwAANAuAADQLwAA0DAAANAyAADQMwAA0HIAANB0AADQdgAA0HgAANB7AADQfAAA0H0AANB+AADQfwAA0IEAANCDAADQhAAA0IUAANCHAADQiAAA0McAANDJAADQywAA0M0AANDQAADQ0QAA0NIAANDTAADQ1AAA0NYAANDYAADQ2QAA0NoAANDcAADQ3QAA0SgAANFLAADRawAA0YsAANGNAADRjwAA0ZEAANGTAADRlgAA0ZcAANGYAADRmwAA0ZwAANGeAADRnwAA0aEAANGkAADRpQAA0aYAANGpAADRqgAA0bMAANHAAADRxQAA0ccAANHJAADRzgAA0dEAANHUAADR1gAA0fsAANIfAADSRgAA0moAANJtAADSbwAA0nEAANJzAADSdQAA0ncAANJ4AADSewAA0ogAANKZAADSmwAA0p0AANKfAADSoQAA0qMAANKlAADSpwAA0qkAANK6AADSvQAA0sAAANLDAADSxgAA0skAANLMAADSzwAA0tIAANLUAADTEwAA0xUAANMXAADTGQAA0xwAANMdAADTHgAA0x8AANMgAADTIgAA0yQAANMlAADTJgAA0ygAANMpAADTaAAA02oAANNsAADTbgAA03EAANNyAADTcwAA03QAANN1AADTdwAA03kAANN6AADTewAA030AANN+AADTvQAA078AANPCAADTxAAA08cAANPIAADTyQAA08oAANPLAADTzQAA088AANPQAADT0QAA09MAANPUAADT4QAA0+IAANPjAADT5QAA1CQAANQmAADUKAAA1CoAANQtAADULgAA1C8AANQwAADUMQAA1DMAANQ1AADUNgAA1DcAANQ5AADUOgAA1HkAANR7AADUfQAA1H8AANSCAADUgwAA1IQAANSFAADUhgAA1IgAANSKAADUiwAA1IwAANSOAADUjwAA1M4AANTQAADU0gAA1NQAANTXAADU2AAA1NkAANTaAADU2wAA1N0AANTfAADU4AAA1OEAANTjAADU5AAA1SMAANUlAADVJwAA1SkAANUsAADVLQAA1S4AANUvAADVMAAA1TIAANU0AADVNQAA1TYAANU4AADVOQAA1XgAANV6AADVfAAA1X4AANWBAADVggAA1YMAANWEAADVhQAA1YcAANWJAADVigAA1YsAANWNAADVjgAA1bMAANXXAADV/gAA1iIAANYlAADWJwAA1ikAANYrAADWLQAA1i8AANYwAADWMwAA1kAAANZPAADWUQAA1lMAANZVAADWVwAA1lkAANZbAADWXQAA1mwAANZvAADWcgAA1nUAANZ4AADWewAA1n4AANaBAADWgwAA1sIAANbEAADWxgAA1sgAANbLAADWzAAA1s0AANbOAADWzwAA1tEAANbTAADW1AAA1tUAANbXAADW2AAA1xcAANcZAADXGwAA1x0AANcgAADXIQAA1yIAANcjAADXJAAA1yYAANcoAADXKQAA1yoAANcsAADXLQAA12wAANduAADXcAAA13IAANd1AADXdgAA13cAANd4AADXeQAA13sAANd9AADXfgAA138AANeBAADXggAA18EAANfDAADXxgAA18gAANfLAADXzAAA180AANfOAADXzwAA19EAANfTAADX1AAA19UAANfXAADX2AAA2BcAANgZAADYGwAA2B0AANggAADYIQAA2CIAANgjAADYJAAA2CYAANgoAADYKQAA2CoAANgsAADYLQAA2GwAANhuAADYcAAA2HIAANh1AADYdgAA2HcAANh4AADYeQAA2HsAANh9AADYfgAA2H8AANiBAADYggAA2MEAANjDAADYxQAA2McAANjKAADYywAA2MwAANjNAADYzgAA2NAAANjSAADY0wAA2NQAANjWAADY1wAA2SIAANlFAADZZQAA2YUAANmHAADZiQAA2YsAANmNAADZkAAA2ZEAANmSAADZlQAA2ZYAANmYAADZmQAA2ZsAANmeAADZnwAA2aAAANmjAADZpAAA2akAANm2AADZuwAA2b0AANm/AADZxAAA2ccAANnKAADZzAAA2fEAANoVAADaPAAA2mAAANpjAADaZQAA2mcAANppAADaawAA2m0AANpuAADacQAA2n4AANqPAADakQAA2pMAANqVAADalwAA2pkAANqbAADanQAA2p8AANqwAADaswAA2rYAANq5AADavAAA2r8AANrCAADaxQAA2sgAANrKAADbCQAA2wsAANsNAADbDwAA2xIAANsTAADbFAAA2xUAANsWAADbGAAA2xoAANsbAADbHAAA2x4AANsfAADbXgAA22AAANtiAADbZAAA22cAANtoAADbaQAA22oAANtrAADbbQAA228AANtwAADbcQAA23MAANt0AADbswAA27UAANu4AADbugAA270AANu+AADbvwAA28AAANvBAADbwwAA28UAANvGAADbxwAA28kAANvKAADb1wAA29gAANvZAADb2wAA3BoAANwcAADcHgAA3CAAANwjAADcJAAA3CUAANwmAADcJwAA3CkAANwrAADcLAAA3C0AANwvAADcMAAA3G8AANxxAADccwAA3HUAANx4AADceQAA3HoAANx7AADcfAAA3H4AANyAAADcgQAA3IIAANyEAADchQAA3MQAANzGAADcyAAA3MoAANzNAADczgAA3M8AANzQAADc0QAA3NMAANzVAADc1gAA3NcAANzZAADc2gAA3RkAAN0bAADdHQAA3R8AAN0iAADdIwAA3SQAAN0lAADdJgAA3SgAAN0qAADdKwAA3SwAAN0uAADdLwAA3W4AAN1wAADdcgAA3XQAAN13AADdeAAA3XkAAN16AADdewAA3X0AAN1/AADdgAAA3YEAAN2DAADdhAAA3akAAN3NAADd9AAA3hgAAN4bAADeHQAA3h8AAN4hAADeIwAA3iUAAN4mAADeKQAA3jYAAN5FAADeRwAA3kkAAN5LAADeTQAA3k8AAN5RAADeUwAA3mIAAN5lAADeaAAA3msAAN5uAADecQAA3nQAAN53AADeeQAA3rgAAN66AADevQAA3r8AAN7CAADewwAA3sQAAN7FAADexgAA3sgAAN7KAADeywAA3swAAN7OAADezwAA3w4AAN8QAADfEgAA3xQAAN8XAADfGAAA3xkAAN8aAADfGwAA3x0AAN8fAADfIAAA3yEAAN8jAADfJAAA32MAAN9lAADfZwAA32kAAN9sAADfbQAA324AAN9vAADfcAAA33IAAN90AADfdQAA33YAAN94AADfeQAA37gAAN+6AADfvQAA378AAN/CAADfwwAA38QAAN/FAADfxgAA38gAAN/KAADfywAA38wAAN/OAADfzwAA4A4AAOAQAADgEgAA4BQAAOAXAADgGAAA4BkAAOAaAADgGwAA4B0AAOAfAADgIAAA4CEAAOAjAADgJAAA4GMAAOBlAADgZwAA4GkAAOBsAADgbQAA4G4AAOBvAADgcAAA4HIAAOB0AADgdQAA4HYAAOB4AADgeQAA4LgAAOC6AADgvAAA4L4AAODBAADgwgAA4MMAAODEAADgxQAA4McAAODJAADgygAA4MsAAODNAADgzgAA4RkAAOE8AADhXAAA4XwAAOF+AADhgAAA4YIAAOGEAADhhwAA4YgAAOGJAADhjAAA4Y0AAOGPAADhkAAA4ZIAAOGVAADhlgAA4ZcAAOGaAADhmwAA4aAAAOGtAADhsgAA4bQAAOG2AADhuwAA4b4AAOHBAADhwwAA4egAAOIMAADiMwAA4lcAAOJaAADiXAAA4l4AAOJgAADiYgAA4mQAAOJlAADiaAAA4nUAAOKGAADiiAAA4ooAAOKMAADijgAA4pAAAOKSAADilAAA4pYAAOKnAADiqgAA4q0AAOKwAADiswAA4rYAAOK5AADivAAA4r8AAOLBAADjAAAA4wIAAOMEAADjBgAA4wkAAOMKAADjCwAA4wwAAOMNAADjDwAA4xEAAOMSAADjEwAA4xUAAOMWAADjVQAA41cAAONZAADjWwAA414AAONfAADjYAAA42EAAONiAADjZAAA42YAAONnAADjaAAA42oAAONrAADjqgAA46wAAOOvAADjsQAA47QAAOO1AADjtgAA47cAAOO4AADjugAA47wAAOO9AADjvgAA48AAAOPBAADjzgAA488AAOPQAADj0gAA5BEAAOQTAADkFQAA5BcAAOQaAADkGwAA5BwAAOQdAADkHgAA5CAAAOQiAADkIwAA5CQAAOQmAADkJwAA5GYAAORoAADkagAA5GwAAORvAADkcAAA5HEAAORyAADkcwAA5HUAAOR3AADkeAAA5HkAAOR7AADkfAAA5LsAAOS9AADkvwAA5MEAAOTEAADkxQAA5MYAAOTHAADkyAAA5MoAAOTMAADkzQAA5M4AAOTQAADk0QAA5RAAAOUSAADlFAAA5RYAAOUZAADlGgAA5RsAAOUcAADlHQAA5R8AAOUhAADlIgAA5SMAAOUlAADlJgAA5WUAAOVnAADlaQAA5WsAAOVuAADlbwAA5XAAAOVxAADlcgAA5XQAAOV2AADldwAA5XgAAOV6AADlewAA5aAAAOXEAADl6wAA5g8AAOYSAADmFAAA5hYAAOYYAADmGgAA5hwAAOYdAADmIAAA5i0AAOY8AADmPgAA5kAAAOZCAADmRAAA5kYAAOZIAADmSgAA5lkAAOZcAADmXwAA5mIAAOZlAADmaAAA5msAAOZuAADmcAAA5q8AAOaxAADmtAAA5rYAAOa5AADmugAA5rsAAOa8AADmvQAA5r8AAObBAADmwgAA5sMAAObFAADmxgAA5wUAAOcHAADnCQAA5wsAAOcOAADnDwAA5xAAAOcRAADnEgAA5xQAAOcWAADnFwAA5xgAAOcaAADnGwAA51oAAOdcAADnXgAA52AAAOdjAADnZAAA52UAAOdmAADnZwAA52kAAOdrAADnbAAA520AAOdvAADncAAA568AAOexAADntAAA57YAAOe5AADnugAA57sAAOe8AADnvQAA578AAOfBAADnwgAA58MAAOfFAADnxgAA6AUAAOgHAADoCQAA6AsAAOgOAADoDwAA6BAAAOgRAADoEgAA6BQAAOgWAADoFwAA6BgAAOgaAADoGwAA6FoAAOhcAADoXgAA6GAAAOhjAADoZAAA6GUAAOhmAADoZwAA6GkAAOhrAADobAAA6G0AAOhvAADocAAA6K8AAOixAADoswAA6LUAAOi4AADouQAA6LoAAOi7AADovAAA6L4AAOjAAADowQAA6MIAAOjEAADoxQAA6RAAAOkzAADpUwAA6XMAAOl1AADpdwAA6XkAAOl7AADpfgAA6X8AAOmAAADpgwAA6YQAAOmGAADphwAA6YkAAOmMAADpjQAA6Y4AAOmRAADpkgAA6ZcAAOmkAADpqQAA6asAAOmtAADpsgAA6bUAAOm4AADpugAA6d8AAOoDAADqKgAA6k4AAOpRAADqUwAA6lUAAOpXAADqWQAA6lsAAOpcAADqXwAA6mwAAOp9AADqfwAA6oEAAOqDAADqhQAA6ocAAOqJAADqiwAA6o0AAOqeAADqoQAA6qQAAOqnAADqqgAA6q0AAOqwAADqswAA6rYAAOq4AADq9wAA6vkAAOr7AADq/QAA6wAAAOsBAADrAgAA6wMAAOsEAADrBgAA6wgAAOsJAADrCgAA6wwAAOsNAADrTAAA604AAOtQAADrUgAA61UAAOtWAADrVwAA61gAAOtZAADrWwAA610AAOteAADrXwAA62EAAOtiAADroQAA66MAAOumAADrqAAA66sAAOusAADrrQAA664AAOuvAADrsQAA67MAAOu0AADrtQAA67cAAOu4AADrxQAA68YAAOvHAADryQAA7AgAAOwKAADsDAAA7A4AAOwRAADsEgAA7BMAAOwUAADsFQAA7BcAAOwZAADsGgAA7BsAAOwdAADsHgAA7F0AAOxfAADsYQAA7GMAAOxmAADsZwAA7GgAAOxpAADsagAA7GwAAOxuAADsbwAA7HAAAOxyAADscwAA7LIAAOy0AADstgAA7LgAAOy7AADsvAAA7L0AAOy+AADsvwAA7MEAAOzDAADsxAAA7MUAAOzHAADsyAAA7QcAAO0JAADtCwAA7Q0AAO0QAADtEQAA7RIAAO0TAADtFAAA7RYAAO0YAADtGQAA7RoAAO0cAADtHQAA7VwAAO1eAADtYAAA7WIAAO1lAADtZgAA7WcAAO1oAADtaQAA7WsAAO1tAADtbgAA7W8AAO1xAADtcgAA7ZcAAO27AADt4gAA7gYAAO4JAADuCwAA7g0AAO4PAADuEQAA7hMAAO4UAADuFwAA7iQAAO4zAADuNQAA7jcAAO45AADuOwAA7j0AAO4/AADuQQAA7lAAAO5TAADuVgAA7lkAAO5cAADuXwAA7mIAAO5lAADuZwAA7qYAAO6oAADuqwAA7q0AAO6wAADusQAA7rIAAO6zAADutAAA7rYAAO64AADuuQAA7roAAO68AADuvQAA7swAAO8LAADvDQAA7w8AAO8RAADvFAAA7xUAAO8WAADvFwAA7xgAAO8aAADvHAAA7x0AAO8eAADvIAAA7yEAAO9gAADvYgAA72QAAO9mAADvaQAA72oAAO9rAADvbAAA720AAO9vAADvcQAA73IAAO9zAADvdQAA73YAAO+1AADvtwAA77oAAO+8AADvvwAA78AAAO/BAADvwgAA78MAAO/FAADvxwAA78gAAO/JAADvywAA78wAAO/PAADwDgAA8BAAAPASAADwFAAA8BcAAPAYAADwGQAA8BoAAPAbAADwHQAA8B8AAPAgAADwIQAA8CMAAPAkAADwYwAA8GUAAPBnAADwaQAA8GwAAPBtAADwbgAA8G8AAPBwAADwcgAA8HQAAPB1AADwdgAA8HgAAPB5AADwuAAA8LoAAPC8AADwvgAA8MEAAPDCAADwwwAA8MQAAPDFAADwxwAA8MkAAPDKAADwywAA8M0AAPDOAADxGQAA8TwAAPFcAADxfAAA8X4AAPGAAADxggAA8YQAAPGHAADxiAAA8YkAAPGMAADxjQAA8Y8AAPGQAADxkgAA8ZUAAPGWAADxlwAA8ZoAAPGbAADxoAAA8a0AAPGyAADxtAAA8bYAAPG7AADxvgAA8cEAAPHDAADx6AAA8gwAAPIzAADyVwAA8loAAPJcAADyXgAA8mAAAPJiAADyZAAA8mUAAPJoAADydQAA8oYAAPKIAADyigAA8owAAPKOAADykAAA8pIAAPKUAADylgAA8qcAAPKqAADyrQAA8rAAAPKzAADytgAA8rkAAPK8AADyvwAA8sEAAPMAAADzAgAA8wQAAPMGAADzCQAA8woAAPMLAADzDAAA8w0AAPMPAADzEQAA8xIAAPMTAADzFQAA8xYAAPNVAADzVwAA81kAAPNbAADzXgAA818AAPNgAADzYQAA82IAAPNkAADzZgAA82cAAPNoAADzagAA82sAAPOqAADzrAAA868AAPOxAADztAAA87UAAPO2AADztwAA87gAAPO6AADzvAAA870AAPO+AADzwAAA88EAAPPOAADzzwAA89AAAPPSAAD0EQAA9BMAAPQVAAD0FwAA9BoAAPQbAAD0HAAA9B0AAPQeAAD0IAAA9CIAAPQjAAD0JAAA9CYAAPQnAAD0ZgAA9GgAAPRqAAD0bAAA9G8AAPRwAAD0cQAA9HIAAPRzAAD0dQAA9HcAAPR4AAD0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VQABBVcAAQVZAAEFWwABBV4AAQVfAAEFYAABBWEAAQViAAEFZAABBWYAAQVnAAEFaAABBWoAAQVrAAEFkAABBbQAAQXbAAEF/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/AAE9QgABPUUAAT1HAAE9hgABPYgAAT2LAAE9jQABPZAAAT2RAAE9kgABPZMAAT2UAAE9lgABPZgAAT2ZAAE9mgABPZwAAT2dAAE93AABPd4AAT3gAAE94gABPeUAAT3mAAE95wABPegAAT3pAAE9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hwABTokAAU6KAAFOyQABTssAAU7NAAFOzwABTtIAAU7TAAFO1AABTtUAAU7WAAFO2AABTtoAAU7bAAFO3AABTt4AAU7fAAFPHgABTyAAAU8iAAFPJAABTycAAU8oAAFPKQABTyoAAU8rAAFPLQABTy8AAU8wAAFPMQABTzMAAU80AAFPcwABT3UAAU93AAFPeQABT3wAAU99AAFPfgABT38AAU+AAAFPggABT4QAAU+FAAFPhgABT4gAAU+JAAFP1AABT/cAAVAXAAFQNwABUDkAAVA7AAFQPQABUD8AAVBCAAFQQwABUEQAAVBHAAFQSAABUEoAAVBLAAFQTQABUFAAAVBRAAFQUgABUFUAAVBWAAFQWwABUGgAAVBtAAFQbwABUHEAAVB2AAFQeQABUHwAAVB+AAFQowABUMcAAVDuAAFREgABURUAAVEXAAFRGQABURsAAVEdAAFRHwABUSAAAVEjAAFRMAABUUEAAVFDAAFRRQABUUcAAVFJAAFRSwABUU0AAVFPAAFRUQABUWIAAVFlAAFRaAABUWsAAVFuAAFRcQABUXQAAVF3AAFRegABUXwAAVG7AAFRvQABUb8AAVHBAAFRxAABUcUAAVHGAAFRxwABUcgAAVHKAAFRzAABUc0AAVHOAAFR0AABUdEAAVIQAAFSEgABUhQAAVIWAAFSGQABUhoAAVIbAAFSHAABUh0AAVIfAAFSIQABUiIAAVIjAAFSJQABUiYAAVJlAAFSZwABUmoAAVJsAAFSbwABUnAAAVJxAAFScgABUnMAAVJ1AAFSdwABUngAAVJ5AAFSewABUnwAAVKJAAFSigABUosAAVKNAAFSzAABUs4AAVLQAAFS0gABUtUAAVLWAAFS1wABUtgAAVLZAAFS2wABUt0AAVLeAAFS3wABUuEAAVLiAAFTIQABUyMAAVMlAAFTJwABUyoAAVMrAAFTLAABUy0AAVMuAAFTMAABUzIAAVMzAAFTNAABUzYAAVM3AAFTdgABU3gAAVN6AAFTfAABU38AAVOAAAFTgQABU4IAAVODAAFThQABU4cAAVOIAAFTiQABU4sAAVOMAAFTywABU80AAVPPAAFT0QABU9QAAVPVAAFT1gABU9cAAVPYAAFT2gABU9wAAVPdAAFT3gABU+AAAVPhAAFUIAABVCIAAVQkAAFUJgABVCkAAVQqAAFUKwABVCwAAVQtAAFULwABVDEAAVQyAAFUMwABVDUAAVQ2AAFUWwABVH8AAVSmAAFUygABVM0AAVTPAAFU0QABVNMAAVTVAAFU1wABVNgAAVTbAAFU6AABVPcAAVT5AAFU+wABVP0AAVT/AAFVAQABVQMAAVUFAAFVFAABVRcAAVUaAAFVHQABVSAAAVUjAAFVJgABVSkAAVUrAAFVagABVWwAAVVuAAFVcAABVXMAAVV0AAFVdQABVXYAAVV3AAFVeQABVXsAAVV8AAFVfQABVX8AAVWAAAFVvwABVcEAAVXDAAFVxQABVcgAAVXJAAFVygABVcsAAVXMAAFVzgABVdAAAVXRAAFV0gABVdQAAVXVAAFWFAABVhYAAVYYAAFWGgABVh0AAVYeAAFWHwABViAAAVYhAAFWIwABViUAAVYmAAFWJwABVikAAVYqAAFWaQABVmsAAVZuAAFWcAABVnMAAVZ0AAFWdQABVnYAAVZ3AAFWeQABVnsAAVZ8AAFWfQABVn8AAVaAAAFWvwABVsEAAVbDAAFWxQABVsgAAVbJAAFWygABVssAAVbMAAFWzgABVtAAAVbRAAFW0gABVtQAAVbVAAFXFAABVxYAAVcYAAFXGgABVx0AAVceAAFXHwABVyAAAVchAAFXIwABVyUAAVcmAAFXJwABVykAAVcqAAFXaQABV2sAAVdtAAFXbwABV3IAAVdzAAFXdAABV3UAAVd2AAFXeAABV3oAAVd7AAFXfAABV34AAVd/AAFXygABV+0AAVgNAAFYLQABWC8AAVgxAAFYMwABWDUAAVg4AAFYOQABWDoAAVg9AAFYPgABWEAAAVhBAAFYQwABWEYAAVhHAAFYSAABWEsAAVhMAAFYUQABWF4AAVhjAAFYZQABWGcAAVhsAAFYbwABWHIAAVh0AAFYmQABWL0AAVjkAAFZCAABWQsAAVkNAAFZDwABWREAAVkTAAFZFQABWRYAAVkZAAFZJgABWTcAAVk5AAFZOwABWT0AAVk/AAFZQQABWUMAAVlFAAFZRwABWVgAAVlbAAFZXgABWWEAAVlkAAFZZwABWWoAAVltAAFZcAABWXIAAVmxAAFZswABWbUAAVm3AAFZugABWbsAAVm8AAFZvQABWb4AAVnAAAFZwgABWcMAAVnEAAFZxgABWccAAVoGAAFaCAABWgoAAVoMAAFaDwABWhAAAVoRAAFaEgABWhMAAVoVAAFaFwABWhgAAVoZAAFaGwABWhwAAVpbAAFaXQABWmAAAVpiAAFaZQABWmYAAVpnAAFaaAABWmkAAVprAAFabQABWm4AAVpvAAFacQABWnIAAVp/AAFagAABWoEAAVqDAAFawgABWsQAAVrGAAFayAABWssAAVrMAAFazQABWs4AAVrPAAFa0QABWtMAAVrUAAFa1QABWtcAAVrYAAFbFwABWxkAAVsbAAFbHQABWyAAAVshAAFbIgABWyMAAVskAAFbJgABWygAAVspAAFbKgABWywAAVstAAFbbAABW24AAVtwAAFbcgABW3UAAVt2AAFbdwABW3gAAVt5AAFbewABW30AAVt+AAFbfwABW4EAAVuCAAFbwQABW8MAAVvFAAFbxwABW8oAAVvLAAFbzAABW80AAVvOAAFb0AABW9IAAVvTAAFb1AABW9YAAVvXAAFcFgABXBgAAVwaAAFcHAABXB8AAVwgAAFcIQABXCIAAVwjAAFcJQABXCcAAVwoAAFcKQABXCsAAVwsAAFcUQABXHUAAVycAAFcwAABXMMAAVzFAAFcxwABXMkAAVzLAAFczQABXM4AAVzRAAFc3gABXO0AAVzvAAFc8QABXPMAAVz1AAFc9wABXPkAAVz7AAFdCgABXQ0AAV0QAAFdEwABXRYAAV0ZAAFdHAABXR8AAV0hAAFdYAABXWIAAV1kAAFdZgABXWkAAV1qAAFdawABXWwAAV1tAAFdbwABXXEAAV1yAAFdcwABXXUAAV12AAFdtQABXbcAAV25AAFduwABXb4AAV2/AAFdwAABXcEAAV3CAAFdxAABXcYAAV3HAAFdyAABXcoAAV3LAAFeCgABXgwAAV4OAAFeEAABXhMAAV4UAAFeFQABXhYAAV4XAAFeGQABXhsAAV4cAAFeHQABXh8AAV4gAAFeXwABXmEAAV5jAAFeZQABXmgAAV5pAAFeagABXmsAAV5sAAFebgABXnAAAV5xAAFecgABXnQAAV51AAFetAABXrYAAV64AAFeugABXr0AAV6+AAFevwABXsAAAV7BAAFewwABXsUAAV7GAAFexwABXskAAV7KAAFfCQABXwsAAV8NAAFfDwABXxIAAV8TAAFfFAABXxUAAV8WAAFfGAABXxoAAV8bAAFfHAABXx4AAV8fAAFfXgABX2AAAV9iAAFfZAABX2cAAV9oAAFfaQABX2oAAV9rAAFfbQABX28AAV9wAAFfcQABX3MAAV90AAFfvwABX+IAAWACAAFgIgABYCQAAWAmAAFgKAABYCoAAWAtAAFgLgABYC8AAWAyAAFgMwABYDUAAWA2AAFgOAABYDsAAWA8AAFgPQABYEAAAWBBAAFgRgABYFMAAWBYAAFgWgABYFwAAWBhAAFgZAABYGcAAWBpAAFgjgABYLIAAWDZAAFg/QABYQAAAWECAAFhBAABYQYAAWEIAAFhCgABYQsAAWEOAAFhGwABYSwAAWEuAAFhMAABYTIAAWE0AAFhNgABYTgAAWE6AAFhPAABYU0AAWFQAAFhUwABYVYAAWFZAAFhXAABYV8AAWFiAAFhZQABYWcAAWGmAAFhqAABYaoAAWGsAAFhrwABYbAAAWGxAAFhsgABYbMAAWG1AAFhtwABYbgAAWG5AAFhuwABYbwAAWH7AAFh/QABYf8AAWIBAAFiBAABYgUAAWIGAAFiBwABYggAAWIKAAFiDAABYg0AAWIOAAFiEAABYhEAAWJQAAFiUgABYlUAAWJXAAFiWgABYlsAAWJcAAFiXQABYl4AAWJgAAFiYgABYmMAAWJkAAFiZgABYmcAAWJ0AAFidQABYnYAAWJ4AAFitwABYrkAAWK7AAFivQABYsAAAWLBAAFiwgABYsMAAWLEAAFixgABYsgAAWLJAAFiygABYswAAWLNAAFjDAABYw4AAWMQAAFjEgABYxUAAWMWAAFjFwABYxgAAWMZAAFjGwABYx0AAWMeAAFjHwABYyEAAWMiAAFjYQABY2MAAWNlAAFjZwABY2oAAWNrAAFjbAABY20AAWNuAAFjcAABY3IAAWNzAAFjdAABY3YAAWN3AAFjtgABY7gAAWO6AAFjvAABY78AAWPAAAFjwQABY8IAAWPDAAFjxQABY8cAAWPIAAFjyQABY8sAAWPMAAFkCwABZA0AAWQPAAFkEQABZBQAAWQVAAFkFgABZBcAAWQYAAFkGgABZBwAAWQdAAFkHgABZCAAAWQhAAFkRgABZGoAAWSRAAFktQABZLgAAWS6AAFkvAABZL4AAWTAAAFkwgABZMMAAWTGAAFk0wABZOIAAWTkAAFk5gABZOgAAWTqAAFk7AABZO4AAWTwAAFk/wABZQIAAWUFAAFlCAABZQsAAWUOAAFlEQABZRQAAWUWAAFlVQABZVcAAWVaAAFlXAABZV8AAWVgAAFlYQABZWIAAWVjAAFlZQABZWcAAWVoAAFlaQABZWsAAWVsAAFlqwABZa0AAWWvAAFlsQABZbQAAWW1AAFltgABZbcAAWW4AAFlugABZbwAAWW9AAFlvgABZcAAAWXBAAFmAAABZgIAAWYEAAFmBgABZgkAAWYKAAFmCwABZgwAAWYNAAFmDwABZhEAAWYSAAFmEwABZhUAAWYWAAFmVQABZlcAAWZaAAFmXAABZl8AAWZgAAFmYQABZmIAAWZjAAFmZQABZmcAAWZoAAFmaQABZmsAAWZsAAFmqwABZq0AAWavAAFmsQABZrQAAWa1AAFmtgABZrcAAWa4AAFmugABZrwAAWa9AAFmvgABZsAAAWbBAAFnAAABZwIAAWcEAAFnBgABZwkAAWcKAAFnCwABZwwAAWcNAAFnDwABZxEAAWcSAAFnEwABZxUAAWcWAAFnVQABZ1cAAWdZAAFnWwABZ14AAWdfAAFnYAABZ2EAAWdiAAFnZAABZ2YAAWdnAAFnaAABZ2oAAWdrAAFntgABZ9kAAWf5AAFoGQABaBsAAWgdAAFoHwABaCEAAWgkAAFoJQABaCYAAWgpAAFoKgABaCwAAWgtAAFoLwABaDIAAWgzAAFoNAABaDcAAWg4AAFoPQABaEoAAWhPAAFoUQABaFMAAWhYAAFoWwABaF4AAWhgAAFohQABaKkAAWjQAAFo9AABaPcAAWj5AAFo+wABaP0AAWj/AAFpAQABaQIAAWkFAAFpEgABaSMAAWklAAFpJwABaSkAAWkrAAFpLQABaS8AAWkxAAFpMwABaUQAAWlHAAFpSgABaU0AAWlQAAFpUwABaVYAAWlZAAFpXAABaV4AAWmdAAFpnwABaaEAAWmjAAFppgABaacAAWmoAAFpqQABaaoAAWmsAAFprgABaa8AAWmwAAFpsgABabMAAWnyAAFp9AABafYAAWn4AAFp+wABafwAAWn9AAFp/gABaf8AAWoBAAFqAwABagQAAWoFAAFqBwABaggAAWpHAAFqSQABakwAAWpOAAFqUQABalIAAWpTAAFqVAABalUAAWpXAAFqWQABaloAAWpbAAFqXQABal4AAWprAAFqbAABam0AAWpvAAFqrgABarAAAWqyAAFqtAABarcAAWq4AAFquQABaroAAWq7AAFqvQABar8AAWrAAAFqwQABasMAAWrEAAFrAwABawUAAWsHAAFrCQABawwAAWsNAAFrDgABaw8AAWsQAAFrEgABaxQAAWsVAAFrFgABaxgAAWsZAAFrWAABa1oAAWtcAAFrXgABa2EAAWtiAAFrYwABa2QAAWtlAAFrZwABa2kAAWtqAAFrawABa20AAWtuAAFrrQABa68AAWuxAAFrswABa7YAAWu3AAFruAABa7kAAWu6AAFrvAABa74AAWu/AAFrwAABa8IAAWvDAAFsAgABbAQAAWwGAAFsCAABbAsAAWwMAAFsDQABbA4AAWwPAAFsEQABbBMAAWwUAAFsFQABbBcAAWwYAAFsPQABbGEAAWyIAAFsrAABbK8AAWyxAAFsswABbLUAAWy3AAFsuQABbLoAAWy9AAFsygABbNkAAWzbAAFs3QABbN8AAWzhAAFs4wABbOUAAWznAAFs9gABbPkAAWz8AAFs/wABbQIAAW0FAAFtCAABbQsAAW0NAAFtTAABbU4AAW1QAAFtUgABbVUAAW1WAAFtVwABbVgAAW1ZAAFtWwABbV0AAW1eAAFtXwABbWEAAW1iAAFtoQABbaMAAW2lAAFtpwABbaoAAW2rAAFtrAABba0AAW2uAAFtsAABbbIAAW2zAAFttAABbbYAAW23AAFt9gABbfgAAW36AAFt/AABbf8AAW4AAAFuAQABbgIAAW4DAAFuBQABbgcAAW4IAAFuCQABbgsAAW4MAAFuSwABbk0AAW5PAAFuUQABblQAAW5VAAFuVgABblcAAW5YAAFuWgABblwAAW5dAAFuXgABbmAAAW5hAAFuoAABbqIAAW6kAAFupgABbqkAAW6qAAFuqwABbqwAAW6tAAFurwABbrEAAW6yAAFuswABbrUAAW62AAFu9QABbvcAAW75AAFu+wABbv4AAW7/AAFvAAABbwEAAW8CAAFvBAABbwYAAW8HAAFvCAABbwoAAW8LAAFvSgABb0wAAW9OAAFvUAABb1MAAW9UAAFvVQABb1YAAW9XAAFvWQABb1sAAW9cAAFvXQABb18AAW9gAAFvqwABb84AAW/uAAFwDgABcBAAAXASAAFwFAABcBYAAXAZAAFwGgABcBsAAXAeAAFwHwABcCEAAXAiAAFwJAABcCcAAXAoAAFwKQABcCwAAXAtAAFwNgABcEMAAXBIAAFwSgABcEwAAXBRAAFwVAABcFcAAXBZAAFwfgABcKIAAXDJAAFw7QABcPAAAXDyAAFw9AABcPYAAXD4AAFw+gABcPsAAXD+AAFxCwABcRwAAXEeAAFxIAABcSIAAXEkAAFxJgABcSgAAXEqAAFxLAABcT0AAXFAAAFxQwABcUYAAXFJAAFxTAABcU8AAXFSAAFxVQABcVcAAXGWAAFxmAABcZoAAXGcAAFxnwABcaAAAXGhAAFxogABcaMAAXGlAAFxpwABcagAAXGpAAFxqwABcawAAXHrAAFx7QABce8AAXHxAAFx9AABcfUAAXH2AAFx9wABcfgAAXH6AAFx/AABcf0AAXH+AAFyAAABcgEAAXJAAAFyQgABckUAAXJHAAFySgABcksAAXJMAAFyTQABck4AAXJQAAFyUgABclMAAXJUAAFyVgABclcAAXJkAAFyZQABcmYAAXJoAAFypwABcqkAAXKrAAFyrQABcrAAAXKxAAFysgABcrMAAXK0AAFytgABcrgAAXK5AAFyugABcrwAAXK9AAFy/AABcv4AAXMAAAFzAgABcwUAAXMGAAFzBwABcwgAAXMJAAFzCwABcw0AAXMOAAFzDwABcxEAAXMSAAFzUQABc1MAAXNVAAFzVwABc1oAAXNbAAFzXAABc10AAXNeAAFzYAABc2IAAXNjAAFzZAABc2YAAXNnAAFzpgABc6gAAXOqAAFzrAABc68AAXOwAAFzsQABc7IAAXOzAAFztQABc7cAAXO4AAFzuQABc7sAAXO8AAFz+wABc/0AAXP/AAF0AQABdAQAAXQFAAF0BgABdAcAAXQIAAF0CgABdAwAAXQNAAF0DgABdBAAAXQRAAF0NgABdFoAAXSBAAF0pQABdKgAAXSqAAF0rAABdK4AAXSwAAF0sgABdLMAAXS2AAF0wwABdNIAAXTUAAF01gABdNgAAXTaAAF03AABdN4AAXTgAAF07wABdPIAAXT1AAF0+AABdPsAAXT+AAF1AQABdQQAAXUGAAF1RQABdUcAAXVKAAF1TAABdU8AAXVQAAF1UQABdVIAAXVTAAF1VQABdVcAAXVYAAF1WQABdVsAAXVcAAF1mwABdZ0AAXWfAAF1oQABdaQAAXWlAAF1pgABdacAAXWoAAF1qgABdawAAXWtAAF1rgABdbAAAXWxAAF18AABdfIAAXX0AAF19gABdfkAAXX6AAF1+wABdfwAAXX9AAF1/wABdgEAAXYCAAF2AwABdgUAAXYGAAF2RQABdkcAAXZKAAF2TAABdk8AAXZQAAF2UQABdlIAAXZTAAF2VQABdlcAAXZYAAF2WQABdlsAAXZcAAF2mwABdp0AAXafAAF2oQABdqQAAXalAAF2pgABdqcAAXaoAAF2qgABdqwAAXatAAF2rgABdrAAAXaxAAF28AABdvIAAXb0AAF29gABdvkAAXb6AAF2+wABdvwAAXb9AAF2/wABdwEAAXcCAAF3AwABdwUAAXcGAAF3RQABd0cAAXdJAAF3SwABd04AAXdPAAF3UAABd1EAAXdSAAF3VAABd1YAAXdXAAF3WAABd1oAAXdbAAF3ZAABd2UAAXdnAAF3qgABd84AAXfyAAF4FQABeDwAAXhcAAF4gwABeKoAAXjKAAF47gABeRIAAXkUAAF5FwABeRkAAXkbAAF5HQABeSAAAXkjAAF5JQABeScAAXkqAAF5LAABeS4AAXkxAAF5NAABeTUAAXk6AAF5RwABeUoAAXlMAAF5TwABeVIAAXlUAAF5eQABeZ0AAXnEAAF56AABeesAAXntAAF57wABefEAAXnzAAF59QABefYAAXn5AAF6BgABehkAAXobAAF6HQABeh8AAXohAAF6IwABeiUAAXonAAF6KQABeisAAXo+AAF6QQABekQAAXpHAAF6SgABek0AAXpQAAF6UwABelYAAXpZAAF6WwABepoAAXqcAAF6nwABeqEAAXqkAAF6pQABeqYAAXqnAAF6qAABeqoAAXqsAAF6rQABeq4AAXqwAAF6sQABeroAAXq7AAF6vQABevwAAXr+AAF7AAABewIAAXsFAAF7BgABewcAAXsIAAF7CQABewsAAXsNAAF7DgABew8AAXsRAAF7EgABe1EAAXtTAAF7VgABe1gAAXtbAAF7XAABe10AAXteAAF7XwABe2EAAXtjAAF7ZAABe2UAAXtnAAF7aAABe3EAAXtyAAF7dAABe7MAAXu1AAF7twABe7kAAXu8AAF7vQABe74AAXu/AAF7wAABe8IAAXvEAAF7xQABe8YAAXvIAAF7yQABfAgAAXwKAAF8DQABfA8AAXwSAAF8EwABfBQAAXwVAAF8FgABfBgAAXwaAAF8GwABfBwAAXweAAF8HwABfCgAAXwpAAF8KwABfGoAAXxsAAF8bgABfHAAAXxzAAF8dAABfHUAAXx2AAF8dwABfHkAAXx7AAF8fAABfH0AAXx/AAF8gAABfL8AAXzBAAF8xAABfMYAAXzJAAF8ygABfMsAAXzMAAF8zQABfM8AAXzRAAF80gABfNMAAXzVAAF81gABfOMAAXzkAAF85QABfOcAAX0mAAF9KAABfSoAAX0sAAF9LwABfTAAAX0xAAF9MgABfTMAAX01AAF9NwABfTgAAX05AAF9OwABfTwAAX17AAF9fQABfYAAAX2CAAF9hQABfYYAAX2HAAF9iAABfYkAAX2LAAF9jQABfY4AAX2PAAF9kQABfZIAAX2cAAF9qQABfboAAX29AAF9wAABfcMAAX3GAAF9yQABfcwAAX3PAAF90gABfeMAAX3mAAF96QABfewAAX3vAAF98gABffUAAX34AAF9+wABff0AAX4QAAF+IgABfi8AAX49AAF+SgABflgAAX5gAAF+ZQABfrAAAX7TAAF+8wABfxMAAX8VAAF/FwABfxkAAX8bAAF/HgABfx8AAX8gAAF/IwABfyQAAX8mAAF/JwABfykAAX8sAAF/LQABfy4AAX8xAAF/MgABfzcAAX9EAAF/SQABf0sAAX9NAAF/UgABf1UAAX9YAAF/WgABf38AAX+jAAF/ygABf+4AAX/xAAF/8wABf/UAAX/3AAF/+QABf/sAAX/8AAF//wABgAwAAYAdAAGAHwABgCEAAYAjAAGAJQABgCcAAYApAAGAKwABgC0AAYA+AAGAQQABgEQAAYBHAAGASgABgE0AAYBQAAGAUwABgFYAAYBYAAGAlwABgJkAAYCbAAGAnQABgKAAAYChAAGAogABgKMAAYCkAAGApgABgKgAAYCpAAGAqgABgKwAAYCtAAGA7AABgO4AAYDwAAGA8gABgPUAAYD2AAGA9wABgPgAAYD5AAGA+wABgP0AAYD+AAGA/wABgQEAAYECAAGBQQABgUMAAYFGAAGBSAABgUsAAYFMAAGBTQABgU4AAYFPAAGBUQABgVMAAYFUAAGBVQABgVcAAYFYAAGBZQABgWYAAYFnAAGBaQABgagAAYGqAAGBrAABga4AAYGxAAGBsgABgbMAAYG0AAGBtQABgbcAAYG5AAGBugABgbsAAYG9AAGBvgABgf0AAYH/AAGCAQABggMAAYIGAAGCBwABgggAAYIJAAGCCgABggwAAYIOAAGCDwABghAAAYISAAGCEwABglIAAYJUAAGCVgABglgAAYJbAAGCXAABgl0AAYJeAAGCXwABgmEAAYJjAAGCZAABgmUAAYJnAAGCaAABgqcAAYKpAAGCqwABgq0AAYKwAAGCsQABgrIAAYKzAAGCtAABgrYAAYK4AAGCuQABgroAAYK8AAGCvQABgvwAAYL+AAGDAAABgwIAAYMFAAGDBgABgwcAAYMIAAGDCQABgwsAAYMNAAGDDgABgw8AAYMRAAGDEgABgzcAAYNbAAGDggABg6YAAYOpAAGDqwABg60AAYOvAAGDsQABg7MAAYO0AAGDtwABg8QAAYPTAAGD1QABg9cAAYPZAAGD2wABg90AAYPfAAGD4QABg/AAAYPzAAGD9gABg/kAAYP8AAGD/wABhAIAAYQFAAGEBwABhEYAAYRIAAGESgABhEwAAYRPAAGEUAABhFEAAYRSAAGEUwABhFUAAYRXAAGEWAABhFkAAYRbAAGEXAABhJsAAYSdAAGEnwABhKEAAYSkAAGEpQABhKYAAYSnAAGEqAABhKoAAYSsAAGErQABhK4AAYSwAAGEsQABhPAAAYTyAAGE9AABhPYAAYT5AAGE+gABhPsAAYT8AAGE/QABhP8AAYUBAAGFAgABhQMAAYUFAAGFBgABhUUAAYVHAAGFSQABhUsAAYVOAAGFTwABhVAAAYVRAAGFUgABhVQAAYVWAAGFVwABhVgAAYVaAAGFWwABhZoAAYWcAAGFngABhaAAAYWjAAGFpAABhaUAAYWmAAGFpwABhakAAYWrAAGFrAABha0AAYWvAAGFsAABhe8AAYXxAAGF8wABhfUAAYX4AAGF+QABhfoAAYX7AAGF/AABhf4AAYYAAAGGAQABhgIAAYYEAAGGBQABhkQAAYZGAAGGSAABhkoAAYZNAAGGTgABhk8AAYZQAAGGUQABhlMAAYZVAAGGVgABhlcAAYZZAAGGWgABhqUAAYbIAAGG6AABhwgAAYcKAAGHDAABhw4AAYcQAAGHEwABhxQAAYcVAAGHGAABhxkAAYcbAAGHHAABhx4AAYchAAGHIgABhyMAAYcmAAGHJwABhywAAYc5AAGHPgABh0AAAYdCAAGHRwABh0oAAYdNAAGHTwABh3QAAYeYAAGHvwABh+MAAYfmAAGH6AABh+oAAYfsAAGH7gABh/AAAYfxAAGH9AABiAEAAYgSAAGIFAABiBYAAYgYAAGIGgABiBwAAYgeAAGIIAABiCIAAYgzAAGINgABiDkAAYg8AAGIPwABiEIAAYhFAAGISAABiEsAAYhNAAGIjAABiI4AAYiQAAGIkgABiJUAAYiWAAGIlwABiJgAAYiZAAGImwABiJ0AAYieAAGInwABiKEAAYiiAAGI4QABiOMAAYjlAAGI5wABiOoAAYjrAAGI7AABiO0AAYjuAAGI8AABiPIAAYjzAAGI9AABiPYAAYj3AAGJNgABiTgAAYk7AAGJPQABiUAAAYlBAAGJQgABiUMAAYlEAAGJRgABiUgAAYlJAAGJSgABiUwAAYlNAAGJWgABiVsAAYlcAAGJXgABiZ0AAYmfAAGJoQABiaMAAYmmAAGJpwABiagAAYmpAAGJqgABiawAAYmuAAGJrwABibAAAYmyAAGJswABifIAAYn0AAGJ9gABifgAAYn7AAGJ/AABif0AAYn+AAGJ/wABigEAAYoDAAGKBAABigUAAYoHAAGKCAABikcAAYpJAAGKSwABik0AAYpQAAGKUQABilIAAYpTAAGKVAABilYAAYpYAAGKWQABiloAAYpcAAGKXQABipwAAYqeAAGKoAABiqIAAYqlAAGKpgABiqcAAYqoAAGKqQABiqsAAYqtAAGKrgABiq8AAYqxAAGKsgABivEAAYrzAAGK9QABivcAAYr6AAGK+wABivwAAYr9AAGK/gABiwAAAYsCAAGLAwABiwQAAYsGAAGLBwABiywAAYtQAAGLdwABi5sAAYueAAGLoAABi6IAAYukAAGLpgABi6gAAYupAAGLrAABi7kAAYvIAAGLygABi8wAAYvOAAGL0AABi9IAAYvUAAGL1gABi+UAAYvoAAGL6wABi+4AAYvxAAGL9AABi/cAAYv6AAGL/AABjDsAAYw9AAGMPwABjEEAAYxEAAGMRQABjEYAAYxHAAGMSAABjEoAAYxMAAGMTQABjE4AAYxQAAGMUQABjJAAAYySAAGMlAABjJYAAYyZAAGMmgABjJsAAYycAAGMnQABjJ8AAYyhAAGMogABjKMAAYylAAGMpgABjOUAAYznAAGM6QABjOsAAYzuAAGM7wABjPAAAYzxAAGM8gABjPQAAYz2AAGM9wABjPgAAYz6AAGM+wABjToAAY08AAGNPgABjUAAAY1DAAGNRAABjUUAAY1GAAGNRwABjUkAAY1LAAGNTAABjU0AAY1PAAGNUAABjY8AAY2RAAGNkwABjZUAAY2YAAGNmQABjZoAAY2bAAGNnAABjZ4AAY2gAAGNoQABjaIAAY2kAAGNpQABjeQAAY3mAAGN6AABjeoAAY3tAAGN7gABje8AAY3wAAGN8QABjfMAAY31AAGN9gABjfcAAY35AAGN+gABjjkAAY47AAGOPQABjj8AAY5CAAGOQwABjkQAAY5FAAGORgABjkgAAY5KAAGOSwABjkwAAY5OAAGOTwABjpoAAY69AAGO3QABjv0AAY7/AAGPAQABjwMAAY8FAAGPCAABjwkAAY8KAAGPDQABjw4AAY8QAAGPEQABjxMAAY8WAAGPFwABjxgAAY8bAAGPHAABjyEAAY8uAAGPMwABjzUAAY83AAGPPAABjz8AAY9CAAGPRAABj2kAAY+NAAGPtAABj9gAAY/bAAGP3QABj98AAY/hAAGP4wABj+UAAY/mAAGP6QABj/YAAZAHAAGQCQABkAsAAZANAAGQDwABkBEAAZATAAGQFQABkBcAAZAoAAGQKwABkC4AAZAxAAGQNAABkDcAAZA6AAGQPQABkEAAAZBCAAGQgQABkIMAAZCFAAGQhwABkIoAAZCLAAGQjAABkI0AAZCOAAGQkAABkJIAAZCTAAGQlAABkJYAAZCXAAGQ1gABkNgAAZDaAAGQ3AABkN8AAZDgAAGQ4QABkOIAAZDjAAGQ5QABkOcAAZDoAAGQ6QABkOsAAZDsAAGRKwABkS0AAZEwAAGRMgABkTUAAZE2AAGRNwABkTgAAZE5AAGROwABkT0AAZE+AAGRPwABkUEAAZFCAAGRTwABkVAAAZFRAAGRUwABkZIAAZGUAAGRlgABkZgAAZGbAAGRnAABkZ0AAZGeAAGRnwABkaEAAZGjAAGRpAABkaUAAZGnAAGRqAABkecAAZHpAAGR6wABke0AAZHwAAGR8QABkfIAAZHzAAGR9AABkfYAAZH4AAGR+QABkfoAAZH8AAGR/QABkjwAAZI+AAGSQAABkkIAAZJFAAGSRgABkkcAAZJIAAGSSQABkksAAZJNAAGSTgABkk8AAZJRAAGSUgABkpEAAZKTAAGSlQABkpcAAZKaAAGSmwABkpwAAZKdAAGSngABkqAAAZKiAAGSowABkqQAAZKmAAGSpwABkuYAAZLoAAGS6gABkuwAAZLvAAGS8AABkvEAAZLyAAGS8wABkvUAAZL3AAGS+AABkvkAAZL7AAGS/AABkyEAAZNFAAGTbAABk5AAAZOTAAGTlQABk5cAAZOZAAGTmwABk50AAZOeAAGToQABk64AAZO9AAGTvwABk8EAAZPDAAGTxQABk8cAAZPJAAGTywABk9oAAZPdAAGT4AABk+MAAZPmAAGT6QABk+wAAZPvAAGT8QABlDAAAZQyAAGUNAABlDYAAZQ5AAGUOgABlDsAAZQ8AAGUPQABlD8AAZRBAAGUQgABlEMAAZRFAAGURgABlIUAAZSHAAGUiQABlIsAAZSOAAGUjwABlJAAAZSRAAGUkgABlJQAAZSWAAGUlwABlJgAAZSaAAGUmwABlNoAAZTcAAGU3gABlOAAAZTjAAGU5AABlOUAAZTmAAGU5wABlOkAAZTrAAGU7AABlO0AAZTvAAGU8AABlS8AAZUxAAGVMwABlTUAAZU4AAGVOQABlToAAZU7AAGVPAABlT4AAZVAAAGVQQABlUIAAZVEAAGVRQABlYQAAZWGAAGViAABlYoAAZWNAAGVjgABlY8AAZWQAAGVkQABlZMAAZWVAAGVlgABlZcAAZWZAAGVmgABldkAAZXbAAGV3QABld8AAZXiAAGV4wABleQAAZXlAAGV5gABlegAAZXqAAGV6wABlewAAZXuAAGV7wABli4AAZYwAAGWMgABljQAAZY3AAGWOAABljkAAZY6AAGWOwABlj0AAZY/AAGWQAABlkEAAZZDAAGWRAABlo8AAZayAAGW0gABlvIAAZb0AAGW9gABlvgAAZb6AAGW/QABlv4AAZb/AAGXAgABlwMAAZcFAAGXBgABlwgAAZcLAAGXDAABlw0AAZcQAAGXEQABlxYAAZcjAAGXKAABlyoAAZcsAAGXMQABlzQAAZc3AAGXOQABl14AAZeCAAGXqQABl80AAZfQAAGX0gABl9QAAZfWAAGX2AABl9oAAZfbAAGX3gABl+sAAZf8AAGX/gABmAAAAZgCAAGYBAABmAYAAZgIAAGYCgABmAwAAZgdAAGYIAABmCMAAZgmAAGYKQABmCwAAZgvAAGYMgABmDUAAZg3AAGYdgABmHgAAZh6AAGYfAABmH8AAZiAAAGYgQABmIIAAZiDAAGYhQABmIcAAZiIAAGYiQABmIsAAZiMAAGYywABmM0AAZjPAAGY0QABmNQAAZjVAAGY1gABmNcAAZjYAAGY2gABmNwAAZjdAAGY3gABmOAAAZjhAAGZIAABmSIAAZklAAGZJwABmSoAAZkrAAGZLAABmS0AAZkuAAGZMAABmTIAAZkzAAGZNAABmTYAAZk3AAGZRAABmUUAAZlGAAGZSAABmYcAAZmJAAGZiwABmY0AAZmQAAGZkQABmZIAAZmTAAGZlAABmZYAAZmYAAGZmQABmZoAAZmcAAGZnQABmdwAAZneAAGZ4AABmeIAAZnlAAGZ5gABmecAAZnoAAGZ6QABmesAAZntAAGZ7gABme8AAZnxAAGZ8gABmjEAAZozAAGaNQABmjcAAZo6AAGaOwABmjwAAZo9AAGaPgABmkAAAZpCAAGaQwABmkQAAZpGAAGaRwABmoYAAZqIAAGaigABmowAAZqPAAGakAABmpEAAZqSAAGakwABmpUAAZqXAAGamAABmpkAAZqbAAGanAABmtsAAZrdAAGa3wABmuEAAZrkAAGa5QABmuYAAZrnAAGa6AABmuoAAZrsAAGa7QABmu4AAZrwAAGa8QABmxYAAZs6AAGbYQABm4UAAZuIAAGbigABm4wAAZuOAAGbkAABm5IAAZuTAAGblgABm6MAAZuyAAGbtAABm7YAAZu4AAGbugABm7wAAZu+AAGbwAABm88AAZvSAAGb1QABm9gAAZvbAAGb3gABm+EAAZvkAAGb5gABnCUAAZwnAAGcKQABnCsAAZwuAAGcLwABnDAAAZwxAAGcMgABnDQAAZw2AAGcNwABnDgAAZw6AAGcOwABnHoAAZx8AAGcfgABnIAAAZyDAAGchAABnIUAAZyGAAGchwABnIkAAZyLAAGcjAABnI0AAZyPAAGckAABnM8AAZzRAAGc0wABnNUAAZzYAAGc2QABnNoAAZzbAAGc3AABnN4AAZzgAAGc4QABnOIAAZzkAAGc5QABnSQAAZ0mAAGdKAABnSoAAZ0tAAGdLgABnS8AAZ0wAAGdMQABnTMAAZ01AAGdNgABnTcAAZ05AAGdOgABnXkAAZ17AAGdfQABnX8AAZ2CAAGdgwABnYQAAZ2FAAGdhgABnYgAAZ2KAAGdiwABnYwAAZ2OAAGdjwABnc4AAZ3QAAGd0gABndQAAZ3XAAGd2AABndkAAZ3aAAGd2wABnd0AAZ3fAAGd4AABneEAAZ3jAAGd5AABniMAAZ4lAAGeJwABnikAAZ4sAAGeLQABni4AAZ4vAAGeMAABnjIAAZ40AAGeNQABnjYAAZ44AAGeOQABnoQAAZ6nAAGexwABnucAAZ7pAAGe6wABnu0AAZ7vAAGe8gABnvMAAZ70AAGe9wABnvgAAZ76AAGe+wABnv0AAZ8AAAGfAQABnwIAAZ8FAAGfBgABnwsAAZ8YAAGfHQABnx8AAZ8hAAGfJgABnykAAZ8sAAGfLgABn1MAAZ93AAGfngABn8IAAZ/FAAGfxwABn8kAAZ/LAAGfzQABn88AAZ/QAAGf0wABn+AAAZ/xAAGf8wABn/UAAZ/3AAGf+QABn/sAAZ/9AAGf/wABoAEAAaASAAGgFQABoBgAAaAbAAGgHgABoCEAAaAkAAGgJwABoCoAAaAsAAGgawABoG0AAaBvAAGgcQABoHQAAaB1AAGgdgABoHcAAaB4AAGgegABoHwAAaB9AAGgfgABoIAAAaCBAAGgwAABoMIAAaDEAAGgxgABoMkAAaDKAAGgywABoMwAAaDNAAGgzwABoNEAAaDSAAGg0wABoNUAAaDWAAGhFQABoRcAAaEaAAGhHAABoR8AAaEgAAGhIQABoSIAAaEjAAGhJQABoScAAaEoAAGhKQABoSsAAaEsAAGhOQABoToAAaE7AAGhPQABoXwAAaF+AAGhgAABoYIAAaGFAAGhhgABoYcAAaGIAAGhiQABoYsAAaGNAAGhjgABoY8AAaGRAAGhkgABodEAAaHTAAGh1QABodcAAaHaAAGh2wABodwAAaHdAAGh3gABoeAAAaHiAAGh4wABoeQAAaHmAAGh5wABoiYAAaIoAAGiKgABoiwAAaIvAAGiMAABojEAAaIyAAGiMwABojUAAaI3AAGiOAABojkAAaI7AAGiPAABonsAAaJ9AAGifwABooEAAaKEAAGihQABooYAAaKHAAGiiAABoooAAaKMAAGijQABoo4AAaKQAAGikQABotAAAaLSAAGi1AABotYAAaLZAAGi2gABotsAAaLcAAGi3QABot8AAaLhAAGi4gABouMAAaLlAAGi5gABowsAAaMvAAGjVgABo3oAAaN9AAGjfwABo4EAAaODAAGjhQABo4cAAaOIAAGjiwABo5gAAaOnAAGjqQABo6sAAaOtAAGjrwABo7EAAaOzAAGjtQABo8QAAaPHAAGjygABo80AAaPQAAGj0wABo9YAAaPZAAGj2wABpBoAAaQcAAGkHgABpCAAAaQjAAGkJAABpCUAAaQmAAGkJwABpCkAAaQrAAGkLAABpC0AAaQvAAGkMAABpG8AAaRxAAGkcwABpHUAAaR4AAGkeQABpHoAAaR7AAGkfAABpH4AAaSAAAGkgQABpIIAAaSEAAGkhQABpMQAAaTGAAGkyAABpMoAAaTNAAGkzgABpM8AAaTQAAGk0QABpNMAAaTVAAGk1gABpNcAAaTZAAGk2gABpRkAAaUbAAGlHQABpR8AAaUiAAGlIwABpSQAAaUlAAGlJgABpSgAAaUqAAGlKwABpSwAAaUuAAGlLwABpW4AAaVwAAGlcgABpXQAAaV3AAGleAABpXkAAaV6AAGlewABpX0AAaV/AAGlgAABpYEAAaWDAAGlhAABpcMAAaXFAAGlxwABpckAAaXMAAGlzQABpc4AAaXPAAGl0AABpdIAAaXUAAGl1QABpdYAAaXYAAGl2QABphgAAaYaAAGmHAABph4AAaYhAAGmIgABpiMAAaYkAAGmJQABpicAAaYpAAGmKgABpisAAaYtAAGmLgABpnkAAaacAAGmvAABptwAAabeAAGm4AABpuIAAabkAAGm5wABpugAAabpAAGm7AABpu0AAabvAAGm8AABpvIAAab1AAGm9gABpvcAAab6AAGm+wABpwAAAacNAAGnEgABpxQAAacWAAGnGwABpx4AAachAAGnIwABp0gAAadsAAGnkwABp7cAAae6AAGnvAABp74AAafAAAGnwgABp8QAAafFAAGnyAABp9UAAafmAAGn6AABp+oAAafsAAGn7gABp/AAAafyAAGn9AABp/YAAagHAAGoCgABqA0AAagQAAGoEwABqBYAAagZAAGoHAABqB8AAaghAAGoYAABqGIAAahkAAGoZgABqGkAAahqAAGoawABqGwAAahtAAGobwABqHEAAahyAAGocwABqHUAAah2AAGotQABqLcAAai5AAGouwABqL4AAai/AAGowAABqMEAAajCAAGoxAABqMYAAajHAAGoyAABqMoAAajLAAGpCgABqQwAAakPAAGpEQABqRQAAakVAAGpFgABqRcAAakYAAGpGgABqRwAAakdAAGpHgABqSAAAakhAAGpLgABqS8AAakwAAGpMgABqXEAAalzAAGpdQABqXcAAal6AAGpewABqXwAAal9AAGpfgABqYAAAamCAAGpgwABqYQAAamGAAGphwABqcYAAanIAAGpygABqcwAAanPAAGp0AABqdEAAanSAAGp0wABqdUAAanXAAGp2AABqdkAAanbAAGp3AABqhsAAaodAAGqHwABqiEAAaokAAGqJQABqiYAAaonAAGqKAABqioAAaosAAGqLQABqi4AAaowAAGqMQABqnAAAapyAAGqdAABqnYAAap5AAGqegABqnsAAap8AAGqfQABqn8AAaqBAAGqggABqoMAAaqFAAGqhgABqsUAAarHAAGqyQABqssAAarOAAGqzwABqtAAAarRAAGq0gABqtQAAarWAAGq1wABqtgAAaraAAGq2wABqwAAAaskAAGrSwABq28AAatyAAGrdAABq3YAAat4AAGregABq3wAAat9AAGrgAABq40AAaucAAGrngABq6AAAauiAAGrpAABq6YAAauoAAGrqgABq7kAAau8AAGrvwABq8IAAavFAAGryAABq8sAAavOAAGr0AABrA8AAawRAAGsEwABrBUAAawYAAGsGQABrBoAAawbAAGsHAABrB4AAawgAAGsIQABrCIAAawkAAGsJQABrGQAAaxmAAGsaAABrGoAAaxtAAGsbgABrG8AAaxwAAGscQABrHMAAax1AAGsdgABrHcAAax5AAGsegABrLkAAay7AAGsvQABrL8AAazCAAGswwABrMQAAazFAAGsxgABrMgAAazKAAGsywABrMwAAazOAAGszwABrQ4AAa0QAAGtEgABrRQAAa0XAAGtGAABrRkAAa0aAAGtGwABrR0AAa0fAAGtIAABrSEAAa0jAAGtJAABrWMAAa1lAAGtZwABrWkAAa1sAAGtbQABrW4AAa1vAAGtcAABrXIAAa10AAGtdQABrXYAAa14AAGteQABrbgAAa26AAGtvAABrb4AAa3BAAGtwgABrcMAAa3EAAGtxQABrccAAa3JAAGtygABrcsAAa3NAAGtzgABrg0AAa4PAAGuEQABrhMAAa4WAAGuFwABrhgAAa4ZAAGuGgABrhwAAa4eAAGuHwABriAAAa4iAAGuIwABrm4AAa6RAAGusQABrtEAAa7TAAGu1QABrtcAAa7ZAAGu3AABrt0AAa7eAAGu4QABruIAAa7kAAGu5QABrucAAa7qAAGu6wABruwAAa7vAAGu8AABrvkAAa8GAAGvCwABrw0AAa8PAAGvFAABrxcAAa8aAAGvHAABr0EAAa9lAAGvjAABr7AAAa+zAAGvtQABr7cAAa+5AAGvuwABr70AAa++AAGvwQABr84AAa/fAAGv4QABr+MAAa/lAAGv5wABr+kAAa/rAAGv7QABr+8AAbAAAAGwAwABsAYAAbAJAAGwDAABsA8AAbASAAGwFQABsBgAAbAaAAGwWQABsFsAAbBdAAGwXwABsGIAAbBjAAGwZAABsGUAAbBmAAGwaAABsGoAAbBrAAGwbAABsG4AAbBvAAGwrgABsLAAAbCyAAGwtAABsLcAAbC4AAGwuQABsLoAAbC7AAGwvQABsL8AAbDAAAGwwQABsMMAAbDEAAGxAwABsQUAAbEIAAGxCgABsQ0AAbEOAAGxDwABsRAAAbERAAGxEwABsRUAAbEWAAGxFwABsRkAAbEaAAGxJwABsSgAAbEpAAGxKwABsWoAAbFsAAGxbgABsXAAAbFzAAGxdAABsXUAAbF2AAGxdwABsXkAAbF7AAGxfAABsX0AAbF/AAGxgAABsb8AAbHBAAGxwwABscUAAbHIAAGxyQABscoAAbHLAAGxzAABsc4AAbHQAAGx0QABsdIAAbHUAAGx1QABshQAAbIWAAGyGAABshoAAbIdAAGyHgABsh8AAbIgAAGyIQABsiMAAbIlAAGyJgABsicAAbIpAAGyKgABsmkAAbJrAAGybQABsm8AAbJyAAGycwABsnQAAbJ1AAGydgABsngAAbJ6AAGyewABsnwAAbJ+AAGyfwABsr4AAbLAAAGywgABssQAAbLHAAGyyAABsskAAbLKAAGyywABss0AAbLPAAGy0AABstEAAbLTAAGy1AABsvkAAbMdAAGzRAABs2gAAbNrAAGzbQABs28AAbNxAAGzcwABs3UAAbN2AAGzeQABs4YAAbOVAAGzlwABs5kAAbObAAGznQABs58AAbOhAAGzowABs7IAAbO1AAGzuAABs7sAAbO+AAGzwQABs8QAAbPHAAGzyQABtAgAAbQKAAG0DAABtA4AAbQRAAG0EgABtBMAAbQUAAG0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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/Policies/Model5toModel6MigrationPolicy.swift b/EhPanda/Database/Migration/Policies/Model5toModel6MigrationPolicy.swift deleted file mode 100644 index dd6102261..000000000 --- a/EhPanda/Database/Migration/Policies/Model5toModel6MigrationPolicy.swift +++ /dev/null @@ -1,82 +0,0 @@ -// -// Model5toModel6MigrationPolicy.swift -// EhPanda -// - -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 deleted file mode 100644 index f5b3fac01..000000000 --- a/EhPanda/Database/Model.xcdatamodeld/.xccurrentversion +++ /dev/null @@ -1,8 +0,0 @@ - - - - - _XCCurrentVersionName - Model 7.xcdatamodel - - diff --git a/EhPanda/Database/Model.xcdatamodeld/Model 2.xcdatamodel/contents b/EhPanda/Database/Model.xcdatamodeld/Model 2.xcdatamodel/contents deleted file mode 100644 index 454ebc253..000000000 --- a/EhPanda/Database/Model.xcdatamodeld/Model 2.xcdatamodel/contents +++ /dev/null @@ -1,60 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/EhPanda/Database/Model.xcdatamodeld/Model 3.xcdatamodel/contents b/EhPanda/Database/Model.xcdatamodeld/Model 3.xcdatamodel/contents deleted file mode 100644 index 47574e2c5..000000000 --- a/EhPanda/Database/Model.xcdatamodeld/Model 3.xcdatamodel/contents +++ /dev/null @@ -1,61 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/EhPanda/Database/Model.xcdatamodeld/Model 4.xcdatamodel/contents b/EhPanda/Database/Model.xcdatamodeld/Model 4.xcdatamodel/contents deleted file mode 100644 index dae72033f..000000000 --- a/EhPanda/Database/Model.xcdatamodeld/Model 4.xcdatamodel/contents +++ /dev/null @@ -1,62 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/EhPanda/Database/Model.xcdatamodeld/Model 5.xcdatamodel/contents b/EhPanda/Database/Model.xcdatamodeld/Model 5.xcdatamodel/contents deleted file mode 100644 index 8cc55b7f7..000000000 --- a/EhPanda/Database/Model.xcdatamodeld/Model 5.xcdatamodel/contents +++ /dev/null @@ -1,64 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/EhPanda/Database/Model.xcdatamodeld/Model 6.xcdatamodel/contents b/EhPanda/Database/Model.xcdatamodeld/Model 6.xcdatamodel/contents deleted file mode 100644 index 94e260309..000000000 --- a/EhPanda/Database/Model.xcdatamodeld/Model 6.xcdatamodel/contents +++ /dev/null @@ -1,67 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/EhPanda/Database/Model.xcdatamodeld/Model 7.xcdatamodel/contents b/EhPanda/Database/Model.xcdatamodeld/Model 7.xcdatamodel/contents deleted file mode 100644 index b70bddd7d..000000000 --- a/EhPanda/Database/Model.xcdatamodeld/Model 7.xcdatamodel/contents +++ /dev/null @@ -1,66 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/EhPanda/Database/Model.xcdatamodeld/Model.xcdatamodel/contents b/EhPanda/Database/Model.xcdatamodeld/Model.xcdatamodel/contents deleted file mode 100644 index a6177140e..000000000 --- a/EhPanda/Database/Model.xcdatamodeld/Model.xcdatamodel/contents +++ /dev/null @@ -1,60 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/EhPanda/Database/Persistence.swift b/EhPanda/Database/Persistence.swift deleted file mode 100644 index 434f903c1..000000000 --- a/EhPanda/Database/Persistence.swift +++ /dev/null @@ -1,103 +0,0 @@ -// -// Persistence.swift -// EhPanda -// - -import CoreData - -struct PersistenceController { - static let shared = PersistenceController() - let migrator = CoreDataMigrator() - - let container: NSPersistentCloudKitContainer = { - let container = NSPersistentCloudKitContainer(name: "Model") - let description = container.persistentStoreDescriptions.first - description?.shouldInferMappingModelAutomatically = false - description?.shouldMigrateStoreAutomatically = false - return container - }() -} - -// MARK: Preparation -extension PersistenceController { - func prepare(completion: @escaping (Result) -> Void) { - do { - try loadPersistentStore(completion: completion) - } catch { - completion(.failure(error as? AppError ?? .databaseCorrupted(nil))) - } - } - 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(qos: .userInitiated).async { - do { - try NSPersistentStoreCoordinator.destroyStore(at: storeURL) - } catch { - completion(.failure(error as? AppError ?? .databaseCorrupted(nil))) - } - container.loadPersistentStores { _, error in - guard error == nil else { - let message = "Was unable to load store \(String(describing: error))." - completion(.failure(.databaseCorrupted(message))) - return - } - completion(.success(())) - } - } - } - private func loadPersistentStore(completion: @escaping (Result) -> Void) throws { - try migrateStoreIfNeeded { result in - switch result { - case .success: - container.loadPersistentStores { _, error in - guard error == nil else { - let message = "Was unable to load store \(String(describing: error))." - completion(.failure(.databaseCorrupted(message))) - return - } - completion(.success(())) - } - case .failure(let error): - completion(.failure(error)) - } - } - } - private func migrateStoreIfNeeded(completion: @escaping (Result) -> Void) throws { - guard let storeURL = container.persistentStoreDescriptions.first?.url else { - throw AppError.databaseCorrupted("PersistentContainer was not set up properly.") - } - - if try migrator.requiresMigration(at: storeURL, toVersion: try CoreDataMigrationVersion.current()) { - DispatchQueue.global(qos: .userInitiated).async { - do { - try migrator.migrateStore(at: storeURL, toVersion: try CoreDataMigrationVersion.current()) - } catch { - completion(.failure(error as? AppError ?? .databaseCorrupted(nil))) - } - completion(.success(())) - } - } else { - completion(.success(())) - } - } -} - -// MARK: Definition -protocol ManagedObjectProtocol { - associatedtype Entity - func toEntity() -> Entity -} - -protocol ManagedObjectConvertible { - associatedtype ManagedObject: NSManagedObject, ManagedObjectProtocol - - @discardableResult - func toManagedObject(in context: NSManagedObjectContext) -> ManagedObject -} - -protocol GalleryIdentifiable: NSManagedObject { - var gid: String { get set } -} diff --git a/EhPanda/Models/Gallery/Category.swift b/EhPanda/Models/Gallery/Category.swift deleted file mode 100644 index 7c1504161..000000000 --- a/EhPanda/Models/Gallery/Category.swift +++ /dev/null @@ -1,64 +0,0 @@ -// -// Category.swift -// EhPanda -// - -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 = "`Private` doesn't have a `filterValue`!" - Logger.error(message) - fatalError(message) - } - } - var value: String { - switch self { - case .doujinshi: return L10n.Localizable.Enum.Category.Value.doujinshi - case .manga: return L10n.Localizable.Enum.Category.Value.manga - case .artistCG: return L10n.Localizable.Enum.Category.Value.artistCG - case .gameCG: return L10n.Localizable.Enum.Category.Value.gameCG - case .western: return L10n.Localizable.Enum.Category.Value.western - case .nonH: return L10n.Localizable.Enum.Category.Value.nonH - case .imageSet: return L10n.Localizable.Enum.Category.Value.imageSet - case .cosplay: return L10n.Localizable.Enum.Category.Value.cosplay - case .asianPorn: return L10n.Localizable.Enum.Category.Value.asianPorn - case .misc: return L10n.Localizable.Enum.Category.Value.misc - case .private: return L10n.Localizable.Enum.Category.Value.private - } - } -} diff --git a/EhPanda/Models/Gallery/Gallery.swift b/EhPanda/Models/Gallery/Gallery.swift deleted file mode 100644 index 8d0aacb6d..000000000 --- a/EhPanda/Models/Gallery/Gallery.swift +++ /dev/null @@ -1,104 +0,0 @@ -// -// Gallery.swift -// EhPanda -// - -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, - tags: [], - category: .doujinshi, - uploader: "", - pageCount: 1, - postedDate: .now, - coverURL: nil, - galleryURL: nil - ) - } - static let preview = Gallery( - gid: UUID().uuidString, - token: "", - title: "Preview", - rating: 3.5, - tags: [], - category: .doujinshi, - uploader: "Anonymous", - pageCount: 1, - postedDate: .now, - coverURL: URL( - string: "https://github.com/" - + "EhPanda-Team/Imageset/blob/" - + "main/JPGs/2.jpg?raw=true" - ), - galleryURL: nil - ) - - var trimmedTitle: String { - var title = title - if let range = title.range(of: "|") { - title = String(title[.. [GalleryTag.Content] { - let tagContents = tags.flatMap(\.contents) - guard maximum > 0 else { return tagContents } - return .init(tagContents.prefix(min(tagContents.count, maximum))) - } - - var id: String { gid } - let gid: String - let token: String - - var title: String - var rating: Float - var tags: [GalleryTag] - let category: Category - var uploader: String? - var pageCount: Int - let postedDate: Date - let coverURL: URL? - let galleryURL: URL? - var lastOpenDate: Date? -} - -extension Gallery: DateFormattable, CustomStringConvertible { - var description: String { - "Gallery(\(gid))" - } - - var filledCount: Int { Int(rating) } - var halfFilledCount: Int { Int(rating - 0.5) == filledCount ? 1 : 0 } - var notFilledCount: Int { 5 - filledCount - halfFilledCount } - - var color: Color { - category.color - } - var originalDate: Date { - postedDate - } -} diff --git a/EhPanda/Models/Gallery/GalleryArchive.swift b/EhPanda/Models/Gallery/GalleryArchive.swift deleted file mode 100644 index eccd39233..000000000 --- a/EhPanda/Models/Gallery/GalleryArchive.swift +++ /dev/null @@ -1,64 +0,0 @@ -// -// GalleryArchive.swift -// EhPanda -// - -import Foundation - -struct GalleryArchive: Codable, Equatable { - struct HathArchive: Codable, Identifiable, Equatable { - var id: String { resolution.rawValue } - - let resolution: ArchiveResolution - let fileSize: String - private let gpPrice: String - - init(resolution: ArchiveResolution, fileSize: String, gpPrice: String) { - self.resolution = resolution - self.fileSize = fileSize - self.gpPrice = gpPrice - } - - var isValid: Bool { - fileSize != "N/A" && gpPrice != "N/A" - } - var price: String { - switch gpPrice { - case "Free": - return L10n.Localizable.Struct.HathArchive.Price.free - default: - return gpPrice - } - } - } - - let hathArchives: [HathArchive] -} - -enum ArchiveResolution: String, Codable, CaseIterable, Equatable { - case x780 = "780x" - case x980 = "980x" - case x1280 = "1280x" - case x1600 = "1600x" - case x2400 = "2400x" - case original = "Original" -} - -extension ArchiveResolution { - var value: String { - switch self { - case .x780, .x980, .x1280, .x1600, .x2400: - return rawValue - case .original: - return L10n.Localizable.Enum.ArchiveResolution.Value.original - } - } - var parameter: String { - switch self { - case .original: - return "org" - default: - return .init(rawValue.dropLast()) - } - } -} diff --git a/EhPanda/Models/Gallery/GalleryComment.swift b/EhPanda/Models/Gallery/GalleryComment.swift deleted file mode 100644 index 75428e3f0..000000000 --- a/EhPanda/Models/Gallery/GalleryComment.swift +++ /dev/null @@ -1,56 +0,0 @@ -// -// GalleryComment.swift -// EhPanda -// - -import Foundation - -struct GalleryComment: Identifiable, Equatable, Codable { - var id: String { commentID } - - var votedUp: Bool - var votedDown: Bool - let votable: Bool - let editable: Bool - - let score: String? - let author: String - let contents: [CommentContent] - let commentID: String - let commentDate: Date - - var plainTextContent: String { - contents - .filter { [.plainText, .linkedText, .singleLink].contains($0.type) } - .compactMap { $0.type == .singleLink ? $0.link?.absoluteString : $0.text }.joined() - } -} - -extension GalleryComment: DateFormattable { - var originalDate: Date { - commentDate - } -} - -struct CommentContent: Identifiable, Equatable, Codable { - var id: UUID = .init() - let type: CommentContentType - var text: String? - var link: URL? - var imgURL: URL? - - var secondLink: URL? - var secondImgURL: URL? -} - -enum CommentContentType: Int, Codable { - case singleImg - case doubleImg - case linkedImg - case doubleLinkedImg - - case plainText - case linkedText - - case singleLink -} diff --git a/EhPanda/Models/Gallery/GalleryDetail.swift b/EhPanda/Models/Gallery/GalleryDetail.swift deleted file mode 100644 index a6cf99284..000000000 --- a/EhPanda/Models/Gallery/GalleryDetail.swift +++ /dev/null @@ -1,102 +0,0 @@ -// -// GalleryDetail.swift -// EhPanda -// - -import Foundation - -struct GalleryDetail: Codable, Equatable { - static let empty: Self = .init( - gid: "", title: "", isFavorited: false, - visibility: .yes, rating: 0, userRating: 0, - ratingCount: 0, category: .private, - language: .japanese, uploader: "", - postedDate: .now, coverURL: nil, - favoritedCount: 0, pageCount: 0, - sizeCount: 0, sizeType: "", - torrentCount: 0 - ) - static let preview = GalleryDetail( - gid: "", - title: "Preview", - jpnTitle: "プレビュー", - isFavorited: true, - visibility: .yes, - rating: 3.5, - userRating: 4.0, - ratingCount: 1919, - category: .doujinshi, - language: .japanese, - uploader: "Anonymous", - postedDate: .distantPast, - coverURL: URL( - string: "https://github.com/" - + "EhPanda-Team/Imageset/blob/" - + "main/JPGs/2.jpg?raw=true" - ), - favoritedCount: 514, - pageCount: 114, - sizeCount: 514, - sizeType: "MB", - torrentCount: 101 - ) - - var trimmedTitle: String { - var title = title - if let range = title.range(of: "|") { - title = String(title[.. String { - let namespace = tag.namespace?.abbreviation ?? tag.namespace?.rawValue ?? tag.rawNamespace.lowercased() - return tag.namespace == .temp ? text : [namespace, text].joined(separator: ":") - } - func serachKeyword(tag: GalleryTag) -> String { - let keyword = text.contains(" ") ? "\"\(text)$\"" : "\(text)$" - let namespace = tag.namespace?.abbreviation ?? tag.namespace?.rawValue ?? tag.rawNamespace.lowercased() - return tag.namespace == .temp ? keyword : [namespace, keyword].joined(separator: ":") - } - - let rawNamespace: String - let text: String - let isVotedUp: Bool - let isVotedDown: Bool - let textColor: Color? - let backgroundColor: Color? - } - - var id: String { rawNamespace } - var namespace: TagNamespace? { - .init(rawValue: rawNamespace) - } - - let rawNamespace: String - let contents: [Content] -} - -enum PreviewConfig: Codable, Equatable { - 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 - } -} diff --git a/EhPanda/Models/Gallery/GalleryTorrent.swift b/EhPanda/Models/Gallery/GalleryTorrent.swift deleted file mode 100644 index c6dfe5ed2..000000000 --- a/EhPanda/Models/Gallery/GalleryTorrent.swift +++ /dev/null @@ -1,28 +0,0 @@ -// -// GalleryTorrent.swift -// EhPanda -// - -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 deleted file mode 100644 index dada937d0..000000000 --- a/EhPanda/Models/Gallery/Language.swift +++ /dev/null @@ -1,106 +0,0 @@ -// -// Language.swift -// EhPanda -// - -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 codes: [String]? { - switch self { - case .english: return ["en-US"] - case .french: return ["fr-FR"] - case .italian: return ["it-IT"] - case .german: return ["de-DE"] - case .spanish: return ["es-ES"] - case .portuguese: return ["pt-BR"] - case .chinese: return ["zh-Hans", "zh-Hant"] - default: return nil - } - } - 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 L10n.Localizable.Enum.Language.Value.invalid - case .other: return L10n.Localizable.Enum.Language.Value.other - case .afrikaans: return L10n.Localizable.Enum.Language.Value.afrikaans - case .albanian: return L10n.Localizable.Enum.Language.Value.albanian - case .arabic: return L10n.Localizable.Enum.Language.Value.arabic - case .bengali: return L10n.Localizable.Enum.Language.Value.bengali - case .bosnian: return L10n.Localizable.Enum.Language.Value.bosnian - case .bulgarian: return L10n.Localizable.Enum.Language.Value.bulgarian - case .burmese: return L10n.Localizable.Enum.Language.Value.burmese - case .catalan: return L10n.Localizable.Enum.Language.Value.catalan - case .cebuano: return L10n.Localizable.Enum.Language.Value.cebuano - case .chinese: return L10n.Localizable.Enum.Language.Value.chinese - case .croatian: return L10n.Localizable.Enum.Language.Value.croatian - case .czech: return L10n.Localizable.Enum.Language.Value.czech - case .danish: return L10n.Localizable.Enum.Language.Value.danish - case .dutch: return L10n.Localizable.Enum.Language.Value.dutch - case .english: return L10n.Localizable.Enum.Language.Value.english - case .esperanto: return L10n.Localizable.Enum.Language.Value.esperanto - case .estonian: return L10n.Localizable.Enum.Language.Value.estonian - case .finnish: return L10n.Localizable.Enum.Language.Value.finnish - case .french: return L10n.Localizable.Enum.Language.Value.french - case .georgian: return L10n.Localizable.Enum.Language.Value.georgian - case .german: return L10n.Localizable.Enum.Language.Value.german - case .greek: return L10n.Localizable.Enum.Language.Value.greek - case .hebrew: return L10n.Localizable.Enum.Language.Value.hebrew - case .hindi: return L10n.Localizable.Enum.Language.Value.hindi - case .hmong: return L10n.Localizable.Enum.Language.Value.hmong - case .hungarian: return L10n.Localizable.Enum.Language.Value.hungarian - case .indonesian: return L10n.Localizable.Enum.Language.Value.indonesian - case .italian: return L10n.Localizable.Enum.Language.Value.italian - case .japanese: return L10n.Localizable.Enum.Language.Value.japanese - case .kazakh: return L10n.Localizable.Enum.Language.Value.kazakh - case .khmer: return L10n.Localizable.Enum.Language.Value.khmer - case .korean: return L10n.Localizable.Enum.Language.Value.korean - case .kurdish: return L10n.Localizable.Enum.Language.Value.kurdish - case .lao: return L10n.Localizable.Enum.Language.Value.lao - case .latin: return L10n.Localizable.Enum.Language.Value.latin - case .mongolian: return L10n.Localizable.Enum.Language.Value.mongolian - case .ndebele: return L10n.Localizable.Enum.Language.Value.ndebele - case .nepali: return L10n.Localizable.Enum.Language.Value.nepali - case .norwegian: return L10n.Localizable.Enum.Language.Value.norwegian - case .oromo: return L10n.Localizable.Enum.Language.Value.oromo - case .pashto: return L10n.Localizable.Enum.Language.Value.pashto - case .persian: return L10n.Localizable.Enum.Language.Value.persian - case .polish: return L10n.Localizable.Enum.Language.Value.polish - case .portuguese: return L10n.Localizable.Enum.Language.Value.portuguese - case .punjabi: return L10n.Localizable.Enum.Language.Value.punjabi - case .romanian: return L10n.Localizable.Enum.Language.Value.romanian - case .russian: return L10n.Localizable.Enum.Language.Value.russian - case .sango: return L10n.Localizable.Enum.Language.Value.sango - case .serbian: return L10n.Localizable.Enum.Language.Value.serbian - case .shona: return L10n.Localizable.Enum.Language.Value.shona - case .slovak: return L10n.Localizable.Enum.Language.Value.slovak - case .slovenian: return L10n.Localizable.Enum.Language.Value.slovenian - case .somali: return L10n.Localizable.Enum.Language.Value.somali - case .spanish: return L10n.Localizable.Enum.Language.Value.spanish - case .swahili: return L10n.Localizable.Enum.Language.Value.swahili - case .swedish: return L10n.Localizable.Enum.Language.Value.swedish - case .tagalog: return L10n.Localizable.Enum.Language.Value.tagalog - case .thai: return L10n.Localizable.Enum.Language.Value.thai - case .tigrinya: return L10n.Localizable.Enum.Language.Value.tigrinya - case .turkish: return L10n.Localizable.Enum.Language.Value.turkish - case .ukrainian: return L10n.Localizable.Enum.Language.Value.ukrainian - case .urdu: return L10n.Localizable.Enum.Language.Value.urdu - case .vietnamese: return L10n.Localizable.Enum.Language.Value.vietnamese - case .zulu: return L10n.Localizable.Enum.Language.Value.zulu - } - } -} diff --git a/EhPanda/Models/Persistent/AppEnv.swift b/EhPanda/Models/Persistent/AppEnv.swift deleted file mode 100644 index cdc7d4335..000000000 --- a/EhPanda/Models/Persistent/AppEnv.swift +++ /dev/null @@ -1,31 +0,0 @@ -// -// AppEnv.swift -// EhPanda -// - -struct AppEnv: Codable, Equatable { - let user: User - let setting: Setting - let searchFilter: Filter - let globalFilter: Filter - let watchedFilter: Filter - let tagTranslator: TagTranslator - let historyKeywords: [String] - let quickSearchWords: [QuickSearchWord] -} - -extension AppEnv: CustomStringConvertible { - var description: String { - let params = String( - describing: [ - "user": user, - "setting": setting, - "tagTranslator": tagTranslator, - "historyKeywordsCount": historyKeywords.count, - "quickSearchWordsCount": quickSearchWords.count - ] - as [String: Any] - ) - return "AppEnv(\(params))" - } -} diff --git a/EhPanda/Models/Persistent/Filter.swift b/EhPanda/Models/Persistent/Filter.swift deleted file mode 100644 index ef7d4e09f..000000000 --- a/EhPanda/Models/Persistent/Filter.swift +++ /dev/null @@ -1,99 +0,0 @@ -// -// Filter.swift -// EhPanda -// - -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 deleted file mode 100644 index 8b65bf940..000000000 --- a/EhPanda/Models/Persistent/Greeting.swift +++ /dev/null @@ -1,74 +0,0 @@ -// -// Greeting.swift -// EhPanda -// - -import Foundation - -struct Greeting: Codable, Equatable, Hashable, Identifiable { - static let mock: Self = { - var greeting = Greeting() - greeting.gainedEXP = 10 - greeting.gainedCredits = 10000 - greeting.gainedGP = 10000 - greeting.gainedHath = 10 - return greeting - }() - - var id = UUID() - - var gainedEXP: Int? - var gainedCredits: Int? - var gainedGP: Int? - var gainedHath: Int? - var updateTime: Date? - - var rewards: [String] { - func formatNumber(_ number: Int?) -> String? { - guard let number = number else { return nil } - let formatter = NumberFormatter() - formatter.numberStyle = .decimal - return formatter.string(from: .init(value: number)) - } - - var rewards = [String]() - if let exp = formatNumber(gainedEXP) { - rewards.append("\(exp) EXP") - } - if let credits = formatNumber(gainedCredits) { - rewards.append("\(credits) Credits") - } - if let galleryPoint = formatNumber(gainedGP) { - rewards.append("\(galleryPoint) GP") - } - if let hath = formatNumber(gainedHath) { - rewards.append("\(hath) Hath") - } - return rewards - } - - var gainContent: String? { - let rewards = rewards - guard !rewards.isEmpty else { return nil } - let and = L10n.Localizable.Struct.Greeting.Mark.and - let end = L10n.Localizable.Struct.Greeting.Mark.end - let start = L10n.Localizable.Struct.Greeting.Mark.start - let separator = L10n.Localizable.Struct.Greeting.Mark.separator - let 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 deleted file mode 100644 index 4ea020c01..000000000 --- a/EhPanda/Models/Persistent/Setting.swift +++ /dev/null @@ -1,222 +0,0 @@ -// -// Setting.swift -// EhPanda -// - -import SwiftUI -import Foundation -import ComposableArchitecture - -struct Setting: Codable, Equatable { - // Account - var galleryHost: GalleryHost = .ehentai - var showsNewDawnGreeting = false - - // General - var enablesTagsExtension = false { - didSet { - if !enablesTagsExtension { - translatesTags = false - showsTagsSearchSuggestion = false - showsImagesInTags = false - } - } - } - var translatesTags = false - var showsTagsSearchSuggestion = false - var showsImagesInTags = false - 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 showsTagsInList = false - var listTagsNumberMaximum = 0 - var displaysJapaneseTitle = true - - // Reading - var readingDirection: ReadingDirection = .vertical - var prefetchLimit = 10 - var enablesLandscape = false - var enablesDualPageMode = false - var exceptCover = false - var contentDividerHeight: Double = 0 - var maximumScaleFactor: Double = 3 - var doubleTapScaleFactor: Double = 2 - - // Laboratory - var bypassesSNIFiltering = false -} - -enum GalleryHost: String, Codable, Equatable, 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 cookieURLs: [URL] { - switch self { - case .ehentai: - return [Defaults.URL.ehentai] - - case .exhentai: - return [Defaults.URL.exhentai, Defaults.URL.sexhentai] - } - } - 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 L10n.Localizable.Enum.AutoLockPolicy.Value.never - case .instantly: - return L10n.Localizable.Enum.AutoLockPolicy.Value.instantly - case .sec15: - return L10n.Localizable.Common.Value.seconds("\(rawValue)") - case .min1: - return L10n.Localizable.Common.Value.minute("\(rawValue / 60)") - case .min5, .min10, .min30: - return L10n.Localizable.Common.Value.minutes("\(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 L10n.Localizable.Enum.PreferredColorScheme.Value.automatic - case .light: - return L10n.Localizable.Enum.PreferredColorScheme.Value.light - case .dark: - return L10n.Localizable.Enum.PreferredColorScheme.Value.dark - } - } - 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 L10n.Localizable.Enum.ReadingDirection.Value.vertical - case .rightToLeft: - return L10n.Localizable.Enum.ReadingDirection.Value.rightToLeft - case .leftToRight: - return L10n.Localizable.Enum.ReadingDirection.Value.leftToRight - } - } -} - -enum ListDisplayMode: Int, Codable, CaseIterable, Identifiable { - var id: Int { rawValue } - - case detail - case thumbnail -} -extension ListDisplayMode { - var value: String { - switch self { - case .detail: - return L10n.Localizable.Enum.ListDisplayMode.Value.detail - case .thumbnail: - return L10n.Localizable.Enum.ListDisplayMode.Value.thumbnail - } - } -} - -// 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 - enablesTagsExtension = (try? container?.decodeIfPresent(Bool.self, forKey: .enablesTagsExtension)) ?? false - translatesTags = (try? container?.decodeIfPresent(Bool.self, forKey: .translatesTags)) ?? false - showsTagsSearchSuggestion = (try? container?.decodeIfPresent(Bool.self, forKey: .showsTagsSearchSuggestion)) ?? false - showsImagesInTags = (try? container?.decodeIfPresent(Bool.self, forKey: .showsImagesInTags)) ?? false - redirectsLinksToSelectedHost = (try? container?.decodeIfPresent(Bool.self, forKey: .redirectsLinksToSelectedHost)) ?? false - detectsLinksFromClipboard = (try? container?.decodeIfPresent(Bool.self, forKey: .detectsLinksFromClipboard)) ?? false - backgroundBlurRadius = (try? container?.decodeIfPresent(Double.self, forKey: .backgroundBlurRadius)) ?? 10 - autoLockPolicy = (try? container?.decodeIfPresent(AutoLockPolicy.self, forKey: .autoLockPolicy)) ?? .never - // 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 - showsTagsInList = (try? container?.decodeIfPresent(Bool.self, forKey: .showsTagsInList)) ?? false - listTagsNumberMaximum = (try? container?.decodeIfPresent(Int.self, forKey: .listTagsNumberMaximum)) ?? 0 - displaysJapaneseTitle = (try? container?.decodeIfPresent(Bool.self, forKey: .displaysJapaneseTitle)) ?? true - // 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/User.swift b/EhPanda/Models/Persistent/User.swift deleted file mode 100644 index f6a082db8..000000000 --- a/EhPanda/Models/Persistent/User.swift +++ /dev/null @@ -1,51 +0,0 @@ -// -// User.swift -// EhPanda -// - -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 L10n.Localizable.Struct.User.FavoriteCategory.all } - let defaultCategory = L10n.Localizable.Struct.User.FavoriteCategory.default("\(index)") - let category = favoriteCategories?[index] ?? defaultCategory - let isDefault = category == "Favorites \(index)" - return isDefault ? defaultCategory : category - } -} - -enum FavoritesType: String, Codable, CaseIterable { - static func getTypeFrom(index: Int) -> FavoritesType { - FavoritesType.allCases.filter({ $0.index == index }).first ?? .all - } - - var index: Int { - Int(rawValue.replacingOccurrences(of: "favorite_", with: "")) ?? -1 - } - - case all = "all" - case favorite0 = "favorite_0" - case favorite1 = "favorite_1" - case favorite2 = "favorite_2" - case favorite3 = "favorite_3" - case favorite4 = "favorite_4" - case favorite5 = "favorite_5" - case favorite6 = "favorite_6" - case favorite7 = "favorite_7" - case favorite8 = "favorite_8" - case favorite9 = "favorite_9" -} diff --git a/EhPanda/Models/Support/AppError.swift b/EhPanda/Models/Support/AppError.swift deleted file mode 100644 index d315149f5..000000000 --- a/EhPanda/Models/Support/AppError.swift +++ /dev/null @@ -1,158 +0,0 @@ -// -// AppError.swift -// EhPanda -// - -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 { - 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 = L10n.Localizable.ErrorView.Title.tryLater - switch self { - case .databaseCorrupted(let reason): - var lines = [L10n.Localizable.ErrorView.Title.databaseCorrupted] - if let reason = reason { - lines.append("(\(reason))") - } - return lines.joined(separator: "\n") - case .copyrightClaim(let owner): - return L10n.Localizable.ErrorView.Title.copyrightClaim(owner) - case .ipBanned(let interval): - return L10n.Localizable.ErrorView.Title.ipBanned(interval.description) - case .expunged(let reason): - switch reason { - case L10n.Constant.Website.Response.galleryUnavailable: - return L10n.Localizable.ErrorView.Title.galleryUnavailable - default: - return reason - } - case .networkingFailed: - return [L10n.Localizable.ErrorView.Title.network, tryLater].joined(separator: "\n") - case .parseFailed: - return [L10n.Localizable.ErrorView.Title.parsing, tryLater].joined(separator: "\n") - case .noUpdates, .webImageFailed: - return "" - case .notFound: - return L10n.Localizable.ErrorView.Title.notFound - case .unknown: - return [L10n.Localizable.ErrorView.Title.unknown, 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 = L10n.Localizable.Enum.BanInterval.Description.and - - 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 ? L10n.Localizable.Common.Value.days("\(days)") - : L10n.Localizable.Common.Value.day("\(days)") - } - private func hoursWithUnit(_ hours: Int) -> String { - hours > 1 ? L10n.Localizable.Common.Value.hours("\(hours)") - : L10n.Localizable.Common.Value.hour("\(hours)") - } - private func minutesWithUnit(_ minutes: Int) -> String { - minutes > 1 ? L10n.Localizable.Common.Value.minutes("\(minutes)") - : L10n.Localizable.Common.Value.minute("\(minutes)") - } - private func secondsWithUnit(_ seconds: Int) -> String { - seconds > 1 ? L10n.Localizable.Common.Value.seconds("\(seconds)") - : L10n.Localizable.Common.Value.second("\(seconds)") - } -} diff --git a/EhPanda/Models/Support/BrowsingCountry.swift b/EhPanda/Models/Support/BrowsingCountry.swift deleted file mode 100644 index 317307624..000000000 --- a/EhPanda/Models/Support/BrowsingCountry.swift +++ /dev/null @@ -1,529 +0,0 @@ -// -// BrowsingCountry.swift -// EhPanda -// - -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" - } -} -extension EhSetting.BrowsingCountry { - var id: Int { hashValue } - var name: String { - switch self { - case .autoDetect: return L10n.Localizable.Enum.BrowsingCountry.Name.autoDetect - case .afghanistan: return L10n.Localizable.Enum.BrowsingCountry.Name.afghanistan - case .alandIslands: return L10n.Localizable.Enum.BrowsingCountry.Name.alandIslands - case .albania: return L10n.Localizable.Enum.BrowsingCountry.Name.albania - case .algeria: return L10n.Localizable.Enum.BrowsingCountry.Name.algeria - case .americanSamoa: return L10n.Localizable.Enum.BrowsingCountry.Name.americanSamoa - case .andorra: return L10n.Localizable.Enum.BrowsingCountry.Name.andorra - case .angola: return L10n.Localizable.Enum.BrowsingCountry.Name.angola - case .anguilla: return L10n.Localizable.Enum.BrowsingCountry.Name.anguilla - case .antarctica: return L10n.Localizable.Enum.BrowsingCountry.Name.antarctica - case .antiguaAndBarbuda: return L10n.Localizable.Enum.BrowsingCountry.Name.antiguaAndBarbuda - case .argentina: return L10n.Localizable.Enum.BrowsingCountry.Name.argentina - case .armenia: return L10n.Localizable.Enum.BrowsingCountry.Name.armenia - case .aruba: return L10n.Localizable.Enum.BrowsingCountry.Name.aruba - case .asiaPacificRegion: return L10n.Localizable.Enum.BrowsingCountry.Name.asiaPacificRegion - case .australia: return L10n.Localizable.Enum.BrowsingCountry.Name.australia - case .austria: return L10n.Localizable.Enum.BrowsingCountry.Name.austria - case .azerbaijan: return L10n.Localizable.Enum.BrowsingCountry.Name.azerbaijan - case .bahamas: return L10n.Localizable.Enum.BrowsingCountry.Name.bahamas - case .bahrain: return L10n.Localizable.Enum.BrowsingCountry.Name.bahrain - case .bangladesh: return L10n.Localizable.Enum.BrowsingCountry.Name.bangladesh - case .barbados: return L10n.Localizable.Enum.BrowsingCountry.Name.barbados - case .belarus: return L10n.Localizable.Enum.BrowsingCountry.Name.belarus - case .belgium: return L10n.Localizable.Enum.BrowsingCountry.Name.belgium - case .belize: return L10n.Localizable.Enum.BrowsingCountry.Name.belize - case .benin: return L10n.Localizable.Enum.BrowsingCountry.Name.benin - case .bermuda: return L10n.Localizable.Enum.BrowsingCountry.Name.bermuda - case .bhutan: return L10n.Localizable.Enum.BrowsingCountry.Name.bhutan - case .bolivia: return L10n.Localizable.Enum.BrowsingCountry.Name.bolivia - case .bonaireSaintEustatiusAndSaba: return L10n.Localizable.Enum.BrowsingCountry.Name.bonaireSaintEustatiusAndSaba - case .bosniaAndHerzegovina: return L10n.Localizable.Enum.BrowsingCountry.Name.bosniaAndHerzegovina - case .botswana: return L10n.Localizable.Enum.BrowsingCountry.Name.botswana - case .bouvetIsland: return L10n.Localizable.Enum.BrowsingCountry.Name.bouvetIsland - case .brazil: return L10n.Localizable.Enum.BrowsingCountry.Name.brazil - case .britishIndianOceanTerritory: return L10n.Localizable.Enum.BrowsingCountry.Name.britishIndianOceanTerritory - case .bruneiDarussalam: return L10n.Localizable.Enum.BrowsingCountry.Name.bruneiDarussalam - case .bulgaria: return L10n.Localizable.Enum.BrowsingCountry.Name.bulgaria - case .burkinaFaso: return L10n.Localizable.Enum.BrowsingCountry.Name.burkinaFaso - case .burundi: return L10n.Localizable.Enum.BrowsingCountry.Name.burundi - case .cambodia: return L10n.Localizable.Enum.BrowsingCountry.Name.cambodia - case .cameroon: return L10n.Localizable.Enum.BrowsingCountry.Name.cameroon - case .canada: return L10n.Localizable.Enum.BrowsingCountry.Name.canada - case .capeVerde: return L10n.Localizable.Enum.BrowsingCountry.Name.capeVerde - case .caymanIslands: return L10n.Localizable.Enum.BrowsingCountry.Name.caymanIslands - case .centralAfricanRepublic: return L10n.Localizable.Enum.BrowsingCountry.Name.centralAfricanRepublic - case .chad: return L10n.Localizable.Enum.BrowsingCountry.Name.chad - case .chile: return L10n.Localizable.Enum.BrowsingCountry.Name.chile - case .china: return L10n.Localizable.Enum.BrowsingCountry.Name.china - case .christmasIsland: return L10n.Localizable.Enum.BrowsingCountry.Name.christmasIsland - case .cocosIslands: return L10n.Localizable.Enum.BrowsingCountry.Name.cocosIslands - case .colombia: return L10n.Localizable.Enum.BrowsingCountry.Name.colombia - case .comoros: return L10n.Localizable.Enum.BrowsingCountry.Name.comoros - case .congo: return L10n.Localizable.Enum.BrowsingCountry.Name.congo - case .theDemocraticRepublicOfTheCongo: return L10n.Localizable.Enum.BrowsingCountry.Name.theDemocraticRepublicOfTheCongo - case .cookIslands: return L10n.Localizable.Enum.BrowsingCountry.Name.cookIslands - case .costaRica: return L10n.Localizable.Enum.BrowsingCountry.Name.costaRica - case .coteDIvoire: return L10n.Localizable.Enum.BrowsingCountry.Name.coteDIvoire - case .croatia: return L10n.Localizable.Enum.BrowsingCountry.Name.croatia - case .cuba: return L10n.Localizable.Enum.BrowsingCountry.Name.cuba - case .curacao: return L10n.Localizable.Enum.BrowsingCountry.Name.curacao - case .cyprus: return L10n.Localizable.Enum.BrowsingCountry.Name.cyprus - case .czechRepublic: return L10n.Localizable.Enum.BrowsingCountry.Name.czechRepublic - case .denmark: return L10n.Localizable.Enum.BrowsingCountry.Name.denmark - case .djibouti: return L10n.Localizable.Enum.BrowsingCountry.Name.djibouti - case .dominica: return L10n.Localizable.Enum.BrowsingCountry.Name.dominica - case .dominicanRepublic: return L10n.Localizable.Enum.BrowsingCountry.Name.dominicanRepublic - case .ecuador: return L10n.Localizable.Enum.BrowsingCountry.Name.ecuador - case .egypt: return L10n.Localizable.Enum.BrowsingCountry.Name.egypt - case .elSalvador: return L10n.Localizable.Enum.BrowsingCountry.Name.elSalvador - case .equatorialGuinea: return L10n.Localizable.Enum.BrowsingCountry.Name.equatorialGuinea - case .eritrea: return L10n.Localizable.Enum.BrowsingCountry.Name.eritrea - case .estonia: return L10n.Localizable.Enum.BrowsingCountry.Name.estonia - case .ethiopia: return L10n.Localizable.Enum.BrowsingCountry.Name.ethiopia - case .europe: return L10n.Localizable.Enum.BrowsingCountry.Name.europe - case .falklandIslands: return L10n.Localizable.Enum.BrowsingCountry.Name.falklandIslands - case .faroeIslands: return L10n.Localizable.Enum.BrowsingCountry.Name.faroeIslands - case .fiji: return L10n.Localizable.Enum.BrowsingCountry.Name.fiji - case .finland: return L10n.Localizable.Enum.BrowsingCountry.Name.finland - case .france: return L10n.Localizable.Enum.BrowsingCountry.Name.france - case .frenchGuiana: return L10n.Localizable.Enum.BrowsingCountry.Name.frenchGuiana - case .frenchPolynesia: return L10n.Localizable.Enum.BrowsingCountry.Name.frenchPolynesia - case .frenchSouthernTerritories: return L10n.Localizable.Enum.BrowsingCountry.Name.frenchSouthernTerritories - case .gabon: return L10n.Localizable.Enum.BrowsingCountry.Name.gabon - case .gambia: return L10n.Localizable.Enum.BrowsingCountry.Name.gambia - case .georgia: return L10n.Localizable.Enum.BrowsingCountry.Name.georgia - case .germany: return L10n.Localizable.Enum.BrowsingCountry.Name.germany - case .ghana: return L10n.Localizable.Enum.BrowsingCountry.Name.ghana - case .gibraltar: return L10n.Localizable.Enum.BrowsingCountry.Name.gibraltar - case .greece: return L10n.Localizable.Enum.BrowsingCountry.Name.greece - case .greenland: return L10n.Localizable.Enum.BrowsingCountry.Name.greenland - case .grenada: return L10n.Localizable.Enum.BrowsingCountry.Name.grenada - case .guadeloupe: return L10n.Localizable.Enum.BrowsingCountry.Name.guadeloupe - case .guam: return L10n.Localizable.Enum.BrowsingCountry.Name.guam - case .guatemala: return L10n.Localizable.Enum.BrowsingCountry.Name.guatemala - case .guernsey: return L10n.Localizable.Enum.BrowsingCountry.Name.guernsey - case .guinea: return L10n.Localizable.Enum.BrowsingCountry.Name.guinea - case .guineaBissau: return L10n.Localizable.Enum.BrowsingCountry.Name.guineaBissau - case .guyana: return L10n.Localizable.Enum.BrowsingCountry.Name.guyana - case .haiti: return L10n.Localizable.Enum.BrowsingCountry.Name.haiti - case .heardIslandAndMcDonaldIslands: return L10n.Localizable.Enum.BrowsingCountry.Name.heardIslandAndMcDonaldIslands - case .vaticanCityState: return L10n.Localizable.Enum.BrowsingCountry.Name.vaticanCityState - case .honduras: return L10n.Localizable.Enum.BrowsingCountry.Name.honduras - case .hongKong: return L10n.Localizable.Enum.BrowsingCountry.Name.hongKong - case .hungary: return L10n.Localizable.Enum.BrowsingCountry.Name.hungary - case .iceland: return L10n.Localizable.Enum.BrowsingCountry.Name.iceland - case .india: return L10n.Localizable.Enum.BrowsingCountry.Name.india - case .indonesia: return L10n.Localizable.Enum.BrowsingCountry.Name.indonesia - case .iran: return L10n.Localizable.Enum.BrowsingCountry.Name.iran - case .iraq: return L10n.Localizable.Enum.BrowsingCountry.Name.iraq - case .ireland: return L10n.Localizable.Enum.BrowsingCountry.Name.ireland - case .isleOfMan: return L10n.Localizable.Enum.BrowsingCountry.Name.isleOfMan - case .israel: return L10n.Localizable.Enum.BrowsingCountry.Name.israel - case .italy: return L10n.Localizable.Enum.BrowsingCountry.Name.italy - case .jamaica: return L10n.Localizable.Enum.BrowsingCountry.Name.jamaica - case .japan: return L10n.Localizable.Enum.BrowsingCountry.Name.japan - case .jersey: return L10n.Localizable.Enum.BrowsingCountry.Name.jersey - case .jordan: return L10n.Localizable.Enum.BrowsingCountry.Name.jordan - case .kazakhstan: return L10n.Localizable.Enum.BrowsingCountry.Name.kazakhstan - case .kenya: return L10n.Localizable.Enum.BrowsingCountry.Name.kenya - case .kiribati: return L10n.Localizable.Enum.BrowsingCountry.Name.kiribati - case .kuwait: return L10n.Localizable.Enum.BrowsingCountry.Name.kuwait - case .kyrgyzstan: return L10n.Localizable.Enum.BrowsingCountry.Name.kyrgyzstan - case .laoPeoplesDemocraticRepublic: return L10n.Localizable.Enum.BrowsingCountry.Name.laoPeoplesDemocraticRepublic - case .latvia: return L10n.Localizable.Enum.BrowsingCountry.Name.latvia - case .lebanon: return L10n.Localizable.Enum.BrowsingCountry.Name.lebanon - case .lesotho: return L10n.Localizable.Enum.BrowsingCountry.Name.lesotho - case .liberia: return L10n.Localizable.Enum.BrowsingCountry.Name.liberia - case .libya: return L10n.Localizable.Enum.BrowsingCountry.Name.libya - case .liechtenstein: return L10n.Localizable.Enum.BrowsingCountry.Name.liechtenstein - case .lithuania: return L10n.Localizable.Enum.BrowsingCountry.Name.lithuania - case .luxembourg: return L10n.Localizable.Enum.BrowsingCountry.Name.luxembourg - case .macau: return L10n.Localizable.Enum.BrowsingCountry.Name.macau - case .macedonia: return L10n.Localizable.Enum.BrowsingCountry.Name.macedonia - case .madagascar: return L10n.Localizable.Enum.BrowsingCountry.Name.madagascar - case .malawi: return L10n.Localizable.Enum.BrowsingCountry.Name.malawi - case .malaysia: return L10n.Localizable.Enum.BrowsingCountry.Name.malaysia - case .maldives: return L10n.Localizable.Enum.BrowsingCountry.Name.maldives - case .mali: return L10n.Localizable.Enum.BrowsingCountry.Name.mali - case .malta: return L10n.Localizable.Enum.BrowsingCountry.Name.malta - case .marshallIslands: return L10n.Localizable.Enum.BrowsingCountry.Name.marshallIslands - case .martinique: return L10n.Localizable.Enum.BrowsingCountry.Name.martinique - case .mauritania: return L10n.Localizable.Enum.BrowsingCountry.Name.mauritania - case .mauritius: return L10n.Localizable.Enum.BrowsingCountry.Name.mauritius - case .mayotte: return L10n.Localizable.Enum.BrowsingCountry.Name.mayotte - case .mexico: return L10n.Localizable.Enum.BrowsingCountry.Name.mexico - case .micronesia: return L10n.Localizable.Enum.BrowsingCountry.Name.micronesia - case .moldova: return L10n.Localizable.Enum.BrowsingCountry.Name.moldova - case .monaco: return L10n.Localizable.Enum.BrowsingCountry.Name.monaco - case .mongolia: return L10n.Localizable.Enum.BrowsingCountry.Name.mongolia - case .montenegro: return L10n.Localizable.Enum.BrowsingCountry.Name.montenegro - case .montserrat: return L10n.Localizable.Enum.BrowsingCountry.Name.montserrat - case .morocco: return L10n.Localizable.Enum.BrowsingCountry.Name.morocco - case .mozambique: return L10n.Localizable.Enum.BrowsingCountry.Name.mozambique - case .myanmar: return L10n.Localizable.Enum.BrowsingCountry.Name.myanmar - case .namibia: return L10n.Localizable.Enum.BrowsingCountry.Name.namibia - case .nauru: return L10n.Localizable.Enum.BrowsingCountry.Name.nauru - case .nepal: return L10n.Localizable.Enum.BrowsingCountry.Name.nepal - case .netherlands: return L10n.Localizable.Enum.BrowsingCountry.Name.netherlands - case .newCaledonia: return L10n.Localizable.Enum.BrowsingCountry.Name.newCaledonia - case .newZealand: return L10n.Localizable.Enum.BrowsingCountry.Name.newZealand - case .nicaragua: return L10n.Localizable.Enum.BrowsingCountry.Name.nicaragua - case .niger: return L10n.Localizable.Enum.BrowsingCountry.Name.niger - case .nigeria: return L10n.Localizable.Enum.BrowsingCountry.Name.nigeria - case .niue: return L10n.Localizable.Enum.BrowsingCountry.Name.niue - case .norfolkIsland: return L10n.Localizable.Enum.BrowsingCountry.Name.norfolkIsland - case .northKorea: return L10n.Localizable.Enum.BrowsingCountry.Name.northKorea - case .northernMarianaIslands: return L10n.Localizable.Enum.BrowsingCountry.Name.northernMarianaIslands - case .norway: return L10n.Localizable.Enum.BrowsingCountry.Name.norway - case .oman: return L10n.Localizable.Enum.BrowsingCountry.Name.oman - case .pakistan: return L10n.Localizable.Enum.BrowsingCountry.Name.pakistan - case .palau: return L10n.Localizable.Enum.BrowsingCountry.Name.palau - case .palestinianTerritory: return L10n.Localizable.Enum.BrowsingCountry.Name.palestinianTerritory - case .panama: return L10n.Localizable.Enum.BrowsingCountry.Name.panama - case .papuaNewGuinea: return L10n.Localizable.Enum.BrowsingCountry.Name.papuaNewGuinea - case .paraguay: return L10n.Localizable.Enum.BrowsingCountry.Name.paraguay - case .peru: return L10n.Localizable.Enum.BrowsingCountry.Name.peru - case .philippines: return L10n.Localizable.Enum.BrowsingCountry.Name.philippines - case .pitcairnIslands: return L10n.Localizable.Enum.BrowsingCountry.Name.pitcairnIslands - case .poland: return L10n.Localizable.Enum.BrowsingCountry.Name.poland - case .portugal: return L10n.Localizable.Enum.BrowsingCountry.Name.portugal - case .puertoRico: return L10n.Localizable.Enum.BrowsingCountry.Name.puertoRico - case .qatar: return L10n.Localizable.Enum.BrowsingCountry.Name.qatar - case .reunion: return L10n.Localizable.Enum.BrowsingCountry.Name.reunion - case .romania: return L10n.Localizable.Enum.BrowsingCountry.Name.romania - case .russianFederation: return L10n.Localizable.Enum.BrowsingCountry.Name.russianFederation - case .rwanda: return L10n.Localizable.Enum.BrowsingCountry.Name.rwanda - case .saintBarthelemy: return L10n.Localizable.Enum.BrowsingCountry.Name.saintBarthelemy - case .saintHelena: return L10n.Localizable.Enum.BrowsingCountry.Name.saintHelena - case .saintKittsAndNevis: return L10n.Localizable.Enum.BrowsingCountry.Name.saintKittsAndNevis - case .saintLucia: return L10n.Localizable.Enum.BrowsingCountry.Name.saintLucia - case .saintMartin: return L10n.Localizable.Enum.BrowsingCountry.Name.saintMartin - case .saintPierreAndMiquelon: return L10n.Localizable.Enum.BrowsingCountry.Name.saintPierreAndMiquelon - case .saintVincentAndTheGrenadines: return L10n.Localizable.Enum.BrowsingCountry.Name.saintVincentAndTheGrenadines - case .samoa: return L10n.Localizable.Enum.BrowsingCountry.Name.samoa - case .sanMarino: return L10n.Localizable.Enum.BrowsingCountry.Name.sanMarino - case .saoTomeAndPrincipe: return L10n.Localizable.Enum.BrowsingCountry.Name.saoTomeAndPrincipe - case .saudiArabia: return L10n.Localizable.Enum.BrowsingCountry.Name.saudiArabia - case .senegal: return L10n.Localizable.Enum.BrowsingCountry.Name.senegal - case .serbia: return L10n.Localizable.Enum.BrowsingCountry.Name.serbia - case .seychelles: return L10n.Localizable.Enum.BrowsingCountry.Name.seychelles - case .sierraLeone: return L10n.Localizable.Enum.BrowsingCountry.Name.sierraLeone - case .singapore: return L10n.Localizable.Enum.BrowsingCountry.Name.singapore - case .sintMaarten: return L10n.Localizable.Enum.BrowsingCountry.Name.sintMaarten - case .slovakia: return L10n.Localizable.Enum.BrowsingCountry.Name.slovakia - case .slovenia: return L10n.Localizable.Enum.BrowsingCountry.Name.slovenia - case .solomonIslands: return L10n.Localizable.Enum.BrowsingCountry.Name.solomonIslands - case .somalia: return L10n.Localizable.Enum.BrowsingCountry.Name.somalia - case .southAfrica: return L10n.Localizable.Enum.BrowsingCountry.Name.southAfrica - case .southGeorgiaAndTheSouthSandwichIslands: return L10n.Localizable.Enum.BrowsingCountry.Name.southGeorgiaAndTheSouthSandwichIslands - case .southKorea: return L10n.Localizable.Enum.BrowsingCountry.Name.southKorea - case .southSudan: return L10n.Localizable.Enum.BrowsingCountry.Name.southSudan - case .spain: return L10n.Localizable.Enum.BrowsingCountry.Name.spain - case .sriLanka: return L10n.Localizable.Enum.BrowsingCountry.Name.sriLanka - case .sudan: return L10n.Localizable.Enum.BrowsingCountry.Name.sudan - case .suriname: return L10n.Localizable.Enum.BrowsingCountry.Name.suriname - case .svalbardAndJanMayen: return L10n.Localizable.Enum.BrowsingCountry.Name.svalbardAndJanMayen - case .swaziland: return L10n.Localizable.Enum.BrowsingCountry.Name.swaziland - case .sweden: return L10n.Localizable.Enum.BrowsingCountry.Name.sweden - case .switzerland: return L10n.Localizable.Enum.BrowsingCountry.Name.switzerland - case .syrianArabRepublic: return L10n.Localizable.Enum.BrowsingCountry.Name.syrianArabRepublic - case .taiwan: return L10n.Localizable.Enum.BrowsingCountry.Name.taiwan - case .tajikistan: return L10n.Localizable.Enum.BrowsingCountry.Name.tajikistan - case .tanzania: return L10n.Localizable.Enum.BrowsingCountry.Name.tanzania - case .thailand: return L10n.Localizable.Enum.BrowsingCountry.Name.thailand - case .timorLeste: return L10n.Localizable.Enum.BrowsingCountry.Name.timorLeste - case .togo: return L10n.Localizable.Enum.BrowsingCountry.Name.togo - case .tokelau: return L10n.Localizable.Enum.BrowsingCountry.Name.tokelau - case .tonga: return L10n.Localizable.Enum.BrowsingCountry.Name.tonga - case .trinidadAndTobago: return L10n.Localizable.Enum.BrowsingCountry.Name.trinidadAndTobago - case .tunisia: return L10n.Localizable.Enum.BrowsingCountry.Name.tunisia - case .turkey: return L10n.Localizable.Enum.BrowsingCountry.Name.turkey - case .turkmenistan: return L10n.Localizable.Enum.BrowsingCountry.Name.turkmenistan - case .turksAndCaicosIslands: return L10n.Localizable.Enum.BrowsingCountry.Name.turksAndCaicosIslands - case .tuvalu: return L10n.Localizable.Enum.BrowsingCountry.Name.tuvalu - case .uganda: return L10n.Localizable.Enum.BrowsingCountry.Name.uganda - case .ukraine: return L10n.Localizable.Enum.BrowsingCountry.Name.ukraine - case .unitedArabEmirates: return L10n.Localizable.Enum.BrowsingCountry.Name.unitedArabEmirates - case .unitedKingdom: return L10n.Localizable.Enum.BrowsingCountry.Name.unitedKingdom - case .unitedStates: return L10n.Localizable.Enum.BrowsingCountry.Name.unitedStates - case .unitedStatesMinorOutlyingIslands: return L10n.Localizable.Enum.BrowsingCountry.Name.unitedStatesMinorOutlyingIslands - case .uruguay: return L10n.Localizable.Enum.BrowsingCountry.Name.uruguay - case .uzbekistan: return L10n.Localizable.Enum.BrowsingCountry.Name.uzbekistan - case .vanuatu: return L10n.Localizable.Enum.BrowsingCountry.Name.vanuatu - case .venezuela: return L10n.Localizable.Enum.BrowsingCountry.Name.venezuela - case .vietnam: return L10n.Localizable.Enum.BrowsingCountry.Name.vietnam - case .virginIslandsBritish: return L10n.Localizable.Enum.BrowsingCountry.Name.virginIslandsBritish - case .virginIslandsUS: return L10n.Localizable.Enum.BrowsingCountry.Name.virginIslandsUS - case .wallisAndFutuna: return L10n.Localizable.Enum.BrowsingCountry.Name.wallisAndFutuna - case .westernSahara: return L10n.Localizable.Enum.BrowsingCountry.Name.westernSahara - case .yemen: return L10n.Localizable.Enum.BrowsingCountry.Name.yemen - case .zambia: return L10n.Localizable.Enum.BrowsingCountry.Name.zambia - case .zimbabwe: return L10n.Localizable.Enum.BrowsingCountry.Name.zimbabwe - } - } - var englishName: String { - switch self { - case .autoDetect: return "Auto-Detect" - case .afghanistan: return "Afghanistan" - case .alandIslands: return "Aland Islands" - case .albania: return "Albania" - case .algeria: return "Algeria" - case .americanSamoa: return "American Samoa" - case .andorra: return "Andorra" - case .angola: return "Angola" - case .anguilla: return "Anguilla" - case .antarctica: return "Antarctica" - case .antiguaAndBarbuda: return "Antigua and Barbuda" - case .argentina: return "Argentina" - case .armenia: return "Armenia" - case .aruba: return "Aruba" - case .asiaPacificRegion: return "Asia-Pacific Region" - case .australia: return "Australia" - case .austria: return "Austria" - case .azerbaijan: return "Azerbaijan" - case .bahamas: return "Bahamas" - case .bahrain: return "Bahrain" - case .bangladesh: return "Bangladesh" - case .barbados: return "Barbados" - case .belarus: return "Belarus" - case .belgium: return "Belgium" - case .belize: return "Belize" - case .benin: return "Benin" - case .bermuda: return "Bermuda" - case .bhutan: return "Bhutan" - case .bolivia: return "Bolivia" - case .bonaireSaintEustatiusAndSaba: return "Bonaire Saint Eustatius and Saba" - case .bosniaAndHerzegovina: return "Bosnia and Herzegovina" - case .botswana: return "Botswana" - case .bouvetIsland: return "Bouvet Island" - case .brazil: return "Brazil" - case .britishIndianOceanTerritory: return "British Indian Ocean Territory" - case .bruneiDarussalam: return "Brunei Darussalam" - case .bulgaria: return "Bulgaria" - case .burkinaFaso: return "Burkina Faso" - case .burundi: return "Burundi" - case .cambodia: return "Cambodia" - case .cameroon: return "Cameroon" - case .canada: return "Canada" - case .capeVerde: return "Cape Verde" - case .caymanIslands: return "Cayman Islands" - case .centralAfricanRepublic: return "Central African Republic" - case .chad: return "Chad" - case .chile: return "Chile" - case .china: return "China" - case .christmasIsland: return "Christmas Island" - case .cocosIslands: return "Cocos Islands" - case .colombia: return "Colombia" - case .comoros: return "Comoros" - case .congo: return "Congo" - case .theDemocraticRepublicOfTheCongo: return "The Democratic Republic of the Congo" - case .cookIslands: return "Cook Islands" - case .costaRica: return "Costa Rica" - case .coteDIvoire: return "Cote D'Ivoire" - case .croatia: return "Croatia" - case .cuba: return "Cuba" - case .curacao: return "Curacao" - case .cyprus: return "Cyprus" - case .czechRepublic: return "Czech Republic" - case .denmark: return "Denmark" - case .djibouti: return "Djibouti" - case .dominica: return "Dominica" - case .dominicanRepublic: return "Dominican Republic" - case .ecuador: return "Ecuador" - case .egypt: return "Egypt" - case .elSalvador: return "El Salvador" - case .equatorialGuinea: return "Equatorial Guinea" - case .eritrea: return "Eritrea" - case .estonia: return "Estonia" - case .ethiopia: return "Ethiopia" - case .europe: return "Europe" - case .falklandIslands: return "Falkland Islands" - case .faroeIslands: return "Faroe Islands" - case .fiji: return "Fiji" - case .finland: return "Finland" - case .france: return "France" - case .frenchGuiana: return "French Guiana" - case .frenchPolynesia: return "French Polynesia" - case .frenchSouthernTerritories: return "French Southern Territories" - case .gabon: return "Gabon" - case .gambia: return "Gambia" - case .georgia: return "Georgia" - case .germany: return "Germany" - case .ghana: return "Ghana" - case .gibraltar: return "Gibraltar" - case .greece: return "Greece" - case .greenland: return "Greenland" - case .grenada: return "Grenada" - case .guadeloupe: return "Guadeloupe" - case .guam: return "Guam" - case .guatemala: return "Guatemala" - case .guernsey: return "Guernsey" - case .guinea: return "Guinea" - case .guineaBissau: return "Guinea-Bissau" - case .guyana: return "Guyana" - case .haiti: return "Haiti" - case .heardIslandAndMcDonaldIslands: return "Heard Island and McDonald Islands" - case .vaticanCityState: return "Vatican City State" - case .honduras: return "Honduras" - case .hongKong: return "Hong Kong" - case .hungary: return "Hungary" - case .iceland: return "Iceland" - case .india: return "India" - case .indonesia: return "Indonesia" - case .iran: return "Iran" - case .iraq: return "Iraq" - case .ireland: return "Ireland" - case .isleOfMan: return "Isle of Man" - case .israel: return "Israel" - case .italy: return "Italy" - case .jamaica: return "Jamaica" - case .japan: return "Japan" - case .jersey: return "Jersey" - case .jordan: return "Jordan" - case .kazakhstan: return "Kazakhstan" - case .kenya: return "Kenya" - case .kiribati: return "Kiribati" - case .kuwait: return "Kuwait" - case .kyrgyzstan: return "Kyrgyzstan" - case .laoPeoplesDemocraticRepublic: return "Lao People's Democratic Republic" - case .latvia: return "Latvia" - case .lebanon: return "Lebanon" - case .lesotho: return "Lesotho" - case .liberia: return "Liberia" - case .libya: return "Libya" - case .liechtenstein: return "Liechtenstein" - case .lithuania: return "Lithuania" - case .luxembourg: return "Luxembourg" - case .macau: return "Macau" - case .macedonia: return "Macedonia" - case .madagascar: return "Madagascar" - case .malawi: return "Malawi" - case .malaysia: return "Malaysia" - case .maldives: return "Maldives" - case .mali: return "Mali" - case .malta: return "Malta" - case .marshallIslands: return "Marshall Islands" - case .martinique: return "Martinique" - case .mauritania: return "Mauritania" - case .mauritius: return "Mauritius" - case .mayotte: return "Mayotte" - case .mexico: return "Mexico" - case .micronesia: return "Micronesia" - case .moldova: return "Moldova" - case .monaco: return "Monaco" - case .mongolia: return "Mongolia" - case .montenegro: return "Montenegro" - case .montserrat: return "Montserrat" - case .morocco: return "Morocco" - case .mozambique: return "Mozambique" - case .myanmar: return "Myanmar" - case .namibia: return "Namibia" - case .nauru: return "Nauru" - case .nepal: return "Nepal" - case .netherlands: return "Netherlands" - case .newCaledonia: return "New Caledonia" - case .newZealand: return "New Zealand" - case .nicaragua: return "Nicaragua" - case .niger: return "Niger" - case .nigeria: return "Nigeria" - case .niue: return "Niue" - case .norfolkIsland: return "Norfolk Island" - case .northKorea: return "North Korea" - case .northernMarianaIslands: return "Northern Mariana Islands" - case .norway: return "Norway" - case .oman: return "Oman" - case .pakistan: return "Pakistan" - case .palau: return "Palau" - case .palestinianTerritory: return "Palestinian Territory" - case .panama: return "Panama" - case .papuaNewGuinea: return "Papua New Guinea" - case .paraguay: return "Paraguay" - case .peru: return "Peru" - case .philippines: return "Philippines" - case .pitcairnIslands: return "Pitcairn Islands" - case .poland: return "Poland" - case .portugal: return "Portugal" - case .puertoRico: return "Puerto Rico" - case .qatar: return "Qatar" - case .reunion: return "Reunion" - case .romania: return "Romania" - case .russianFederation: return "Russian Federation" - case .rwanda: return "Rwanda" - case .saintBarthelemy: return "Saint Barthelemy" - case .saintHelena: return "Saint Helena" - case .saintKittsAndNevis: return "Saint Kitts and Nevis" - case .saintLucia: return "Saint Lucia" - case .saintMartin: return "Saint Martin" - case .saintPierreAndMiquelon: return "Saint Pierre and Miquelon" - case .saintVincentAndTheGrenadines: return "Saint Vincent and the Grenadines" - case .samoa: return "Samoa" - case .sanMarino: return "San Marino" - case .saoTomeAndPrincipe: return "Sao Tome and Principe" - case .saudiArabia: return "Saudi Arabia" - case .senegal: return "Senegal" - case .serbia: return "Serbia" - case .seychelles: return "Seychelles" - case .sierraLeone: return "Sierra Leone" - case .singapore: return "Singapore" - case .sintMaarten: return "Sint Maarten" - case .slovakia: return "Slovakia" - case .slovenia: return "Slovenia" - case .solomonIslands: return "Solomon Islands" - case .somalia: return "Somalia" - case .southAfrica: return "South Africa" - case .southGeorgiaAndTheSouthSandwichIslands: return "South Georgia and the South Sandwich Islands" - case .southKorea: return "South Korea" - case .southSudan: return "South Sudan" - case .spain: return "Spain" - case .sriLanka: return "Sri Lanka" - case .sudan: return "Sudan" - case .suriname: return "Suriname" - case .svalbardAndJanMayen: return "Svalbard and Jan Mayen" - case .swaziland: return "Swaziland" - case .sweden: return "Sweden" - case .switzerland: return "Switzerland" - case .syrianArabRepublic: return "Syrian Arab Republic" - case .taiwan: return "Taiwan" - case .tajikistan: return "Tajikistan" - case .tanzania: return "Tanzania" - case .thailand: return "Thailand" - case .timorLeste: return "Timor-Leste" - case .togo: return "Togo" - case .tokelau: return "Tokelau" - case .tonga: return "Tonga" - case .trinidadAndTobago: return "Trinidad and Tobago" - case .tunisia: return "Tunisia" - case .turkey: return "Turkey" - case .turkmenistan: return "Turkmenistan" - case .turksAndCaicosIslands: return "Turks and Caicos Islands" - case .tuvalu: return "Tuvalu" - case .uganda: return "Uganda" - case .ukraine: return "Ukraine" - case .unitedArabEmirates: return "United Arab Emirates" - case .unitedKingdom: return "United Kingdom" - case .unitedStates: return "United States" - case .unitedStatesMinorOutlyingIslands: return "United States Minor Outlying Islands" - case .uruguay: return "Uruguay" - case .uzbekistan: return "Uzbekistan" - case .vanuatu: return "Vanuatu" - case .venezuela: return "Venezuela" - case .vietnam: return "Vietnam" - case .virginIslandsBritish: return "British Virgin Islands" - case .virginIslandsUS: return "U.S. Virgin Islands" - case .wallisAndFutuna: return "Wallis and Futuna" - case .westernSahara: return "Western Sahara" - case .yemen: return "Yemen" - case .zambia: return "Zambia" - case .zimbabwe: return "Zimbabwe" - } - } -} -// swiftlint:enable line_length diff --git a/EhPanda/Models/Support/EhSetting.swift b/EhPanda/Models/Support/EhSetting.swift deleted file mode 100644 index 1d5a19447..000000000 --- a/EhPanda/Models/Support/EhSetting.swift +++ /dev/null @@ -1,543 +0,0 @@ -// -// EhSetting.swift -// EhSetting -// - -// MARK: EhSetting -struct EhSetting: Equatable { - // swiftlint:disable line_length - static let empty: Self = .init(ehProfiles: [.empty], isCapableOfCreatingNewProfile: true, capableLoadThroughHathSetting: .anyClient, capableImageResolution: .auto, capableSearchResultCount: .fifty, capableThumbnailConfigRowCount: .forty, capableThumbnailConfigSizes: [], loadThroughHathSetting: .anyClient, browsingCountry: .autoDetect, literalBrowsingCountry: "", imageResolution: .auto, imageSizeWidth: 0, imageSizeHeight: 0, galleryName: .default, archiverBehavior: .autoSelectOriginalAutoStart, displayMode: .compact, showSearchRangeIndicator: true, enableGalleryThumbnailSelector: false, disabledCategories: Array(repeating: false, count: 10), favoriteCategories: Array(repeating: "", count: 10), favoritesSortOrder: .favoritedTime, ratingsColor: "", tagFilteringThreshold: 0, tagWatchingThreshold: 0, showFilteredRemovalCount: true, excludedLanguages: Array(repeating: false, count: 50), excludedUploaders: "", searchResultCount: .fifty, thumbnailLoadTiming: .onPageLoad, thumbnailConfigSize: .normal, thumbnailConfigRows: .ten, coverScaleFactor: 0, viewportVirtualWidth: 0, commentsSortOrder: .recent, commentVotesShowTiming: .always, tagsSortOrder: .alphabetical, galleryPageNumbering: .none) - // swiftlint:enable line_length - - static let categoryNames = Category.allFiltersCases.map(\.rawValue).map { value in - value.lowercased().replacingOccurrences(of: " ", with: "") - } - static let languageValues = [ - 1024, 2048, 1, 1025, 2049, 10, 1034, 2058, - 20, 1044, 2068, 30, 1054, 2078, 40, 1064, 2088, - 50, 1074, 2098, 60, 1084, 2108, 70, 1094, 2118, - 80, 1104, 2128, 90, 1114, 2138, 100, 1124, 2148, - 110, 1134, 2158, 120, 1144, 2168, 130, 1154, 2178, - 254, 1278, 2302, 255, 1279, 2303 - ] - - let ehProfiles: [EhProfile] - var ehpandaProfile: EhProfile? { - ehProfiles.filter({ EhSetting.verifyEhPandaProfileName(with: $0.name) }).first - } - static func verifyEhPandaProfileName(with name: String?) -> Bool { - ["EhPanda", "EhPanda (Default)"].contains(name ?? "") - } - - let isCapableOfCreatingNewProfile: Bool - let capableLoadThroughHathSetting: LoadThroughHathSetting - let capableImageResolution: ImageResolution - let capableSearchResultCount: SearchResultCount - let capableThumbnailConfigRowCount: ThumbnailRowCount - let capableThumbnailConfigSizes: [ThumbnailSize] - - var capableLoadThroughHathSettings: [LoadThroughHathSetting] { - LoadThroughHathSetting.allCases.filter { setting in - setting <= capableLoadThroughHathSetting - } - } - var capableImageResolutions: [ImageResolution] { - ImageResolution.allCases.filter { resolution in - resolution <= capableImageResolution - } - } - var capableSearchResultCounts: [SearchResultCount] { - SearchResultCount.allCases.filter { count in - count <= capableSearchResultCount - } - } - var capableThumbnailConfigRowCounts: [ThumbnailRowCount] { - ThumbnailRowCount.allCases.filter { row in - row <= capableThumbnailConfigRowCount - } - } - var localizedLiteralBrowsingCountry: String? { - BrowsingCountry.allCases.first(where: { $0.englishName == literalBrowsingCountry })?.name - } - - var loadThroughHathSetting: LoadThroughHathSetting - var browsingCountry: BrowsingCountry - let literalBrowsingCountry: String - var imageResolution: ImageResolution - var imageSizeWidth: Float - var imageSizeHeight: Float - var galleryName: GalleryName - var archiverBehavior: ArchiverBehavior - var displayMode: DisplayMode - var showSearchRangeIndicator: Bool - var enableGalleryThumbnailSelector: Bool - var disabledCategories: [Bool] - var favoriteCategories: [String] - var favoritesSortOrder: FavoritesSortOrder - var ratingsColor: String - var tagFilteringThreshold: Float - var tagWatchingThreshold: Float - var showFilteredRemovalCount: Bool - var excludedLanguages: [Bool] - var excludedUploaders: String - var searchResultCount: SearchResultCount - var thumbnailLoadTiming: ThumbnailLoadTiming - var thumbnailConfigSize: ThumbnailSize - var thumbnailConfigRows: ThumbnailRowCount - var coverScaleFactor: Float - var viewportVirtualWidth: Float - var commentsSortOrder: CommentsSortOrder - var commentVotesShowTiming: CommentVotesShowTiming - var tagsSortOrder: TagsSortOrder - var galleryPageNumbering: GalleryPageNumbering - var useOriginalImages: Bool? - var useMultiplePageViewer: Bool? - var multiplePageViewerStyle: MultiplePageViewerStyle? - var multiplePageViewerShowThumbnailPane: Bool? -} - -// MARK: EhProfile -struct EhProfile: Comparable, Identifiable, Hashable { - static let empty: Self = .init( - value: 0, name: "", isSelected: true - ) - static func < (lhs: EhProfile, rhs: EhProfile) -> Bool { - lhs.value < rhs.value - } - var id: Int { value } - - let value: Int - let name: String - let isSelected: Bool - var isDefault: Bool { - value == 1 - } -} -enum EhProfileAction: String { - case create - case delete - case rename - case `default` -} - -// MARK: LoadThroughHathSetting -extension EhSetting { - enum LoadThroughHathSetting: Int, CaseIterable, Identifiable, Comparable { - case anyClient - case defaultPortOnly - case modernNo - case legacyNo - } -} -extension EhSetting.LoadThroughHathSetting { - var id: Int { rawValue } - static func < ( - lhs: EhSetting.LoadThroughHathSetting, - rhs: EhSetting.LoadThroughHathSetting - ) -> Bool { - lhs.rawValue < rhs.rawValue - } - - var value: String { - switch self { - case .anyClient: - return L10n.Localizable.Enum.EhSetting.LoadThroughHathSetting.Value.anyClient - case .defaultPortOnly: - return L10n.Localizable.Enum.EhSetting.LoadThroughHathSetting.Value.defaultPortOnly - case .modernNo: - return L10n.Localizable.Enum.EhSetting.LoadThroughHathSetting.Value.modernNo - case .legacyNo: - return L10n.Localizable.Enum.EhSetting.LoadThroughHathSetting.Value.legacyNo - } - } - var description: String { - switch self { - case .anyClient: - return L10n.Localizable.Enum.EhSetting.LoadThroughHathSetting.Description.anyClient - case .defaultPortOnly: - return L10n.Localizable.Enum.EhSetting.LoadThroughHathSetting.Description.defaultPortOnly - case .modernNo: - return L10n.Localizable.Enum.EhSetting.LoadThroughHathSetting.Description.modernNo - case .legacyNo: - return L10n.Localizable.Enum.EhSetting.LoadThroughHathSetting.Description.legacyNo - } - } -} - -// MARK: ImageResolution -extension EhSetting { - enum ImageResolution: Int, CaseIterable, Identifiable, Comparable { - case auto - case x780 - /// Deprecated - case x980 - case x1280 - case x1600 - case x2400 - } -} -extension EhSetting.ImageResolution { - var id: Int { rawValue } - static func < (lhs: Self, rhs: Self) -> Bool { - lhs.rawValue < rhs.rawValue - } - - var value: String { - switch self { - case .auto: - return L10n.Localizable.Enum.EhSetting.ImageResolution.Value.auto - case .x780: - return "780x" - case .x980: - return "980x" - case .x1280: - return "1280x" - case .x1600: - return "1600x" - case .x2400: - return "2400x" - } - } -} - -// MARK: GalleryName -extension EhSetting { - enum GalleryName: Int, CaseIterable, Identifiable { - case `default` - case japanese - } -} -extension EhSetting.GalleryName { - var id: Int { rawValue } - - var value: String { - switch self { - case .default: - return L10n.Localizable.Enum.EhSetting.GalleryName.Value.default - case .japanese: - return L10n.Localizable.Enum.EhSetting.GalleryName.Value.japanese - } - } -} - -// MARK: ArchiverBehavior -extension EhSetting { - enum ArchiverBehavior: Int, CaseIterable, Identifiable { - case manualSelectManualStart - case manualSelectAutoStart - case autoSelectOriginalManualStart - case autoSelectOriginalAutoStart - case autoSelectResampleManualStart - case autoSelectResampleAutoStart - } -} -extension EhSetting.ArchiverBehavior { - var id: Int { rawValue } - - var value: String { - switch self { - case .manualSelectManualStart: - return L10n.Localizable.Enum.EhSetting.ArchiverBehavior.Value.manualSelectManualStart - case .manualSelectAutoStart: - return L10n.Localizable.Enum.EhSetting.ArchiverBehavior.Value.manualSelectAutoStart - case .autoSelectOriginalManualStart: - return L10n.Localizable.Enum.EhSetting.ArchiverBehavior.Value.autoSelectOriginalManualStart - case .autoSelectOriginalAutoStart: - return L10n.Localizable.Enum.EhSetting.ArchiverBehavior.Value.autoSelectOriginalAutoStart - case .autoSelectResampleManualStart: - return L10n.Localizable.Enum.EhSetting.ArchiverBehavior.Value.autoSelectResampleManualStart - case .autoSelectResampleAutoStart: - return L10n.Localizable.Enum.EhSetting.ArchiverBehavior.Value.autoSelectResampleAutoStart - } - } -} - -// MARK: DisplayMode -extension EhSetting { - enum DisplayMode: Int, CaseIterable, Identifiable { - case compact - case thumbnail - case extended - case minimal - case minimalPlus - } -} -extension EhSetting.DisplayMode { - var id: Int { rawValue } - - var value: String { - switch self { - case .compact: - return L10n.Localizable.Enum.EhSetting.DisplayMode.Value.compact - case .thumbnail: - return L10n.Localizable.Enum.EhSetting.DisplayMode.Value.thumbnail - case .extended: - return L10n.Localizable.Enum.EhSetting.DisplayMode.Value.extended - case .minimal: - return L10n.Localizable.Enum.EhSetting.DisplayMode.Value.minimal - case .minimalPlus: - return L10n.Localizable.Enum.EhSetting.DisplayMode.Value.minimalPlus - } - } -} - -// MARK: FavoritesSortOrder -extension EhSetting { - enum FavoritesSortOrder: Int, CaseIterable, Identifiable { - case lastUpdateTime - case favoritedTime - } -} -extension EhSetting.FavoritesSortOrder { - var id: Int { rawValue } - - var value: String { - switch self { - case .lastUpdateTime: - return L10n.Localizable.Enum.EhSetting.FavoritesSortOrder.Value.lastUpdateTime - case .favoritedTime: - return L10n.Localizable.Enum.EhSetting.FavoritesSortOrder.Value.favoritedTime - } - } -} - -// MARK: ExcludedLanguagesCategory -extension EhSetting { - enum ExcludedLanguagesCategory: Int, Identifiable, CaseIterable { - case original - case translated - case rewrite - } -} -extension EhSetting.ExcludedLanguagesCategory { - var id: Int { rawValue } - - var value: String { - switch self { - case .original: - return L10n.Localizable.Enum.EhSetting.ExcludedLanguagesCategory.Value.original - case .translated: - return L10n.Localizable.Enum.EhSetting.ExcludedLanguagesCategory.Value.translated - case .rewrite: - return L10n.Localizable.Enum.EhSetting.ExcludedLanguagesCategory.Value.rewrite - } - } -} - -// MARK: SearchResultCount -extension EhSetting { - enum SearchResultCount: Int, CaseIterable, Identifiable, Comparable { - case twentyFive - case fifty - case oneHundred - case twoHundred - } -} -extension EhSetting.SearchResultCount { - var id: Int { rawValue } - static func < (lhs: Self, rhs: Self) -> Bool { - lhs.rawValue < rhs.rawValue - } - - var value: String { - switch self { - case .twentyFive: - return "25" - case .fifty: - return "50" - case .oneHundred: - return "100" - case .twoHundred: - return "200" - } - } -} - -// MARK: ThumbnailLoadTiming -extension EhSetting { - enum ThumbnailLoadTiming: Int, CaseIterable, Identifiable { - case onMouseOver - case onPageLoad - } -} -extension EhSetting.ThumbnailLoadTiming { - var id: Int { rawValue } - - var value: String { - switch self { - case .onMouseOver: - return L10n.Localizable.Enum.EhSetting.ThumbnailLoadTiming.Value.onMouseOver - case .onPageLoad: - return L10n.Localizable.Enum.EhSetting.ThumbnailLoadTiming.Value.onPageLoad - } - } - var description: String { - switch self { - case .onMouseOver: - return L10n.Localizable.Enum.EhSetting.ThumbnailLoadTiming.Description.onMouseOver - case .onPageLoad: - return L10n.Localizable.Enum.EhSetting.ThumbnailLoadTiming.Description.onPageLoad - } - } -} - -// MARK: ThumbnailSize -extension EhSetting { - enum ThumbnailSize: Int, CaseIterable, Identifiable, Comparable { - case auto - case small - case normal - /// Deprecated - case large - } -} -extension EhSetting.ThumbnailSize { - var id: Int { rawValue } - static func < (lhs: Self, rhs: Self) -> Bool { - lhs.rawValue < rhs.rawValue - } - - var value: String { - switch self { - case .normal: - return L10n.Localizable.Enum.EhSetting.ThumbnailSize.Value.normal - case .large: - return L10n.Localizable.Enum.EhSetting.ThumbnailSize.Value.large - case .small: - return L10n.Localizable.Enum.EhSetting.ThumbnailSize.Value.small - case .auto: - return L10n.Localizable.Enum.EhSetting.ThumbnailSize.Value.auto - } - } -} - -// MARK: ThumbnailRowCount -extension EhSetting { - enum ThumbnailRowCount: Int, CaseIterable, Identifiable, Comparable { - case four - case ten - case twenty - case forty - } -} -extension EhSetting.ThumbnailRowCount { - var id: Int { rawValue } - static func < (lhs: Self, rhs: Self) -> Bool { - lhs.rawValue < rhs.rawValue - } - - var value: String { - switch self { - case .four: "4" - case .ten: "8" - case .twenty: "20" - case .forty: "40" - } - } -} - -// MARK: CommentsSortOrder -extension EhSetting { - enum CommentsSortOrder: Int, CaseIterable, Identifiable { - case oldest - case recent - case highestScore - } -} -extension EhSetting.CommentsSortOrder { - var id: Int { rawValue } - - var value: String { - switch self { - case .oldest: - return L10n.Localizable.Enum.EhSetting.CommentsSortOrder.Value.oldest - case .recent: - return L10n.Localizable.Enum.EhSetting.CommentsSortOrder.Value.recent - case .highestScore: - return L10n.Localizable.Enum.EhSetting.CommentsSortOrder.Value.highestScore - } - } -} - -// MARK: CommentVotesShowTiming -extension EhSetting { - enum CommentVotesShowTiming: Int, CaseIterable, Identifiable { - case onHoverOrClick - case always - } -} -extension EhSetting.CommentVotesShowTiming { - var id: Int { rawValue } - - var value: String { - switch self { - case .onHoverOrClick: - return L10n.Localizable.Enum.EhSetting.CommentsVotesShowTiming.Value.onHoverOrClick - case .always: - return L10n.Localizable.Enum.EhSetting.CommentsVotesShowTiming.Value.always - } - } -} - -// MARK: TagsSortOrder -extension EhSetting { - enum TagsSortOrder: Int, CaseIterable, Identifiable { - case alphabetical - case tagPower - } -} -extension EhSetting.TagsSortOrder { - var id: Int { rawValue } - - var value: String { - switch self { - case .alphabetical: - return L10n.Localizable.Enum.EhSetting.TagsSortOrder.Value.alphabetical - case .tagPower: - return L10n.Localizable.Enum.EhSetting.TagsSortOrder.Value.tagPower - } - } -} - -// MARK: MultiplePageViewerStyle -extension EhSetting { - enum MultiplePageViewerStyle: Int, CaseIterable, Identifiable { - case alignLeftScaleIfOverWidth - case alignCenterScaleIfOverWidth - case alignCenterAlwaysScale - } -} -extension EhSetting.MultiplePageViewerStyle { - var id: Int { rawValue } - - var value: String { - switch self { - case .alignLeftScaleIfOverWidth: - return L10n.Localizable.Enum.EhSetting.MultiplePageViewerStyle.Value.alignLeftScaleIfOverWidth - case .alignCenterScaleIfOverWidth: - return L10n.Localizable.Enum.EhSetting.MultiplePageViewerStyle.Value.alignCenterScaleIfOverWidth - case .alignCenterAlwaysScale: - return L10n.Localizable.Enum.EhSetting.MultiplePageViewerStyle.Value.alignCenterAlwaysScale - } - } -} - -// MARK: GalleryPageNumbering -extension EhSetting { - enum GalleryPageNumbering: Int, CaseIterable, Identifiable { - case none - case pageNumberOnly - case pageNumberAndName - } -} -extension EhSetting.GalleryPageNumbering { - var id: Int { rawValue } - - var value: String { - switch self { - case .none: L10n.Localizable.Enum.EhSetting.GalleryPageNumbering.Value.none - case .pageNumberOnly: L10n.Localizable.Enum.EhSetting.GalleryPageNumbering.Value.pageNumberOnly - case .pageNumberAndName: L10n.Localizable.Enum.EhSetting.GalleryPageNumbering.Value.pageNumberAndName - } - } -} diff --git a/EhPanda/Models/Support/Misc.swift b/EhPanda/Models/Support/Misc.swift deleted file mode 100644 index 46d141190..000000000 --- a/EhPanda/Models/Support/Misc.swift +++ /dev/null @@ -1,57 +0,0 @@ -// -// Misc.swift -// EhPanda -// - -import CasePaths -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 lastItemTimestamp: String? - var isNextButtonEnabled = false - - var isSinglePage: Bool { - current == 0 && maximum == 0 - } - func hasNextPage(isNumericBased: Bool = false) -> Bool { - isNumericBased ? current < maximum : isNextButtonEnabled - } - mutating func resetPages() { - self = Self() - } -} - -struct QuickSearchWord: Codable, Equatable, Identifiable { - static var empty: Self { .init(name: "", content: "") } - - var id: UUID = .init() - var name: String - var content: String -} - -@dynamicMemberLookup @CasePathable -enum LoadingState: Equatable, Hashable { - case idle - case loading - case failed(AppError) -} diff --git a/EhPanda/Models/Tags/EhTagTranslationDatabaseModel.swift b/EhPanda/Models/Tags/EhTagTranslationDatabaseModel.swift deleted file mode 100644 index fa30aa91a..000000000 --- a/EhPanda/Models/Tags/EhTagTranslationDatabaseModel.swift +++ /dev/null @@ -1,37 +0,0 @@ -// -// EhTagTranslationDatabaseModel.swift -// EhPanda -// - -import Foundation - -struct EhTagTranslationDatabaseResponse: Codable { - struct Item: Codable { - let name: String - var intro: String? - var links: String? - } - - struct Model: Codable { - let namespace: String - let data: [String: Item] - - var tagTranslations: [TagTranslation] { - guard let namespace = TagNamespace(rawValue: namespace) else { return .init() } - return data.map { - .init( - namespace: namespace, key: $0, value: $1.name, - description: $1.intro, linksString: $1.links - ) - } - } - } - - let data: [Model] - - var tagTranslations: [String: TagTranslation] { - .init(uniqueKeysWithValues: data.flatMap(\.tagTranslations).map({ - ($0.namespace.rawValue + $0.key, $0) - })) - } -} diff --git a/EhPanda/Models/Tags/TagDetail.swift b/EhPanda/Models/Tags/TagDetail.swift deleted file mode 100644 index 4a4a7da14..000000000 --- a/EhPanda/Models/Tags/TagDetail.swift +++ /dev/null @@ -1,13 +0,0 @@ -// -// TagDetail.swift -// EhPanda -// - -import Foundation - -struct TagDetail: Equatable { - let title: String - let description: String - let imageURLs: [URL] - let links: [URL] -} diff --git a/EhPanda/Models/Tags/TagTranslation.swift b/EhPanda/Models/Tags/TagTranslation.swift deleted file mode 100644 index b50e01a98..000000000 --- a/EhPanda/Models/Tags/TagTranslation.swift +++ /dev/null @@ -1,99 +0,0 @@ -// -// TagTranslation.swift -// EhPanda -// - -import OpenCC -import Foundation - -struct TagTranslation: Codable, Equatable, Hashable { - let namespace: TagNamespace - let key: String - let value: String - var description: String? - var linksString: String? - - var displayValue: String { - valuePlainText ?? value - } - - var valuePlainText: String? { - MarkdownUtil.parseTexts(markdown: value).first - } - var valueImageURL: URL? { - MarkdownUtil.parseImages(markdown: value).first - } - var descriptionPlainText: String? { - if let description = description { - return MarkdownUtil.parseTexts(markdown: description.replacingOccurrences(of: "`", with: " ")).joined() - } - return nil - } - var descriptionImageURLs: [URL] { - if let description = description { - return MarkdownUtil.parseImages(markdown: description) - } - return .init() - } - var links: [URL] { - if let linksString = linksString { - return MarkdownUtil.parseLinks(markdown: linksString) - } - return .init() - } - - var searchKeyword: String { - [namespace.abbreviation ?? namespace.rawValue, ":", - key.contains(" ") ? "\"\(key)$\"" : "\(key)$"].joined() - } - - func getSuggestion(keyword: String, originalKeyword: String, matchesNamespace: Bool) -> TagSuggestion { - func getWeight(value: String, range: Range) -> Float { - namespace.weight * .init(keyword.count + 1) / .init(value.count) - * (range.lowerBound == value.startIndex ? 2.0 : 1.0) - } - - var weight: Float = .zero - let keyRange = key.range(of: keyword, options: .caseInsensitive) - let valueRange = value.range(of: keyword, options: .caseInsensitive) - if let range = keyRange { weight += getWeight(value: key, range: range) } - if let range = valueRange { weight += getWeight(value: value, range: range) } - return .init( - tag: self, weight: weight, keyRange: keyRange, valueRange: valueRange, - originalKeyword: originalKeyword, matchesNamespace: matchesNamespace - ) - } -} - -extension Dictionary where Value == TagTranslation { - var chtConverted: Self { - func customConversion(text: String) -> String { - switch text { - case "full color": - return "全彩" - default: - return text - } - } - - guard let preferredLanguage = Locale.preferredLanguages.first else { return self } - - var options: ChineseConverter.Options = [.traditionalize] - if preferredLanguage.contains("HK") { - options = [.traditionalize, .hkStandard] - } else if preferredLanguage.contains("TW") { - options = [.traditionalize, .twStandard, .twIdiom] - } - - guard let converter = try? ChineseConverter(options: options) else { return self } - var dictionary = self - dictionary.forEach { (key, value) in - dictionary[key] = TagTranslation( - namespace: value.namespace, key: value.key, - value: customConversion(text: converter.convert(value.value)), - description: value.description, linksString: value.linksString - ) - } - return dictionary - } -} diff --git a/EhPanda/Models/Tags/TagTranslator.swift b/EhPanda/Models/Tags/TagTranslator.swift deleted file mode 100644 index 315721194..000000000 --- a/EhPanda/Models/Tags/TagTranslator.swift +++ /dev/null @@ -1,42 +0,0 @@ -// -// TagTranslator.swift -// EhPanda -// - -import Foundation - -struct TagTranslator: Codable, Equatable { - var language: TranslatableLanguage? - var hasCustomTranslations = false - var updatedDate: Date = .distantPast - var translations = [String: TagTranslation]() - - func lookup(word: String, returnOriginal: Bool) -> (String, TagTranslation?) { - guard !returnOriginal else { return (word, nil) } - let (lhs, rhs) = word.stringsBesideColon - - var key = rhs - if let lhs = lhs { - key = lhs + rhs - } - guard let translation = translations[key] else { return (word, nil) } - - var result = translation.displayValue - if let lhs = lhs { - result = [lhs, ":", result].joined() - } - return (result, translation) - } -} - -extension TagTranslator: CustomStringConvertible { - var description: String { - let params = String(describing: [ - "language": language as Any, - "updatedDate": updatedDate, - "translationsCount": translations.count, - "hasCustomTranslations": hasCustomTranslations - ]) - return "TagTranslator(\(params))" - } -} diff --git a/EhPanda/Network/Request.swift b/EhPanda/Network/Request.swift deleted file mode 100644 index f70b746a3..000000000 --- a/EhPanda/Network/Request.swift +++ /dev/null @@ -1,1087 +0,0 @@ -// -// PopularItemsRequest.swift -// EhPanda -// - -import Kanna -import Combine -import Foundation -import ComposableArchitecture - -protocol Request { - associatedtype Response - - var publisher: AnyPublisher { get } -} -extension Request { - func response() async -> Result { - await publisher.receive(on: DispatchQueue.main).async() - } - - func mapAppError(error: Error) -> AppError { - switch error { - case is ParseError: - return .parseFailed - - case is URLError: - return .networkingFailed - - case is DecodingError: - return .parseFailed - - default: - return error as? AppError ?? .unknown - } - } -} - -private extension Publisher { - func genericRetry() -> Publishers.Retry { - retry(3) - } - - func async() async -> Result where Failure == AppError { - await withCheckedContinuation { continuation in - var cancellable: AnyCancellable? - var finishedWithoutValue = true - cancellable = first() - .sink { result in - switch result { - case .finished: - if finishedWithoutValue { - continuation.resume(returning: .failure(.unknown)) - } - case let .failure(error): - continuation.resume(returning: .failure(error)) - } - cancellable?.cancel() - } receiveValue: { value in - finishedWithoutValue = false - continuation.resume(returning: .success(value)) - } - } - } -} -private extension URLRequest { - mutating func setURLEncodedContentType() { - setValue("application/x-www-form-urlencoded", forHTTPHeaderField: "Content-Type") - } -} -private extension Dictionary where Key == String, Value == String { - func dictString() -> String { - var array = [String]() - keys.forEach { key in - array.append(key + "=" + self[key].forceUnwrapped) - } - return array.joined(separator: "&") - } -} - -// MARK: Routine -struct GreetingRequest: Request { - var publisher: AnyPublisher { - URLSession.shared.dataTaskPublisher(for: Defaults.URL.news) - .genericRetry() - .tryMap { try Kanna.HTML(html: $0.data, encoding: .utf8) } - .tryMap(Parser.parseGreeting) - .mapError(mapAppError) - .eraseToAnyPublisher() - } -} - -struct UserInfoRequest: Request { - let uid: String - - var publisher: AnyPublisher { - 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 FavoriteCategoriesRequest: Request { - var publisher: AnyPublisher<[Int: String], AppError> { - URLSession.shared.dataTaskPublisher(for: Defaults.URL.uConfig) - .genericRetry() - .tryMap { try Kanna.HTML(html: $0.data, encoding: .utf8) } - .tryMap(Parser.parseFavoriteCategories) - .mapError(mapAppError) - .eraseToAnyPublisher() - } -} - -struct TagTranslatorRequest: Request { - let language: TranslatableLanguage - let updatedDate: Date - - var dateFormatter: DateFormatter { - let formatter = DateFormatter() - formatter.dateFormat = Defaults.DateFormat.github - formatter.timeZone = TimeZone(secondsFromGMT: 0) - formatter.locale = Locale(identifier: "en_US_POSIX") - return formatter - } - - var publisher: AnyPublisher { - 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, - let postedDate = dateFormatter.date(from: postedDateString) - else { throw AppError.parseFailed } - - guard postedDate > updatedDate - else { throw AppError.noUpdates } - return postedDate - } - .flatMap { date in - URLSession.shared.dataTaskPublisher(for: language.downloadURL) - .tryMap { data, _ in - let response = try JSONDecoder().decode(EhTagTranslationDatabaseResponse.self, from: data) - var translations = response.tagTranslations - guard !translations.isEmpty else { throw AppError.parseFailed } - if language == .traditionalChinese { - translations = translations.chtConverted - } - return TagTranslator(language: language, updatedDate: date, translations: translations) - } - } - .mapError(mapAppError) - .eraseToAnyPublisher() - } -} - -// MARK: Fetch ListItems -struct SearchGalleriesRequest: Request { - let keyword: String - let filter: Filter - - var publisher: AnyPublisher<(PageNumber, [Gallery]), AppError> { - URLSession.shared.dataTaskPublisher( - for: URLUtil.searchList(keyword: keyword, filter: filter) - ) - .genericRetry() - .tryMap { try Kanna.HTML(html: $0.data, encoding: .utf8) } - .tryMap { (Parser.parsePageNum(doc: $0), try Parser.parseGalleries(doc: $0)) } - .mapError(mapAppError) - .eraseToAnyPublisher() - } -} - -struct MoreSearchGalleriesRequest: Request { - let keyword: String - let filter: Filter - let lastID: String - - var publisher: AnyPublisher<(PageNumber, [Gallery]), AppError> { - URLSession.shared.dataTaskPublisher( - for: URLUtil.moreSearchList(keyword: keyword, filter: filter, lastID: lastID) - ) - .genericRetry() - .tryMap { try Kanna.HTML(html: $0.data, encoding: .utf8) } - .tryMap { (Parser.parsePageNum(doc: $0), try Parser.parseGalleries(doc: $0)) } - .mapError(mapAppError) - .eraseToAnyPublisher() - } -} - -struct FrontpageGalleriesRequest: Request { - let filter: Filter - - var publisher: AnyPublisher<(PageNumber, [Gallery]), AppError> { - URLSession.shared.dataTaskPublisher(for: URLUtil.frontpageList(filter: filter)) - .genericRetry() - .tryMap { try Kanna.HTML(html: $0.data, encoding: .utf8) } - .tryMap { (Parser.parsePageNum(doc: $0), try Parser.parseGalleries(doc: $0)) } - .mapError(mapAppError) - .eraseToAnyPublisher() - } -} - -struct MoreFrontpageGalleriesRequest: Request { - let filter: Filter - let lastID: String - - var publisher: AnyPublisher<(PageNumber, [Gallery]), AppError> { - URLSession.shared.dataTaskPublisher(for: URLUtil.moreFrontpageList(filter: filter, lastID: lastID)) - .genericRetry() - .tryMap { try Kanna.HTML(html: $0.data, encoding: .utf8) } - .tryMap { (Parser.parsePageNum(doc: $0), try Parser.parseGalleries(doc: $0)) } - .mapError(mapAppError) - .eraseToAnyPublisher() - } -} - -struct PopularGalleriesRequest: Request { - let filter: Filter - - var publisher: AnyPublisher<[Gallery], AppError> { - URLSession.shared.dataTaskPublisher(for: URLUtil.popularList(filter: filter)) - .genericRetry() - .tryMap { try Kanna.HTML(html: $0.data, encoding: .utf8) } - .tryMap(Parser.parseGalleries) - .mapError(mapAppError) - .eraseToAnyPublisher() - } -} - -struct WatchedGalleriesRequest: Request { - let filter: Filter - let keyword: String - - var publisher: AnyPublisher<(PageNumber, [Gallery]), AppError> { - URLSession.shared.dataTaskPublisher(for: URLUtil.watchedList(filter: filter, keyword: keyword)) - .genericRetry() - .tryMap { try Kanna.HTML(html: $0.data, encoding: .utf8) } - .tryMap { (Parser.parsePageNum(doc: $0), try Parser.parseGalleries(doc: $0)) } - .mapError(mapAppError) - .eraseToAnyPublisher() - } -} - -struct MoreWatchedGalleriesRequest: Request { - let filter: Filter - let lastID: String - let keyword: String - - var publisher: AnyPublisher<(PageNumber, [Gallery]), AppError> { - URLSession.shared.dataTaskPublisher( - for: URLUtil.moreWatchedList(filter: filter, lastID: lastID, keyword: keyword) - ) - .genericRetry() - .tryMap { try Kanna.HTML(html: $0.data, encoding: .utf8) } - .tryMap { (Parser.parsePageNum(doc: $0), try Parser.parseGalleries(doc: $0)) } - .mapError(mapAppError) - .eraseToAnyPublisher() - } -} - -struct FavoritesGalleriesRequest: Request { - let favIndex: Int - let keyword: String - var sortOrder: FavoritesSortOrder? - - var publisher: AnyPublisher<(PageNumber, FavoritesSortOrder?, [Gallery]), AppError> { - URLSession.shared.dataTaskPublisher( - for: URLUtil.favoritesList(favIndex: favIndex, keyword: keyword, sortOrder: sortOrder) - ) - .genericRetry() - .tryMap { try Kanna.HTML(html: $0.data, encoding: .utf8) } - .tryMap { - ( - Parser.parsePageNum(doc: $0), - Parser.parseFavoritesSortOrder(doc: $0), - try Parser.parseGalleries(doc: $0) - ) - } - .mapError(mapAppError) - .eraseToAnyPublisher() - } -} - -struct MoreFavoritesGalleriesRequest: Request { - let favIndex: Int - let lastID: String - var lastTimestamp: String - let keyword: String - - var publisher: AnyPublisher<(PageNumber, FavoritesSortOrder?, [Gallery]), AppError> { - URLSession.shared.dataTaskPublisher( - for: URLUtil.moreFavoritesList( - favIndex: favIndex, lastID: lastID, lastTimestamp: lastTimestamp, keyword: keyword - ) - ) - .genericRetry() - .tryMap { try Kanna.HTML(html: $0.data, encoding: .utf8) } - .tryMap { - ( - Parser.parsePageNum(doc: $0), - Parser.parseFavoritesSortOrder(doc: $0), - try Parser.parseGalleries(doc: $0) - ) - } - .mapError(mapAppError) - .eraseToAnyPublisher() - } -} - -struct ToplistsGalleriesRequest: Request { - let catIndex: Int - var pageNum: Int? - - var publisher: AnyPublisher<(PageNumber, [Gallery]), AppError> { - URLSession.shared.dataTaskPublisher( - for: URLUtil.toplistsList(catIndex: catIndex, pageNum: pageNum) - ) - .genericRetry() - .tryMap { try Kanna.HTML(html: $0.data, encoding: .utf8) } - .tryMap { (Parser.parsePageNum(doc: $0), try Parser.parseGalleries(doc: $0)) } - .mapError(mapAppError) - .eraseToAnyPublisher() - } -} - -struct MoreToplistsGalleriesRequest: Request { - let catIndex: Int - let pageNum: Int - - var publisher: AnyPublisher<(PageNumber, [Gallery]), AppError> { - URLSession.shared.dataTaskPublisher( - for: URLUtil.moreToplistsList( - catIndex: catIndex, pageNum: pageNum - ) - ) - .genericRetry() - .tryMap { try Kanna.HTML(html: $0.data, encoding: .utf8) } - .tryMap { (Parser.parsePageNum(doc: $0), try Parser.parseGalleries(doc: $0)) } - .mapError(mapAppError) - .eraseToAnyPublisher() - } -} - -// MARK: Fetch others -struct GalleryDetailRequest: Request { - let gid: String - let galleryURL: URL - - var publisher: AnyPublisher<(GalleryDetail, GalleryState, String, Greeting?), AppError> { - URLSession.shared.dataTaskPublisher(for: URLUtil.galleryDetail(url: galleryURL)) - .genericRetry() - .compactMap { resp -> HTMLDocument? in - var htmlDocument: HTMLDocument? - do { - htmlDocument = try Kanna.HTML(html: resp.data, encoding: .utf8) - } catch { - guard let parseError = error as? ParseError, parseError == .EncodingMismatch - else { return htmlDocument } - - htmlDocument = try? Kanna.HTML(html: resp.data.utf8InvalidCharactersRipped, encoding: .utf8) - } - return htmlDocument - } - .tryMap { - let (detail, state) = try Parser.parseGalleryDetail(doc: $0, gid: gid) - return ($0, detail, state, try Parser.parseAPIKey(doc: $0)) - } - .map { doc, detail, state, apiKey in - (detail, state, apiKey, try? Parser.parseGreeting(doc: doc)) - } - .mapError(mapAppError) - .eraseToAnyPublisher() - } -} - -struct GalleryReverseRequest: Request { - let url: URL - let isGalleryImageURL: Bool - - func getGallery(from detail: GalleryDetail?, and url: URL) -> Gallery? { - if let detail = detail { - return Gallery( - gid: url.pathComponents[2], - token: url.pathComponents[3], - title: detail.title, - rating: detail.rating, - tags: [], - category: detail.category, - uploader: detail.uploader, - pageCount: detail.pageCount, - postedDate: detail.postedDate, - coverURL: detail.coverURL, - galleryURL: url - ) - } else { - return nil - } - } - - var publisher: AnyPublisher { - galleryURL(url: url) - .genericRetry() - .flatMap(gallery) - .eraseToAnyPublisher() - } - - func galleryURL(url: URL) -> AnyPublisher { - switch isGalleryImageURL { - case true: - return URLSession.shared.dataTaskPublisher(for: url) - .tryMap { try Kanna.HTML(html: $0.data, encoding: .utf8) } - .tryMap(Parser.parseGalleryURL) - .mapError(mapAppError) - .eraseToAnyPublisher() - - case false: - return Just(url) - .setFailureType(to: AppError.self) - .eraseToAnyPublisher() - } - } - - func gallery(url: URL) -> AnyPublisher { - URLSession.shared.dataTaskPublisher(for: url) - .tryMap { try Kanna.HTML(html: $0.data, encoding: .utf8) } - .compactMap { - guard let (detail, _) = try? Parser.parseGalleryDetail(doc: $0, gid: url.pathComponents[2]) - else { return nil } - - return getGallery(from: detail, and: url) - } - .mapError(mapAppError) - .eraseToAnyPublisher() - } -} - -struct GalleryArchiveRequest: Request { - let archiveURL: URL - - var publisher: AnyPublisher<(GalleryArchive, String?, String?), AppError> { - URLSession.shared.dataTaskPublisher(for: archiveURL) - .genericRetry() - .tryMap { try Kanna.HTML(html: $0.data, encoding: .utf8) } - .tryMap { (html: HTMLDocument) -> (HTMLDocument, GalleryArchive) in - let archive = try Parser.parseGalleryArchive(doc: html) - return (html, archive) - } - .map { html, archive in - guard let (currentGP, currentCredits) = try? Parser.parseCurrentFunds(doc: html) - else { return (archive, nil, nil) } - return (archive, currentGP, currentCredits) - } - .mapError(mapAppError) - .eraseToAnyPublisher() - } -} - -struct GalleryArchiveFundsRequest: Request { - let gid: String - let galleryURL: URL - - var publisher: AnyPublisher<(String, String), AppError> { - archiveURL(url: galleryURL) - .genericRetry() - .flatMap(funds) - .eraseToAnyPublisher() - } - - func archiveURL(url: URL) -> AnyPublisher { - URLSession.shared.dataTaskPublisher(for: url) - .tryMap { try Kanna.HTML(html: $0.data, encoding: .utf8) } - .compactMap { try? Parser.parseGalleryDetail(doc: $0, gid: gid).0.archiveURL } - .mapError(mapAppError) - .eraseToAnyPublisher() - } - - func funds(url: URL) -> AnyPublisher<(String, String), AppError> { - URLSession.shared.dataTaskPublisher(for: url) - .tryMap { try Kanna.HTML(html: $0.data, encoding: .utf8) } - .tryMap(Parser.parseCurrentFunds) - .mapError(mapAppError) - .eraseToAnyPublisher() - } -} - -struct GalleryTorrentsRequest: Request { - let gid: String - let token: String - - var publisher: AnyPublisher<[GalleryTorrent], AppError> { - URLSession.shared.dataTaskPublisher(for: URLUtil.galleryTorrents(gid: gid, token: token)) - .genericRetry() - .tryMap { try Kanna.HTML(html: $0.data, encoding: .utf8) } - .map(Parser.parseGalleryTorrents) - .mapError(mapAppError) - .eraseToAnyPublisher() - } -} - -struct GalleryPreviewURLsRequest: Request { - let galleryURL: URL - let pageNum: Int - - var publisher: AnyPublisher<[Int: URL], AppError> { - URLSession.shared.dataTaskPublisher(for: URLUtil.detailPage(url: galleryURL, pageNum: pageNum)) - .genericRetry() - .tryMap { try Kanna.HTML(html: $0.data, encoding: .utf8) } - .tryMap(Parser.parsePreviewURLs) - .mapError(mapAppError) - .eraseToAnyPublisher() - } -} - -struct MPVKeysRequest: Request { - let mpvURL: URL - - var publisher: AnyPublisher<(String, [Int: String]), AppError> { - URLSession.shared.dataTaskPublisher(for: mpvURL) - .genericRetry() - .tryMap { try Kanna.HTML(html: $0.data, encoding: .utf8) } - .tryMap(Parser.parseMPVKeys) - .mapError(mapAppError) - .eraseToAnyPublisher() - } -} - -struct ThumbnailURLsRequest: Request { - let galleryURL: URL - let pageNum: Int - - var publisher: AnyPublisher<[Int: URL], AppError> { - URLSession.shared.dataTaskPublisher(for: URLUtil.detailPage(url: galleryURL, pageNum: pageNum)) - .genericRetry() - .tryMap { try Kanna.HTML(html: $0.data, encoding: .utf8) } - .tryMap(Parser.parseThumbnailURLs) - .mapError(mapAppError) - .eraseToAnyPublisher() - } -} - -struct GalleryNormalImageURLsRequest: Request { - let thumbnailURLs: [Int: URL] - - var publisher: AnyPublisher<([Int: URL], [Int: URL]), AppError> { - thumbnailURLs.publisher - .flatMap { index, url in - URLSession.shared.dataTaskPublisher(for: url) - .genericRetry() - .tryMap { try Kanna.HTML(html: $0.data, encoding: .utf8) } - .tryMap { try Parser.parseGalleryNormalImageURL(doc: $0, index: index) } - } - .collect() - .map { tuples in - var imageURLs = [Int: URL]() - var originalImageURLs = [Int: URL]() - for (index, imageURL, originalImageURL) in tuples { - imageURLs[index] = imageURL - originalImageURLs[index] = originalImageURL - } - return (imageURLs, originalImageURLs) - } - .mapError(mapAppError) - .eraseToAnyPublisher() - } -} - -struct GalleryNormalImageURLRefetchRequest: Request { - let index: Int - let pageNum: Int - let galleryURL: URL - let thumbnailURL: URL? - let storedImageURL: URL - - var publisher: AnyPublisher<([Int: URL], HTTPURLResponse?), AppError> { - storedThumbnailURL() - .flatMap(renewThumbnailURL) - .flatMap(imageURL) - .genericRetry() - .map { imageURL1, imageURL2, response in - ([index: imageURL1 != storedImageURL ? imageURL1 : imageURL2], response) - } - .eraseToAnyPublisher() - } - - func storedThumbnailURL() -> AnyPublisher { - if let thumbnailURL = thumbnailURL { - return Just(thumbnailURL) - .setFailureType(to: AppError.self) - .eraseToAnyPublisher() - } else { - return URLSession.shared.dataTaskPublisher(for: URLUtil.detailPage(url: galleryURL, pageNum: pageNum)) - .tryMap { try Kanna.HTML(html: $0.data, encoding: .utf8) } - .tryMap(Parser.parseThumbnailURLs) - .compactMap({ thumbnailURLs in thumbnailURLs[index] }) - .mapError(mapAppError) - .eraseToAnyPublisher() - } - } - - func renewThumbnailURL(stored: URL) -> AnyPublisher<(URL, URL), AppError> { - URLSession.shared.dataTaskPublisher(for: stored) - .tryMap { try Kanna.HTML(html: $0.data, encoding: .utf8) } - .tryMap { - let identifier = try Parser.parseSkipServerIdentifier(doc: $0) - let imageURL = try Parser.parseGalleryNormalImageURL(doc: $0, index: index).1 - return (stored.appending(queryItems: [.skipServerIdentifier: identifier]), imageURL) - } - .mapError(mapAppError) - .eraseToAnyPublisher() - } - - func imageURL(thumbnailURL: URL, anotherImageURL: URL) - -> AnyPublisher<(URL, URL, HTTPURLResponse?), AppError> { - URLSession.shared.dataTaskPublisher(for: thumbnailURL) - .tryMap { - (try Kanna.HTML(html: $0.data, encoding: .utf8), $0.response as? HTTPURLResponse) - } - .tryMap { html, response in - (try Parser.parseGalleryNormalImageURL(doc: html, index: index), response) - } - .map { imageURL, response in - (anotherImageURL, imageURL.1, response) - } - .mapError(mapAppError) - .eraseToAnyPublisher() - } -} - -struct GalleryMPVImageURLRequest: Request { - let gid: Int - let index: Int - let mpvKey: String - let mpvImageKey: String - let skipServerIdentifier: String? - - var publisher: AnyPublisher<(URL, URL?, String), AppError> { - var params: [String: Any] = [ - "method": "imagedispatch", - "gid": gid, - "page": index, - "imgkey": mpvImageKey, - "mpvkey": mpvKey - ] - if let skipServerIdentifier = skipServerIdentifier { - params["nl"] = skipServerIdentifier - } - - var request = URLRequest(url: Defaults.URL.api) - request.httpMethod = "POST" - request.httpBody = try? JSONSerialization.data(withJSONObject: params, options: []) - - return URLSession.shared.dataTaskPublisher(for: request) - .genericRetry() - .map(\.data) - .tryMap { data in - guard let dict = try JSONSerialization - .jsonObject(with: data) as? [String: Any], - let imageURLString = dict["i"] as? String, - let imageURL = URL(string: imageURLString) - else { throw AppError.parseFailed } - - var skipServerIdentifier: String? - - if let integerIdentifier = dict["s"] as? Int { - skipServerIdentifier = integerIdentifier.description - } else if let stringIdentifier = dict["s"] as? String { - skipServerIdentifier = stringIdentifier - } - - guard let skipServerIdentifier else { throw AppError.parseFailed } - - if let originalImageURLStringSlice = dict["lf"] as? String { - let originalImageURL = Defaults.URL.host.appendingPathComponent(originalImageURLStringSlice) - return (imageURL, originalImageURL, skipServerIdentifier) - } else { - return (imageURL, nil, skipServerIdentifier) - } - } - .mapError(mapAppError) - .eraseToAnyPublisher() - } -} - -// MARK: Tool -struct DataRequest: Request { - let url: URL - - var publisher: AnyPublisher { - URLSession.shared.dataTaskPublisher(for: url) - .genericRetry() - .map(\.data) - .mapError(mapAppError) - .eraseToAnyPublisher() - } -} - -// MARK: Account Ops -struct LoginRequest: Request { - let username: String - let password: String - - var publisher: AnyPublisher { - let params: [String: String] = [ - "b": "d", - "bt": "1-1", - "CookieDate": "1", - "UserName": username, - "PassWord": password, - "ipb_login_submit": "Login!" - ] - - var request = URLRequest(url: Defaults.URL.login) - request.httpMethod = "POST" - request.httpBody = params.dictString().urlEncoded.data(using: .utf8) - request.setURLEncodedContentType() - - return URLSession.shared.dataTaskPublisher(for: request) - .genericRetry() - .map { $0.response as? HTTPURLResponse } - .mapError(mapAppError) - .eraseToAnyPublisher() - } -} - -struct IgneousRequest: Request { - var publisher: AnyPublisher { - URLSession.shared.dataTaskPublisher(for: Defaults.URL.exhentai) - .genericRetry() - .compactMap { $0.response as? HTTPURLResponse } - .mapError(mapAppError) - .eraseToAnyPublisher() - } -} - -struct VerifyEhProfileResponse: Equatable { - let profileValue: Int? - let isProfileNotFound: Bool -} -struct VerifyEhProfileRequest: Request { - var publisher: AnyPublisher { - URLSession.shared.dataTaskPublisher(for: Defaults.URL.uConfig) - .genericRetry() - .tryMap { try Kanna.HTML(html: $0.data, encoding: .utf8) } - .tryMap(Parser.parseProfileIndex) - .mapError(mapAppError) - .eraseToAnyPublisher() - } -} - -struct EhProfileRequest: Request { - var action: EhProfileAction? - var name: String? - var set: Int? - - var publisher: AnyPublisher { - var params = [String: String]() - - if let action = action { - params["profile_action"] = action.rawValue - } - if let name = name { - params["profile_name"] = name - } - if let set = set { - params["profile_set"] = "\(set)" - } - - var request = URLRequest(url: Defaults.URL.uConfig) - request.httpMethod = "POST" - request.httpBody = params.dictString().urlEncoded.data(using: .utf8) - request.setURLEncodedContentType() - - return URLSession.shared.dataTaskPublisher(for: request) - .genericRetry() - .tryMap { try Kanna.HTML(html: $0.data, encoding: .utf8) } - .tryMap(Parser.parseEhSetting) - .mapError(mapAppError) - .eraseToAnyPublisher() - } -} - -struct EhSettingRequest: Request { - var publisher: AnyPublisher { - URLSession.shared.dataTaskPublisher(for: Defaults.URL.uConfig) - .genericRetry() - .tryMap { try Kanna.HTML(html: $0.data, encoding: .utf8) } - .tryMap(Parser.parseEhSetting) - .mapError(mapAppError) - .eraseToAnyPublisher() - } -} - -struct SubmitEhSettingChangesRequest: Request { - let ehSetting: EhSetting - - var publisher: AnyPublisher { - let url = Defaults.URL.uConfig - var params: [String: String] = [ - "uh": String(ehSetting.loadThroughHathSetting.rawValue), - "co": ehSetting.browsingCountry.rawValue, - "xr": String(ehSetting.imageResolution.rawValue), - "rx": String(Int(ehSetting.imageSizeWidth)), - "ry": String(Int(ehSetting.imageSizeHeight)), - "tl": String(ehSetting.galleryName.rawValue), - "ar": String(ehSetting.archiverBehavior.rawValue), - "dm": String(ehSetting.displayMode.rawValue), - "pp": ehSetting.showSearchRangeIndicator ? "0" : "1", - "fs": String(ehSetting.favoritesSortOrder.rawValue), - "ru": ehSetting.ratingsColor, - "ft": String(Int(ehSetting.tagFilteringThreshold)), - "wt": String(Int(ehSetting.tagWatchingThreshold)), - "tf": ehSetting.showFilteredRemovalCount ? "0" : "1", - "xu": ehSetting.excludedUploaders, - "rc": String(ehSetting.searchResultCount.rawValue), - "lt": String(ehSetting.thumbnailLoadTiming.rawValue), - "tr": String(ehSetting.thumbnailConfigRows.rawValue), - "tp": String(Int(ehSetting.coverScaleFactor)), - "vp": String(Int(ehSetting.viewportVirtualWidth)), - "cs": String(ehSetting.commentsSortOrder.rawValue), - "sc": String(ehSetting.commentVotesShowTiming.rawValue), - "tb": String(ehSetting.tagsSortOrder.rawValue), - "pn": String(ehSetting.galleryPageNumbering.rawValue), - "apply": "Apply" - ] - - if ehSetting.enableGalleryThumbnailSelector { - params["xn_0"] = "on" - } - - switch ehSetting.thumbnailConfigSize { - case .auto: params["ts"] = "0" - case .normal: params["ts"] = "1" - case .small: params["ts"] = "2" - default: break - } - - EhSetting.categoryNames.enumerated().forEach { index, name in - params["ct_\(name)"] = ehSetting.disabledCategories[index] ? "1" : "0" - } - Array(0...9).forEach { index in - params["favorite_\(index)"] = ehSetting.favoriteCategories[index] - } - ehSetting.excludedLanguages.enumerated().forEach { index, value in - if value { - params["xl_\(EhSetting.languageValues[index])"] = "on" - } - } - - if let useOriginalImages = ehSetting.useOriginalImages { - params["oi"] = useOriginalImages ? "1" : "0" - } - if let useMultiplePageViewer = ehSetting.useMultiplePageViewer { - params["qb"] = useMultiplePageViewer ? "1" : "0" - } - if let multiplePageViewerStyle = ehSetting.multiplePageViewerStyle { - params["ms"] = String(multiplePageViewerStyle.rawValue) - } - if let multiplePageViewerShowThumbnailPane = ehSetting.multiplePageViewerShowThumbnailPane { - params["mt"] = multiplePageViewerShowThumbnailPane ? "0" : "1" - } - - var request = URLRequest(url: url) - request.httpMethod = "POST" - request.httpBody = params.dictString().urlEncoded.data(using: .utf8) - request.setURLEncodedContentType() - - return URLSession.shared.dataTaskPublisher(for: request) - .genericRetry() - .tryMap { try Kanna.HTML(html: $0.data, encoding: .utf8) } - .tryMap(Parser.parseEhSetting) - .mapError(mapAppError) - .eraseToAnyPublisher() - } -} - -struct FavorGalleryRequest: Request { - let gid: String - let token: String - let favIndex: Int - - var publisher: AnyPublisher { - let url = URLUtil.addFavorite(gid: gid, token: token) - let params: [String: String] = [ - "favcat": "\(favIndex)", - "favnote": "", - "apply": "Add to Favorites", - "update": "1" - ] - - var request = URLRequest(url: url) - request.httpMethod = "POST" - request.httpBody = params.dictString().urlEncoded.data(using: .utf8) - request.setURLEncodedContentType() - - return URLSession.shared.dataTaskPublisher(for: request) - .genericRetry() - .map { $0 } - .mapError(mapAppError) - .eraseToAnyPublisher() - } -} - -struct UnfavorGalleryRequest: Request { - let gid: String - - var publisher: AnyPublisher { - let params: [String: String] = [ - "ddact": "delete", - "modifygids[]": gid, - "apply": "Apply" - ] - - var request = URLRequest(url: Defaults.URL.favorites) - request.httpMethod = "POST" - request.httpBody = params.dictString().urlEncoded.data(using: .utf8) - request.setURLEncodedContentType() - - return URLSession.shared.dataTaskPublisher(for: request) - .genericRetry() - .map { $0 } - .mapError(mapAppError) - .eraseToAnyPublisher() - } -} - -struct SendDownloadCommandRequest: Request { - let archiveURL: URL - let resolution: String - - var publisher: AnyPublisher { - let params: [String: String] = [ - "hathdl_xres": resolution - ] - - var request = URLRequest(url: archiveURL) - request.httpMethod = "POST" - request.httpBody = params.dictString().urlEncoded.data(using: .utf8) - request.setURLEncodedContentType() - - return URLSession.shared.dataTaskPublisher(for: request) - .genericRetry() - .tryMap { try Kanna.HTML(html: $0.data, encoding: .utf8) } - .tryMap(Parser.parseDownloadCommandResponse) - .mapError(mapAppError) - .eraseToAnyPublisher() - } -} - -struct RateGalleryRequest: Request { - let apiuid: Int - let apikey: String - let gid: Int - let token: String - let rating: Int - - var publisher: AnyPublisher { - let params: [String: Any] = [ - "method": "rategallery", - "apiuid": apiuid, - "apikey": apikey, - "gid": gid, - "token": token, - "rating": rating - ] - - var request = URLRequest(url: Defaults.URL.api) - request.httpMethod = "POST" - request.httpBody = try? JSONSerialization.data(withJSONObject: params, options: []) - - return URLSession.shared.dataTaskPublisher(for: request) - .genericRetry() - .map { $0 } - .mapError(mapAppError) - .eraseToAnyPublisher() - } -} - -struct CommentGalleryRequest: Request { - let content: String - let galleryURL: URL - - var publisher: AnyPublisher { - let fixedContent = content.replacingOccurrences(of: "\n", with: "%0A") - let params: [String: String] = [ - "commenttext_new": fixedContent - ] - - var request = URLRequest(url: galleryURL) - request.httpMethod = "POST" - request.httpBody = params.dictString().urlEncoded.data(using: .utf8) - request.setURLEncodedContentType() - - return URLSession.shared.dataTaskPublisher(for: request) - .genericRetry() - .map { $0 } - .mapError(mapAppError) - .eraseToAnyPublisher() - } -} - -struct EditGalleryCommentRequest: Request { - let commentID: String - let content: String - let galleryURL: URL - - var publisher: AnyPublisher { - let fixedContent = content.replacingOccurrences(of: "\n", with: "%0A") - let params: [String: String] = [ - "edit_comment": commentID, - "commenttext_edit": fixedContent - ] - - var request = URLRequest(url: galleryURL) - request.httpMethod = "POST" - request.httpBody = params.dictString().urlEncoded.data(using: .utf8) - request.setURLEncodedContentType() - - return URLSession.shared.dataTaskPublisher(for: request) - .genericRetry() - .map { $0 } - .mapError(mapAppError) - .eraseToAnyPublisher() - } -} - -struct VoteGalleryCommentRequest: Request { - let apiuid: Int - let apikey: String - let gid: Int - let token: String - let commentID: Int - let commentVote: Int - - var publisher: AnyPublisher { - let params: [String: Any] = [ - "method": "votecomment", - "apiuid": apiuid, - "apikey": apikey, - "gid": gid, - "token": token, - "comment_id": commentID, - "comment_vote": commentVote - ] - - var request = URLRequest(url: Defaults.URL.api) - request.httpMethod = "POST" - request.httpBody = try? JSONSerialization.data(withJSONObject: params, options: []) - - return URLSession.shared.dataTaskPublisher(for: request) - .genericRetry() - .map { $0 } - .mapError(mapAppError) - .eraseToAnyPublisher() - } -} - -struct VoteGalleryTagRequest: Request { - let apiuid: Int - let apikey: String - let gid: Int - let token: String - let tag: String - let vote: Int - - var publisher: AnyPublisher { - let params: [String: Any] = [ - "method": "taggallery", - "apiuid": apiuid, - "apikey": apikey, - "gid": gid, - "token": token, - "tags": tag, - "vote": vote - ] - - var request = URLRequest(url: Defaults.URL.api) - request.httpMethod = "POST" - request.httpBody = try? JSONSerialization.data(withJSONObject: params, options: []) - - return URLSession.shared.dataTaskPublisher(for: request) - .genericRetry() - .map { $0 } - .mapError(mapAppError) - .eraseToAnyPublisher() - } -} diff --git a/EhPanda/View/Detail/Comments/CommentsView.swift b/EhPanda/View/Detail/Comments/CommentsView.swift deleted file mode 100644 index 9a809df95..000000000 --- a/EhPanda/View/Detail/Comments/CommentsView.swift +++ /dev/null @@ -1,284 +0,0 @@ -// -// CommentsView.swift -// EhPanda -// - -import SwiftUI -import Kingfisher -import ComposableArchitecture - -struct CommentsView: View { - @Bindable private var store: StoreOf - 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: StoreOf, - gid: String, token: String, apiKey: String, galleryURL: URL, - comments: [GalleryComment], user: User, setting: Binding, - blurRadius: Double, tagTranslator: TagTranslator - ) { - self.store = 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: { store.send(.handleCommentLink($0)) } - ) - .opacity( - comment.commentID == store.scrollCommentID - ? store.scrollRowOpacity : 1 - ) - .swipeActions(edge: .leading) { - if comment.votable { - Button { - store.send(.voteComment(gid, token, apiKey, comment.commentID, -1)) - } label: { - Image(systemSymbol: .handThumbsdown) - } - .tint(.red) - } - } - .swipeActions(edge: .trailing) { - if comment.votable { - Button { - store.send(.voteComment(gid, token, apiKey, comment.commentID, 1)) - } label: { - Image(systemSymbol: .handThumbsup) - } - .tint(.green) - } - if comment.editable { - Button { - store.send(.setCommentContent(comment.plainTextContent)) - store.send(.setNavigation(.postComment(comment.commentID))) - } label: { - Image(systemSymbol: .squareAndPencil) - } - } - } - } - .onAppear { - if let scrollCommentID = store.scrollCommentID { - DispatchQueue.main.asyncAfter(deadline: .now() + 0.75) { - withAnimation { - proxy.scrollTo(scrollCommentID, anchor: .top) - } - } - } - } - } - .sheet(item: $store.route.sending(\.setNavigation).postComment, id: \.self) { route in - let hasCommentID = !route.wrappedValue.isEmpty - PostCommentView( - title: hasCommentID - ? L10n.Localizable.PostCommentView.Title.editComment - : L10n.Localizable.PostCommentView.Title.postComment, - content: $store.commentContent, - isFocused: $store.postCommentFocused, - postAction: { - if hasCommentID { - store.send(.postComment(galleryURL, route.wrappedValue)) - } else { - store.send(.postComment(galleryURL)) - } - store.send(.setNavigation(nil)) - }, - cancelAction: { store.send(.setNavigation(nil)) }, - onAppearAction: { store.send(.onPostCommentAppear) } - ) - .accentColor(setting.accentColor) - .autoBlur(radius: blurRadius) - } - .progressHUD( - config: store.hudConfig, - unwrapping: $store.route, - case: \.hud - ) - .animation(.default, value: store.scrollRowOpacity) - .onAppear { - store.send(.onAppear) - } - .background(navigationLink) - .toolbar(content: toolbar) - .navigationTitle(L10n.Localizable.CommentsView.Title.comments) - } - - private func toolbar() -> some ToolbarContent { - CustomToolbarItem { - Button { - store.send(.setNavigation(.postComment(""))) - } label: { - Image(systemSymbol: .squareAndPencil) - } - .disabled(!CookieUtil.didLogin) - } - } -} - -// MARK: NavigationLinks -private extension CommentsView { - @ViewBuilder var navigationLink: some View { - NavigationLink(unwrapping: $store.route, case: \.detail) { route in - DetailView( - store: store.scope(state: \.detailState.wrappedValue!, action: \.detail), - gid: route.wrappedValue, user: user, setting: $setting, - blurRadius: blurRadius, tagTranslator: tagTranslator - ) - } - } -} - -// 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.init), - gid: .init(), - token: .init(), - apiKey: .init(), - galleryURL: .mock, - comments: [], - user: .init(), - setting: .constant(.init()), - blurRadius: 0, - tagTranslator: .init() - ) - } - } -} diff --git a/EhPanda/View/Detail/DetailReducer.swift b/EhPanda/View/Detail/DetailReducer.swift deleted file mode 100644 index 5e6c98230..000000000 --- a/EhPanda/View/Detail/DetailReducer.swift +++ /dev/null @@ -1,486 +0,0 @@ -// -// DetailReducer.swift -// EhPanda -// - -import SwiftUI -import Foundation -import ComposableArchitecture - -@Reducer -struct DetailReducer { - @CasePathable - enum Route: Equatable { - case reading(EquatableVoid = .init()) - case archives(URL, URL) - case torrents(EquatableVoid = .init()) - case previews - case comments(URL) - case share(URL) - case postComment(EquatableVoid = .init()) - case newDawn(Greeting) - case detailSearch(String) - case tagDetail(TagDetail) - case galleryInfos(Gallery, GalleryDetail) - } - - private enum CancelID: CaseIterable { - case fetchDatabaseInfos, fetchGalleryDetail, rateGallery, favorGallery, unfavorGallery, postComment, voteTag - } - - @ObservableState - struct State: Equatable { - var route: Route? - var commentContent = "" - var postCommentFocused = false - - var showsNewDawnGreeting = false - var showsUserRating = false - var showsFullTitle = false - var userRating = 0 - - var apiKey = "" - var loadingState: LoadingState = .idle - var gallery: Gallery = .empty - var galleryDetail: GalleryDetail? - var galleryTags = [GalleryTag]() - var galleryPreviewURLs = [Int: URL]() - var galleryComments = [GalleryComment]() - - var readingState = ReadingReducer.State() - var archivesState = ArchivesReducer.State() - var torrentsState = TorrentsReducer.State() - var previewsState = PreviewsReducer.State() - var commentsState: Heap - var galleryInfosState = GalleryInfosReducer.State() - var detailSearchState: Heap - - init() { - commentsState = .init(nil) - detailSearchState = .init(nil) - } - - mutating func updateRating(value: DragGesture.Value) { - let rating = Int(value.location.x / 31 * 2) + 1 - userRating = min(max(rating, 1), 10) - } - } - - indirect enum Action: BindableAction { - case binding(BindingAction) - case setNavigation(Route?) - 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 voteTag(String, Int) - case anyGalleryOpsDone(Result) - - case reading(ReadingReducer.Action) - case archives(ArchivesReducer.Action) - case torrents(TorrentsReducer.Action) - case previews(PreviewsReducer.Action) - case comments(CommentsReducer.Action) - case galleryInfos(GalleryInfosReducer.Action) - case detailSearch(DetailSearchReducer.Action) - } - - @Dependency(\.databaseClient) private var databaseClient - @Dependency(\.hapticsClient) private var hapticsClient - @Dependency(\.cookieClient) private var cookieClient - - func coreReducer(self: Reduce) -> some Reducer { - Reduce { state, action in - switch action { - case .binding: - return .none - - case .setNavigation(let route): - state.route = route - return route == nil ? .send(.clearSubStates) : .none - - case .clearSubStates: - state.readingState = .init() - state.archivesState = .init() - state.torrentsState = .init() - state.previewsState = .init() - state.commentsState.wrappedValue = .init() - state.commentContent = .init() - state.postCommentFocused = false - state.galleryInfosState = .init() - state.detailSearchState.wrappedValue = .init() - return .merge( - .send(.reading(.teardown)), - .send(.archives(.teardown)), - .send(.torrents(.teardown)), - .send(.previews(.teardown)), - .send(.comments(.teardown)), - .send(.detailSearch(.teardown)) - ) - - case .onPostCommentAppear: - return .run { send in - try await Task.sleep(for: .milliseconds(750)) - await send(.setPostCommentFocused(true)) - } - - case .onAppear(let gid, let showsNewDawnGreeting): - state.showsNewDawnGreeting = showsNewDawnGreeting - if state.detailSearchState.wrappedValue == nil { - state.detailSearchState.wrappedValue = .init() - } - if state.commentsState.wrappedValue == nil { - state.commentsState.wrappedValue = .init() - } - return .send(.fetchDatabaseInfos(gid)) - - case .toggleShowFullTitle: - state.showsFullTitle.toggle() - return .run(operation: { _ in hapticsClient.generateFeedback(.soft) }) - - case .toggleShowUserRating: - state.showsUserRating.toggle() - return .run(operation: { _ in hapticsClient.generateFeedback(.soft) }) - - case .setCommentContent(let content): - state.commentContent = content - return .none - - case .setPostCommentFocused(let isFocused): - state.postCommentFocused = isFocused - return .none - - case .updateRating(let value): - state.updateRating(value: value) - return .none - - case .confirmRating(let value): - state.updateRating(value: value) - return .merge( - .send(.rateGallery), - .run(operation: { _ in hapticsClient.generateFeedback(.soft) }), - .run { send in - try await Task.sleep(for: .seconds(1)) - await send(.confirmRatingDone) - } - ) - - case .confirmRatingDone: - state.showsUserRating = false - return .none - - case .syncGalleryTags: - return .run { [state] _ in - await databaseClient.updateGalleryTags(gid: state.gallery.id, tags: state.galleryTags) - } - - case .syncGalleryDetail: - guard let detail = state.galleryDetail else { return .none } - return .run(operation: { _ in await databaseClient.cacheGalleryDetail(detail) }) - - case .syncGalleryPreviewURLs: - return .run { [state] _ in - await databaseClient - .updatePreviewURLs(gid: state.gallery.id, previewURLs: state.galleryPreviewURLs) - } - - case .syncGalleryComments: - return .run { [state] _ in - await databaseClient.updateComments(gid: state.gallery.id, comments: state.galleryComments) - } - - case .syncGreeting(let greeting): - return .run(operation: { _ in await databaseClient.updateGreeting(greeting) }) - - case .syncPreviewConfig(let config): - return .run { [state] _ in - await databaseClient.updatePreviewConfig(gid: state.gallery.id, config: config) - } - - case .saveGalleryHistory: - return .run { [state] _ in - await databaseClient.updateLastOpenDate(gid: state.gallery.id) - } - - case .updateReadingProgress(let progress): - return .run { [state] _ in - await databaseClient.updateReadingProgress(gid: state.gallery.id, progress: progress) - } - - case .teardown: - return .merge(CancelID.allCases.map(Effect.cancel(id:))) - - case .fetchDatabaseInfos(let gid): - guard let gallery = databaseClient.fetchGallery(gid: gid) else { return .none } - state.gallery = gallery - if let detail = databaseClient.fetchGalleryDetail(gid: gid) { - state.galleryDetail = detail - } - return .merge( - .send(.saveGalleryHistory), - .run { [galleryID = state.gallery.id] send in - guard let dbState = await databaseClient.fetchGalleryState(gid: galleryID) else { return } - await send(.fetchDatabaseInfosDone(dbState)) - } - .cancellable(id: CancelID.fetchDatabaseInfos) - ) - - case .fetchDatabaseInfosDone(let galleryState): - state.galleryTags = galleryState.tags - state.galleryPreviewURLs = galleryState.previewURLs - state.galleryComments = galleryState.comments - return .send(.fetchGalleryDetail) - - case .fetchGalleryDetail: - guard state.loadingState != .loading, - let galleryURL = state.gallery.galleryURL - else { return .none } - state.loadingState = .loading - return .run { [galleryID = state.gallery.id] send in - let response = await GalleryDetailRequest(gid: galleryID, galleryURL: galleryURL).response() - await send(.fetchGalleryDetailDone(response)) - } - .cancellable(id: CancelID.fetchGalleryDetail) - - case .fetchGalleryDetailDone(let result): - state.loadingState = .idle - switch result { - case .success(let (galleryDetail, galleryState, apiKey, greeting)): - var effects: [Effect] = [ - .send(.syncGalleryTags), - .send(.syncGalleryDetail), - .send(.syncGalleryPreviewURLs), - .send(.syncGalleryComments) - ] - state.apiKey = apiKey - state.galleryDetail = galleryDetail - state.galleryTags = galleryState.tags - state.galleryPreviewURLs = galleryState.previewURLs - state.galleryComments = galleryState.comments - state.userRating = Int(galleryDetail.userRating) * 2 - if let greeting = greeting { - effects.append(.send(.syncGreeting(greeting))) - if !greeting.gainedNothing && state.showsNewDawnGreeting { - effects.append(.send(.setNavigation(.newDawn(greeting)))) - } - } - if let config = galleryState.previewConfig { - effects.append(.send(.syncPreviewConfig(config))) - } - return .merge(effects) - case .failure(let error): - state.loadingState = .failed(error) - } - return .none - - case .rateGallery: - guard let apiuid = Int(cookieClient.apiuid), let gid = Int(state.gallery.id) - else { return .none } - return .run { [state] send in - let response = await RateGalleryRequest( - apiuid: apiuid, - apikey: state.apiKey, - gid: gid, - token: state.gallery.token, - rating: state.userRating - ) - .response() - await send(.anyGalleryOpsDone(response)) - }.cancellable(id: CancelID.rateGallery) - - case .favorGallery(let favIndex): - return .run { [state] send in - let response = await FavorGalleryRequest( - gid: state.gallery.id, - token: state.gallery.token, - favIndex: favIndex - ) - .response() - await send(.anyGalleryOpsDone(response)) - } - .cancellable(id: CancelID.favorGallery) - - case .unfavorGallery: - return .run { [galleryID = state.gallery.id] send in - let response = await UnfavorGalleryRequest(gid: galleryID).response() - await send(.anyGalleryOpsDone(response)) - } - .cancellable(id: CancelID.unfavorGallery) - - case .postComment(let galleryURL): - guard !state.commentContent.isEmpty else { return .none } - return .run { [commentContent = state.commentContent] send in - let response = await CommentGalleryRequest( - content: commentContent, galleryURL: galleryURL - ) - .response() - await send(.anyGalleryOpsDone(response)) - } - .cancellable(id: CancelID.postComment) - - case .voteTag(let tag, let vote): - guard let apiuid = Int(cookieClient.apiuid), let gid = Int(state.gallery.id) - else { return .none } - return .run { [state] send in - let response = await VoteGalleryTagRequest( - apiuid: apiuid, - apikey: state.apiKey, - gid: gid, - token: state.gallery.token, - tag: tag, - vote: vote - ) - .response() - await send(.anyGalleryOpsDone(response)) - } - .cancellable(id: CancelID.voteTag) - - case .anyGalleryOpsDone(let result): - if case .success = result { - return .merge( - .send(.fetchGalleryDetail), - .run(operation: { _ in hapticsClient.generateNotificationFeedback(.success) }) - ) - } - return .run(operation: { _ in hapticsClient.generateNotificationFeedback(.error) }) - - case .reading(.onPerformDismiss): - return .send(.setNavigation(nil)) - - case .reading: - return .none - - case .archives: - return .none - - case .torrents: - return .none - - case .previews: - return .none - - case .comments(.performCommentActionDone(let result)): - return .send(.anyGalleryOpsDone(result)) - - case .comments(.detail(let recursiveAction)): - guard state.commentsState.wrappedValue != nil else { return .none } - return self.reduce( - into: &state.commentsState.wrappedValue!.detailState.wrappedValue!, action: recursiveAction - ) - .map({ Action.comments(.detail($0)) }) - - case .comments: - return .none - - case .galleryInfos: - return .none - - case .detailSearch(.detail(let recursiveAction)): - guard state.detailSearchState.wrappedValue != nil else { return .none } - return self.reduce( - into: &state.detailSearchState.wrappedValue!.detailState.wrappedValue!, action: recursiveAction - ) - .map({ Action.detailSearch(.detail($0)) }) - - case .detailSearch: - return .none - } - } - .ifLet( - \.commentsState.wrappedValue, - action: \.comments, - then: CommentsReducer.init - ) - .ifLet( - \.detailSearchState.wrappedValue, - action: \.detailSearch, - then: DetailSearchReducer.init - ) - } - - func hapticsReducer( - @ReducerBuilder reducer: () -> some Reducer - ) -> some Reducer { - reducer() - .haptics( - unwrapping: \.route, - case: \.detailSearch, - hapticsClient: hapticsClient, - style: .soft - ) - .haptics( - unwrapping: \.route, - case: \.postComment, - hapticsClient: hapticsClient - ) - .haptics( - unwrapping: \.route, - case: \.tagDetail, - hapticsClient: hapticsClient - ) - .haptics( - unwrapping: \.route, - case: \.torrents, - hapticsClient: hapticsClient - ) - .haptics( - unwrapping: \.route, - case: \.archives, - hapticsClient: hapticsClient - ) - .haptics( - unwrapping: \.route, - case: \.reading, - hapticsClient: hapticsClient - ) - .haptics( - unwrapping: \.route, - case: \.share, - hapticsClient: hapticsClient - ) - } - - var body: some Reducer { - RecurseReducer { (self) in - BindingReducer() - .onChange(of: \.route) { _, newValue in - Reduce({ _, _ in newValue == nil ? .send(.clearSubStates) : .none }) - } - - coreReducer(self: self) - - Scope(state: \.readingState, action: \.reading, child: ReadingReducer.init) - Scope(state: \.archivesState, action: \.archives, child: ArchivesReducer.init) - Scope(state: \.torrentsState, action: \.torrents, child: TorrentsReducer.init) - Scope(state: \.previewsState, action: \.previews, child: PreviewsReducer.init) - Scope(state: \.galleryInfosState, action: \.galleryInfos, child: GalleryInfosReducer.init) - } - } -} diff --git a/EhPanda/View/Detail/DetailSearch/DetailSearchView.swift b/EhPanda/View/Detail/DetailSearch/DetailSearchView.swift deleted file mode 100644 index da4cccf59..000000000 --- a/EhPanda/View/Detail/DetailSearch/DetailSearchView.swift +++ /dev/null @@ -1,132 +0,0 @@ -// -// DetailSearchView.swift -// EhPanda -// - -import SwiftUI -import ComposableArchitecture - -struct DetailSearchView: View { - @Bindable private var store: StoreOf - private let keyword: String - private let user: User - @Binding private var setting: Setting - private let blurRadius: Double - private let tagTranslator: TagTranslator - - init( - store: StoreOf, - keyword: String, user: User, setting: Binding, blurRadius: Double, tagTranslator: TagTranslator - ) { - self.store = store - self.keyword = keyword - self.user = user - _setting = setting - self.blurRadius = blurRadius - self.tagTranslator = tagTranslator - } - - var body: some View { - let content = - GenericList( - galleries: store.galleries, - setting: setting, - pageNumber: store.pageNumber, - loadingState: store.loadingState, - footerLoadingState: store.footerLoadingState, - fetchAction: { store.send(.fetchGalleries()) }, - fetchMoreAction: { store.send(.fetchMoreGalleries) }, - navigateAction: { store.send(.setNavigation(.detail($0))) }, - translateAction: { - tagTranslator.lookup(word: $0, returnOriginal: !setting.translatesTags) - } - ) - .sheet(item: $store.route.sending(\.setNavigation).quickSearch) { _ in - QuickSearchView( - store: store.scope(state: \.quickDetailSearchState, action: \.quickSearch) - ) { keyword in - store.send(.setNavigation(nil)) - store.send(.fetchGalleries(keyword)) - } - .accentColor(setting.accentColor) - .autoBlur(radius: blurRadius) - } - .sheet(item: $store.route.sending(\.setNavigation).filters) { _ in - FiltersView(store: store.scope(state: \.filtersState, action: \.filters)) - .accentColor(setting.accentColor).autoBlur(radius: blurRadius) - } - .searchable(text: $store.keyword) - .searchSuggestions { - TagSuggestionView( - keyword: $store.keyword, translations: tagTranslator.translations, - showsImages: setting.showsImagesInTags, isEnabled: setting.showsTagsSearchSuggestion - ) - } - .onSubmit(of: .search) { - store.send(.fetchGalleries()) - } - .onAppear { - if store.galleries.isEmpty { - DispatchQueue.main.async { - store.send(.fetchGalleries(keyword)) - } - } - } - .background(navigationLink) - .toolbar(content: toolbar) - .navigationTitle(store.lastKeyword) - - if DeviceUtil.isPad { - content - .sheet(item: $store.route.sending(\.setNavigation).detail, id: \.self) { route in - NavigationView { - DetailView( - store: store.scope(state: \.detailState.wrappedValue!, action: \.detail), - gid: route.wrappedValue, user: user, setting: $setting, - blurRadius: blurRadius, tagTranslator: tagTranslator - ) - } - .autoBlur(radius: blurRadius).environment(\.inSheet, true).navigationViewStyle(.stack) - } - } else { - content - } - } - - @ViewBuilder private var navigationLink: some View { - if DeviceUtil.isPhone { - NavigationLink(unwrapping: $store.route, case: \.detail) { route in - DetailView( - store: store.scope(state: \.detailState.wrappedValue!, action: \.detail), - gid: route.wrappedValue, user: user, setting: $setting, - blurRadius: blurRadius, tagTranslator: tagTranslator - ) - } - } - } - private func toolbar() -> some ToolbarContent { - CustomToolbarItem { - ToolbarFeaturesMenu { - FiltersButton { - store.send(.setNavigation(.filters())) - } - QuickSearchButton { - store.send(.setNavigation(.quickSearch())) - } - } - } - } -} - -struct DetailSearchView_Previews: PreviewProvider { - static var previews: some View { - DetailSearchView( - store: .init(initialState: .init(), reducer: DetailSearchReducer.init), - 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 deleted file mode 100644 index 21d5d72fe..000000000 --- a/EhPanda/View/Detail/DetailView.swift +++ /dev/null @@ -1,906 +0,0 @@ -// -// DetailView.swift -// EhPanda -// - -import SwiftUI -import Kingfisher -import ComposableArchitecture -import CommonMark - -struct DetailView: View { - @Bindable private var store: StoreOf - private let gid: String - private let user: User - @Binding private var setting: Setting - private let blurRadius: Double - private let tagTranslator: TagTranslator - - init( - store: StoreOf, gid: String, - user: User, setting: Binding, blurRadius: Double, tagTranslator: TagTranslator - ) { - self.store = store - self.gid = gid - self.user = user - _setting = setting - self.blurRadius = blurRadius - self.tagTranslator = tagTranslator - } - - var content: some View { - ZStack { - ScrollView(showsIndicators: false) { - let content = - VStack(spacing: 30) { - HeaderSection( - gallery: store.gallery, - galleryDetail: store.galleryDetail ?? .empty, - user: user, - displaysJapaneseTitle: setting.displaysJapaneseTitle, - showFullTitle: store.showsFullTitle, - showFullTitleAction: { store.send(.toggleShowFullTitle) }, - favorAction: { store.send(.favorGallery($0)) }, - unfavorAction: { store.send(.unfavorGallery) }, - navigateReadingAction: { store.send(.setNavigation(.reading())) }, - navigateUploaderAction: { - if let uploader = store.galleryDetail?.uploader { - let keyword = "uploader:" + "\"\(uploader)\"" - store.send(.setNavigation(.detailSearch(keyword))) - } - } - ) - .padding(.horizontal) - DescriptionSection( - gallery: store.gallery, - galleryDetail: store.galleryDetail ?? .empty, - navigateGalleryInfosAction: { - if let galleryDetail = store.galleryDetail { - store.send(.setNavigation(.galleryInfos(store.gallery, galleryDetail))) - } - } - ) - ActionSection( - galleryDetail: store.galleryDetail ?? .empty, - userRating: store.userRating, - showUserRating: store.showsUserRating, - showUserRatingAction: { store.send(.toggleShowUserRating) }, - updateRatingAction: { store.send(.updateRating($0)) }, - confirmRatingAction: { store.send(.confirmRating($0)) }, - navigateSimilarGalleryAction: { - if let trimmedTitle = store.galleryDetail?.trimmedTitle { - store.send(.setNavigation(.detailSearch(trimmedTitle))) - } - } - ) - if !store.galleryTags.isEmpty { - TagsSection( - tags: store.galleryTags, showsImages: setting.showsImagesInTags, - voteTagAction: { store.send(.voteTag($0, $1)) }, - navigateSearchAction: { store.send(.setNavigation(.detailSearch($0))) }, - navigateTagDetailAction: { store.send(.setNavigation(.tagDetail($0))) }, - translateAction: { tagTranslator.lookup(word: $0, returnOriginal: !setting.translatesTags) } - ) - .padding(.horizontal) - } - if !store.galleryPreviewURLs.isEmpty { - PreviewsSection( - pageCount: store.galleryDetail?.pageCount ?? 0, - previewURLs: store.galleryPreviewURLs, - navigatePreviewsAction: { store.send(.setNavigation(.previews)) }, - navigateReadingAction: { - store.send(.updateReadingProgress($0)) - store.send(.setNavigation(.reading())) - } - ) - } - CommentsSection( - comments: store.galleryComments, - navigateCommentAction: { - if let galleryURL = store.gallery.galleryURL { - store.send(.setNavigation(.comments(galleryURL))) - } - }, - navigatePostCommentAction: { store.send(.setNavigation(.postComment())) } - ) - } - .padding(.bottom, 20) - - if #available(iOS 18.0, *) { - content - .padding(.top, 25) - } else { - content - .padding(.top, -25) - } - } - .opacity(store.galleryDetail == nil ? 0 : 1) - - LoadingView() - .opacity( - store.galleryDetail == nil - && store.loadingState == .loading ? 1 : 0 - ) - - let error = store.loadingState.failed - let retryAction: () -> Void = { store.send(.fetchGalleryDetail) } - ErrorView(error: error ?? .unknown, action: error?.isRetryable != false ? retryAction : nil) - .opacity(store.galleryDetail == nil && error != nil ? 1 : 0) - } - } - - func modalModifiers(@ViewBuilder content: () -> Content) -> some View { - content() - .fullScreenCover(item: $store.route.sending(\.setNavigation).reading) { _ in - ReadingView( - store: store.scope(state: \.readingState, action: \.reading), - gid: gid, - setting: $setting, - blurRadius: blurRadius - ) - .accentColor(setting.accentColor) - .autoBlur(radius: blurRadius) - } - .sheet(item: $store.route.sending(\.setNavigation).archives, id: \.0.absoluteString) { urls in - let (galleryURL, archiveURL) = urls - ArchivesView( - store: store.scope(state: \.archivesState, action: \.archives), - gid: gid, - user: user, - galleryURL: galleryURL, - archiveURL: archiveURL - ) - .accentColor(setting.accentColor) - .autoBlur(radius: blurRadius) - } - .sheet(item: $store.route.sending(\.setNavigation).torrents) { _ in - TorrentsView( - store: store.scope(state: \.torrentsState, action: \.torrents), - gid: gid, - token: store.gallery.token, - blurRadius: blurRadius - ) - .accentColor(setting.accentColor) - .autoBlur(radius: blurRadius) - } - .sheet(item: $store.route.sending(\.setNavigation).share, id: \.absoluteString) { url in - ActivityView(activityItems: [url]) - .autoBlur(radius: blurRadius) - } - .sheet(item: $store.route.sending(\.setNavigation).postComment) { _ in - PostCommentView( - title: L10n.Localizable.PostCommentView.Title.postComment, - content: $store.commentContent, - isFocused: $store.postCommentFocused, - postAction: { - if let galleryURL = store.gallery.galleryURL { - store.send(.postComment(galleryURL)) - } - store.send(.setNavigation(nil)) - }, - cancelAction: { store.send(.setNavigation(nil)) }, - onAppearAction: { store.send(.onPostCommentAppear) } - ) - .accentColor(setting.accentColor) - .autoBlur(radius: blurRadius) - } - .sheet(item: $store.route.sending(\.setNavigation).newDawn) { greeting in - NewDawnView(greeting: greeting) - .autoBlur(radius: blurRadius) - } - .sheet(item: $store.route.sending(\.setNavigation).tagDetail, id: \.title) { detail in - TagDetailView(detail: detail) - .autoBlur(radius: blurRadius) - } - } - - var body: some View { - modalModifiers(content: { content }) - .animation(.default, value: store.showsUserRating) - .animation(.default, value: store.showsFullTitle) - .animation(.default, value: store.galleryDetail) - .onAppear { - DispatchQueue.main.async { - store.send(.onAppear(gid, setting.showsNewDawnGreeting)) - } - } - .background(navigationLinks) - .toolbar(content: toolbar) - } -} - -// MARK: NavigationLinks -private extension DetailView { - @ViewBuilder var navigationLinks: some View { - NavigationLink(unwrapping: $store.route, case: \.previews) { _ in - PreviewsView( - store: store.scope(state: \.previewsState, action: \.previews), - gid: gid, setting: $setting, blurRadius: blurRadius - ) - } - NavigationLink(unwrapping: $store.route, case: \.comments) { route in - if let commentStore = store.scope(state: \.commentsState.wrappedValue, action: \.comments) { - CommentsView( - store: commentStore, gid: gid, token: store.gallery.token, apiKey: store.apiKey, - galleryURL: route.wrappedValue, comments: store.galleryComments, user: user, - setting: $setting, blurRadius: blurRadius, - tagTranslator: tagTranslator - ) - } - } - NavigationLink(unwrapping: $store.route, case: \.detailSearch) { route in - if let detailSearchStore = store.scope(state: \.detailSearchState.wrappedValue, action: \.detailSearch) { - DetailSearchView( - store: detailSearchStore, keyword: route.wrappedValue, user: user, setting: $setting, - blurRadius: blurRadius, tagTranslator: tagTranslator - ) - } - } - NavigationLink(unwrapping: $store.route, case: \.galleryInfos) { route in - let (gallery, galleryDetail) = route.wrappedValue - GalleryInfosView( - store: store.scope(state: \.galleryInfosState, action: \.galleryInfos), - gallery: gallery, galleryDetail: galleryDetail - ) - } - } -} - -// MARK: ToolBar -private extension DetailView { - func toolbar() -> some ToolbarContent { - CustomToolbarItem { - ToolbarFeaturesMenu { - Button { - if let galleryURL = store.gallery.galleryURL, - let archiveURL = store.galleryDetail?.archiveURL - { - store.send(.setNavigation(.archives(galleryURL, archiveURL))) - } - } label: { - Label(L10n.Localizable.DetailView.ToolbarItem.Button.archives, systemSymbol: .docZipper) - } - .disabled(store.galleryDetail?.archiveURL == nil || !CookieUtil.didLogin) - Button { - store.send(.setNavigation(.torrents())) - } label: { - let base = L10n.Localizable.DetailView.ToolbarItem.Button.torrents - let torrentCount = store.galleryDetail?.torrentCount ?? 0 - let baseWithCount = [base, "(\(torrentCount))"].joined(separator: " ") - Label(torrentCount > 0 ? baseWithCount : base, systemSymbol: .leaf) - } - .disabled((store.galleryDetail?.torrentCount ?? 0 > 0) != true) - Button { - if let galleryURL = store.gallery.galleryURL { - store.send(.setNavigation(.share(galleryURL))) - } - } label: { - Label(L10n.Localizable.DetailView.ToolbarItem.Button.share, systemSymbol: .squareAndArrowUp) - } - } - .disabled(store.galleryDetail == nil || store.loadingState == .loading) - } - } -} - -// MARK: HeaderSection -private struct HeaderSection: View { - private let gallery: Gallery - private let galleryDetail: GalleryDetail - private let user: User - private let displaysJapaneseTitle: Bool - private let showFullTitle: Bool - private let showFullTitleAction: () -> Void - private let favorAction: (Int) -> Void - private let unfavorAction: () -> Void - private let navigateReadingAction: () -> Void - private let navigateUploaderAction: () -> Void - - init( - gallery: Gallery, galleryDetail: GalleryDetail, - user: User, displaysJapaneseTitle: Bool, showFullTitle: Bool, - showFullTitleAction: @escaping () -> Void, - favorAction: @escaping (Int) -> Void, - unfavorAction: @escaping () -> Void, - navigateReadingAction: @escaping () -> Void, - navigateUploaderAction: @escaping () -> Void - ) { - self.gallery = gallery - self.galleryDetail = galleryDetail - self.user = user - self.displaysJapaneseTitle = displaysJapaneseTitle - self.showFullTitle = showFullTitle - self.showFullTitleAction = showFullTitleAction - self.favorAction = favorAction - self.unfavorAction = unfavorAction - self.navigateReadingAction = navigateReadingAction - self.navigateUploaderAction = navigateUploaderAction - } - - private var title: String { - let normalTitle = galleryDetail.title - return displaysJapaneseTitle ? galleryDetail.jpnTitle ?? normalTitle : normalTitle - } - - var body: some View { - HStack { - KFImage(gallery.coverURL) - .placeholder({ Placeholder(style: .activity(ratio: Defaults.ImageSize.headerAspect)) }) - .defaultModifier() - .scaledToFit() - .frame( - width: Defaults.ImageSize.headerW, - height: Defaults.ImageSize.headerH - ) - - VStack(alignment: .leading) { - Button(action: showFullTitleAction) { - Text(title) - .font(.title3.bold()) - .multilineTextAlignment(.leading) - .tint(.primary) - .lineLimit(showFullTitle ? nil : 3) - .fixedSize(horizontal: false, vertical: true) - } - - Button(gallery.uploader ?? "", action: navigateUploaderAction) - .lineLimit(1) - .font(.callout) - .foregroundStyle(.secondary) - - Spacer() - - HStack { - CategoryLabel( - text: gallery.category.value, - color: gallery.color, - font: .headline, - insets: .init(top: 2, leading: 4, bottom: 2, trailing: 4), - cornerRadius: 3 - ) - - Spacer() - - ZStack { - Button(action: unfavorAction) { - Image(systemSymbol: .heartFill) - } - .opacity(galleryDetail.isFavorited ? 1 : 0) - - Menu { - ForEach(0..<10) { index in - Button(user.getFavoriteCategory(index: index)) { - favorAction(index) - } - } - } label: { - Image(systemSymbol: .heart) - } - .opacity(galleryDetail.isFavorited ? 0 : 1) - } - .imageScale(.large) - .foregroundStyle(.tint) - .buttonStyle(.glass(.regular.interactive())) - .disabled(!CookieUtil.didLogin) - - Button(action: navigateReadingAction) { - Text(L10n.Localizable.DetailView.Button.read) - .bold().textCase(.uppercase).font(.headline) - .foregroundColor(.white).padding(.vertical, -2) - .padding(.horizontal, 2).lineLimit(1) - } - .buttonStyle(.glassProminent) - .buttonBorderShape(.capsule) - } - .minimumScaleFactor(0.5) - } - .padding(.horizontal, 10) - .frame(minHeight: Defaults.ImageSize.headerH) - } - } -} - -// MARK: DescriptionSection -private struct DescriptionSection: View { - private let gallery: Gallery - private let galleryDetail: GalleryDetail - private let navigateGalleryInfosAction: () -> Void - - init( - gallery: Gallery, galleryDetail: GalleryDetail, - navigateGalleryInfosAction: @escaping () -> Void - ) { - self.gallery = gallery - self.galleryDetail = galleryDetail - self.navigateGalleryInfosAction = navigateGalleryInfosAction - } - - private var infos: [DescScrollInfo] {[ - DescScrollInfo( - title: L10n.Localizable.DetailView.DescriptionSection.Title.favorited, - description: L10n.Localizable.DetailView.DescriptionSection.Description.favorited, - value: .init(galleryDetail.favoritedCount) - ), - DescScrollInfo( - title: L10n.Localizable.DetailView.DescriptionSection.Title.language, - description: galleryDetail.language.value, - value: galleryDetail.language.abbreviation - ), - DescScrollInfo( - title: L10n.Localizable.DetailView.DescriptionSection.Title.ratings("\(galleryDetail.ratingCount)"), - description: .init(), value: .init(), rating: galleryDetail.rating, isRating: true - ), - DescScrollInfo( - title: L10n.Localizable.DetailView.DescriptionSection.Title.pageCount, - description: L10n.Localizable.DetailView.DescriptionSection.Description.pageCount, - value: .init(galleryDetail.pageCount) - ), - DescScrollInfo( - title: L10n.Localizable.DetailView.DescriptionSection.Title.fileSize, - description: galleryDetail.sizeType, value: .init(galleryDetail.sizeCount) - ) - ]} - private var itemWidth: Double { - max(DeviceUtil.absWindowW / 5, 80) - } - - var body: some View { - ScrollView(.horizontal, showsIndicators: false) { - HStack { - ForEach(infos) { info in - Group { - if info.isRating { - DescScrollRatingItem(title: info.title, rating: info.rating) - } else { - DescScrollItem(title: info.title, value: info.value, description: info.description) - } - } - .frame(width: itemWidth).drawingGroup() - Divider() - if info == infos.last { - Button(action: navigateGalleryInfosAction) { - Image(systemSymbol: .ellipsis) - .font(.system(size: 20, weight: .bold)) - } - .frame(width: itemWidth) - } - } - .withHorizontalSpacing() - } - } - .frame(height: 60) - } -} - -private extension DescriptionSection { - struct DescScrollInfo: Identifiable, Equatable { - var id: String { title } - - let title: String - let description: String - let value: String - var rating: Float = 0 - var isRating = false - } - struct DescScrollItem: View { - private let title: String - private let value: String - private let description: String - - init(title: String, value: String, description: String) { - self.title = title - self.value = value - self.description = description - } - - var body: some View { - VStack(spacing: 3) { - Text(title).textCase(.uppercase).font(.caption) - Text(value).fontWeight(.medium).font(.title3).lineLimit(1) - Text(description).font(.caption) - } - } - } - struct DescScrollRatingItem: View { - private let title: String - private let rating: Float - - init(title: String, rating: Float) { - self.title = title - self.rating = rating - } - - var body: some View { - VStack(spacing: 3) { - Text(title).textCase(.uppercase).font(.caption).lineLimit(1) - Text(String(format: "%.2f", rating)).fontWeight(.medium).font(.title3) - RatingView(rating: rating).font(.system(size: 12)).foregroundStyle(.primary) - } - } - } -} - -// MARK: ActionSection -private struct ActionSection: View { - private let galleryDetail: GalleryDetail - private let userRating: Int - private let showUserRating: Bool - private let showUserRatingAction: () -> Void - private let updateRatingAction: (DragGesture.Value) -> Void - private let confirmRatingAction: (DragGesture.Value) -> Void - private let navigateSimilarGalleryAction: () -> Void - - init( - galleryDetail: GalleryDetail, - userRating: Int, showUserRating: Bool, - showUserRatingAction: @escaping () -> Void, - updateRatingAction: @escaping (DragGesture.Value) -> Void, - confirmRatingAction: @escaping (DragGesture.Value) -> Void, - navigateSimilarGalleryAction: @escaping () -> Void - ) { - self.galleryDetail = galleryDetail - self.userRating = userRating - self.showUserRating = showUserRating - self.showUserRatingAction = showUserRatingAction - self.updateRatingAction = updateRatingAction - self.confirmRatingAction = confirmRatingAction - self.navigateSimilarGalleryAction = navigateSimilarGalleryAction - } - - var body: some View { - VStack { - HStack { - Group { - Button(action: showUserRatingAction) { - Spacer() - Image(systemSymbol: .squareAndPencil) - Text(L10n.Localizable.DetailView.ActionSection.Button.giveARating).bold() - Spacer() - } - .disabled(!CookieUtil.didLogin) - Button(action: navigateSimilarGalleryAction) { - Spacer() - Image(systemSymbol: .photoOnRectangleAngled) - Text(L10n.Localizable.DetailView.ActionSection.Button.similarGallery).bold() - Spacer() - } - } - .font(.callout).foregroundStyle(.primary) - } - if showUserRating { - HStack { - RatingView(rating: Float(userRating) / 2) - .font(.system(size: 24)) - .foregroundStyle(.yellow) - .gesture( - DragGesture(minimumDistance: 0) - .onChanged(updateRatingAction) - .onEnded(confirmRatingAction) - ) - } - .padding(.top, 10) - } - } - .padding(.horizontal) - } -} - -// MARK: TagsSection -private struct TagsSection: View { - private let tags: [GalleryTag] - private let showsImages: Bool - private let voteTagAction: (String, Int) -> Void - private let navigateSearchAction: (String) -> Void - private let navigateTagDetailAction: (TagDetail) -> Void - private let translateAction: (String) -> (String, TagTranslation?) - - init( - tags: [GalleryTag], showsImages: Bool, - voteTagAction: @escaping (String, Int) -> Void, - navigateSearchAction: @escaping (String) -> Void, - navigateTagDetailAction: @escaping (TagDetail) -> Void, - translateAction: @escaping (String) -> (String, TagTranslation?) - ) { - self.tags = tags - self.showsImages = showsImages - self.voteTagAction = voteTagAction - self.navigateSearchAction = navigateSearchAction - self.navigateTagDetailAction = navigateTagDetailAction - self.translateAction = translateAction - } - - var body: some View { - VStack(alignment: .leading) { - ForEach(tags) { tag in - TagRow( - tag: tag, showsImages: showsImages, - voteTagAction: voteTagAction, - navigateSearchAction: navigateSearchAction, - navigateTagDetailAction: navigateTagDetailAction, - translateAction: translateAction - ) - } - } - .padding(.horizontal) - } -} - -private extension TagsSection { - struct TagRow: View { - @Environment(\.colorScheme) private var colorScheme - @Environment(\.inSheet) private var inSheet - - private let tag: GalleryTag - private let showsImages: Bool - private let voteTagAction: (String, Int) -> Void - private let navigateSearchAction: (String) -> Void - private let navigateTagDetailAction: (TagDetail) -> Void - private let translateAction: (String) -> (String, TagTranslation?) - - init( - tag: GalleryTag, showsImages: Bool, - voteTagAction: @escaping (String, Int) -> Void, - navigateSearchAction: @escaping (String) -> Void, - navigateTagDetailAction: @escaping (TagDetail) -> Void, - translateAction: @escaping (String) -> (String, TagTranslation?) - ) { - self.tag = tag - self.showsImages = showsImages - self.voteTagAction = voteTagAction - self.navigateSearchAction = navigateSearchAction - self.navigateTagDetailAction = navigateTagDetailAction - self.translateAction = translateAction - } - - private var reversedPrimary: Color { - colorScheme == .light ? .white : .black - } - private var backgroundColor: Color { - inSheet && colorScheme == .dark ? Color(.systemGray4) : Color(.systemGray5) - } - private var padding: EdgeInsets { - .init(top: 5, leading: 14, bottom: 5, trailing: 14) - } - - var body: some View { - HStack(alignment: .top) { - Text(tag.namespace?.value ?? tag.rawNamespace).font(.subheadline.bold()) - .foregroundColor(reversedPrimary).padding(padding) - .background(Color(.systemGray)).cornerRadius(5) - TagCloudView(data: tag.contents) { content in - let (_, translation) = translateAction(content.rawNamespace + content.text) - Button { - navigateSearchAction(content.serachKeyword(tag: tag)) - } label: { - TagCloudCell( - text: translation?.displayValue ?? content.text, - imageURL: translation?.valueImageURL, - showsImages: showsImages, - font: .subheadline, padding: padding, textColor: .primary, - backgroundColor: backgroundColor - ) - } - .contextMenu { - if let translation = translation, - let description = translation.descriptionPlainText, - !description.isEmpty - { - Button { - navigateTagDetailAction(.init( - title: translation.displayValue, description: description, - imageURLs: translation.descriptionImageURLs, - links: translation.links - )) - } label: { - Image(systemSymbol: .docRichtext) - Text(L10n.Localizable.DetailView.ContextMenu.Button.detail) - } - } - if CookieUtil.didLogin { - if content.isVotedUp || content.isVotedDown { - Button { - voteTagAction(content.voteKeyword(tag: tag), content.isVotedUp ? -1 : 1) - } label: { - Image(systemSymbol: content.isVotedUp ? .handThumbsup : .handThumbsdown) - .symbolVariant(.fill) - Text(L10n.Localizable.DetailView.ContextMenu.Button.withdrawVote) - } - } else { - Button { - voteTagAction(content.voteKeyword(tag: tag), 1) - } label: { - Image(systemSymbol: .handThumbsup) - Text(L10n.Localizable.DetailView.ContextMenu.Button.voteUp) - } - Button { - voteTagAction(content.voteKeyword(tag: tag), -1) - } label: { - Image(systemSymbol: .handThumbsdown) - Text(L10n.Localizable.DetailView.ContextMenu.Button.voteDown) - } - } - } - } - } - } - } - } -} - -// MARK: PreviewSection -private struct PreviewsSection: View { - private let pageCount: Int - private let previewURLs: [Int: URL] - private let navigatePreviewsAction: () -> Void - private let navigateReadingAction: (Int) -> Void - - init( - pageCount: Int, previewURLs: [Int: URL], - navigatePreviewsAction: @escaping () -> Void, - navigateReadingAction: @escaping (Int) -> Void - ) { - self.pageCount = pageCount - self.previewURLs = previewURLs - self.navigatePreviewsAction = navigatePreviewsAction - self.navigateReadingAction = navigateReadingAction - } - - private var width: CGFloat { - Defaults.ImageSize.previewAvgW - } - private var height: CGFloat { - width / Defaults.ImageSize.previewAspect - } - - var body: some View { - SubSection( - title: L10n.Localizable.DetailView.Section.Title.previews, - showAll: pageCount > 20, showAllAction: navigatePreviewsAction - ) { - ScrollView(.horizontal, showsIndicators: false) { - LazyHStack { - ForEach(previewURLs.tuples.sorted(by: { $0.0 < $1.0 }), id: \.0) { index, previewURL in - let (url, modifier) = PreviewResolver.getPreviewConfigs(originalURL: previewURL) - Button { - navigateReadingAction(index) - } label: { - KFImage.url(url, cacheKey: previewURL.absoluteString) - .placeholder { Placeholder(style: .activity(ratio: Defaults.ImageSize.previewAspect)) } - .imageModifier(modifier).fade(duration: 0.25).resizable().scaledToFit() - .frame(width: width, height: height) - } - } - .withHorizontalSpacing(height: height) - } - } - } - } -} - -// MARK: CommentsSection -private struct CommentsSection: View { - @Environment(\.colorScheme) private var colorScheme - @Environment(\.inSheet) private var inSheet - - private let comments: [GalleryComment] - private let navigateCommentAction: () -> Void - private let navigatePostCommentAction: () -> Void - - init( - comments: [GalleryComment], - navigateCommentAction: @escaping () -> Void, - navigatePostCommentAction: @escaping () -> Void - ) { - self.comments = comments - self.navigateCommentAction = navigateCommentAction - self.navigatePostCommentAction = navigatePostCommentAction - } - - private var backgroundColor: Color { - inSheet && colorScheme == .dark ? Color(.systemGray5) : Color(.systemGray6) - } - - var body: some View { - SubSection( - title: L10n.Localizable.DetailView.Section.Title.comments, - showAll: !comments.isEmpty, showAllAction: navigateCommentAction - ) { - ScrollView(.horizontal, showsIndicators: false) { - HStack { - ForEach(comments.prefix(min(comments.count, 6))) { comment in - CommentCell(comment: comment, backgroundColor: backgroundColor) - } - .withHorizontalSpacing() - } - .drawingGroup() - } - CommentButton(backgroundColor: backgroundColor, action: navigatePostCommentAction) - .padding(.horizontal).disabled(!CookieUtil.didLogin) - } - } -} - -private struct CommentCell: View { - private let comment: GalleryComment - private let backgroundColor: Color - - init(comment: GalleryComment, backgroundColor: Color) { - self.comment = comment - self.backgroundColor = backgroundColor - } - - private var content: String { - comment.contents - .filter({ [.plainText, .linkedText].contains($0.type) }) - .compactMap(\.text).joined() - } - - var body: some View { - VStack(alignment: .leading) { - HStack { - Text(comment.author).font(.subheadline.bold()) - Spacer() - Group { - ZStack { - Image(systemSymbol: .handThumbsupFill) - .opacity(comment.votedUp ? 1 : 0) - Image(systemSymbol: .handThumbsdownFill) - .opacity(comment.votedDown ? 1 : 0) - } - Text(comment.score ?? "") - Text(comment.formattedDateString).lineLimit(1) - } - .font(.footnote).foregroundStyle(.secondary) - } - .minimumScaleFactor(0.75).lineLimit(1) - Text(content).padding(.top, 1) - Spacer() - } - .padding().background(backgroundColor) - .frame(width: 300, height: 120) - .cornerRadius(15) - } -} - -private struct CommentButton: View { - private let backgroundColor: Color - private let action: () -> Void - - init(backgroundColor: Color, action: @escaping () -> Void) { - self.backgroundColor = backgroundColor - self.action = action - } - - var body: some View { - let shape = RoundedRectangle(cornerRadius: 15) - - Button(action: action) { - HStack { - Image(systemSymbol: .squareAndPencil) - - Text(L10n.Localizable.DetailView.Button.postComment) - .bold() - } - .padding() - .frame(maxWidth: .infinity) - .background(backgroundColor) - .clipShape(shape) - } - .glassEffect(.clear.interactive(), in: shape) - } -} - -struct DetailView_Previews: PreviewProvider { - static var previews: some View { - NavigationView { - DetailView( - store: .init(initialState: .init(), reducer: DetailReducer.init), - gid: .init(), - user: .init(), - setting: .constant(.init()), - blurRadius: 0, - tagTranslator: .init() - ) - } - } -} diff --git a/EhPanda/View/Detail/GalleryInfos/GalleryInfosReducer.swift b/EhPanda/View/Detail/GalleryInfos/GalleryInfosReducer.swift deleted file mode 100644 index 4f0d4f05f..000000000 --- a/EhPanda/View/Detail/GalleryInfos/GalleryInfosReducer.swift +++ /dev/null @@ -1,47 +0,0 @@ -// -// GalleryInfosReducer.swift -// EhPanda -// - -import TTProgressHUD -import ComposableArchitecture - -@Reducer -struct GalleryInfosReducer { - @CasePathable - enum Route { - case hud - } - - @ObservableState - struct State: Equatable { - var route: Route? - var hudConfig: TTProgressHUDConfig = .copiedToClipboardSucceeded - } - - enum Action: BindableAction, Equatable { - case binding(BindingAction) - case copyText(String) - } - - @Dependency(\.clipboardClient) private var clipboardClient - @Dependency(\.hapticsClient) private var hapticsClient - - var body: some Reducer { - BindingReducer() - - Reduce { state, action in - switch action { - case .binding: - return .none - - case .copyText(let text): - state.route = .hud - return .merge( - .run(operation: { _ in clipboardClient.saveText(text) }), - .run(operation: { _ in hapticsClient.generateNotificationFeedback(.success) }) - ) - } - } - } -} diff --git a/EhPanda/View/Detail/Previews/PreviewsReducer.swift b/EhPanda/View/Detail/Previews/PreviewsReducer.swift deleted file mode 100644 index fa41832d0..000000000 --- a/EhPanda/View/Detail/Previews/PreviewsReducer.swift +++ /dev/null @@ -1,152 +0,0 @@ -// -// PreviewsReducer.swift -// EhPanda -// - -import Foundation -import ComposableArchitecture - -@Reducer -struct PreviewsReducer { - @CasePathable - enum Route: Equatable { - case reading(EquatableVoid = .init()) - } - - private enum CancelID: CaseIterable { - case fetchDatabaseInfos, fetchPreviewURLs - } - - @ObservableState - struct State: Equatable { - var route: Route? - - var gallery: Gallery = .empty - var loadingState: LoadingState = .idle - var databaseLoadingState: LoadingState = .loading - - var previewURLs = [Int: URL]() - var previewConfig: PreviewConfig = .normal(rows: 4) - - var readingState = ReadingReducer.State() - - mutating func updatePreviewURLs(_ previewURLs: [Int: URL]) { - self.previewURLs = self.previewURLs.merging( - previewURLs, uniquingKeysWith: { stored, _ in stored } - ) - } - } - - enum Action: BindableAction { - case binding(BindingAction) - case setNavigation(Route?) - case clearSubStates - - case syncPreviewURLs([Int: URL]) - case updateReadingProgress(Int) - - case teardown - case fetchDatabaseInfos(String) - case fetchDatabaseInfosDone(GalleryState) - case fetchPreviewURLs(Int) - case fetchPreviewURLsDone(Result<[Int: URL], AppError>) - - case reading(ReadingReducer.Action) - } - - @Dependency(\.databaseClient) private var databaseClient - @Dependency(\.hapticsClient) private var hapticsClient - - var body: some Reducer { - BindingReducer() - .onChange(of: \.route) { _, newValue in - Reduce({ _, _ in newValue == nil ? .send(.clearSubStates) : .none }) - } - - Reduce { state, action in - switch action { - case .binding: - return .none - - case .setNavigation(let route): - state.route = route - return route == nil ? .send(.clearSubStates) : .none - - case .clearSubStates: - state.readingState = .init() - return .send(.reading(.teardown)) - - case .syncPreviewURLs(let previewURLs): - return .run { [state] _ in - await databaseClient.updatePreviewURLs(gid: state.gallery.id, previewURLs: previewURLs) - } - - case .updateReadingProgress(let progress): - return .run { [state] _ in - await databaseClient.updateReadingProgress(gid: state.gallery.id, progress: progress) - } - - case .teardown: - return .merge(CancelID.allCases.map(Effect.cancel(id:))) - - case .fetchDatabaseInfos(let gid): - guard let gallery = databaseClient.fetchGallery(gid: gid) else { return .none } - state.gallery = gallery - return .run { [state] send in - guard let dbState = await databaseClient.fetchGalleryState(gid: state.gallery.id) else { return } - await send(.fetchDatabaseInfosDone(dbState)) - } - .cancellable(id: CancelID.fetchDatabaseInfos) - - case .fetchDatabaseInfosDone(let galleryState): - if let previewConfig = galleryState.previewConfig { - state.previewConfig = previewConfig - } - state.previewURLs = galleryState.previewURLs - state.databaseLoadingState = .idle - return .none - - 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 .run { send in - let response = await GalleryPreviewURLsRequest(galleryURL: galleryURL, pageNum: pageNum).response() - await send(.fetchPreviewURLsDone(response)) - } - .cancellable(id: CancelID.fetchPreviewURLs) - - 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 .send(.syncPreviewURLs(previewURLs)) - case .failure(let error): - state.loadingState = .failed(error) - } - return .none - - case .reading(.onPerformDismiss): - return .send(.setNavigation(nil)) - - case .reading: - return .none - } - } - .haptics( - unwrapping: \.route, - case: \.reading, - hapticsClient: hapticsClient - ) - - Scope(state: \.readingState, action: \.reading, child: ReadingReducer.init) - } -} diff --git a/EhPanda/View/Favorites/FavoritesReducer.swift b/EhPanda/View/Favorites/FavoritesReducer.swift deleted file mode 100644 index 0ccf39e5f..000000000 --- a/EhPanda/View/Favorites/FavoritesReducer.swift +++ /dev/null @@ -1,201 +0,0 @@ -// -// FavoritesReducer.swift -// EhPanda -// - -import SwiftUI -import IdentifiedCollections -import ComposableArchitecture - -@Reducer -struct FavoritesReducer { - @CasePathable - enum Route: Equatable { - case quickSearch(EquatableVoid = .init()) - case detail(String) - } - - @ObservableState - struct State: Equatable { - var route: Route? - var keyword = "" - - 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] - } - - var detailState: Heap - var quickSearchState = QuickSearchReducer.State() - - init() { - detailState = .init(.init()) - } - - mutating func insertGalleries(index: Int, galleries: [Gallery]) { - galleries.forEach { gallery in - if rawGalleries[index]?.contains(gallery) == false { - rawGalleries[index]?.append(gallery) - } - } - } - } - - enum Action: BindableAction { - case binding(BindingAction) - case setNavigation(Route?) - case setFavoritesIndex(Int) - case clearSubStates - case onNotLoginViewButtonTapped - - case fetchGalleries(String? = nil, FavoritesSortOrder? = nil) - case fetchGalleriesDone(Int, Result<(PageNumber, FavoritesSortOrder?, [Gallery]), AppError>) - case fetchMoreGalleries - case fetchMoreGalleriesDone(Int, Result<(PageNumber, FavoritesSortOrder?, [Gallery]), AppError>) - - case detail(DetailReducer.Action) - case quickSearch(QuickSearchReducer.Action) - } - - @Dependency(\.databaseClient) private var databaseClient - @Dependency(\.hapticsClient) private var hapticsClient - - var body: some Reducer { - BindingReducer() - .onChange(of: \.route) { _, newValue in - Reduce({ _, _ in newValue == nil ? .send(.clearSubStates) : .none }) - } - - Reduce { state, action in - switch action { - case .binding: - return .none - - case .setNavigation(let route): - state.route = route - return route == nil ? .send(.clearSubStates) : .none - - case .setFavoritesIndex(let index): - state.index = index - guard state.galleries?.isEmpty != false else { return .none } - return .send(.fetchGalleries()) - - case .clearSubStates: - state.detailState.wrappedValue = .init() - return .send(.detail(.teardown)) - - case .onNotLoginViewButtonTapped: - return .none - - case .fetchGalleries(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]?.resetPages() - } - return .run { [state] send in - let response = await FavoritesGalleriesRequest( - favIndex: state.index, keyword: state.keyword, sortOrder: sortOrder - ) - .response() - await send(.fetchGalleriesDone(state.index, response)) - } - - case .fetchGalleriesDone(let targetFavIndex, let result): - state.rawLoadingState[targetFavIndex] = .idle - switch result { - case .success(let (pageNumber, sortOrder, galleries)): - guard !galleries.isEmpty else { - state.rawLoadingState[targetFavIndex] = .failed(.notFound) - guard pageNumber.hasNextPage() else { return .none } - return .send(.fetchMoreGalleries) - } - state.rawPageNumber[targetFavIndex] = pageNumber - state.rawGalleries[targetFavIndex] = galleries - state.sortOrder = sortOrder - return .run(operation: { _ in await databaseClient.cacheGalleries(galleries) }) - case .failure(let error): - state.rawLoadingState[targetFavIndex] = .failed(error) - } - return .none - - case .fetchMoreGalleries: - let pageNumber = state.pageNumber ?? .init() - guard pageNumber.hasNextPage(), - state.footerLoadingState != .loading, - let lastID = state.galleries?.last?.id, - let lastItemTimestamp = pageNumber.lastItemTimestamp - else { return .none } - state.rawFooterLoadingState[state.index] = .loading - return .run { [state] send in - let response = await MoreFavoritesGalleriesRequest( - favIndex: state.index, - lastID: lastID, - lastTimestamp: lastItemTimestamp, - keyword: state.keyword - ) - .response() - await send(.fetchMoreGalleriesDone(state.index, response)) - } - - 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] = [ - .run(operation: { _ in await databaseClient.cacheGalleries(galleries) }) - ] - if galleries.isEmpty, pageNumber.hasNextPage() { - effects.append(.send(.fetchMoreGalleries)) - } else if !galleries.isEmpty { - state.rawLoadingState[targetFavIndex] = .idle - } - 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: \.quickSearch, - hapticsClient: hapticsClient - ) - - Scope(state: \.detailState.wrappedValue!, action: \.detail, child: DetailReducer.init) - Scope(state: \.quickSearchState, action: \.quickSearch, child: QuickSearchReducer.init) - } -} diff --git a/EhPanda/View/Favorites/FavoritesView.swift b/EhPanda/View/Favorites/FavoritesView.swift deleted file mode 100644 index b864bd9d3..000000000 --- a/EhPanda/View/Favorites/FavoritesView.swift +++ /dev/null @@ -1,144 +0,0 @@ -// -// FavoritesView.swift -// EhPanda -// - -import SwiftUI -import AlertKit -import ComposableArchitecture - -struct FavoritesView: View { - @Bindable private var store: StoreOf - private let user: User - @Binding private var setting: Setting - private let blurRadius: Double - private let tagTranslator: TagTranslator - - init( - store: StoreOf, - user: User, setting: Binding, blurRadius: Double, tagTranslator: TagTranslator - ) { - self.store = store - self.user = user - _setting = setting - self.blurRadius = blurRadius - self.tagTranslator = tagTranslator - } - - private var navigationTitle: String { - let favoriteCategory = user.getFavoriteCategory(index: store.index) - return (store.index == -1 ? L10n.Localizable.FavoritesView.Title.favorites : favoriteCategory) - } - - var body: some View { - NavigationView { - let content = - ZStack { - if CookieUtil.didLogin { - GenericList( - galleries: store.galleries ?? [], - setting: setting, - pageNumber: store.pageNumber, - loadingState: store.loadingState ?? .idle, - footerLoadingState: store.footerLoadingState ?? .idle, - fetchAction: { store.send(.fetchGalleries()) }, - fetchMoreAction: { store.send(.fetchMoreGalleries) }, - navigateAction: { store.send(.setNavigation(.detail($0))) }, - translateAction: { - tagTranslator.lookup(word: $0, returnOriginal: !setting.translatesTags) - } - ) - } else { - NotLoginView(action: { store.send(.onNotLoginViewButtonTapped) }) - } - } - .sheet(item: $store.route.sending(\.setNavigation).quickSearch) { _ in - QuickSearchView( - store: store.scope(state: \.quickSearchState, action: \.quickSearch) - ) { keyword in - store.send(.setNavigation(nil)) - store.send(.fetchGalleries(keyword)) - } - .accentColor(setting.accentColor) - .autoBlur(radius: blurRadius) - } - .searchable(text: $store.keyword) - .searchSuggestions { - TagSuggestionView( - keyword: $store.keyword, translations: tagTranslator.translations, - showsImages: setting.showsImagesInTags, isEnabled: setting.showsTagsSearchSuggestion - ) - } - .onSubmit(of: .search) { - store.send(.fetchGalleries()) - } - .onAppear { - if store.galleries?.isEmpty != false && CookieUtil.didLogin { - DispatchQueue.main.async { - store.send(.fetchGalleries()) - } - } - } - .background(navigationLink) - .toolbar(content: toolbar) - .navigationTitle(navigationTitle) - - if DeviceUtil.isPad { - content - .sheet(item: $store.route.sending(\.setNavigation).detail, id: \.self) { route in - NavigationView { - DetailView( - store: store.scope(state: \.detailState.wrappedValue!, action: \.detail), - gid: route.wrappedValue, user: user, setting: $setting, - blurRadius: blurRadius, tagTranslator: tagTranslator - ) - } - .autoBlur(radius: blurRadius).environment(\.inSheet, true).navigationViewStyle(.stack) - } - } else { - content - } - } - } - - @ViewBuilder private var navigationLink: some View { - if DeviceUtil.isPhone { - NavigationLink(unwrapping: $store.route, case: \.detail) { route in - DetailView( - store: store.scope(state: \.detailState.wrappedValue!, action: \.detail), - gid: route.wrappedValue, user: user, setting: $setting, - blurRadius: blurRadius, tagTranslator: tagTranslator - ) - } - } - } - private func toolbar() -> some ToolbarContent { - CustomToolbarItem(tint: .primary) { - FavoritesIndexMenu(user: user, index: store.index) { index in - if index != store.index { - store.send(.setFavoritesIndex(index)) - } - } - SortOrderMenu(sortOrder: store.sortOrder) { order in - if store.sortOrder != order { - store.send(.fetchGalleries(nil, order)) - } - } - QuickSearchButton(hideText: true) { - store.send(.setNavigation(.quickSearch())) - } - } - } -} - -struct FavoritesView_Previews: PreviewProvider { - static var previews: some View { - FavoritesView( - store: .init(initialState: .init(), reducer: FavoritesReducer.init), - user: .init(), - setting: .constant(.init()), - blurRadius: 0, - tagTranslator: .init() - ) - } -} diff --git a/EhPanda/View/Home/Frontpage/FrontpageReducer.swift b/EhPanda/View/Home/Frontpage/FrontpageReducer.swift deleted file mode 100644 index f27c5aee5..000000000 --- a/EhPanda/View/Home/Frontpage/FrontpageReducer.swift +++ /dev/null @@ -1,171 +0,0 @@ -// -// FrontpageReducer.swift -// EhPanda -// - -import ComposableArchitecture - -@Reducer -struct FrontpageReducer { - @CasePathable - enum Route: Equatable { - case filters(EquatableVoid = .init()) - case detail(String) - } - - private enum CancelID: CaseIterable { - case fetchGalleries, fetchMoreGalleries - } - - @ObservableState - struct State: Equatable { - var route: Route? - var keyword = "" - - var filteredGalleries: [Gallery] { - guard !keyword.isEmpty else { return galleries } - return galleries.filter({ $0.title.caseInsensitiveContains(keyword) }) - } - var galleries = [Gallery]() - var pageNumber = PageNumber() - var loadingState: LoadingState = .idle - var footerLoadingState: LoadingState = .idle - - var filtersState = FiltersReducer.State() - var detailState: Heap - - init() { - detailState = .init(.init()) - } - - mutating func insertGalleries(_ galleries: [Gallery]) { - galleries.forEach { gallery in - if !self.galleries.contains(gallery) { - self.galleries.append(gallery) - } - } - } - } - - enum Action: BindableAction { - case binding(BindingAction) - case setNavigation(Route?) - case clearSubStates - - case teardown - case fetchGalleries - case fetchGalleriesDone(Result<(PageNumber, [Gallery]), AppError>) - case fetchMoreGalleries - case fetchMoreGalleriesDone(Result<(PageNumber, [Gallery]), AppError>) - - case filters(FiltersReducer.Action) - case detail(DetailReducer.Action) - } - - @Dependency(\.databaseClient) private var databaseClient - @Dependency(\.hapticsClient) private var hapticsClient - - var body: some Reducer { - BindingReducer() - .onChange(of: \.route) { _, newValue in - Reduce({ _, _ in newValue == nil ? .send(.clearSubStates) : .none }) - } - - Reduce { state, action in - switch action { - case .binding: - return .none - - case .setNavigation(let route): - state.route = route - return route == nil ? .send(.clearSubStates) : .none - - case .clearSubStates: - state.detailState.wrappedValue = .init() - state.filtersState = .init() - return .send(.detail(.teardown)) - - case .teardown: - return .merge(CancelID.allCases.map(Effect.cancel(id:))) - - case .fetchGalleries: - guard state.loadingState != .loading else { return .none } - state.loadingState = .loading - state.pageNumber.resetPages() - let filter = databaseClient.fetchFilterSynchronously(range: .global) - return .run { send in - let response = await FrontpageGalleriesRequest(filter: filter).response() - await send(.fetchGalleriesDone(response)) - } - .cancellable(id: CancelID.fetchGalleries) - - case .fetchGalleriesDone(let result): - state.loadingState = .idle - switch result { - case .success(let (pageNumber, galleries)): - guard !galleries.isEmpty else { - state.loadingState = .failed(.notFound) - guard pageNumber.hasNextPage() else { return .none } - return .send(.fetchMoreGalleries) - } - state.pageNumber = pageNumber - state.galleries = galleries - return .run(operation: { _ in await databaseClient.cacheGalleries(galleries) }) - case .failure(let error): - state.loadingState = .failed(error) - } - return .none - - case .fetchMoreGalleries: - let pageNumber = state.pageNumber - guard pageNumber.hasNextPage(), - state.footerLoadingState != .loading, - let lastID = state.galleries.last?.id - else { return .none } - state.footerLoadingState = .loading - let filter = databaseClient.fetchFilterSynchronously(range: .global) - return .run { send in - let response = await MoreFrontpageGalleriesRequest(filter: filter, lastID: lastID).response() - await send(.fetchMoreGalleriesDone(response)) - } - .cancellable(id: CancelID.fetchMoreGalleries) - - case .fetchMoreGalleriesDone(let result): - state.footerLoadingState = .idle - switch result { - case .success(let (pageNumber, galleries)): - state.pageNumber = pageNumber - state.insertGalleries(galleries) - - var effects: [Effect] = [ - .run(operation: { _ in await databaseClient.cacheGalleries(galleries) }) - ] - if galleries.isEmpty, pageNumber.hasNextPage() { - effects.append(.send(.fetchMoreGalleries)) - } else if !galleries.isEmpty { - state.loadingState = .idle - } - 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: \.filters, - hapticsClient: hapticsClient - ) - - Scope(state: \.filtersState, action: \.filters, child: FiltersReducer.init) - Scope(state: \.detailState.wrappedValue!, action: \.detail, child: DetailReducer.init) - } -} diff --git a/EhPanda/View/Home/Frontpage/FrontpageView.swift b/EhPanda/View/Home/Frontpage/FrontpageView.swift deleted file mode 100644 index 571b15c76..000000000 --- a/EhPanda/View/Home/Frontpage/FrontpageView.swift +++ /dev/null @@ -1,108 +0,0 @@ -// -// FrontpageView.swift -// EhPanda -// - -import SwiftUI -import AlertKit -import ComposableArchitecture - -struct FrontpageView: View { - @Bindable private var store: StoreOf - private let user: User - @Binding private var setting: Setting - private let blurRadius: Double - private let tagTranslator: TagTranslator - - init( - store: StoreOf, - user: User, setting: Binding, blurRadius: Double, tagTranslator: TagTranslator - ) { - self.store = store - self.user = user - _setting = setting - self.blurRadius = blurRadius - self.tagTranslator = tagTranslator - } - - var body: some View { - let content = - GenericList( - galleries: store.filteredGalleries, - setting: setting, - pageNumber: store.pageNumber, - loadingState: store.loadingState, - footerLoadingState: store.footerLoadingState, - fetchAction: { store.send(.fetchGalleries) }, - fetchMoreAction: { store.send(.fetchMoreGalleries) }, - navigateAction: { store.send(.setNavigation(.detail($0))) }, - translateAction: { - tagTranslator.lookup(word: $0, returnOriginal: !setting.translatesTags) - } - ) - .sheet(item: $store.route.sending(\.setNavigation).filters) { _ in - FiltersView(store: store.scope(state: \.filtersState, action: \.filters)) - .autoBlur(radius: blurRadius).environment(\.inSheet, true) - } - .searchable(text: $store.keyword, prompt: L10n.Localizable.Searchable.Prompt.filter) - .onAppear { - if store.galleries.isEmpty { - DispatchQueue.main.async { - store.send(.fetchGalleries) - } - } - } - .background(navigationLink) - .toolbar(content: toolbar) - .navigationTitle(L10n.Localizable.FrontpageView.Title.frontpage) - - if DeviceUtil.isPad { - content - .sheet(item: $store.route.sending(\.setNavigation).detail, id: \.self) { route in - NavigationView { - DetailView( - store: store.scope(state: \.detailState.wrappedValue!, action: \.detail), - gid: route.wrappedValue, user: user, setting: $setting, - blurRadius: blurRadius, tagTranslator: tagTranslator - ) - } - .autoBlur(radius: blurRadius).environment(\.inSheet, true).navigationViewStyle(.stack) - } - } else { - content - } - } - - @ViewBuilder private var navigationLink: some View { - if DeviceUtil.isPhone { - NavigationLink(unwrapping: $store.route, case: \.detail) { route in - DetailView( - store: store.scope(state: \.detailState.wrappedValue!, action: \.detail), - gid: route.wrappedValue, user: user, setting: $setting, - blurRadius: blurRadius, tagTranslator: tagTranslator - ) - } - } - } - private func toolbar() -> some ToolbarContent { - CustomToolbarItem { - FiltersButton(hideText: true) { - store.send(.setNavigation(.filters())) - } - } - } -} - -struct FrontpageView_Previews: PreviewProvider { - static var previews: some View { - NavigationView { - FrontpageView( - store: .init(initialState: .init(), reducer: FrontpageReducer.init), - user: .init(), - setting: .constant(.init()), - blurRadius: 0, - tagTranslator: .init() - ) - } - } -} diff --git a/EhPanda/View/Home/History/HistoryReducer.swift b/EhPanda/View/Home/History/HistoryReducer.swift deleted file mode 100644 index df4ffc279..000000000 --- a/EhPanda/View/Home/History/HistoryReducer.swift +++ /dev/null @@ -1,104 +0,0 @@ -// -// HistoryReducer.swift -// EhPanda -// - -import Foundation -import ComposableArchitecture - -@Reducer -struct HistoryReducer { - @CasePathable - enum Route: Equatable { - case detail(String) - case clearHistory - } - - @ObservableState - struct State: Equatable { - var route: Route? - var keyword = "" - 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 - - var detailState: Heap - - init() { - detailState = .init(.init()) - } - } - - enum Action: BindableAction { - case binding(BindingAction) - case setNavigation(Route?) - case clearSubStates - case clearHistoryGalleries - - case fetchGalleries - case fetchGalleriesDone([Gallery]) - - case detail(DetailReducer.Action) - } - - @Dependency(\.databaseClient) private var databaseClient - @Dependency(\.hapticsClient) private var hapticsClient - - var body: some Reducer { - BindingReducer() - .onChange(of: \.route) { _, newValue in - Reduce({ _, _ in newValue == nil ? .send(.clearSubStates) : .none }) - } - - Reduce { state, action in - switch action { - case .binding: - return .none - - case .setNavigation(let route): - state.route = route - return route == nil ? .send(.clearSubStates) : .none - - case .clearSubStates: - state.detailState.wrappedValue = .init() - return .send(.detail(.teardown)) - - case .clearHistoryGalleries: - return .merge( - .run(operation: { _ in await databaseClient.clearHistoryGalleries() }), - .run { send in - try await Task.sleep(for: .milliseconds(200)) - await send(.fetchGalleries) - } - ) - - case .fetchGalleries: - guard state.loadingState != .loading else { return .none } - state.loadingState = .loading - return .run { send in - let historyGalleries = await databaseClient.fetchHistoryGalleries() - await send(.fetchGalleriesDone(historyGalleries)) - } - - case .fetchGalleriesDone(let galleries): - state.loadingState = .idle - if galleries.isEmpty { - state.loadingState = .failed(.notFound) - } else { - state.galleries = galleries - } - return .none - - case .detail: - return .none - } - } - - Scope(state: \.detailState.wrappedValue!, action: \.detail, child: DetailReducer.init) - } -} diff --git a/EhPanda/View/Home/History/HistoryView.swift b/EhPanda/View/Home/History/HistoryView.swift deleted file mode 100644 index da046d569..000000000 --- a/EhPanda/View/Home/History/HistoryView.swift +++ /dev/null @@ -1,114 +0,0 @@ -// -// HistoryView.swift -// EhPanda -// - -import SwiftUI -import ComposableArchitecture - -struct HistoryView: View { - @Bindable private var store: StoreOf - private let user: User - @Binding private var setting: Setting - private let blurRadius: Double - private let tagTranslator: TagTranslator - - init( - store: StoreOf, - user: User, setting: Binding, blurRadius: Double, tagTranslator: TagTranslator - ) { - self.store = store - self.user = user - _setting = setting - self.blurRadius = blurRadius - self.tagTranslator = tagTranslator - } - - var body: some View { - let content = - GenericList( - galleries: store.filteredGalleries, - setting: setting, - pageNumber: nil, - loadingState: store.loadingState, - footerLoadingState: .idle, - fetchAction: { store.send(.fetchGalleries) }, - navigateAction: { store.send(.setNavigation(.detail($0))) }, - translateAction: { - tagTranslator.lookup(word: $0, returnOriginal: !setting.translatesTags) - } - ) - .searchable(text: $store.keyword, prompt: L10n.Localizable.Searchable.Prompt.filter) - .onAppear { - if store.galleries.isEmpty { - DispatchQueue.main.async { - store.send(.fetchGalleries) - } - } - } - .background(navigationLink) - .toolbar(content: toolbar) - .navigationTitle(L10n.Localizable.HistoryView.Title.history) - - if DeviceUtil.isPad { - content - .sheet(item: $store.route.sending(\.setNavigation).detail, id: \.self) { route in - NavigationView { - DetailView( - store: store.scope(state: \.detailState.wrappedValue!, action: \.detail), - gid: route.wrappedValue, user: user, setting: $setting, - blurRadius: blurRadius, tagTranslator: tagTranslator - ) - } - .autoBlur(radius: blurRadius).environment(\.inSheet, true).navigationViewStyle(.stack) - } - } else { - content - } - } - - @ViewBuilder private var navigationLink: some View { - if DeviceUtil.isPhone { - NavigationLink(unwrapping: $store.route, case: \.detail) { route in - DetailView( - store: store.scope(state: \.detailState.wrappedValue!, action: \.detail), - gid: route.wrappedValue, user: user, setting: $setting, - blurRadius: blurRadius, tagTranslator: tagTranslator - ) - } - } - } - private func toolbar() -> some ToolbarContent { - CustomToolbarItem { - Button { - store.send(.setNavigation(.clearHistory)) - } label: { - Image(systemSymbol: .trashCircle) - } - .disabled(store.loadingState != .idle || store.galleries.isEmpty) - .confirmationDialog( - message: L10n.Localizable.ConfirmationDialog.Title.clear, - unwrapping: $store.route, - case: \.clearHistory - ) { - Button(L10n.Localizable.ConfirmationDialog.Button.clear, role: .destructive) { - store.send(.clearHistoryGalleries) - } - } - } - } -} - -struct HistoryView_Previews: PreviewProvider { - static var previews: some View { - NavigationView { - HistoryView( - store: .init(initialState: .init(), reducer: HistoryReducer.init), - user: .init(), - setting: .constant(.init()), - blurRadius: 0, - tagTranslator: .init() - ) - } - } -} diff --git a/EhPanda/View/Home/HomeReducer.swift b/EhPanda/View/Home/HomeReducer.swift deleted file mode 100644 index 6165d918e..000000000 --- a/EhPanda/View/Home/HomeReducer.swift +++ /dev/null @@ -1,272 +0,0 @@ -// -// HomeReducer.swift -// EhPanda -// - -import SwiftUI -import Kingfisher -import UIImageColors -import ComposableArchitecture - -@Reducer -struct HomeReducer { - @CasePathable - enum Route: Equatable, Hashable { - case detail(String) - case misc(HomeMiscGridType) - case section(HomeSectionType) - } - - @ObservableState - struct State: Equatable { - var route: Route? - var cardPageIndex = 1 - var currentCardID = "" - var allowsCardHitTesting = true - var rawCardColors = [String: [Color]]() - 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 = FrontpageReducer.State() - var toplistsState = ToplistsReducer.State() - var popularState = PopularReducer.State() - var watchedState = WatchedReducer.State() - var historyState = HistoryReducer.State() - var detailState: Heap - - init() { - detailState = .init(.init()) - } - - mutating func setPopularGalleries(_ galleries: [Gallery]) { - let sortedGalleries = galleries.sorted { lhs, rhs in - lhs.title.count > rhs.title.count - } - var trimmedGalleries = Array(sortedGalleries.prefix(min(sortedGalleries.count, 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(min(galleries.count, 25))) - .removeDuplicates(by: \.trimmedTitle) - } - } - - enum Action: BindableAction { - case binding(BindingAction) - case setNavigation(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 - case fetchFrontpageGalleriesDone(Result<(PageNumber, [Gallery]), AppError>) - case fetchToplistsGalleries(Int, Int? = nil) - case fetchToplistsGalleriesDone(Int, Result<(PageNumber, [Gallery]), AppError>) - - case frontpage(FrontpageReducer.Action) - case toplists(ToplistsReducer.Action) - case popular(PopularReducer.Action) - case watched(WatchedReducer.Action) - case history(HistoryReducer.Action) - case detail(DetailReducer.Action) - } - - @Dependency(\.databaseClient) private var databaseClient - @Dependency(\.libraryClient) private var libraryClient - - var body: some Reducer { - BindingReducer() - .onChange(of: \.route) { _, newValue in - Reduce({ _, _ in newValue == nil ? .send(.clearSubStates) : .none }) - } - .onChange(of: \.cardPageIndex) { _, newValue in - Reduce { state, _ in - guard newValue < state.popularGalleries.count else { return .none } - state.currentCardID = state.popularGalleries[state.cardPageIndex].gid - state.allowsCardHitTesting = false - return .run { send in - try await Task.sleep(for: .milliseconds(300)) - await send(.setAllowsCardHitTesting(true)) - } - } - } - - Reduce { state, action in - switch action { - case .binding: - return .none - - case .setNavigation(let route): - state.route = route - return route == nil ? .send(.clearSubStates) : .none - - case .clearSubStates: - state.frontpageState = .init() - state.toplistsState = .init() - state.popularState = .init() - state.watchedState = .init() - state.historyState = .init() - state.detailState.wrappedValue = .init() - return .merge( - .send(.frontpage(.teardown)), - .send(.toplists(.teardown)), - .send(.popular(.teardown)), - .send(.watched(.teardown)), - .send(.detail(.teardown)) - ) - - case .setAllowsCardHitTesting(let isAllowed): - state.allowsCardHitTesting = isAllowed - return .none - - case .fetchAllGalleries: - return .merge( - .send(.fetchPopularGalleries), - .send(.fetchFrontpageGalleries), - .send(.fetchAllToplistsGalleries) - ) - - case .fetchAllToplistsGalleries: - return .merge( - ToplistsType.allCases - .map { Action.fetchToplistsGalleries($0.categoryIndex) } - .map(Effect.send) - ) - - case .fetchPopularGalleries: - guard state.popularLoadingState != .loading else { return .none } - state.popularLoadingState = .loading - state.rawCardColors = [String: [Color]]() - let filter = databaseClient.fetchFilterSynchronously(range: .global) - return .run { send in - let response = await PopularGalleriesRequest(filter: filter).response() - await send(.fetchPopularGalleriesDone(response)) - } - - case .fetchPopularGalleriesDone(let result): - state.popularLoadingState = .idle - switch result { - case .success(let galleries): - guard !galleries.isEmpty else { - state.popularLoadingState = .failed(.notFound) - return .none - } - state.setPopularGalleries(galleries) - return .run(operation: { _ in await databaseClient.cacheGalleries(galleries) }) - case .failure(let error): - state.popularLoadingState = .failed(error) - } - return .none - - case .fetchFrontpageGalleries: - guard state.frontpageLoadingState != .loading else { return .none } - state.frontpageLoadingState = .loading - let filter = databaseClient.fetchFilterSynchronously(range: .global) - return .run { send in - let response = await FrontpageGalleriesRequest(filter: filter).response() - await send(.fetchFrontpageGalleriesDone(response)) - } - - case .fetchFrontpageGalleriesDone(let result): - state.frontpageLoadingState = .idle - switch result { - case .success(let (_, galleries)): - guard !galleries.isEmpty else { - state.frontpageLoadingState = .failed(.notFound) - return .none - } - state.setFrontpageGalleries(galleries) - return .run(operation: { _ in await databaseClient.cacheGalleries(galleries) }) - case .failure(let error): - state.frontpageLoadingState = .failed(error) - } - return .none - - case .fetchToplistsGalleries(let index, let pageNum): - guard state.toplistsLoadingState[index] != .loading else { return .none } - state.toplistsLoadingState[index] = .loading - return .run { send in - let response = await ToplistsGalleriesRequest(catIndex: index, pageNum: pageNum).response() - await send(.fetchToplistsGalleriesDone(index, response)) - } - - case .fetchToplistsGalleriesDone(let index, let result): - state.toplistsLoadingState[index] = .idle - switch result { - case .success(let (_, galleries)): - guard !galleries.isEmpty else { - state.toplistsLoadingState[index] = .failed(.notFound) - return .none - } - state.toplistsGalleries[index] = galleries - return .run(operation: { _ in await databaseClient.cacheGalleries(galleries) }) - case .failure(let error): - state.toplistsLoadingState[index] = .failed(error) - } - return .none - - case .analyzeImageColors(let gid, let result): - guard !state.rawCardColors.keys.contains(gid) else { return .none } - return .run { send in - let colors = await libraryClient.analyzeImageColors(result.image) - await send(.analyzeImageColorsDone(gid, colors)) - } - - case .analyzeImageColorsDone(let gid, let colors): - if let colors = colors { - state.rawCardColors[gid] = [ - colors.primary, colors.secondary, - colors.detail, colors.background - ] - .map(Color.init) - } - return .none - - case .frontpage: - return .none - - case .toplists: - return .none - - case .popular: - return .none - - case .watched: - return .none - - case .history: - return .none - - case .detail: - return .none - } - } - - Scope(state: \.frontpageState, action: \.frontpage, child: FrontpageReducer.init) - Scope(state: \.toplistsState, action: \.toplists, child: ToplistsReducer.init) - Scope(state: \.popularState, action: \.popular, child: PopularReducer.init) - Scope(state: \.watchedState, action: \.watched, child: WatchedReducer.init) - Scope(state: \.historyState, action: \.history, child: HistoryReducer.init) - Scope(state: \.detailState.wrappedValue!, action: \.detail, child: DetailReducer.init) - } -} diff --git a/EhPanda/View/Home/HomeView.swift b/EhPanda/View/Home/HomeView.swift deleted file mode 100644 index 09d1940c2..000000000 --- a/EhPanda/View/Home/HomeView.swift +++ /dev/null @@ -1,530 +0,0 @@ -// -// HomeView.swift -// EhPanda -// - -import SwiftUI -import Kingfisher -import SwiftUIPager -import SFSafeSymbols -import ComposableArchitecture - -struct HomeView: View { - @Bindable private var store: StoreOf - private let user: User - @Binding private var setting: Setting - private let blurRadius: Double - private let tagTranslator: TagTranslator - - init( - store: StoreOf, - user: User, setting: Binding, blurRadius: Double, tagTranslator: TagTranslator - ) { - self.store = store - self.user = user - _setting = setting - self.blurRadius = blurRadius - self.tagTranslator = tagTranslator - } - - // MARK: HomeView - var body: some View { - NavigationView { - let content = - ZStack { - ScrollView(showsIndicators: false) { - VStack { - if !store.popularGalleries.isEmpty { - CardSlideSection( - galleries: store.popularGalleries, - pageIndex: $store.cardPageIndex, - currentID: store.currentCardID, - colors: store.cardColors, - navigateAction: navigateTo(gid:), - webImageSuccessAction: { gid, result in - store.send(.analyzeImageColors(gid, result)) - } - ) - .equatable().allowsHitTesting(store.allowsCardHitTesting) - } - Group { - if store.frontpageGalleries.count > 1 { - CoverWallSection( - galleries: store.frontpageGalleries, - isLoading: store.frontpageLoadingState == .loading, - navigateAction: navigateTo(gid:), - showAllAction: { store.send(.setNavigation(.section(.frontpage))) }, - reloadAction: { store.send(.fetchFrontpageGalleries) } - ) - } - ToplistsSection( - galleries: store.toplistsGalleries, - isLoading: !store.toplistsLoadingState - .values.allSatisfy({ $0 != .loading }), - navigateAction: navigateTo(gid:), - showAllAction: { store.send(.setNavigation(.section(.toplists))) }, - reloadAction: { store.send(.fetchAllToplistsGalleries) } - ) - MiscGridSection(navigateAction: navigateTo(type:)) - } - .padding(.vertical) - } - } - .opacity(store.popularGalleries.isEmpty ? 0 : 1).zIndex(2) - - LoadingView() - .opacity( - store.popularLoadingState == .loading - && store.popularGalleries.isEmpty ? 1 : 0 - ) - .zIndex(0) - - let error = store.popularLoadingState.failed - ErrorView(error: error ?? .unknown) { - store.send(.fetchAllGalleries) - } - .opacity(store.popularGalleries.isEmpty && error != nil ? 1 : 0) - .zIndex(1) - } - .animation(.default, value: store.popularLoadingState) - .onAppear { - if store.popularGalleries.isEmpty { - store.send(.fetchAllGalleries) - } - } - .background(navigationLinks) - .toolbar(content: toolbar) - .navigationTitle(L10n.Localizable.HomeView.Title.home) - - if DeviceUtil.isPad { - content - .sheet(item: $store.route.sending(\.setNavigation).detail, id: \.self) { route in - NavigationView { - DetailView( - store: store.scope(state: \.detailState.wrappedValue!, action: \.detail), - gid: route.wrappedValue, user: user, setting: $setting, - blurRadius: blurRadius, tagTranslator: tagTranslator - ) - } - .autoBlur(radius: blurRadius).environment(\.inSheet, true).navigationViewStyle(.stack) - } - } else { - content - } - } - } - - private func toolbar() -> some ToolbarContent { - CustomToolbarItem(tint: .primary) { - Button { - store.send(.fetchAllGalleries) - } label: { - Image(systemSymbol: .arrowCounterclockwise) - } - .opacity(store.popularLoadingState == .loading ? 0 : 1) - .overlay(ProgressView().opacity(store.popularLoadingState == .loading ? 1 : 0)) - } - } -} - -// MARK: NavigationLinks -private extension HomeView { - @ViewBuilder var navigationLinks: some View { - if DeviceUtil.isPhone { - detailViewLink - } - miscGridLink - sectionLink - } - var detailViewLink: some View { - NavigationLink(unwrapping: $store.route, case: \.detail) { route in - DetailView( - store: store.scope(state: \.detailState.wrappedValue!, action: \.detail), - gid: route.wrappedValue, user: user, setting: $setting, - blurRadius: blurRadius, tagTranslator: tagTranslator - ) - } - } - var miscGridLink: some View { - NavigationLink(unwrapping: $store.route, case: \.misc) { route in - switch route.wrappedValue { - case .popular: - PopularView( - store: store.scope(state: \.popularState, action: \.popular), - user: user, setting: $setting, blurRadius: blurRadius, tagTranslator: tagTranslator - ) - case .watched: - WatchedView( - store: store.scope(state: \.watchedState, action: \.watched), - user: user, setting: $setting, blurRadius: blurRadius, tagTranslator: tagTranslator - ) - case .history: - HistoryView( - store: store.scope(state: \.historyState, action: \.history), - user: user, setting: $setting, blurRadius: blurRadius, tagTranslator: tagTranslator - ) - } - } - } - var sectionLink: some View { - NavigationLink(unwrapping: $store.route, case: \.section) { route in - switch route.wrappedValue { - case .frontpage: - FrontpageView( - store: store.scope(state: \.frontpageState, action: \.frontpage), - user: user, setting: $setting, blurRadius: blurRadius, tagTranslator: tagTranslator - ) - case .toplists: - ToplistsView( - store: store.scope(state: \.toplistsState, action: \.toplists), - user: user, setting: $setting, blurRadius: blurRadius, tagTranslator: tagTranslator - ) - } - } - } - func navigateTo(gid: String) { - store.send(.setNavigation(.detail(gid))) - } - func navigateTo(type: HomeMiscGridType) { - store.send(.setNavigation(.misc(type))) - } -} - -// MARK: CardSlideSection -private struct CardSlideSection: View, Equatable { - @StateObject private var page: Page = .withIndex(1) - @Binding private var pageIndex: Int - - private let galleries: [Gallery] - private let currentID: String - private let colors: [Color] - private let navigateAction: (String) -> Void - private let webImageSuccessAction: (String, RetrieveImageResult) -> Void - - init( - galleries: [Gallery], pageIndex: Binding, currentID: String, - colors: [Color], navigateAction: @escaping (String) -> Void, - webImageSuccessAction: @escaping (String, RetrieveImageResult) -> Void - ) { - self.galleries = galleries - _pageIndex = pageIndex - self.currentID = currentID - self.colors = colors - self.navigateAction = navigateAction - self.webImageSuccessAction = webImageSuccessAction - } - - static func == (lhs: CardSlideSection, rhs: CardSlideSection) -> Bool { - lhs.galleries == rhs.galleries - && lhs.currentID == rhs.currentID - && lhs.colors == rhs.colors - } - - var body: some View { - Pager(page: page, data: galleries) { gallery in - Button { - navigateAction(gallery.id) - } label: { - GalleryCardCell(gallery: gallery, currentID: currentID, colors: colors) { - webImageSuccessAction(gallery.gid, $0) - } - .tint(.primary).multilineTextAlignment(.leading) - } - } - .preferredItemSize(Defaults.FrameSize.cardCellSize) - .interactive(opacity: 0.2).itemSpacing(20) - .loopPages().pagingPriority(.high) - .synchronize($pageIndex, $page.index) - .frame(height: Defaults.FrameSize.cardCellHeight) - } -} - -// MARK: CoverWallSection -private struct CoverWallSection: View { - private let galleries: [Gallery] - private let isLoading: Bool - private let navigateAction: (String) -> Void - private let showAllAction: () -> Void - private let reloadAction: () -> Void - - init( - galleries: [Gallery], isLoading: Bool, - navigateAction: @escaping (String) -> Void, - showAllAction: @escaping () -> Void, - reloadAction: @escaping () -> Void - ) { - self.galleries = galleries - self.isLoading = isLoading - self.navigateAction = navigateAction - self.showAllAction = showAllAction - self.reloadAction = reloadAction - } - - private var dataSource: [[Gallery]] { - var galleries = galleries - if galleries.isEmpty { - galleries = Gallery.mockGalleries(count: 25) - } - if galleries.count % 2 != 0 { galleries = galleries.dropLast() } - return stride(from: 0, to: galleries.count, by: 2).map { index in - [galleries[index], galleries[index + 1]] - } - } - - var body: some View { - SubSection( - title: L10n.Localizable.HomeView.Section.Title.frontpage, - tint: .secondary, isLoading: isLoading, - reloadAction: reloadAction, - showAllAction: showAllAction - ) { - ScrollView(.horizontal, showsIndicators: false) { - HStack(spacing: 20) { - ForEach(dataSource, id: \.first) { - VerticalCoverStack(galleries: $0, navigateAction: navigateAction) - } - .withHorizontalSpacing(width: 0) - } - } - .frame(height: Defaults.ImageSize.rowH * 2 + 30) - } - } -} - -private struct VerticalCoverStack: View { - private let galleries: [Gallery] - private let navigateAction: (String) -> Void - - init(galleries: [Gallery], navigateAction: @escaping (String) -> Void) { - self.galleries = galleries - self.navigateAction = navigateAction - } - - private func placeholder() -> some View { - Placeholder(style: .activity(ratio: Defaults.ImageSize.headerAspect)) - } - private func imageContainer(gallery: Gallery) -> some View { - Button { - navigateAction(gallery.id) - } label: { - KFImage(gallery.coverURL).placeholder(placeholder).defaultModifier().scaledToFill() - .frame(width: Defaults.ImageSize.rowW, height: Defaults.ImageSize.rowH).cornerRadius(2) - } - } - - var body: some View { - VStack(spacing: 20) { - ForEach(galleries, content: imageContainer) - } - } -} - -// MARK: ToplistsSection -private struct ToplistsSection: View { - private let galleries: [Int: [Gallery]] - private let isLoading: Bool - private let navigateAction: (String) -> Void - private let showAllAction: () -> Void - private let reloadAction: () -> Void - - init( - galleries: [Int: [Gallery]], isLoading: Bool, - navigateAction: @escaping (String) -> Void, - showAllAction: @escaping () -> Void, - reloadAction: @escaping () -> Void - ) { - self.galleries = galleries - self.isLoading = isLoading - self.navigateAction = navigateAction - self.showAllAction = showAllAction - self.reloadAction = reloadAction - } - - private var dataSource: [Int: [Gallery]] { - guard !galleries.isEmpty else { - var dictionary = [Int: [Gallery]]() - var gallery: Gallery = .empty - gallery.title = "......" - gallery.uploader = "......" - let galleries = Array(repeating: gallery, count: 6) - - ToplistsType.allCases.forEach { type in - dictionary[type.categoryIndex] = galleries - } - return dictionary - } - return galleries - } - private func galleries(type: ToplistsType, range: ClosedRange) -> [Gallery] { - let galleries = dataSource[type.categoryIndex] ?? [] - guard galleries.count > range.upperBound else { return [] } - return Array(galleries[range]) - } - - var body: some View { - SubSection( - title: L10n.Localizable.HomeView.Section.Title.toplists, - tint: .secondary, isLoading: isLoading, - reloadAction: reloadAction, - showAllAction: showAllAction - ) { - ScrollView(.horizontal, showsIndicators: false) { - HStack { - ForEach(ToplistsType.allCases, content: verticalStacks) - } - } - } - } - private func verticalStacks(type: ToplistsType) -> some View { - VStack(alignment: .leading) { - Text(type.value).font(.subheadline.bold()) - HStack { - VerticalToplistsStack( - galleries: galleries(type: type, range: 0...2), startRanking: 1, - navigateAction: navigateAction - ) - if DeviceUtil.isPad { - VerticalToplistsStack( - galleries: galleries(type: type, range: 3...5), startRanking: 4, - navigateAction: navigateAction - ) - } - } - } - .padding(.horizontal, 20).padding(.vertical, 5) - } -} - -private struct VerticalToplistsStack: View { - private let galleries: [Gallery] - private let startRanking: Int - private let navigateAction: (String) -> Void - - init(galleries: [Gallery], startRanking: Int, navigateAction: @escaping (String) -> Void) { - self.galleries = galleries - self.startRanking = startRanking - self.navigateAction = navigateAction - } - - var body: some View { - VStack(spacing: 10) { - ForEach(0.. Void - - init(navigateAction: @escaping (HomeMiscGridType) -> Void) { - self.navigateAction = navigateAction - } - - var body: some View { - SubSection(title: L10n.Localizable.HomeView.Section.Title.other, showAll: false) { - ScrollView(.horizontal, showsIndicators: false) { - HStack { - let types = HomeMiscGridType.allCases - ForEach(types) { type in - Button { - navigateAction(type) - } label: { - MiscGridItem(title: type.title, symbol: type.symbol).tint(.primary) - } - .padding(.trailing, type == types.last ? 0 : 10) - } - .withHorizontalSpacing() - } - } - } - } -} - -private struct MiscGridItem: View { - private let title: String - private let subTitle: String? - private let symbol: SFSymbol - - init(title: String, subTitle: String? = nil, symbol: SFSymbol) { - self.title = title - self.subTitle = subTitle - self.symbol = symbol - } - - var body: some View { - HStack { - VStack(alignment: .leading) { - Text(title).font(.title2.bold()).lineLimit(1).frame(minWidth: 100) - if let subTitle = subTitle { - Text(subTitle).font(.subheadline).foregroundColor(.secondary).lineLimit(2) - } - } - Image(systemSymbol: symbol).font(.system(size: 50, weight: .light, design: .default)) - .foregroundColor(.secondary).imageScale(.large).offset(x: 20, y: 20) - } - .padding(30).cornerRadius(15).background(Color(.systemGray6).cornerRadius(15)) - } -} - -// MARK: Definition -enum HomeMiscGridType: CaseIterable, Identifiable { - var id: String { title } - - case popular - case watched - case history -} - -extension HomeMiscGridType { - var title: String { - switch self { - case .popular: - return L10n.Localizable.Enum.HomeMiscGridType.Title.popular - case .watched: - return L10n.Localizable.Enum.HomeMiscGridType.Title.watched - case .history: - return L10n.Localizable.Enum.HomeMiscGridType.Title.history - } - } - var symbol: SFSymbol { - switch self { - case .popular: - return .flame - case .watched: - return .tagCircle - case .history: - return .clockArrowCirclepath - } - } -} - -enum HomeSectionType: String, CaseIterable, Identifiable { - var id: String { rawValue } - - case frontpage - case toplists -} - -struct HomeView_Previews: PreviewProvider { - static var previews: some View { - HomeView( - store: .init(initialState: .init(), reducer: HomeReducer.init), - user: .init(), - setting: .constant(.init()), - blurRadius: 0, - tagTranslator: .init() - ) - } -} diff --git a/EhPanda/View/Home/Popular/PopularReducer.swift b/EhPanda/View/Home/Popular/PopularReducer.swift deleted file mode 100644 index 1b8df2a4d..000000000 --- a/EhPanda/View/Home/Popular/PopularReducer.swift +++ /dev/null @@ -1,120 +0,0 @@ -// -// PopularReducer.swift -// EhPanda -// - -import ComposableArchitecture - -@Reducer -struct PopularReducer { - @dynamicMemberLookup @CasePathable - enum Route: Equatable { - case filters(EquatableVoid = .unique) - case detail(String) - } - - private enum CancelID { - case fetchGalleries - } - - @ObservableState - struct State: Equatable { - var route: Route? - 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 = FiltersReducer.State() - var detailState: Heap - - init() { - detailState = .init(.init()) - } - } - - enum Action: BindableAction { - case binding(BindingAction) - case setNavigation(Route?) - case clearSubStates - - case teardown - case fetchGalleries - case fetchGalleriesDone(Result<[Gallery], AppError>) - - case filters(FiltersReducer.Action) - case detail(DetailReducer.Action) - } - - @Dependency(\.databaseClient) private var databaseClient - @Dependency(\.hapticsClient) private var hapticsClient - - var body: some Reducer { - BindingReducer() - .onChange(of: \.route) { _, newValue in - Reduce({ _, _ in newValue == nil ? .send(.clearSubStates) : .none }) - } - - Reduce { state, action in - switch action { - case .binding: - return .none - - case .setNavigation(let route): - state.route = route - return route == nil ? .send(.clearSubStates) : .none - - case .clearSubStates: - state.detailState.wrappedValue = .init() - state.filtersState = .init() - return .send(.detail(.teardown)) - - case .teardown: - return .cancel(id: CancelID.fetchGalleries) - - case .fetchGalleries: - guard state.loadingState != .loading else { return .none } - state.loadingState = .loading - let filter = databaseClient.fetchFilterSynchronously(range: .global) - return .run { send in - let response = await PopularGalleriesRequest(filter: filter).response() - await send(.fetchGalleriesDone(response)) - } - .cancellable(id: CancelID.fetchGalleries) - - 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 .run(operation: { _ in await databaseClient.cacheGalleries(galleries) }) - case .failure(let error): - state.loadingState = .failed(error) - } - return .none - - case .filters: - return .none - - case .detail: - return .none - } - } - .haptics( - unwrapping: \.route, - case: \.filters, - hapticsClient: hapticsClient - ) - - Scope(state: \.filtersState, action: \.filters, child: FiltersReducer.init) - Scope(state: \.detailState.wrappedValue!, action: \.detail, child: DetailReducer.init) - } -} diff --git a/EhPanda/View/Home/Popular/PopularView.swift b/EhPanda/View/Home/Popular/PopularView.swift deleted file mode 100644 index 3c7e3c56d..000000000 --- a/EhPanda/View/Home/Popular/PopularView.swift +++ /dev/null @@ -1,105 +0,0 @@ -// -// PopularView.swift -// EhPanda -// - -import SwiftUI -import ComposableArchitecture - -struct PopularView: View { - @Bindable private var store: StoreOf - private let user: User - @Binding private var setting: Setting - private let blurRadius: Double - private let tagTranslator: TagTranslator - - init( - store: StoreOf, - user: User, setting: Binding, blurRadius: Double, tagTranslator: TagTranslator - ) { - self.store = store - self.user = user - _setting = setting - self.blurRadius = blurRadius - self.tagTranslator = tagTranslator - } - - var body: some View { - let content = - GenericList( - galleries: store.filteredGalleries, - setting: setting, pageNumber: nil, - loadingState: store.loadingState, - footerLoadingState: .idle, - fetchAction: { store.send(.fetchGalleries) }, - navigateAction: { store.send(.setNavigation(.detail($0))) }, - translateAction: { - tagTranslator.lookup(word: $0, returnOriginal: !setting.translatesTags) - } - ) - .sheet(item: $store.route.sending(\.setNavigation).filters) { _ in - FiltersView(store: store.scope(state: \.filtersState, action: \.filters)) - .autoBlur(radius: blurRadius).environment(\.inSheet, true) - } - .searchable(text: $store.keyword, prompt: L10n.Localizable.Searchable.Prompt.filter) - .onAppear { - if store.galleries.isEmpty { - DispatchQueue.main.async { - store.send(.fetchGalleries) - } - } - } - .background(navigationLink) - .toolbar(content: toolbar) - .navigationTitle(L10n.Localizable.PopularView.Title.popular) - - if DeviceUtil.isPad { - content - .sheet(item: $store.route.sending(\.setNavigation).detail, id: \.self) { route in - NavigationView { - DetailView( - store: store.scope(state: \.detailState.wrappedValue!, action: \.detail), - gid: route.wrappedValue, user: user, setting: $setting, - blurRadius: blurRadius, tagTranslator: tagTranslator - ) - } - .autoBlur(radius: blurRadius).environment(\.inSheet, true).navigationViewStyle(.stack) - } - } else { - content - } - } - - @ViewBuilder private var navigationLink: some View { - if DeviceUtil.isPhone { - NavigationLink(unwrapping: $store.route, case: \.detail) { route in - DetailView( - store: store.scope(state: \.detailState.wrappedValue!, action: \.detail), - gid: route.wrappedValue, user: user, setting: $setting, - blurRadius: blurRadius, tagTranslator: tagTranslator - ) - } - } - } - private func toolbar() -> some ToolbarContent { - CustomToolbarItem { - FiltersButton(hideText: true) { - store.send(.setNavigation(.filters())) - } - } - } -} - -struct PopularView_Previews: PreviewProvider { - static var previews: some View { - NavigationView { - PopularView( - store: .init(initialState: .init(), reducer: PopularReducer.init), - user: .init(), - setting: .constant(.init()), - blurRadius: 0, - tagTranslator: .init() - ) - } - } -} diff --git a/EhPanda/View/Home/Toplists/ToplistsView.swift b/EhPanda/View/Home/Toplists/ToplistsView.swift deleted file mode 100644 index 0f53cb03e..000000000 --- a/EhPanda/View/Home/Toplists/ToplistsView.swift +++ /dev/null @@ -1,163 +0,0 @@ -// -// ToplistsView.swift -// EhPanda -// - -import SwiftUI -import ComposableArchitecture - -struct ToplistsView: View { - @Bindable private var store: StoreOf - private let user: User - @Binding private var setting: Setting - private let blurRadius: Double - private let tagTranslator: TagTranslator - - init( - store: StoreOf, - user: User, setting: Binding, blurRadius: Double, tagTranslator: TagTranslator - ) { - self.store = store - self.user = user - _setting = setting - self.blurRadius = blurRadius - self.tagTranslator = tagTranslator - } - - private var navigationTitle: String { - [L10n.Localizable.ToplistsView.Title.toplists, store.type.value].joined(separator: " - ") - } - - var body: some View { - let content = - GenericList( - galleries: store.filteredGalleries ?? [], - setting: setting, - pageNumber: store.pageNumber, - loadingState: store.loadingState ?? .idle, - footerLoadingState: store.footerLoadingState ?? .idle, - fetchAction: { store.send(.fetchGalleries()) }, - fetchMoreAction: { store.send(.fetchMoreGalleries) }, - navigateAction: { store.send(.setNavigation(.detail($0))) }, - translateAction: { - tagTranslator.lookup(word: $0, returnOriginal: !setting.translatesTags) - } - ) - .jumpPageAlert( - index: $store.jumpPageIndex, - isPresented: $store.jumpPageAlertPresented, - isFocused: $store.jumpPageAlertFocused, - pageNumber: store.pageNumber ?? .init(), - jumpAction: { store.send(.performJumpPage) } - ) - .searchable(text: $store.keyword, prompt: L10n.Localizable.Searchable.Prompt.filter) - .navigationBarBackButtonHidden(store.jumpPageAlertPresented) - .animation(.default, value: store.jumpPageAlertPresented) - .onAppear { - if store.galleries?.isEmpty != false { - DispatchQueue.main.async { - store.send(.fetchGalleries()) - } - } - } - .background(navigationLink) - .toolbar(content: toolbar) - .navigationTitle(navigationTitle) - - if DeviceUtil.isPad { - content - .sheet(item: $store.route.sending(\.setNavigation).detail, id: \.self) { route in - NavigationView { - DetailView( - store: store.scope(state: \.detailState.wrappedValue!, action: \.detail), - gid: route.wrappedValue, user: user, setting: $setting, - blurRadius: blurRadius, tagTranslator: tagTranslator - ) - } - .autoBlur(radius: blurRadius).environment(\.inSheet, true).navigationViewStyle(.stack) - } - } else { - content - } - } - - @ViewBuilder private var navigationLink: some View { - if DeviceUtil.isPhone { - NavigationLink(unwrapping: $store.route, case: \.detail) { route in - DetailView( - store: store.scope(state: \.detailState.wrappedValue!, action: \.detail), - gid: route.wrappedValue, user: user, setting: $setting, - blurRadius: blurRadius, tagTranslator: tagTranslator - ) - } - } - } - private func toolbar() -> some ToolbarContent { - CustomToolbarItem(disabled: store.jumpPageAlertPresented) { - ToplistsTypeMenu(type: store.type) { type in - if type != store.type { - store.send(.setToplistsType(type)) - } - } - if AppUtil.galleryHost == .ehentai { - JumpPageButton(pageNumber: store.pageNumber ?? .init(), hideText: true) { - store.send(.presentJumpPageAlert) - DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) { - store.send(.setJumpPageAlertFocused(true)) - } - } - } - } - } -} - -// MARK: Definition -enum ToplistsType: Int, Codable, CaseIterable, Identifiable { - var id: Int { rawValue } - - case yesterday - case pastMonth - case pastYear - case allTime -} - -extension ToplistsType { - var value: String { - switch self { - case .yesterday: - return L10n.Localizable.Enum.ToplistsType.Value.yesterday - case .pastMonth: - return L10n.Localizable.Enum.ToplistsType.Value.pastMonth - case .pastYear: - return L10n.Localizable.Enum.ToplistsType.Value.pastYear - case .allTime: - return L10n.Localizable.Enum.ToplistsType.Value.allTime - } - } - var categoryIndex: Int { - switch self { - case .yesterday: - return 15 - case .pastMonth: - return 13 - case .pastYear: - return 12 - case .allTime: - return 11 - } - } -} - -struct ToplistsView_Previews: PreviewProvider { - static var previews: some View { - NavigationView { - ToplistsView( - store: .init(initialState: .init(), reducer: ToplistsReducer.init), - user: .init(), - setting: .constant(.init()), - blurRadius: 0, - tagTranslator: .init() - ) - } - } -} diff --git a/EhPanda/View/Home/Watched/WatchedReducer.swift b/EhPanda/View/Home/Watched/WatchedReducer.swift deleted file mode 100644 index 374139728..000000000 --- a/EhPanda/View/Home/Watched/WatchedReducer.swift +++ /dev/null @@ -1,193 +0,0 @@ -// -// WatchedReducer.swift -// EhPanda -// - -import ComposableArchitecture - -@Reducer -struct WatchedReducer { - @CasePathable - enum Route: Equatable { - case filters(EquatableVoid = .init()) - case quickSearch(EquatableVoid = .init()) - case detail(String) - } - - private enum CancelID: CaseIterable { - case fetchGalleries, fetchMoreGalleries - } - - @ObservableState - struct State: Equatable { - var route: Route? - var keyword = "" - - var galleries = [Gallery]() - var pageNumber = PageNumber() - var loadingState: LoadingState = .idle - var footerLoadingState: LoadingState = .idle - - var filtersState = FiltersReducer.State() - var quickSearchState = QuickSearchReducer.State() - var detailState: Heap - - init() { - detailState = .init(.init()) - } - - mutating func insertGalleries(_ galleries: [Gallery]) { - galleries.forEach { gallery in - if !self.galleries.contains(gallery) { - self.galleries.append(gallery) - } - } - } - } - - enum Action: BindableAction { - case binding(BindingAction) - case setNavigation(Route?) - case clearSubStates - case onNotLoginViewButtonTapped - - case teardown - case fetchGalleries(String? = nil) - case fetchGalleriesDone(Result<(PageNumber, [Gallery]), AppError>) - case fetchMoreGalleries - case fetchMoreGalleriesDone(Result<(PageNumber, [Gallery]), AppError>) - - case filters(FiltersReducer.Action) - case detail(DetailReducer.Action) - case quickSearch(QuickSearchReducer.Action) - } - - @Dependency(\.databaseClient) private var databaseClient - @Dependency(\.hapticsClient) private var hapticsClient - - var body: some Reducer { - BindingReducer() - .onChange(of: \.route) { _, newValue in - Reduce({ _, _ in newValue == nil ? .send(.clearSubStates) : .none }) - } - - Reduce { state, action in - switch action { - case .binding: - return .none - - case .setNavigation(let route): - state.route = route - return route == nil ? .send(.clearSubStates) : .none - - case .clearSubStates: - state.detailState.wrappedValue = .init() - state.filtersState = .init() - state.quickSearchState = .init() - return .merge( - .send(.detail(.teardown)), - .send(.quickSearch(.teardown)) - ) - - case .onNotLoginViewButtonTapped: - return .none - - case .teardown: - return .merge(CancelID.allCases.map(Effect.cancel(id:))) - - case .fetchGalleries(let keyword): - guard state.loadingState != .loading else { return .none } - if let keyword = keyword { - state.keyword = keyword - } - state.loadingState = .loading - state.pageNumber.resetPages() - let filter = databaseClient.fetchFilterSynchronously(range: .watched) - return .run { [keyword = state.keyword] send in - let response = await WatchedGalleriesRequest(filter: filter, keyword: keyword).response() - await send(.fetchGalleriesDone(response)) - } - .cancellable(id: CancelID.fetchGalleries) - - case .fetchGalleriesDone(let result): - state.loadingState = .idle - switch result { - case .success(let (pageNumber, galleries)): - guard !galleries.isEmpty else { - state.loadingState = .failed(.notFound) - guard pageNumber.hasNextPage() else { return .none } - return .send(.fetchMoreGalleries) - } - state.pageNumber = pageNumber - state.galleries = galleries - return .run(operation: { _ in await databaseClient.cacheGalleries(galleries) }) - case .failure(let error): - state.loadingState = .failed(error) - } - return .none - - case .fetchMoreGalleries: - let pageNumber = state.pageNumber - guard pageNumber.hasNextPage(), - state.footerLoadingState != .loading, - let lastID = state.galleries.last?.id - else { return .none } - state.footerLoadingState = .loading - let filter = databaseClient.fetchFilterSynchronously(range: .watched) - return .run { [keyword = state.keyword] send in - let response = await MoreWatchedGalleriesRequest( - filter: filter, lastID: lastID, keyword: keyword - ) - .response() - await send(.fetchMoreGalleriesDone(response)) - } - .cancellable(id: CancelID.fetchMoreGalleries) - - case .fetchMoreGalleriesDone(let result): - state.footerLoadingState = .idle - switch result { - case .success(let (pageNumber, galleries)): - state.pageNumber = pageNumber - state.insertGalleries(galleries) - - var effects: [Effect] = [ - .run(operation: { _ in await databaseClient.cacheGalleries(galleries) }) - ] - if galleries.isEmpty, pageNumber.hasNextPage() { - effects.append(.send(.fetchMoreGalleries)) - } else if !galleries.isEmpty { - state.loadingState = .idle - } - 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: \.quickSearch, - hapticsClient: hapticsClient - ) - .haptics( - unwrapping: \.route, - case: \.filters, - hapticsClient: hapticsClient - ) - - Scope(state: \.filtersState, action: \.filters, child: FiltersReducer.init) - Scope(state: \.quickSearchState, action: \.quickSearch, child: QuickSearchReducer.init) - Scope(state: \.detailState.wrappedValue!, action: \.detail, child: DetailReducer.init) - } -} diff --git a/EhPanda/View/Home/Watched/WatchedView.swift b/EhPanda/View/Home/Watched/WatchedView.swift deleted file mode 100644 index 3a59da563..000000000 --- a/EhPanda/View/Home/Watched/WatchedView.swift +++ /dev/null @@ -1,137 +0,0 @@ -// -// WatchedView.swift -// EhPanda -// - -import SwiftUI -import ComposableArchitecture - -struct WatchedView: View { - @Bindable private var store: StoreOf - private let user: User - @Binding private var setting: Setting - private let blurRadius: Double - private let tagTranslator: TagTranslator - - init( - store: StoreOf, - user: User, setting: Binding, blurRadius: Double, tagTranslator: TagTranslator - ) { - self.store = store - self.user = user - _setting = setting - self.blurRadius = blurRadius - self.tagTranslator = tagTranslator - } - - var body: some View { - let content = - ZStack { - if CookieUtil.didLogin { - GenericList( - galleries: store.galleries, - setting: setting, - pageNumber: store.pageNumber, - loadingState: store.loadingState, - footerLoadingState: store.footerLoadingState, - fetchAction: { store.send(.fetchGalleries()) }, - fetchMoreAction: { store.send(.fetchMoreGalleries) }, - navigateAction: { store.send(.setNavigation(.detail($0))) }, - translateAction: { - tagTranslator.lookup(word: $0, returnOriginal: !setting.translatesTags) - } - ) - } else { - NotLoginView(action: { store.send(.onNotLoginViewButtonTapped) }) - } - } - .sheet(item: $store.route.sending(\.setNavigation).quickSearch) { _ in - QuickSearchView( - store: store.scope(state: \.quickSearchState, action: \.quickSearch) - ) { keyword in - store.send(.setNavigation(nil)) - store.send(.fetchGalleries(keyword)) - } - .accentColor(setting.accentColor) - .autoBlur(radius: blurRadius) - } - .sheet(item: $store.route.sending(\.setNavigation).filters) { _ in - FiltersView(store: store.scope(state: \.filtersState, action: \.filters)) - .autoBlur(radius: blurRadius).environment(\.inSheet, true) - } - .searchable(text: $store.keyword) - .searchSuggestions { - TagSuggestionView( - keyword: $store.keyword, translations: tagTranslator.translations, - showsImages: setting.showsImagesInTags, isEnabled: setting.showsTagsSearchSuggestion - ) - } - .onSubmit(of: .search) { - store.send(.fetchGalleries()) - } - .onAppear { - if store.galleries.isEmpty && CookieUtil.didLogin { - DispatchQueue.main.async { - store.send(.fetchGalleries()) - } - } - } - .background(navigationLink) - .toolbar(content: toolbar) - .navigationTitle(L10n.Localizable.WatchedView.Title.watched) - - if DeviceUtil.isPad { - content - .sheet(item: $store.route.sending(\.setNavigation).detail, id: \.self) { route in - NavigationView { - DetailView( - store: store.scope(state: \.detailState.wrappedValue!, action: \.detail), - gid: route.wrappedValue, user: user, setting: $setting, - blurRadius: blurRadius, tagTranslator: tagTranslator - ) - } - .autoBlur(radius: blurRadius).environment(\.inSheet, true).navigationViewStyle(.stack) - } - } else { - content - } - } - - @ViewBuilder private var navigationLink: some View { - if DeviceUtil.isPhone { - NavigationLink(unwrapping: $store.route, case: \.detail) { route in - DetailView( - store: store.scope(state: \.detailState.wrappedValue!, action: \.detail), - gid: route.wrappedValue, user: user, setting: $setting, - blurRadius: blurRadius, tagTranslator: tagTranslator - ) - } - } - } - private func toolbar() -> some ToolbarContent { - CustomToolbarItem { - ToolbarFeaturesMenu { - FiltersButton { - store.send(.setNavigation(.filters())) - } - QuickSearchButton { - store.send(.setNavigation(.quickSearch())) - } - } - } - } -} - -struct WatchedView_Previews: PreviewProvider { - static var previews: some View { - NavigationView { - WatchedView( - store: .init(initialState: .init(), reducer: WatchedReducer.init), - user: .init(), - setting: .constant(.init()), - blurRadius: 0, - tagTranslator: .init() - ) - } - } -} diff --git a/EhPanda/View/Migration/MigrationReducer.swift b/EhPanda/View/Migration/MigrationReducer.swift deleted file mode 100644 index a8a8c54a9..000000000 --- a/EhPanda/View/Migration/MigrationReducer.swift +++ /dev/null @@ -1,95 +0,0 @@ -// -// MigrationReducer.swift -// EhPanda -// - -import Foundation -import ComposableArchitecture - -@Reducer -struct MigrationReducer { - @CasePathable - enum Route: Equatable { - case dropDialog - } - - @ObservableState - struct State: Equatable { - var route: Route? - var databaseState: LoadingState = .loading - } - - enum Action: BindableAction, Equatable { - case binding(BindingAction) - case setNavigation(Route?) - case onDatabasePreparationSuccess - - case prepareDatabase - case prepareDatabaseDone(AppError?) - case dropDatabase - case dropDatabaseDone(AppError?) - } - - @Dependency(\.databaseClient) private var databaseClient - - var body: some Reducer { - BindingReducer() - - Reduce { state, action in - switch action { - case .binding: - return .none - - case .setNavigation(let route): - state.route = route - return .none - - case .onDatabasePreparationSuccess: - return .none - - case .prepareDatabase: - return .run { send in - let result = await databaseClient.prepareDatabase() - await send(.prepareDatabaseDone(result.error)) - } - - case .prepareDatabaseDone(let appError): - if let appError { - state.databaseState = .failed(appError) - return .none - } else { - state.databaseState = .idle - return .send(.onDatabasePreparationSuccess) - } - - case .dropDatabase: - state.databaseState = .loading - return .run { send in - try await Task.sleep(for: .milliseconds(500)) - let result = await databaseClient.dropDatabase() - await send(.dropDatabaseDone(result.error)) - } - - case .dropDatabaseDone(let appError): - if let appError { - state.databaseState = .failed(appError) - return .none - } else { - state.databaseState = .idle - return .send(.onDatabasePreparationSuccess) - } - } - } - } -} - -private extension Result { - var error: Failure? { - switch self { - case .success: - return nil - case let .failure(error): - return error - } - } -} diff --git a/EhPanda/View/Migration/MigrationView.swift b/EhPanda/View/Migration/MigrationView.swift deleted file mode 100644 index 071a19e91..000000000 --- a/EhPanda/View/Migration/MigrationView.swift +++ /dev/null @@ -1,54 +0,0 @@ -// -// MigrationView.swift -// EhPanda -// - -import SwiftUI -import ComposableArchitecture - -struct MigrationView: View { - @Environment(\.colorScheme) private var colorScheme - @Bindable private var store: StoreOf - - private var reversedPrimary: Color { - colorScheme == .light ? .white : .black - } - - init(store: StoreOf) { - self.store = store - } - - var body: some View { - NavigationView { - ZStack { - reversedPrimary.ignoresSafeArea() - LoadingView(title: L10n.Localizable.LoadingView.Title.preparingDatabase) - .opacity(store.databaseState == .loading ? 1 : 0) - let error = store.databaseState.failed - let errorNonNil = error ?? .databaseCorrupted(nil) - AlertView(symbol: errorNonNil.symbol, message: errorNonNil.localizedDescription) { - AlertViewButton(title: L10n.Localizable.ErrorView.Button.dropDatabase) { - store.send(.setNavigation(.dropDialog)) - } - .confirmationDialog( - message: L10n.Localizable.ConfirmationDialog.Title.dropDatabase, - unwrapping: $store.route, - case: \.dropDialog - ) { - Button(L10n.Localizable.ConfirmationDialog.Button.dropDatabase, role: .destructive) { - store.send(.dropDatabase) - } - } - } - .opacity(error != nil ? 1 : 0) - } - .animation(.default, value: store.databaseState) - } - } -} - -struct MigrationView_Previews: PreviewProvider { - static var previews: some View { - MigrationView(store: .init(initialState: .init(), reducer: MigrationReducer.init)) - } -} diff --git a/EhPanda/View/Reading/ReadingReducer.swift b/EhPanda/View/Reading/ReadingReducer.swift deleted file mode 100644 index a0a0fd3f9..000000000 --- a/EhPanda/View/Reading/ReadingReducer.swift +++ /dev/null @@ -1,645 +0,0 @@ -// -// ReadingReducer.swift -// EhPanda -// - -import SwiftUI -import TTProgressHUD -import ComposableArchitecture - -@Reducer -struct ReadingReducer { - @CasePathable - enum Route: Equatable { - case hud - case share(IdentifiableBox) - case readingSetting(EquatableVoid = .init()) - } - - enum ShareItem: Equatable { - var associatedValue: Any { - switch self { - case .data(let data): - return data - case .image(let image): - return image - } - } - case data(Data) - case image(UIImage) - } - - enum ImageAction { - case copy(Bool) - case save(Bool) - case share(Bool) - } - - private enum CancelID: CaseIterable { - case fetchImage - case fetchDatabaseInfos - case fetchPreviewURLs - case fetchThumbnailURLs - case fetchNormalImageURLs - case refetchNormalImageURLs - case fetchMPVKeys - case fetchMPVImageURL - } - - @ObservableState - struct State: Equatable { - var route: Route? - var gallery: Gallery = .empty - var galleryDetail: GalleryDetail? - - var readingProgress: Int = .zero - var forceRefreshID: UUID = .init() - var hudConfig: TTProgressHUDConfig = .loading - - var webImageLoadSuccessIndices = Set() - var imageURLLoadingStates = [Int: LoadingState]() - var previewLoadingStates = [Int: LoadingState]() - var databaseLoadingState: LoadingState = .loading - var previewConfig: PreviewConfig = .normal(rows: 4) - - var previewURLs = [Int: URL]() - - var thumbnailURLs = [Int: URL]() - var imageURLs = [Int: URL]() - var originalImageURLs = [Int: URL]() - - var mpvKey: String? - var mpvImageKeys = [Int: String]() - var mpvSkipServerIdentifiers = [Int: String]() - - var showsPanel = false - var showsSliderPreview = false - - // 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) - } - - // 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 - ) - } - } - - enum Action: BindableAction { - case binding(BindingAction) - case setNavigation(Route?) - - case toggleShowsPanel - case setOrientationPortrait(Bool) - case onPerformDismiss - case onAppear(String, Bool) - - case onWebImageRetry(Int) - case onWebImageSucceeded(Int) - case onWebImageFailed(Int) - case reloadAllWebImages - case retryAllFailedWebImages - - case copyImage(URL) - case saveImage(URL) - case saveImageDone(Bool) - case shareImage(URL) - case fetchImage(ImageAction, URL) - case fetchImageDone(ImageAction, Result) - - case syncReadingProgress(Int) - case syncPreviewURLs([Int: URL]) - case syncThumbnailURLs([Int: URL]) - case syncImageURLs([Int: URL], [Int: URL]) - - case teardown - case fetchDatabaseInfos(String) - 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>) - } - - @Dependency(\.appDelegateClient) private var appDelegateClient - @Dependency(\.clipboardClient) private var clipboardClient - @Dependency(\.databaseClient) private var databaseClient - @Dependency(\.hapticsClient) private var hapticsClient - @Dependency(\.cookieClient) private var cookieClient - @Dependency(\.deviceClient) private var deviceClient - @Dependency(\.imageClient) private var imageClient - @Dependency(\.urlClient) private var urlClient - - var body: some Reducer { - BindingReducer() - .onChange(of: \.showsSliderPreview) { _, _ in - Reduce({ _, _ in .run(operation: { _ in hapticsClient.generateFeedback(.soft) }) }) - } - - Reduce { state, action in - switch action { - case .binding: - return .none - - case .setNavigation(let route): - state.route = route - return .none - - case .toggleShowsPanel: - state.showsPanel.toggle() - return .none - - case .setOrientationPortrait(let isPortrait): - var effects = [Effect]() - if isPortrait { - effects.append(.run(operation: { _ in appDelegateClient.setPortraitOrientationMask() })) - effects.append(.run(operation: { _ in await appDelegateClient.setPortraitOrientation() })) - } else { - effects.append(.run(operation: { _ in appDelegateClient.setAllOrientationMask() })) - } - return .merge(effects) - - case .onPerformDismiss: - return .run(operation: { _ in hapticsClient.generateFeedback(.light) }) - - case .onAppear(let gid, let enablesLandscape): - var effects: [Effect] = [ - .send(.fetchDatabaseInfos(gid)) - ] - if enablesLandscape { - effects.append(.send(.setOrientationPortrait(false))) - } - return .merge(effects) - - case .onWebImageRetry(let index): - state.imageURLLoadingStates[index] = .idle - return .none - - case .onWebImageSucceeded(let index): - state.imageURLLoadingStates[index] = .idle - state.webImageLoadSuccessIndices.insert(index) - return .none - - case .onWebImageFailed(let index): - state.imageURLLoadingStates[index] = .failed(.webImageFailed) - return .none - - case .reloadAllWebImages: - state.previewURLs = .init() - state.thumbnailURLs = .init() - state.imageURLs = .init() - state.originalImageURLs = .init() - state.mpvKey = nil - state.mpvImageKeys = .init() - state.mpvSkipServerIdentifiers = .init() - state.forceRefreshID = .init() - return .run { [state] _ in - await databaseClient.removeImageURLs(gid: state.gallery.id) - } - - case .retryAllFailedWebImages: - state.imageURLLoadingStates.forEach { (index, loadingState) in - if case .failed = loadingState { - state.imageURLLoadingStates[index] = .idle - } - } - state.previewLoadingStates.forEach { (index, loadingState) in - if case .failed = loadingState { - state.previewLoadingStates[index] = .idle - } - } - return .none - - case .copyImage(let imageURL): - return .send(.fetchImage(.copy(imageURL.isGIF), imageURL)) - - case .saveImage(let imageURL): - return .send(.fetchImage(.save(imageURL.isGIF), imageURL)) - - case .saveImageDone(let isSucceeded): - state.hudConfig = isSucceeded ? .savedToPhotoLibrary : .error - return .send(.setNavigation(.hud)) - - case .shareImage(let imageURL): - return .send(.fetchImage(.share(imageURL.isGIF), imageURL)) - - case .fetchImage(let action, let imageURL): - return .run { send in - let result = await imageClient.fetchImage(url: imageURL) - await send(.fetchImageDone(action, result)) - } - .cancellable(id: CancelID.fetchImage) - - case .fetchImageDone(let action, let result): - if case .success(let image) = result { - switch action { - case .copy(let isAnimated): - state.hudConfig = .copiedToClipboardSucceeded - return .merge( - .send(.setNavigation(.hud)), - .run(operation: { _ in clipboardClient.saveImage(image, isAnimated) }) - ) - case .save(let isAnimated): - return .run { send in - let success = await imageClient.saveImageToPhotoLibrary(image, isAnimated) - await send(.saveImageDone(success)) - } - case .share(let isAnimated): - if isAnimated, let data = image.kf.data(format: .GIF) { - return .send(.setNavigation(.share(.init(value: .data(data))))) - } else { - return .send(.setNavigation(.share(.init(value: .image(image))))) - } - } - } else { - state.hudConfig = .error - return .send(.setNavigation(.hud)) - } - - case .syncReadingProgress(let progress): - return .run { [state] _ in - await databaseClient.updateReadingProgress(gid: state.gallery.id, progress: progress) - } - - case .syncPreviewURLs(let previewURLs): - return .run { [state] _ in - await databaseClient.updatePreviewURLs(gid: state.gallery.id, previewURLs: previewURLs) - } - - case .syncThumbnailURLs(let thumbnailURLs): - return .run { [state] _ in - await databaseClient.updateThumbnailURLs(gid: state.gallery.id, thumbnailURLs: thumbnailURLs) - } - - case .syncImageURLs(let imageURLs, let originalImageURLs): - return .run { [state] _ in - await databaseClient.updateImageURLs( - gid: state.gallery.id, - imageURLs: imageURLs, - originalImageURLs: originalImageURLs - ) - } - - case .teardown: - var effects: [Effect] = [ - .merge(CancelID.allCases.map(Effect.cancel(id:))) - ] - if !deviceClient.isPad() { - effects.append(.send(.setOrientationPortrait(true))) - } - return .merge(effects) - - case .fetchDatabaseInfos(let gid): - guard let gallery = databaseClient.fetchGallery(gid: gid) else { return .none } - state.gallery = gallery - state.galleryDetail = databaseClient.fetchGalleryDetail(gid: state.gallery.id) - return .run { [state] send in - guard let dbState = await databaseClient.fetchGalleryState(gid: state.gallery.id) else { return } - await send(.fetchDatabaseInfosDone(dbState)) - } - .cancellable(id: CancelID.fetchDatabaseInfos) - - case .fetchDatabaseInfosDone(let galleryState): - if let previewConfig = galleryState.previewConfig { - state.previewConfig = previewConfig - } - state.previewURLs = galleryState.previewURLs - state.imageURLs = galleryState.imageURLs - state.thumbnailURLs = galleryState.thumbnailURLs - state.originalImageURLs = galleryState.originalImageURLs - state.readingProgress = galleryState.readingProgress - state.databaseLoadingState = .idle - return .none - - case .fetchPreviewURLs(let index): - guard state.previewLoadingStates[index] != .loading, - let galleryURL = state.gallery.galleryURL - else { return .none } - state.previewLoadingStates[index] = .loading - let pageNum = state.previewConfig.pageNumber(index: index) - return .run { send in - let response = await GalleryPreviewURLsRequest(galleryURL: galleryURL, pageNum: pageNum).response() - await send(.fetchPreviewURLsDone(index, response)) - } - .cancellable(id: CancelID.fetchPreviewURLs) - - case .fetchPreviewURLsDone(let index, let result): - switch result { - case .success(let previewURLs): - guard !previewURLs.isEmpty else { - state.previewLoadingStates[index] = .failed(.notFound) - return .none - } - state.previewLoadingStates[index] = .idle - state.updatePreviewURLs(previewURLs) - return .send(.syncPreviewURLs(previewURLs)) - case .failure(let error): - state.previewLoadingStates[index] = .failed(error) - } - return .none - - case .fetchImageURLs(let index): - if state.mpvKey != nil { - return .send(.fetchMPVImageURL(index, false)) - } else { - return .send(.fetchThumbnailURLs(index)) - } - - case .refetchImageURLs(let index): - if state.mpvKey != nil { - return .send(.fetchMPVImageURL(index, true)) - } else { - return .send(.refetchNormalImageURLs(index)) - } - - case .prefetchImages(let index, let prefetchLimit): - func getPrefetchImageURLs(range: ClosedRange) -> [URL] { - (range.lowerBound...range.upperBound).compactMap { index in - if let url = state.imageURLs[index] { - return url - } - return nil - } - } - func getFetchImageURLIndices(range: ClosedRange) -> [Int] { - (range.lowerBound...range.upperBound).compactMap { index in - if state.imageURLs[index] == nil, state.imageURLLoadingStates[index] != .loading { - return index - } - return nil - } - } - var prefetchImageURLs = [URL]() - var fetchImageURLIndices = [Int]() - var effects = [Effect]() - let previousUpperBound = max(index - 2, 1) - let previousLowerBound = max(previousUpperBound - prefetchLimit / 2, 1) - if previousUpperBound - previousLowerBound > 0 { - prefetchImageURLs += getPrefetchImageURLs(range: previousLowerBound...previousUpperBound) - fetchImageURLIndices += getFetchImageURLIndices(range: previousLowerBound...previousUpperBound) - } - let nextLowerBound = min(index + 2, state.gallery.pageCount) - let nextUpperBound = min(nextLowerBound + prefetchLimit / 2, state.gallery.pageCount) - if nextUpperBound - nextLowerBound > 0 { - prefetchImageURLs += getPrefetchImageURLs(range: nextLowerBound...nextUpperBound) - fetchImageURLIndices += getFetchImageURLIndices(range: nextLowerBound...nextUpperBound) - } - fetchImageURLIndices.forEach { - effects.append(.send(.fetchImageURLs($0))) - } - effects.append( - .run { [prefetchImageURLs] _ in - imageClient.prefetchImages(prefetchImageURLs) - } - ) - return .merge(effects) - - case .fetchThumbnailURLs(let index): - guard state.imageURLLoadingStates[index] != .loading, - let galleryURL = state.gallery.galleryURL - else { return .none } - state.previewConfig.batchRange(index: index).forEach { - state.imageURLLoadingStates[$0] = .loading - } - let pageNum = state.previewConfig.pageNumber(index: index) - return .run { send in - let response = await ThumbnailURLsRequest(galleryURL: galleryURL, pageNum: pageNum).response() - await send(.fetchThumbnailURLsDone(index, response)) - } - .cancellable(id: CancelID.fetchThumbnailURLs) - - case .fetchThumbnailURLsDone(let index, let result): - let batchRange = state.previewConfig.batchRange(index: index) - switch result { - case .success(let thumbnailURLs): - guard !thumbnailURLs.isEmpty else { - batchRange.forEach { - state.imageURLLoadingStates[$0] = .failed(.notFound) - } - return .none - } - if let url = thumbnailURLs[index], urlClient.checkIfMPVURL(url) { - return .send(.fetchMPVKeys(index, url)) - } else { - state.updateThumbnailURLs(thumbnailURLs) - return .merge( - .send(.syncThumbnailURLs(thumbnailURLs)), - .send(.fetchNormalImageURLs(index, thumbnailURLs)) - ) - } - case .failure(let error): - batchRange.forEach { - state.imageURLLoadingStates[$0] = .failed(error) - } - } - return .none - - case .fetchNormalImageURLs(let index, let thumbnailURLs): - return .run { send in - let response = await GalleryNormalImageURLsRequest(thumbnailURLs: thumbnailURLs).response() - await send(.fetchNormalImageURLsDone(index, response)) - } - .cancellable(id: CancelID.fetchNormalImageURLs) - - case .fetchNormalImageURLsDone(let index, let result): - let batchRange = state.previewConfig.batchRange(index: index) - switch result { - case .success(let (imageURLs, originalImageURLs)): - guard !imageURLs.isEmpty else { - batchRange.forEach { - state.imageURLLoadingStates[$0] = .failed(.notFound) - } - return .none - } - batchRange.forEach { - state.imageURLLoadingStates[$0] = .idle - } - state.updateImageURLs(imageURLs, originalImageURLs) - return .send(.syncImageURLs(imageURLs, originalImageURLs)) - case .failure(let error): - batchRange.forEach { - state.imageURLLoadingStates[$0] = .failed(error) - } - } - return .none - - case .refetchNormalImageURLs(let index): - guard state.imageURLLoadingStates[index] != .loading, - let galleryURL = state.gallery.galleryURL, - let imageURL = state.imageURLs[index] - else { return .none } - state.imageURLLoadingStates[index] = .loading - let pageNum = state.previewConfig.pageNumber(index: index) - return .run { [thumbnailURL = state.thumbnailURLs[index]] send in - let response = await GalleryNormalImageURLRefetchRequest( - index: index, - pageNum: pageNum, - galleryURL: galleryURL, - thumbnailURL: thumbnailURL, - storedImageURL: imageURL - ) - .response() - await send(.refetchNormalImageURLsDone(index, response)) - } - .cancellable(id: CancelID.refetchNormalImageURLs) - - case .refetchNormalImageURLsDone(let index, let result): - switch result { - case .success(let (imageURLs, response)): - var effects = [Effect]() - if let response = response { - effects.append(.run(operation: { _ in cookieClient.setSkipServer(response: response) })) - } - guard !imageURLs.isEmpty else { - state.imageURLLoadingStates[index] = .failed(.notFound) - return effects.isEmpty ? .none : .merge(effects) - } - state.imageURLLoadingStates[index] = .idle - state.updateImageURLs(imageURLs, [:]) - effects.append(.send(.syncImageURLs(imageURLs, [:]))) - return .merge(effects) - case .failure(let error): - state.imageURLLoadingStates[index] = .failed(error) - } - return .none - - case .fetchMPVKeys(let index, let mpvURL): - return .run { send in - let response = await MPVKeysRequest(mpvURL: mpvURL).response() - await send(.fetchMPVKeysDone(index, response)) - } - .cancellable(id: CancelID.fetchMPVKeys) - - case .fetchMPVKeysDone(let index, let result): - let batchRange = state.previewConfig.batchRange(index: index) - switch result { - case .success(let (mpvKey, mpvImageKeys)): - let pageCount = state.gallery.pageCount - guard mpvImageKeys.count == pageCount else { - batchRange.forEach { - state.imageURLLoadingStates[$0] = .failed(.notFound) - } - return .none - } - batchRange.forEach { - state.imageURLLoadingStates[$0] = .idle - } - state.mpvKey = mpvKey - state.mpvImageKeys = mpvImageKeys - return .merge( - Array(1...min(3, max(1, pageCount))).map { - .send(.fetchMPVImageURL($0, false)) - } - ) - case .failure(let error): - batchRange.forEach { - state.imageURLLoadingStates[$0] = .failed(error) - } - } - return .none - - case .fetchMPVImageURL(let index, let isRefresh): - guard let gidInteger = Int(state.gallery.id), let mpvKey = state.mpvKey, - let mpvImageKey = state.mpvImageKeys[index], - state.imageURLLoadingStates[index] != .loading - else { return .none } - state.imageURLLoadingStates[index] = .loading - let skipServerIdentifier = isRefresh ? state.mpvSkipServerIdentifiers[index] : nil - return .run { send in - let response = await GalleryMPVImageURLRequest( - gid: gidInteger, - index: index, - mpvKey: mpvKey, - mpvImageKey: mpvImageKey, - skipServerIdentifier: skipServerIdentifier - ) - .response() - await send(.fetchMPVImageURLDone(index, response)) - } - .cancellable(id: CancelID.fetchMPVImageURL) - - case .fetchMPVImageURLDone(let index, let result): - switch result { - case .success(let (imageURL, originalImageURL, skipServerIdentifier)): - let imageURLs: [Int: URL] = [index: imageURL] - var originalImageURLs = [Int: URL]() - if let originalImageURL = originalImageURL { - originalImageURLs[index] = originalImageURL - } - state.imageURLLoadingStates[index] = .idle - state.mpvSkipServerIdentifiers[index] = skipServerIdentifier - state.updateImageURLs(imageURLs, originalImageURLs) - return .send(.syncImageURLs(imageURLs, originalImageURLs)) - case .failure(let error): - state.imageURLLoadingStates[index] = .failed(error) - } - return .none - } - } - .haptics( - unwrapping: \.route, - case: \.readingSetting, - hapticsClient: hapticsClient - ) - .haptics( - unwrapping: \.route, - case: \.share, - hapticsClient: hapticsClient - ) - } -} diff --git a/EhPanda/View/Reading/ReadingView.swift b/EhPanda/View/Reading/ReadingView.swift deleted file mode 100644 index 504213a30..000000000 --- a/EhPanda/View/Reading/ReadingView.swift +++ /dev/null @@ -1,636 +0,0 @@ -// -// ReadingView.swift -// EhPanda -// - -import SwiftUI -import Kingfisher -import SwiftUIPager -import ComposableArchitecture - -struct ReadingView: View { - @Environment(\.colorScheme) private var colorScheme - - @Bindable var store: StoreOf - private let gid: String - @Binding private var setting: Setting - private let blurRadius: Double - - @StateObject private var liveTextHandler = LiveTextHandler() - @StateObject private var autoPlayHandler = AutoPlayHandler() - @StateObject private var gestureHandler = GestureHandler() - @StateObject private var pageHandler = PageHandler() - @StateObject private var page: Page = .first() - - init( - store: StoreOf, - gid: String, setting: Binding, blurRadius: Double - ) { - self.store = store - self.gid = gid - _setting = setting - self.blurRadius = blurRadius - } - - private var backgroundColor: Color { - colorScheme == .light ? Color(.systemGray4) : Color(.systemGray6) - } - - var body: some View { - changeTriggers(content: { content }) - .sheet(item: $store.route.sending(\.setNavigation).readingSetting) { _ in - NavigationView { - ReadingSettingView( - readingDirection: $setting.readingDirection, - prefetchLimit: $setting.prefetchLimit, - enablesLandscape: $setting.enablesLandscape, - contentDividerHeight: $setting.contentDividerHeight, - maximumScaleFactor: $setting.maximumScaleFactor, - doubleTapScaleFactor: $setting.doubleTapScaleFactor - ) - .toolbar { - if !DeviceUtil.isPad && DeviceUtil.isLandscape { - CustomToolbarItem(placement: .cancellationAction) { - Button { - store.send(.setNavigation(nil)) - } label: { - Image(systemSymbol: .chevronDown) - } - } - } - } - } - .accentColor(setting.accentColor) - .tint(setting.accentColor) - .autoBlur(radius: blurRadius) - .navigationViewStyle(.stack) - } - .sheet(item: $store.route.sending(\.setNavigation).share) { shareItemBox in - ActivityView(activityItems: [shareItemBox.wrappedValue.associatedValue]) - .accentColor(setting.accentColor) - .autoBlur(radius: blurRadius) - } - .progressHUD( - config: store.hudConfig, - unwrapping: $store.route, - case: \.hud - ) - - .animation(.linear(duration: 0.1), value: gestureHandler.offset) - .animation(.default, value: liveTextHandler.enablesLiveText) - .animation(.default, value: liveTextHandler.liveTextGroups) - .animation(.default, value: gestureHandler.scale) - .animation(.default, value: store.showsPanel) - .statusBar(hidden: !store.showsPanel) - .onDisappear { - liveTextHandler.cancelRequests() - setAutoPlayPolocy(.off) - } - .onAppear { store.send(.onAppear(gid, setting.enablesLandscape)) } - } - - var content: some View { - ZStack { - backgroundColor.ignoresSafeArea() - - ZStack { - if setting.readingDirection == .vertical { - AdvancedList( - page: page, - data: store.state.containerDataSource(setting: setting), - id: \.self, - spacing: setting.contentDividerHeight, - gesture: SimultaneousGesture(magnificationGesture, tapGesture), - content: imageStack - ) - .scrollDisabled(gestureHandler.scale != 1) - } else { - Pager( - page: page, - data: store.state.containerDataSource(setting: setting), - id: \.self, - content: imageStack - ) - .horizontal(setting.readingDirection == .rightToLeft ? .endToStart : .startToEnd) - .swipeInteractionArea(.allAvailable) - .allowsDragging(gestureHandler.scale == 1) - } - } - .scaleEffect(gestureHandler.scale, anchor: gestureHandler.scaleAnchor) - .offset(gestureHandler.offset) - .highPriorityGesture( - dragGesture.simultaneously(with: tapGesture), - isEnabled: gestureHandler.scale > 1 - ) - .gesture(tapGesture, isEnabled: gestureHandler.scale == 1) - .gesture(magnificationGesture) - .ignoresSafeArea() - .id(store.databaseLoadingState) - .id(store.forceRefreshID) - - ControlPanel( - showsPanel: $store.showsPanel, - showsSliderPreview: $store.showsSliderPreview, - sliderValue: $pageHandler.sliderValue, setting: $setting, - enablesLiveText: $liveTextHandler.enablesLiveText, - autoPlayPolicy: .init(get: { autoPlayHandler.policy }, set: { setAutoPlayPolocy($0) }), - range: 1...Float(store.gallery.pageCount), - previewURLs: store.previewURLs, - dismissGesture: controlPanelDismissGesture, - dismissAction: { store.send(.onPerformDismiss) }, - navigateSettingAction: { store.send(.setNavigation(.readingSetting())) }, - reloadAllImagesAction: { store.send(.reloadAllWebImages) }, - retryAllFailedImagesAction: { store.send(.retryAllFailedWebImages) }, - fetchPreviewURLsAction: { store.send(.fetchPreviewURLs($0)) } - ) - } - } - - @ViewBuilder - private func changeTriggers(@ViewBuilder content: () -> Content) -> some View { - content() - // Page - .onChange(of: page.index) { _, newValue in - Logger.info("page.index changed", context: ["pageIndex": newValue]) - let newValue = pageHandler.mapFromPager( - index: newValue, pageCount: store.gallery.pageCount, setting: setting - ) - pageHandler.sliderValue = .init(newValue) - if store.databaseLoadingState == .idle { - store.send(.syncReadingProgress(.init(newValue))) - } - } - .onChange(of: pageHandler.sliderValue) { _, newValue in - Logger.info("pageHandler.sliderValue changed", context: ["sliderValue": newValue]) - if !store.showsSliderPreview { - setPageIndex(sliderValue: newValue) - } - } - .onChange(of: store.showsSliderPreview) { _, newValue in - Logger.info("store.showsSliderPreview changed", context: ["isShown": newValue]) - if !newValue { setPageIndex(sliderValue: pageHandler.sliderValue) } - setAutoPlayPolocy(.off) - } - .onChange(of: store.readingProgress) { _, newValue in - Logger.info("store.readingProgress changed", context: ["readingProgress": newValue]) - pageHandler.sliderValue = .init(newValue) - } - - // AutoPlay - .onChange(of: store.route) { _, newValue in - Logger.info("store.route changed", context: ["route": newValue]) - if ![.hud, .none].contains(newValue) { - setAutoPlayPolocy(.off) - } - } - - // LiveText - .onChange(of: liveTextHandler.enablesLiveText) { _, newValue in - Logger.info("liveTextHandler.enablesLiveText changed", context: ["isEnabled": newValue]) - if newValue { store.webImageLoadSuccessIndices.forEach(analyzeImageForLiveText) } - } - .onChange(of: store.webImageLoadSuccessIndices) { _, newValue in - Logger.info("store.webImageLoadSuccessIndices changed", context: [ - "count": store.webImageLoadSuccessIndices.count - ]) - if liveTextHandler.enablesLiveText { - newValue.forEach(analyzeImageForLiveText) - } - } - - // Orientation - .onChange(of: setting.enablesLandscape) { _, newValue in - Logger.info("setting.enablesLandscape changed", context: ["newValue": newValue]) - store.send(.setOrientationPortrait(!newValue)) - } - } - - @ViewBuilder private func imageStack(index: Int) -> some View { - let imageStackConfig = store.state.imageContainerConfigs(index: index, setting: setting) - let isDualPage = setting.enablesDualPageMode && setting.readingDirection != .vertical && DeviceUtil.isLandscape - HorizontalImageStack( - index: index, - isDualPage: isDualPage, - isDatabaseLoading: store.databaseLoadingState != .idle, - backgroundColor: backgroundColor, - config: imageStackConfig, - imageURLs: store.imageURLs, - originalImageURLs: store.originalImageURLs, - loadingStates: store.imageURLLoadingStates, - enablesLiveText: liveTextHandler.enablesLiveText, - liveTextGroups: liveTextHandler.liveTextGroups, - focusedLiveTextGroup: liveTextHandler.focusedLiveTextGroup, - liveTextTapAction: liveTextHandler.setFocusedLiveTextGroup, - fetchAction: { store.send(.fetchImageURLs($0)) }, - refetchAction: { store.send(.refetchImageURLs($0)) }, - prefetchAction: { store.send(.prefetchImages($0, setting.prefetchLimit)) }, - loadRetryAction: { store.send(.onWebImageRetry($0)) }, - loadSucceededAction: { store.send(.onWebImageSucceeded($0)) }, - loadFailedAction: { store.send(.onWebImageFailed($0)) }, - copyImageAction: { store.send(.copyImage($0)) }, - saveImageAction: { store.send(.saveImage($0)) }, - shareImageAction: { store.send(.shareImage($0)) } - ) - } -} - -// MARK: Handler methods -extension ReadingView { - func setPageIndex(sliderValue: Float) { - let newValue = pageHandler.mapToPager( - index: .init(sliderValue), setting: setting - ) - if page.index != newValue { - page.update(.new(index: newValue)) - Logger.info("Pager.update", context: ["update": newValue]) - } - } - func setAutoPlayPolocy(_ policy: AutoPlayPolicy) { - autoPlayHandler.setPolicy(policy, updatePageAction: { - page.update(.next) - Logger.info("Pager.update", context: ["update": "next"]) - }) - } - func analyzeImageForLiveText(index: Int) { - Logger.info("analyzeImageForLiveText", context: ["index": index]) - guard liveTextHandler.liveTextGroups[index] == nil else { - Logger.info("analyzeImageForLiveText duplicated", context: ["index": index]) - return - } - guard let key = store.imageURLs[index]?.absoluteString else { - Logger.info("analyzeImageForLiveText URL not found", context: ["index": index]) - return - } - KingfisherManager.shared.cache.retrieveImage(forKey: key) { result in - switch result { - case .success(let result): - if let image = result.image, let cgImage = image.cgImage { - liveTextHandler.analyzeImage( - cgImage, size: image.size, index: index, recognitionLanguages: - store.galleryDetail?.language.codes - ) - } else { - Logger.info("analyzeImageForLiveText image not found", context: ["index": index]) - } - case .failure(let error): - Logger.info( - "analyzeImageForLiveText failed", - context: [ - "index": index, - "error": error - ] - as [String: Any] - ) - } - } - } -} - -// MARK: Gesture -extension ReadingView { - var tapGesture: some Gesture { - let singleTap = TapGesture(count: 1) - .onEnded { - gestureHandler.onSingleTapGestureEnded( - readingDirection: setting.readingDirection, - setPageIndexOffsetAction: { - let newValue = page.index + $0 - page.update(.new(index: newValue)) - Logger.info("Pager.update", context: ["update": newValue]) - }, - toggleShowsPanelAction: { store.send(.toggleShowsPanel) } - ) - } - let doubleTap = TapGesture(count: 2) - .onEnded { - gestureHandler.onDoubleTapGestureEnded( - scaleMaximum: setting.maximumScaleFactor, - doubleTapScale: setting.doubleTapScaleFactor - ) - } - return ExclusiveGesture(doubleTap, singleTap) - } - var magnificationGesture: some Gesture { - MagnificationGesture() - .onChanged { - gestureHandler.onMagnificationGestureChanged( - value: $0, scaleMaximum: setting.maximumScaleFactor - ) - } - .onEnded { - gestureHandler.onMagnificationGestureEnded( - value: $0, scaleMaximum: setting.maximumScaleFactor - ) - } - } - var dragGesture: some Gesture { - DragGesture(minimumDistance: .zero, coordinateSpace: .local) - .onChanged(gestureHandler.onDragGestureChanged) - .onEnded(gestureHandler.onDragGestureEnded) - } - var controlPanelDismissGesture: some Gesture { - DragGesture().onEnded { - gestureHandler.onControlPanelDismissGestureEnded( - value: $0, dismissAction: { store.send(.onPerformDismiss) } - ) - } - } -} - -// MARK: HorizontalImageStack -private struct HorizontalImageStack: View { - private let index: Int - private let isDualPage: Bool - private let isDatabaseLoading: Bool - private let backgroundColor: Color - private let config: ImageStackConfig - private let imageURLs: [Int: URL] - private let originalImageURLs: [Int: URL] - private let loadingStates: [Int: LoadingState] - private let enablesLiveText: Bool - private let liveTextGroups: [Int: [LiveTextGroup]] - private let focusedLiveTextGroup: LiveTextGroup? - private let liveTextTapAction: (LiveTextGroup) -> Void - private let fetchAction: (Int) -> Void - private let refetchAction: (Int) -> Void - private let prefetchAction: (Int) -> Void - private let loadRetryAction: (Int) -> Void - private let loadSucceededAction: (Int) -> Void - private let loadFailedAction: (Int) -> Void - private let copyImageAction: (URL) -> Void - private let saveImageAction: (URL) -> Void - private let shareImageAction: (URL) -> Void - - init( - index: Int, isDualPage: Bool, isDatabaseLoading: Bool, backgroundColor: Color, - config: ImageStackConfig, imageURLs: [Int: URL], originalImageURLs: [Int: URL], - loadingStates: [Int: LoadingState], enablesLiveText: Bool, - liveTextGroups: [Int: [LiveTextGroup]], focusedLiveTextGroup: LiveTextGroup?, - liveTextTapAction: @escaping (LiveTextGroup) -> Void, - fetchAction: @escaping (Int) -> Void, - refetchAction: @escaping (Int) -> Void, prefetchAction: @escaping (Int) -> Void, - loadRetryAction: @escaping (Int) -> Void, loadSucceededAction: @escaping (Int) -> Void, - loadFailedAction: @escaping (Int) -> Void, copyImageAction: @escaping (URL) -> Void, - saveImageAction: @escaping (URL) -> Void, shareImageAction: @escaping (URL) -> Void - ) { - self.index = index - self.isDualPage = isDualPage - self.isDatabaseLoading = isDatabaseLoading - self.backgroundColor = backgroundColor - self.config = config - self.imageURLs = imageURLs - self.originalImageURLs = originalImageURLs - self.loadingStates = loadingStates - self.enablesLiveText = enablesLiveText - self.liveTextGroups = liveTextGroups - self.focusedLiveTextGroup = focusedLiveTextGroup - self.liveTextTapAction = liveTextTapAction - self.fetchAction = fetchAction - self.refetchAction = refetchAction - self.prefetchAction = prefetchAction - self.loadRetryAction = loadRetryAction - self.loadSucceededAction = loadSucceededAction - self.loadFailedAction = loadFailedAction - self.copyImageAction = copyImageAction - self.saveImageAction = saveImageAction - self.shareImageAction = shareImageAction - } - - var body: some View { - HStack(spacing: 0) { - if config.isFirstAvailable { - imageContainer(index: config.firstIndex) - } - if config.isSecondAvailable { - imageContainer(index: config.secondIndex) - } - } - } - - func imageContainer(index: Int) -> some View { - ImageContainer( - index: index, - imageURL: imageURLs[index], - loadingState: loadingStates[index] ?? .idle, - isDualPage: isDualPage, - backgroundColor: backgroundColor, - enablesLiveText: enablesLiveText, - liveTextGroups: liveTextGroups[index] ?? [], - focusedLiveTextGroup: focusedLiveTextGroup, - liveTextTapAction: liveTextTapAction, - refetchAction: refetchAction, - loadRetryAction: loadRetryAction, - loadSucceededAction: loadSucceededAction, - loadFailedAction: loadFailedAction - ) - .onAppear { - if !isDatabaseLoading { - if imageURLs[index] == nil { - fetchAction(index) - } - prefetchAction(index) - } - } - .contextMenu { contextMenuItems(index: index) } - } - @ViewBuilder private func contextMenuItems(index: Int) -> some View { - Button { - refetchAction(index) - } label: { - Label(L10n.Localizable.ReadingView.ContextMenu.Button.reload, systemSymbol: .arrowCounterclockwise) - } - if let imageURL = imageURLs[index] { - Button { - copyImageAction(imageURL) - } label: { - Label(L10n.Localizable.ReadingView.ContextMenu.Button.copy, systemSymbol: .plusSquareOnSquare) - } - Button { - saveImageAction(imageURL) - } label: { - Label(L10n.Localizable.ReadingView.ContextMenu.Button.save, systemSymbol: .squareAndArrowDown) - } - if let originalImageURL = originalImageURLs[index] { - Button { - saveImageAction(originalImageURL) - } label: { - Label( - L10n.Localizable.ReadingView.ContextMenu.Button.saveOriginal, - systemSymbol: .squareAndArrowDownOnSquare - ) - } - } - Button { - shareImageAction(imageURL) - } label: { - Label(L10n.Localizable.ReadingView.ContextMenu.Button.share, systemSymbol: .squareAndArrowUp) - } - } - } -} - -// MARK: ImageContainer -private struct ImageContainer: View { - private var width: CGFloat { - DeviceUtil.windowW / (isDualPage ? 2 : 1) - } - private var height: CGFloat { - width / Defaults.ImageSize.contentAspect - } - - private let index: Int - private let imageURL: URL? - private let loadingState: LoadingState - private let isDualPage: Bool - private let backgroundColor: Color - private let enablesLiveText: Bool - private let liveTextGroups: [LiveTextGroup] - private let focusedLiveTextGroup: LiveTextGroup? - private let liveTextTapAction: (LiveTextGroup) -> Void - private let refetchAction: (Int) -> Void - private let loadRetryAction: (Int) -> Void - private let loadSucceededAction: (Int) -> Void - private let loadFailedAction: (Int) -> Void - - init( - index: Int, imageURL: URL?, - loadingState: LoadingState, - isDualPage: Bool, - backgroundColor: Color, - enablesLiveText: Bool, - liveTextGroups: [LiveTextGroup], - focusedLiveTextGroup: LiveTextGroup?, - liveTextTapAction: @escaping (LiveTextGroup) -> Void, - refetchAction: @escaping (Int) -> Void, - loadRetryAction: @escaping (Int) -> Void, - loadSucceededAction: @escaping (Int) -> Void, - loadFailedAction: @escaping (Int) -> Void - ) { - self.index = index - self.imageURL = imageURL - self.loadingState = loadingState - self.isDualPage = isDualPage - self.backgroundColor = backgroundColor - self.enablesLiveText = enablesLiveText - self.liveTextGroups = liveTextGroups - self.focusedLiveTextGroup = focusedLiveTextGroup - self.liveTextTapAction = liveTextTapAction - self.refetchAction = refetchAction - self.loadRetryAction = loadRetryAction - self.loadSucceededAction = loadSucceededAction - self.loadFailedAction = loadFailedAction - } - - private func placeholder(_ progress: Progress) -> some View { - Placeholder(style: .progress( - pageNumber: index, progress: progress, - isDualPage: isDualPage, backgroundColor: backgroundColor - )) - .frame(width: width, height: height) - } - @ViewBuilder private func image(url: URL?) -> some View { - if url?.isGIF != true { - KFImage(url) - .placeholder(placeholder) - .defaultModifier(withRoundedCorners: false) - .onSuccess(onSuccess).onFailure(onFailure) - } else { - KFAnimatedImage(url) - .placeholder(placeholder).fade(duration: 0.25) - .onSuccess(onSuccess).onFailure(onFailure) - } - } - - var body: some View { - if loadingState == .idle { - image(url: imageURL).scaledToFit().overlay( - LiveTextView( - liveTextGroups: liveTextGroups, - focusedLiveTextGroup: focusedLiveTextGroup, - tapAction: liveTextTapAction - ) - .opacity(enablesLiveText ? 1 : 0) - ) - } else { - ZStack { - backgroundColor - VStack { - Text(String(index)).font(.largeTitle.bold()) - .foregroundColor(.gray).padding(.bottom, 30) - ZStack { - Button(action: reloadImage) { - Image(systemSymbol: .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 let error = loadingState.failed { - if case .webImageFailed = error { - loadRetryAction(index) - } else { - refetchAction(index) - } - } - } - private func onSuccess(_: RetrieveImageResult) { - loadSucceededAction(index) - } - private func onFailure(_: KingfisherError) { - if imageURL != nil { - loadFailedAction(index) - } - } -} - -// MARK: Definition -struct ImageStackConfig { - let firstIndex: Int - let secondIndex: Int - let isFirstAvailable: Bool - let isSecondAvailable: Bool -} - -enum AutoPlayPolicy: Int, CaseIterable, Identifiable { - var id: Int { rawValue } - - case off = -1 - case sec1 = 1 - case sec2 = 2 - case sec3 = 3 - case sec4 = 4 - case sec5 = 5 -} - -extension AutoPlayPolicy { - var value: String { - switch self { - case .off: - return L10n.Localizable.Enum.AutoPlayPolicy.Value.off - default: - return L10n.Localizable.Common.Value.seconds("\(rawValue)") - } - } -} - -struct ReadingView_Previews: PreviewProvider { - static var previews: some View { - NavigationView { - Text("") - .fullScreenCover(isPresented: .constant(true)) { - ReadingView( - store: .init(initialState: .init(gallery: .empty), reducer: ReadingReducer.init), - gid: .init(), - setting: .constant(.init()), - blurRadius: 0 - ) - } - } - } -} diff --git a/EhPanda/View/Reading/Support/LiveTextHandler.swift b/EhPanda/View/Reading/Support/LiveTextHandler.swift deleted file mode 100644 index 28266c851..000000000 --- a/EhPanda/View/Reading/Support/LiveTextHandler.swift +++ /dev/null @@ -1,189 +0,0 @@ -// -// LiveTextHandler.swift -// EhPanda -// -// swiftlint:disable line_length -// Refercence -// https://www.codeproject.com/Articles/15573/2D-Polygon-Collision-Detection -// https://developer.apple.com/documentation/vision/recognizing_text_in_images -// https://github.com/TelegramMessenger/Telegram-iOS/blob/2a32c871882c4e1b1ccdecd34fccd301723b30d9/submodules/Translate/Sources/Translate.swift -// https://github.com/TelegramMessenger/Telegram-iOS/blob/0be460b147321b7455247aedca81ca819702959d/submodules/ImageContentAnalysis/Sources/ImageContentAnalysis.swift -// swiftlint:enable line_length -// - -import Vision -import SwiftUI -import Foundation - -final class LiveTextHandler: ObservableObject { - @Published var enablesLiveText = false - @Published var liveTextGroups = [Int: [LiveTextGroup]]() - @Published private(set) var focusedLiveTextGroup: LiveTextGroup? - - private var processingRequests = [VNRequest]() - - deinit { - cancelRequests() - } - - func cancelRequests() { - Logger.info("cancelRequests", context: [ - "processingRequestsCount": processingRequests.count - ]) - processingRequests.forEach { request in - request.cancel() - } - } - - func setFocusedLiveTextGroup(_ group: LiveTextGroup) { - Logger.info("setFocusedLiveTextGroup", context: ["group": group]) - focusedLiveTextGroup = group - } - - func analyzeImage(_ cgImage: CGImage, size: CGSize, index: Int, recognitionLanguages: [String]?) { - Logger.info("analyzeImage", context: [ - "index": index, "recognitionLanguages": recognitionLanguages as Any - ]) - - let requestHandler = VNImageRequestHandler(cgImage: cgImage) - let textRecognitionRequest = VNRecognizeTextRequest { [weak self] in - self?.textRecognitionHandler(request: $0, error: $1, size: size, index: index) - } - textRecognitionRequest.usesLanguageCorrection = true - textRecognitionRequest.preferBackgroundProcessing = true - if let languages = recognitionLanguages { - textRecognitionRequest.recognitionLanguages = languages - } - - processingRequests.append(textRecognitionRequest) - DispatchQueue.global(qos: .utility).async { [weak self] in - guard let self = self else { return } - do { - try requestHandler.perform([textRecognitionRequest]) - } catch { - self.removeRequest(textRecognitionRequest) - Logger.info("Unable to perform the requests.", context: ["error": error]) - } - } - } - - private func removeRequest(_ request: VNRequest) { - if let index = processingRequests.firstIndex(of: request) { - processingRequests.remove(at: index) - } - } - - private func textRecognitionHandler(request: VNRequest, error: Error?, size: CGSize, index: Int) { - Logger.info("textRecognitionHandler", context: [ - "request": request, "error": error as Any, "index": index - ]) - removeRequest(request) - - guard let observations = request.results as? [VNRecognizedTextObservation] else { return } - - DispatchQueue.global(qos: .userInteractive).async { [weak self] in - guard let self = self else { return } - let blocks: [LiveTextBlock] = observations.compactMap { observation in - guard let recognizedText = observation.topCandidates(1).first?.string else { return nil } - return .init( - text: recognizedText, - bounds: .init( - topLeft: observation.topLeft.verticalReversed, - topRight: observation.topRight.verticalReversed, - bottomLeft: observation.bottomLeft.verticalReversed, - bottomRight: observation.bottomRight.verticalReversed - ) - ) - } - - var groupData = [[LiveTextBlock]]() - blocks.forEach { newItem in - if let groupIndex = groupData.firstIndex(where: { items in - items.first { item in - let angle = abs(item.bounds.getAngle(size) - newItem.bounds.getAngle(size)) - .truncatingRemainder(dividingBy: 360.0) - let isAngleValid = angle < 5 || angle > (360 - 5) - let aHeight = item.bounds.getHeight(size) - let bHeight = newItem.bounds.getHeight(size) - let isHeightValid = abs(aHeight - bHeight) < (min(aHeight, bHeight) / 2) - - guard isAngleValid && isHeightValid else { return false } - return self.polygonsIntersecting( - lhs: item.bounds.expandingHalfHeight(size).edges, - rhs: newItem.bounds.expandingHalfHeight(size).edges - ) - } != nil - }) { - groupData[groupIndex].append(newItem) - } else { - groupData.append([newItem]) - } - } - - let groups = groupData.compactMap(LiveTextGroup.init) - DispatchQueue.main.async { - self.liveTextGroups[index] = groups - } - } - } - - private func polygonsIntersecting(lhs: [CGPoint], rhs: [CGPoint]) -> Bool { - guard !lhs.isEmpty, !rhs.isEmpty, lhs.count == rhs.count else { return false } - for points in [lhs, rhs] { - for index1 in 0.. - var quickSearchState = QuickSearchReducer.State() - - init() { - detailState = .init(.init()) - } - - mutating func insertGalleries(_ galleries: [Gallery]) { - galleries.forEach { gallery in - if !self.galleries.contains(gallery) { - self.galleries.append(gallery) - } - } - } - } - - enum Action: BindableAction { - case binding(BindingAction) - case setNavigation(Route?) - case clearSubStates - - case teardown - case fetchGalleries(String? = nil) - case fetchGalleriesDone(Result<(PageNumber, [Gallery]), AppError>) - case fetchMoreGalleries - case fetchMoreGalleriesDone(Result<(PageNumber, [Gallery]), AppError>) - - case detail(DetailReducer.Action) - case filters(FiltersReducer.Action) - case quickSearch(QuickSearchReducer.Action) - } - - @Dependency(\.databaseClient) private var databaseClient - @Dependency(\.hapticsClient) private var hapticsClient - - var body: some Reducer { - BindingReducer() - .onChange(of: \.route) { _, newValue in - Reduce({ _, _ in newValue == nil ? .send(.clearSubStates) : .none }) - } - .onChange(of: \.keyword) { _, newValue in - Reduce { state, _ in - if !newValue.isEmpty { - state.lastKeyword = newValue - } - return .none - } - } - - Reduce { state, action in - switch action { - case .binding: - return .none - - case .setNavigation(let route): - state.route = route - return route == nil ? .send(.clearSubStates) : .none - - case .clearSubStates: - state.detailState.wrappedValue = .init() - state.filtersState = .init() - state.quickSearchState = .init() - return .merge( - .send(.detail(.teardown)), - .send(.quickSearch(.teardown)) - ) - - case .teardown: - return .merge(CancelID.allCases.map(Effect.cancel(id:))) - - case .fetchGalleries(let keyword): - guard state.loadingState != .loading else { return .none } - if let keyword = keyword { - state.keyword = keyword - state.lastKeyword = keyword - } - state.loadingState = .loading - state.pageNumber.resetPages() - let filter = databaseClient.fetchFilterSynchronously(range: .search) - return .run { [lastKeyword = state.lastKeyword] send in - let response = await SearchGalleriesRequest(keyword: lastKeyword, filter: filter).response() - await send(.fetchGalleriesDone(response)) - } - .cancellable(id: CancelID.fetchGalleries) - - case .fetchGalleriesDone(let result): - state.loadingState = .idle - switch result { - case .success(let (pageNumber, galleries)): - guard !galleries.isEmpty else { - state.loadingState = .failed(.notFound) - guard pageNumber.hasNextPage() else { return .none } - return .send(.fetchMoreGalleries) - } - state.pageNumber = pageNumber - state.galleries = galleries - return .run(operation: { _ in await databaseClient.cacheGalleries(galleries) }) - case .failure(let error): - state.loadingState = .failed(error) - } - return .none - - case .fetchMoreGalleries: - let pageNumber = state.pageNumber - guard pageNumber.hasNextPage(), - state.footerLoadingState != .loading, - let lastID = state.galleries.last?.id - else { return .none } - state.footerLoadingState = .loading - let filter = databaseClient.fetchFilterSynchronously(range: .search) - return .run { [lastKeyword = state.lastKeyword] send in - let response = await MoreSearchGalleriesRequest( - keyword: lastKeyword, filter: filter, lastID: lastID - ) - .response() - await send(.fetchMoreGalleriesDone(response)) - } - .cancellable(id: CancelID.fetchMoreGalleries) - - case .fetchMoreGalleriesDone(let result): - state.footerLoadingState = .idle - switch result { - case .success(let (pageNumber, galleries)): - state.pageNumber = pageNumber - state.insertGalleries(galleries) - - var effects: [Effect] = [ - .run(operation: { _ in await databaseClient.cacheGalleries(galleries) }) - ] - if galleries.isEmpty, pageNumber.hasNextPage() { - effects.append(.send(.fetchMoreGalleries)) - } else if !galleries.isEmpty { - state.loadingState = .idle - } - 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: \.quickSearch, - hapticsClient: hapticsClient - ) - .haptics( - unwrapping: \.route, - case: \.filters, - hapticsClient: hapticsClient - ) - - Scope(state: \.filtersState, action: \.filters, child: FiltersReducer.init) - Scope(state: \.quickSearchState, action: \.quickSearch, child: QuickSearchReducer.init) - Scope(state: \.detailState.wrappedValue!, action: \.detail, child: DetailReducer.init) - } -} diff --git a/EhPanda/View/Search/SearchRootReducer.swift b/EhPanda/View/Search/SearchRootReducer.swift deleted file mode 100644 index c55a95fdf..000000000 --- a/EhPanda/View/Search/SearchRootReducer.swift +++ /dev/null @@ -1,203 +0,0 @@ -// -// SearchRootReducer.swift -// EhPanda -// - -import ComposableArchitecture - -@Reducer -struct SearchRootReducer { - @CasePathable - enum Route: Equatable { - case search - case filters(EquatableVoid = .init()) - case quickSearch(EquatableVoid = .init()) - case detail(String) - } - - @ObservableState - struct State: Equatable { - var route: Route? - var keyword = "" - var historyGalleries = [Gallery]() - - var historyKeywords = [String]() - var quickSearchWords = [QuickSearchWord]() - - var searchState = SearchReducer.State() - var filtersState = FiltersReducer.State() - var quickSearchState = QuickSearchReducer.State() - var detailState: Heap - - init() { - detailState = .init(.init()) - } - - 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 Action: BindableAction { - case binding(BindingAction) - case setNavigation(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(SearchReducer.Action) - case filters(FiltersReducer.Action) - case quickSearch(QuickSearchReducer.Action) - case detail(DetailReducer.Action) - } - - @Dependency(\.databaseClient) private var databaseClient - @Dependency(\.hapticsClient) private var hapticsClient - - var body: some Reducer { - BindingReducer() - .onChange(of: \.route) { _, newValue in - Reduce { _, _ in - newValue == nil - ? .merge( - .send(.clearSubStates), - .send(.fetchDatabaseInfos) - ) - : .none - } - } - - Reduce { state, action in - switch action { - case .binding: - return .none - - case .setNavigation(let route): - state.route = route - return route == nil - ? .merge( - .send(.clearSubStates), - .send(.fetchDatabaseInfos) - ) - : .none - - case .setKeyword(let keyword): - state.keyword = keyword - return .none - - case .clearSubStates: - state.searchState = .init() - state.detailState.wrappedValue = .init() - state.filtersState = .init() - state.quickSearchState = .init() - return .merge( - .send(.search(.teardown)), - .send(.quickSearch(.teardown)), - .send(.detail(.teardown)) - ) - - case .syncHistoryKeywords: - return .run { [state] _ in - await databaseClient.updateHistoryKeywords(state.historyKeywords) - } - - case .fetchDatabaseInfos: - return .run { send in - let appEnv = await databaseClient.fetchAppEnv() - await send(.fetchDatabaseInfosDone(appEnv)) - } - - case .fetchDatabaseInfosDone(let appEnv): - state.historyKeywords = appEnv.historyKeywords - state.quickSearchWords = appEnv.quickSearchWords - return .none - - case .appendHistoryKeyword(let keyword): - state.appendHistoryKeywords([keyword]) - return .send(.syncHistoryKeywords) - - case .removeHistoryKeyword(let keyword): - state.removeHistoryKeyword(keyword) - return .send(.syncHistoryKeywords) - - case .fetchHistoryGalleries: - return .run { send in - let historyGalleries = await databaseClient.fetchHistoryGalleries(fetchLimit: 10) - await send(.fetchHistoryGalleriesDone(historyGalleries)) - } - - case .fetchHistoryGalleriesDone(let galleries): - state.historyGalleries = Array(galleries.prefix(min(galleries.count, 10))) - return .none - - case .search(.fetchGalleries(let keyword)): - if let keyword = keyword { - state.appendHistoryKeywords([keyword]) - } else { - state.appendHistoryKeywords([state.searchState.lastKeyword]) - } - return .send(.syncHistoryKeywords) - - case .search: - return .none - - case .filters: - return .none - - case .quickSearch: - return .none - - case .detail: - return .none - } - } - .haptics( - unwrapping: \.route, - case: \.quickSearch, - hapticsClient: hapticsClient - ) - .haptics( - unwrapping: \.route, - case: \.filters, - hapticsClient: hapticsClient - ) - - Scope(state: \.searchState, action: \.search, child: SearchReducer.init) - Scope(state: \.filtersState, action: \.filters, child: FiltersReducer.init) - Scope(state: \.quickSearchState, action: \.quickSearch, child: QuickSearchReducer.init) - Scope(state: \.detailState.wrappedValue!, action: \.detail, child: DetailReducer.init) - } -} diff --git a/EhPanda/View/Search/SearchRootView.swift b/EhPanda/View/Search/SearchRootView.swift deleted file mode 100644 index e2cd9eb3a..000000000 --- a/EhPanda/View/Search/SearchRootView.swift +++ /dev/null @@ -1,437 +0,0 @@ -// -// SearchRootView.swift -// EhPanda -// - -import SwiftUI -import ComposableArchitecture - -struct SearchRootView: View { - @Bindable private var store: StoreOf - private let user: User - @Binding private var setting: Setting - private let blurRadius: Double - private let tagTranslator: TagTranslator - - init( - store: StoreOf, - user: User, setting: Binding, blurRadius: Double, tagTranslator: TagTranslator - ) { - self.store = store - self.user = user - _setting = setting - self.blurRadius = blurRadius - self.tagTranslator = tagTranslator - } - - var body: some View { - NavigationView { - let content = - ScrollView(showsIndicators: false) { - SuggestionsPanel( - historyKeywords: store.historyKeywords.reversed(), - historyGalleries: store.historyGalleries, - quickSearchWords: store.quickSearchWords, - navigateGalleryAction: { store.send(.setNavigation(.detail($0))) }, - navigateQuickSearchAction: { store.send(.setNavigation(.quickSearch())) }, - searchKeywordAction: { keyword in - store.send(.setKeyword(keyword)) - store.send(.setNavigation(.search)) - }, - removeKeywordAction: { store.send(.removeHistoryKeyword($0)) } - ) - } - .sheet(item: $store.route.sending(\.setNavigation).filters) { _ in - FiltersView(store: store.scope(state: \.filtersState, action: \.filters)) - .autoBlur(radius: blurRadius).environment(\.inSheet, true) - } - .sheet(item: $store.route.sending(\.setNavigation).quickSearch) { _ in - QuickSearchView( - store: store.scope(state: \.quickSearchState, action: \.quickSearch) - ) { keyword in - store.send(.setNavigation(nil)) - store.send(.setKeyword(keyword)) - DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { - store.send(.setNavigation(.search)) - } - } - .accentColor(setting.accentColor) - .autoBlur(radius: blurRadius) - } - .searchable(text: $store.keyword) - .searchSuggestions { - TagSuggestionView( - keyword: $store.keyword, translations: tagTranslator.translations, - showsImages: setting.showsImagesInTags, isEnabled: setting.showsTagsSearchSuggestion - ) - } - .onSubmit(of: .search) { - store.send(.setNavigation(.search)) - } - .onAppear { - store.send(.fetchHistoryGalleries) - store.send(.fetchDatabaseInfos) - } - .background(navigationLinks) - .toolbar(content: toolbar) - .navigationTitle(L10n.Localizable.SearchView.Title.search) - - if DeviceUtil.isPad { - content - .sheet(item: $store.route.sending(\.setNavigation).detail, id: \.self) { gid in - NavigationView { - DetailView( - store: store.scope(state: \.detailState.wrappedValue!, action: \.detail), - gid: gid, - user: user, - setting: $setting, - blurRadius: blurRadius, - tagTranslator: tagTranslator - ) - } - .autoBlur(radius: blurRadius).environment(\.inSheet, true).navigationViewStyle(.stack) - } - } else { - // Workaround: Prevent the title disappearing issue. - if store.historyKeywords.isEmpty && store.historyGalleries.isEmpty { - content - .navigationSubtitle(Text(" ")) - } else { - content - } - } - } - } - - private func toolbar() -> some ToolbarContent { - CustomToolbarItem(tint: .primary) { - ToolbarFeaturesMenu(symbolRenderingMode: .hierarchical) { - FiltersButton { - store.send(.setNavigation(.filters())) - } - QuickSearchButton { - store.send(.setNavigation(.quickSearch())) - } - } - } - } -} - -private extension SearchRootView { - @ViewBuilder var navigationLinks: some View { - if DeviceUtil.isPhone { - detailViewLink - } - searchViewLink - } - var detailViewLink: some View { - NavigationLink(unwrapping: $store.route, case: \.detail) { route in - DetailView( - store: store.scope(state: \.detailState.wrappedValue!, action: \.detail), - gid: route.wrappedValue, user: user, setting: $setting, - blurRadius: blurRadius, tagTranslator: tagTranslator - ) - } - } - var searchViewLink: some View { - NavigationLink(unwrapping: $store.route, case: \.search) { _ in - SearchView( - store: store.scope(state: \.searchState, action: \.search), - keyword: store.keyword, user: user, setting: $setting, - blurRadius: blurRadius, tagTranslator: tagTranslator - ) - } - } -} - -// MARK: SuggestionsPanel -private struct SuggestionsPanel: View { - private let historyKeywords: [String] - 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({ .init(keyword: $0.content, displayText: $0.name) }) - .removeDuplicates() - } - - var body: some View { - SubSection( - title: L10n.Localizable.SearchView.Section.Title.quickSearch, - 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: L10n.Localizable.SearchView.Section.Title.recentlySearched, showAll: false) { - DoubleVerticalKeywordsStack( - keywords: keywords.map(WrappedKeyword.init), - 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] { - .init(keywords.prefix(min(keywords.count, 10))) - } - var doubleKeywords: ([WrappedKeyword], [WrappedKeyword]) { - var leadingKeywords = [WrappedKeyword]() - var trailingKeywords = [WrappedKeyword]() - keywords.enumerated().forEach { (index, keyword) in - guard index < 20 else { return } - if index % 2 == 0 { - leadingKeywords.append(keyword) - } else { - trailingKeywords.append(keyword) - } - } - return (leadingKeywords, trailingKeywords) - } - - var body: some View { - HStack(alignment: .top, spacing: 30) { - if !DeviceUtil.isPad { - VerticalKeywordsStack( - keywords: singleKeywords, - searchAction: searchAction, - removeAction: removeAction - ) - } else { - let (leadingKeywords, trailingKeywords) = doubleKeywords - VerticalKeywordsStack( - keywords: leadingKeywords, - searchAction: searchAction, - removeAction: removeAction - ) - VerticalKeywordsStack( - keywords: trailingKeywords, - searchAction: searchAction, - removeAction: removeAction - ) - } - } - .padding() - } -} - -private struct VerticalKeywordsStack: View { - private let keywords: [WrappedKeyword] - private let searchAction: (String) -> Void - private let removeAction: ((String) -> Void)? - - init(keywords: [WrappedKeyword], searchAction: @escaping (String) -> Void, removeAction: ((String) -> Void)?) { - self.keywords = keywords - self.searchAction = searchAction - self.removeAction = removeAction - } - - var body: some View { - VStack(spacing: 10) { - ForEach(keywords, id: \.self) { keyword in - VStack(alignment: .leading, spacing: 10) { - KeywordCell(wrappedKeyword: keyword, searchAction: searchAction, removeAction: removeAction) - Divider().opacity(keyword == keywords.last ? 0 : 1) - } - } - } - } -} - -private struct KeywordCell: View { - private let wrappedKeyword: WrappedKeyword - private let searchAction: (String) -> Void - private let removeAction: ((String) -> Void)? - - init(wrappedKeyword: WrappedKeyword, searchAction: @escaping (String) -> Void, removeAction: ((String) -> Void)?) { - self.wrappedKeyword = wrappedKeyword - self.searchAction = searchAction - self.removeAction = removeAction - } - - var title: String { - wrappedKeyword.displayText.isEmpty ? wrappedKeyword.keyword : wrappedKeyword.displayText - } - - var body: some View { - HStack(spacing: 20) { - Button { - searchAction(wrappedKeyword.keyword) - } label: { - Image(systemSymbol: .magnifyingglass) - - Text(title) - .frame(maxWidth: .infinity, alignment: .leading) - .lineLimit(1) - } - .tint(.primary) - - if removeAction != nil { - Button { - removeAction?(wrappedKeyword.keyword) - } label: { - Image(systemSymbol: .xmark) - .imageScale(.small) - .foregroundColor(.secondary) - } - } - } - } -} - -// MARK: HistoryGalleriesSection -private struct HistoryGalleriesSection: View { - private let galleries: [Gallery] - private let navigationAction: (String) -> Void - - init(galleries: [Gallery], navigationAction: @escaping (String) -> Void) { - self.galleries = galleries - self.navigationAction = navigationAction - } - - var body: some View { - SubSection(title: L10n.Localizable.SearchView.Section.Title.recentlySeen, 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 - let displayText: String - - init(keyword: String, displayText: String) { - self.keyword = keyword - self.displayText = displayText - } - - init(keyword: String) { - self.init(keyword: keyword, displayText: .init()) - } -} - -struct SearchRootView_Previews: PreviewProvider { - static var previews: some View { - SearchRootView( - store: .init(initialState: .init(), reducer: SearchRootReducer.init), - user: .init(), - setting: .constant(.init()), - blurRadius: 0, - tagTranslator: .init() - ) - } -} diff --git a/EhPanda/View/Search/SearchView.swift b/EhPanda/View/Search/SearchView.swift deleted file mode 100644 index 0ba8c1d26..000000000 --- a/EhPanda/View/Search/SearchView.swift +++ /dev/null @@ -1,132 +0,0 @@ -// -// SearchView.swift -// EhPanda -// - -import SwiftUI -import ComposableArchitecture - -struct SearchView: View { - @Bindable private var store: StoreOf - private let keyword: String - private let user: User - @Binding private var setting: Setting - private let blurRadius: Double - private let tagTranslator: TagTranslator - - init( - store: StoreOf, - keyword: String, user: User, setting: Binding, blurRadius: Double, tagTranslator: TagTranslator - ) { - self.store = store - self.keyword = keyword - self.user = user - _setting = setting - self.blurRadius = blurRadius - self.tagTranslator = tagTranslator - } - - var body: some View { - let content = - GenericList( - galleries: store.galleries, - setting: setting, - pageNumber: store.pageNumber, - loadingState: store.loadingState, - footerLoadingState: store.footerLoadingState, - fetchAction: { store.send(.fetchGalleries()) }, - fetchMoreAction: { store.send(.fetchMoreGalleries) }, - navigateAction: { store.send(.setNavigation(.detail($0))) }, - translateAction: { - tagTranslator.lookup(word: $0, returnOriginal: !setting.translatesTags) - } - ) - .sheet(item: $store.route.sending(\.setNavigation).quickSearch) { _ in - QuickSearchView( - store: store.scope(state: \.quickSearchState, action: \.quickSearch) - ) { keyword in - store.send(.setNavigation(nil)) - store.send(.fetchGalleries(keyword)) - } - .accentColor(setting.accentColor) - .autoBlur(radius: blurRadius) - } - .sheet(item: $store.route.sending(\.setNavigation).filters) { _ in - FiltersView(store: store.scope(state: \.filtersState, action: \.filters)) - .accentColor(setting.accentColor).autoBlur(radius: blurRadius) - } - .searchable(text: $store.keyword) - .searchSuggestions { - TagSuggestionView( - keyword: $store.keyword, translations: tagTranslator.translations, - showsImages: setting.showsImagesInTags, isEnabled: setting.showsTagsSearchSuggestion - ) - } - .onSubmit(of: .search) { - store.send(.fetchGalleries()) - } - .onAppear { - if store.galleries.isEmpty { - DispatchQueue.main.async { - store.send(.fetchGalleries(keyword)) - } - } - } - .background(navigationLink) - .toolbar(content: toolbar) - .navigationTitle(store.lastKeyword) - - if DeviceUtil.isPad { - content - .sheet(item: $store.route.sending(\.setNavigation).detail, id: \.self) { route in - NavigationView { - DetailView( - store: store.scope(state: \.detailState.wrappedValue!, action: \.detail), - gid: route.wrappedValue, user: user, setting: $setting, - blurRadius: blurRadius, tagTranslator: tagTranslator - ) - } - .autoBlur(radius: blurRadius).environment(\.inSheet, true).navigationViewStyle(.stack) - } - } else { - content - } - } - - @ViewBuilder private var navigationLink: some View { - if DeviceUtil.isPhone { - NavigationLink(unwrapping: $store.route, case: \.detail) { route in - DetailView( - store: store.scope(state: \.detailState.wrappedValue!, action: \.detail), - gid: route.wrappedValue, user: user, setting: $setting, - blurRadius: blurRadius, tagTranslator: tagTranslator - ) - } - } - } - private func toolbar() -> some ToolbarContent { - CustomToolbarItem { - ToolbarFeaturesMenu { - FiltersButton { - store.send(.setNavigation(.filters())) - } - QuickSearchButton { - store.send(.setNavigation(.quickSearch())) - } - } - } - } -} - -struct SearchView_Previews: PreviewProvider { - static var previews: some View { - SearchView( - store: .init(initialState: .init(), reducer: SearchReducer.init), - keyword: .init(), - user: .init(), - setting: .constant(.init()), - blurRadius: 0, - tagTranslator: .init() - ) - } -} diff --git a/EhPanda/View/Search/Support/QuickSearchReducer.swift b/EhPanda/View/Search/Support/QuickSearchReducer.swift deleted file mode 100644 index 1c48d3712..000000000 --- a/EhPanda/View/Search/Support/QuickSearchReducer.swift +++ /dev/null @@ -1,139 +0,0 @@ -// -// QuickSearchReducer.swift -// EhPanda -// - -import SwiftUI -import ComposableArchitecture - -@Reducer -struct QuickSearchReducer { - @CasePathable - enum Route: Equatable { - case newWord - case editWord - case deleteWord(QuickSearchWord) - } - - enum FocusField { - case name - case content - } - - private enum CancelID { - case fetchQuickSearchWords - } - - @ObservableState - struct State: Equatable { - var route: Route? - var focusedField: FocusField? - var editingWord: QuickSearchWord = .empty - var listEditMode: EditMode = .inactive - var isListEditing: Bool { - get { listEditMode == .active } - set { listEditMode = newValue ? .active : .inactive } - } - - var loadingState: LoadingState = .idle - var quickSearchWords = [QuickSearchWord]() - } - - enum Action: BindableAction, Equatable { - case binding(BindingAction) - case setNavigation(Route?) - case clearSubStates - - case syncQuickSearchWords - - case toggleListEditing - case setEditingWord(QuickSearchWord) - - case appendWord - case editWord - case deleteWord(QuickSearchWord) - case deleteWordWithOffsets(IndexSet) - case moveWord(IndexSet, Int) - - case teardown - case fetchQuickSearchWords - case fetchQuickSearchWordsDone([QuickSearchWord]) - } - - @Dependency(\.databaseClient) private var databaseClient - - var body: some Reducer { - BindingReducer() - .onChange(of: \.route) { _, newValue in - Reduce({ _, _ in newValue == nil ? .send(.clearSubStates) : .none }) - } - - Reduce { state, action in - switch action { - case .binding: - return .none - - case .setNavigation(let route): - state.route = route - return route == nil ? .send(.clearSubStates) : .none - - case .clearSubStates: - state.focusedField = nil - state.editingWord = .empty - return .none - - case .syncQuickSearchWords: - return .run { [state] _ in - await databaseClient.updateQuickSearchWords(state.quickSearchWords) - } - - case .toggleListEditing: - state.isListEditing.toggle() - return .none - - case .setEditingWord(let word): - state.editingWord = word - return .none - - case .appendWord: - state.quickSearchWords.append(state.editingWord) - return .send(.syncQuickSearchWords) - - case .editWord: - if let index = state.quickSearchWords.firstIndex(where: { $0.id == state.editingWord.id }) { - state.quickSearchWords[index] = state.editingWord - return .send(.syncQuickSearchWords) - } - return .none - - case .deleteWord(let word): - state.quickSearchWords = state.quickSearchWords.filter({ $0 != word }) - return .send(.syncQuickSearchWords) - - case .deleteWordWithOffsets(let offsets): - state.quickSearchWords.remove(atOffsets: offsets) - return .send(.syncQuickSearchWords) - - case .moveWord(let source, let destination): - state.quickSearchWords.move(fromOffsets: source, toOffset: destination) - return .send(.syncQuickSearchWords) - - case .teardown: - return .cancel(id: CancelID.fetchQuickSearchWords) - - case .fetchQuickSearchWords: - state.loadingState = .loading - return .run { send in - let quickSearchWords = await databaseClient.fetchQuickSearchWords() - await send(.fetchQuickSearchWordsDone(quickSearchWords)) - } - .cancellable(id: CancelID.fetchQuickSearchWords) - - case .fetchQuickSearchWordsDone(let words): - state.loadingState = .idle - state.quickSearchWords = words - return .none - } - } - } -} diff --git a/EhPanda/View/Setting/AccountSetting/AccountSettingReducer.swift b/EhPanda/View/Setting/AccountSetting/AccountSettingReducer.swift deleted file mode 100644 index e36a84fc7..000000000 --- a/EhPanda/View/Setting/AccountSetting/AccountSettingReducer.swift +++ /dev/null @@ -1,159 +0,0 @@ -// -// AccountSettingReducer.swift -// EhPanda -// - -import Foundation -import TTProgressHUD -import ComposableArchitecture - -@Reducer -struct AccountSettingReducer { - @dynamicMemberLookup @CasePathable - enum Route: Equatable { - case hud - case login - case logout - case ehSetting - case webView(URL) - } - - @ObservableState - struct State: Equatable { - var route: Route? - var ehCookiesState: CookiesState = .empty(.ehentai) - var exCookiesState: CookiesState = .empty(.exhentai) - var hudConfig: TTProgressHUDConfig = .copiedToClipboardSucceeded - - var loginState = LoginReducer.State() - var ehSettingState = EhSettingReducer.State() - } - - enum Action: BindableAction, Equatable { - case binding(BindingAction) - case setNavigation(Route?) - case onLogoutConfirmButtonTapped - case clearSubStates - case loadCookies - case copyCookies(GalleryHost) - case login(LoginReducer.Action) - case ehSetting(EhSettingReducer.Action) - } - - @Dependency(\.clipboardClient) private var clipboardClient - @Dependency(\.cookieClient) private var cookieClient - @Dependency(\.hapticsClient) private var hapticsClient - - var body: some Reducer { - BindingReducer() - .onChange(of: \.route) { _, newValue in - Reduce({ _, _ in newValue == nil ? .send(.clearSubStates) : .none }) - } - .onChange(of: \.ehCookiesState) { _, newValue in - Reduce({ _, _ in .run(operation: { _ in cookieClient.setCookies(state: newValue) }) }) - } - .onChange(of: \.exCookiesState) { _, newValue in - Reduce({ _, _ in .run(operation: { _ in cookieClient.setCookies(state: newValue) }) }) - } - - Reduce { state, action in - switch action { - case .binding: - return .none - - case .setNavigation(let route): - state.route = route - return route == nil ? .send(.clearSubStates) : .none - - case .onLogoutConfirmButtonTapped: - return .send(.loadCookies) - - case .clearSubStates: - state.loginState = .init() - state.ehSettingState = .init() - return .merge( - .send(.login(.teardown)), - .send(.ehSetting(.teardown)) - ) - - case .loadCookies: - state.ehCookiesState = cookieClient.loadCookiesState(host: .ehentai) - state.exCookiesState = cookieClient.loadCookiesState(host: .exhentai) - return .none - - case .copyCookies(let host): - let cookiesDescription = cookieClient.getCookiesDescription(host: host) - return .merge( - .send(.setNavigation(.hud)), - .run(operation: { _ in clipboardClient.saveText(cookiesDescription) }), - .run(operation: { _ in hapticsClient.generateNotificationFeedback(.success) }) - ) - - case .login(.loginDone): - return cookieClient.didLogin ? .send(.setNavigation(nil)) : .none - - case .login: - return .none - - case .ehSetting: - return .none - } - } - .haptics( - unwrapping: \.route, - case: \.webView, - hapticsClient: hapticsClient - ) - - Scope(state: \.loginState, action: \.login, child: LoginReducer.init) - Scope(state: \.ehSettingState, action: \.ehSetting, child: EhSettingReducer.init) - } -} - -// MARK: Models -struct CookieValue: Equatable { - static let empty: Self = .init( - rawValue: .init(), localizedString: .init() - ) - - let rawValue: String - let localizedString: String - - var isInvalid: Bool { - !localizedString.isEmpty && !rawValue.isEmpty - } - var placeholder: String { - localizedString.isEmpty ? rawValue : localizedString - } -} - -struct CookiesState: Equatable { - static func empty(_ host: GalleryHost) -> Self { - .init( - host: host, - igneous: .empty, - memberID: .empty, - passHash: .empty - ) - } - var allCases: [CookieState] {[ - igneous, memberID, passHash - ]} - - let host: GalleryHost - var igneous: CookieState - var memberID: CookieState - var passHash: CookieState -} - -struct CookieState: Equatable { - static let empty: Self = .init( - key: "", value: .init( - rawValue: "", localizedString: "" - ) - ) - - let key: String - var value: CookieValue - var editingText = "" -} diff --git a/EhPanda/View/Setting/AppearanceSetting/AppearanceSettingReducer.swift b/EhPanda/View/Setting/AppearanceSetting/AppearanceSettingReducer.swift deleted file mode 100644 index d1b617c61..000000000 --- a/EhPanda/View/Setting/AppearanceSetting/AppearanceSettingReducer.swift +++ /dev/null @@ -1,39 +0,0 @@ -// -// AppearanceSettingReducer.swift -// EhPanda -// - -import ComposableArchitecture - -@Reducer -struct AppearanceSettingReducer { - @CasePathable - enum Route { - case appIcon - } - - @ObservableState - struct State: Equatable { - var route: Route? - } - - enum Action: BindableAction, Equatable { - case binding(BindingAction) - case setNavigation(Route?) - } - - var body: some Reducer { - BindingReducer() - - Reduce { state, action in - switch action { - case .binding: - return .none - - case .setNavigation(let route): - state.route = route - return .none - } - } - } -} diff --git a/EhPanda/View/Setting/AppearanceSetting/AppearanceSettingView.swift b/EhPanda/View/Setting/AppearanceSetting/AppearanceSettingView.swift deleted file mode 100644 index 8b0053a90..000000000 --- a/EhPanda/View/Setting/AppearanceSetting/AppearanceSettingView.swift +++ /dev/null @@ -1,238 +0,0 @@ -// -// AppearanceSettingView.swift -// EhPanda -// - -import SwiftUI -import ComposableArchitecture - -struct AppearanceSettingView: View { - @Bindable private var store: StoreOf - - @Binding private var preferredColorScheme: PreferredColorScheme - @Binding private var accentColor: Color - @Binding private var appIconType: AppIconType - @Binding private var listDisplayMode: ListDisplayMode - @Binding private var showsTagsInList: Bool - @Binding private var listTagsNumberMaximum: Int - @Binding private var displaysJapaneseTitle: Bool - - init( - store: StoreOf, - preferredColorScheme: Binding, - accentColor: Binding, - appIconType: Binding, - listDisplayMode: Binding, - showsTagsInList: Binding, - listTagsNumberMaximum: Binding, - displaysJapaneseTitle: Binding - ) { - self.store = store - _preferredColorScheme = preferredColorScheme - _accentColor = accentColor - _appIconType = appIconType - _listDisplayMode = listDisplayMode - _showsTagsInList = showsTagsInList - _listTagsNumberMaximum = listTagsNumberMaximum - _displaysJapaneseTitle = displaysJapaneseTitle - } - - var body: some View { - Form { - Section { - Picker( - L10n.Localizable.AppearanceSettingView.Title.theme, - selection: $preferredColorScheme - ) { - ForEach(PreferredColorScheme.allCases) { colorScheme in - Text(colorScheme.value) - .tag(colorScheme) - } - } - .pickerStyle(.menu) - - ColorPicker(L10n.Localizable.AppearanceSettingView.Title.tintColor, selection: $accentColor) - - Button(L10n.Localizable.AppearanceSettingView.Button.appIcon) { - store.send(.setNavigation(.appIcon)) - } - .foregroundStyle(.primary) - .withArrow() - } - Section(L10n.Localizable.AppearanceSettingView.Section.Title.list) { - Picker( - L10n.Localizable.AppearanceSettingView.Title.displayMode, - selection: $listDisplayMode, - content: { - ForEach(ListDisplayMode.allCases) { listMode in - Text(listMode.value) - .tag(listMode) - } - } - ) - .pickerStyle(.menu) - - Toggle(isOn: $showsTagsInList) { - Text(L10n.Localizable.AppearanceSettingView.Title.showsTagsInList) - } - - Picker( - L10n.Localizable.AppearanceSettingView.Title.maximumNumberOfTags, - selection: $listTagsNumberMaximum - ) { - Text(L10n.Localizable.AppearanceSettingView.Menu.Title.infite) - .tag(0) - - ForEach(Array(stride(from: 5, through: 20, by: 5)), id: \.self) { num in - Text("\(num)") - .tag(num) - } - } - .pickerStyle(.menu) - .disabled(!showsTagsInList) - } - Section(L10n.Localizable.AppearanceSettingView.Section.Title.gallery) { - Toggle( - L10n.Localizable.AppearanceSettingView.Title.displaysJapaneseTitle, - isOn: $displaysJapaneseTitle - ) - } - } - .background(navigationLink) - .navigationTitle(L10n.Localizable.AppearanceSettingView.Title.appearance) - } - - private var navigationLink: some View { - NavigationLink(unwrapping: $store.route, case: \.appIcon) { _ in - AppIconView(appIconType: $appIconType) - } - } -} - -// MARK: SelectAppIconView -private struct AppIconView: View { - @Binding private var appIconType: AppIconType - - init(appIconType: Binding) { - _appIconType = appIconType - } - - var body: some View { - Form { - Section { - ForEach(AppIconType.allCases) { icon in - AppIconRow( - iconName: icon.name, - filename: icon.filename, - isSelected: icon == appIconType - ) - .contentShape(Rectangle()) - .onTapGesture { appIconType = icon } - } - } - } - .navigationTitle(L10n.Localizable.AppIconView.Title.appIcon) - } -} - -// MARK: AppIconRow -private struct AppIconRow: View { - private let iconName: String - private let filename: String - private let isSelected: Bool - - init(iconName: String, filename: String, isSelected: Bool) { - self.iconName = iconName - self.filename = filename - self.isSelected = isSelected - } - - var body: some View { - HStack(spacing: 20) { - UIImage(named: filename, in: .main, with: nil) - .map(Image.init)? - .resizable() - .scaledToFit() - .frame(width: 60, height: 60) - .clipShape(RoundedRectangle(cornerRadius: 15, style: .continuous)) - .padding(.vertical, 10) - - Text(iconName) - - Spacer() - - Image(systemSymbol: .checkmarkCircleFill) - .opacity(isSelected ? 1 : 0) - .foregroundStyle(.tint) - .imageScale(.large) - } - } -} - -// MARK: Definition -enum AppIconType: Int, Codable, Identifiable, CaseIterable { - var id: Int { rawValue } - - case `default` - case ukiyoe - case developer - case standWithUkraine2022 - case notMyPresidnet -} - -extension AppIconType { - var name: String { - switch self { - case .default: - return L10n.Localizable.Enum.AppIconType.Value.default - - case .ukiyoe: - return L10n.Localizable.Enum.AppIconType.Value.ukiyoe - - case .developer: - return L10n.Localizable.Enum.AppIconType.Value.developer - - case .standWithUkraine2022: - return L10n.Localizable.Enum.AppIconType.Value.standWithUkraine2022 - - case .notMyPresidnet: - return L10n.Localizable.Enum.AppIconType.Value.notMyPresident - } - } - - var filename: String { - switch self { - case .default: - return "AppIcon_Default" - - case .ukiyoe: - return "AppIcon_Ukiyoe" - - case .developer: - return "AppIcon_Developer" - - case .standWithUkraine2022: - return "AppIcon_StandWithUkraine2022" - - case .notMyPresidnet: - return "AppIcon_NotMyPresident" - } - } -} - -struct AppearanceSettingView_Previews: PreviewProvider { - static var previews: some View { - NavigationView { - AppearanceSettingView( - store: .init(initialState: .init(), reducer: AppearanceSettingReducer.init), - preferredColorScheme: .constant(.automatic), - accentColor: .constant(.blue), - appIconType: .constant(.default), - listDisplayMode: .constant(.detail), - showsTagsInList: .constant(false), - listTagsNumberMaximum: .constant(0), - displaysJapaneseTitle: .constant(true) - ) - } - } -} diff --git a/EhPanda/View/Setting/Components/AboutView.swift b/EhPanda/View/Setting/Components/AboutView.swift deleted file mode 100644 index 9b6c91ec5..000000000 --- a/EhPanda/View/Setting/Components/AboutView.swift +++ /dev/null @@ -1,260 +0,0 @@ -// -// AboutView.swift -// EhPanda -// - -import SwiftUI - -struct AboutView: View { - private var version: String { - [ - L10n.Localizable.AboutView.Title.version, - AppUtil.version, "(\(AppUtil.build))" - ] - .joined(separator: " ") - } - - var body: some View { - Form { - Section { - ForEach(contacts) { contact in - LinkRow(urlString: contact.urlString, text: contact.text) - } - } - Section(L10n.Localizable.AboutView.Section.Title.specialThanks) { - ForEach(specialThanks) { specialThank in - LinkRow(urlString: specialThank.urlString, text: specialThank.text) - } - } - Section(L10n.Localizable.AboutView.Section.Title.codeLevelContributors) { - ForEach(codeLevelContributors) { codeLevelContributor in - LinkRow(urlString: codeLevelContributor.urlString, text: codeLevelContributor.text) - } - } - Section(L10n.Localizable.AboutView.Section.Title.translationContributors) { - ForEach(translationContributors) { translationContributor in - LinkRow(urlString: translationContributor.urlString, text: translationContributor.text) - } - } - Section(L10n.Localizable.AboutView.Section.Title.acknowledgements) { - ForEach(acknowledgements) { acknowledgement in - LinkRow(urlString: acknowledgement.urlString, text: acknowledgement.text) - } - } - } - .navigationTitle(L10n.Localizable.AboutView.Title.ehPanda) - .toolbar { - ToolbarItem(placement: .largeSubtitle) { - VStack(alignment: .leading) { - Text(L10n.Constant.App.copyright) - Text(version) - } - .foregroundStyle(.gray) - .font(.caption2.bold()) - .frame(maxWidth: .infinity, alignment: .leading) - .padding(.vertical, 4) - } - } - } - - // MARK: Contacts - private let contacts: [Info] = {[ - .init( - urlString: L10n.Constant.App.Contact.Link.website, - text: L10n.Localizable.AboutView.Button.website - ), - .init( - urlString: L10n.Constant.App.Contact.Link.gitHub, - text: L10n.Constant.App.Contact.Text.gitHub - ), - .init( - urlString: L10n.Constant.App.Contact.Link.discord, - text: L10n.Constant.App.Contact.Text.discord - ), - .init( - urlString: L10n.Constant.App.Contact.Link.telegram, - text: L10n.Constant.App.Contact.Text.telegram - ), - .init( - urlString: L10n.Constant.App.Contact.Link.altStore, - text: L10n.Localizable.AboutView.Button.altStoreSource - ) - ]}() - - // MARK: Special thanks - private let specialThanks: [Info] = {[ - .init( - urlString: L10n.Constant.App.SpecialThanks.Link.taylorlannister, - text: L10n.Constant.App.SpecialThanks.Text.taylorlannister - ), - .init( - urlString: L10n.Constant.App.SpecialThanks.Link.luminescentYq, - text: L10n.Constant.App.SpecialThanks.Text.luminescentYq - ), - .init( - urlString: L10n.Constant.App.SpecialThanks.Link.caxerx, - text: L10n.Constant.App.SpecialThanks.Text.caxerx - ), - .init( - urlString: L10n.Constant.App.SpecialThanks.Link.honjow, - text: L10n.Constant.App.SpecialThanks.Text.honjow - ) - ]}() - - // MARK: Code level contributors - private let codeLevelContributors: [Info] = {[ - .init( - urlString: L10n.Constant.App.CodeLevelContributor.Link.chihchy, - text: L10n.Constant.App.CodeLevelContributor.Text.chihchy - ), - .init( - urlString: L10n.Constant.App.CodeLevelContributor.Link.aalberrty, - text: L10n.Constant.App.CodeLevelContributor.Text.aalberrty - ), - .init( - urlString: L10n.Constant.App.CodeLevelContributor.Link.jimmyPrime, - text: L10n.Constant.App.CodeLevelContributor.Text.jimmyPrime - ), - .init( - urlString: L10n.Constant.App.CodeLevelContributor.Link.xioxin, - text: L10n.Constant.App.CodeLevelContributor.Text.xioxin - ), - .init( - urlString: L10n.Constant.App.CodeLevelContributor.Link.vvbbnn00, - text: L10n.Constant.App.CodeLevelContributor.Text.vvbbnn00 - ) - ]}() - - // MARK: Translation contributors - private let translationContributors: [Info] = {[ - .init( - urlString: L10n.Constant.App.TranslationContributor.Link.nebulosaCat, - text: L10n.Constant.App.TranslationContributor.Text.nebulosaCat - ), - .init( - urlString: L10n.Constant.App.TranslationContributor.Link.paulHaeussler, - text: L10n.Constant.App.TranslationContributor.Text.paulHaeussler - ), - .init( - urlString: L10n.Constant.App.TranslationContributor.Link.caxerx, - text: L10n.Constant.App.TranslationContributor.Text.caxerx - ), - .init( - urlString: L10n.Constant.App.TranslationContributor.Link.neKoOuO, - text: L10n.Constant.App.TranslationContributor.Text.neKoOuO - ) - ]}() - - // MARK: Acknowledgements - private let acknowledgements: [Info] = {[ - .init( - urlString: L10n.Constant.App.Acknowledgement.Link.kanna, - text: L10n.Constant.App.Acknowledgement.Text.kanna - ), - .init( - urlString: L10n.Constant.App.Acknowledgement.Link.swiftGen, - text: L10n.Constant.App.Acknowledgement.Text.swiftGen - ), - .init( - urlString: L10n.Constant.App.Acknowledgement.Link.alertKit, - text: L10n.Constant.App.Acknowledgement.Text.alertKit - ), - .init( - urlString: L10n.Constant.App.Acknowledgement.Link.colorful, - text: L10n.Constant.App.Acknowledgement.Text.colorful - ), - .init( - urlString: L10n.Constant.App.Acknowledgement.Link.filePicker, - text: L10n.Constant.App.Acknowledgement.Text.filePicker - ), - .init( - urlString: L10n.Constant.App.Acknowledgement.Link.kingfisher, - text: L10n.Constant.App.Acknowledgement.Text.kingfisher - ), - .init( - urlString: L10n.Constant.App.Acknowledgement.Link.swiftUIPager, - text: L10n.Constant.App.Acknowledgement.Text.swiftUIPager - ), - .init( - urlString: L10n.Constant.App.Acknowledgement.Link.swiftyBeaver, - text: L10n.Constant.App.Acknowledgement.Text.swiftyBeaver - ), - .init( - urlString: L10n.Constant.App.Acknowledgement.Link.waterfallGrid, - text: L10n.Constant.App.Acknowledgement.Text.waterfallGrid - ), - .init( - urlString: L10n.Constant.App.Acknowledgement.Link.swiftyOpenCC, - text: L10n.Constant.App.Acknowledgement.Text.swiftyOpenCC - ), - .init( - urlString: L10n.Constant.App.Acknowledgement.Link.uiImageColors, - text: L10n.Constant.App.Acknowledgement.Text.uiImageColors - ), - .init( - urlString: L10n.Constant.App.Acknowledgement.Link.sfSafeSymbols, - text: L10n.Constant.App.Acknowledgement.Text.sfSafeSymbols - ), - .init( - urlString: L10n.Constant.App.Acknowledgement.Link.ttProgressHUD, - text: L10n.Constant.App.Acknowledgement.Text.ttProgressHUD - ), - .init( - urlString: L10n.Constant.App.Acknowledgement.Link.swiftUINavigation, - text: L10n.Constant.App.Acknowledgement.Text.swiftUINavigation - ), - .init( - urlString: L10n.Constant.App.Acknowledgement.Link.swiftCommonMark, - text: L10n.Constant.App.Acknowledgement.Text.swiftCommonMark - ), - .init( - urlString: L10n.Constant.App.Acknowledgement.Link.ehTagTranslationDatabase, - text: L10n.Constant.App.Acknowledgement.Text.ehTagTranslationDatabase - ), - .init( - urlString: L10n.Constant.App.Acknowledgement.Link.tca, - text: L10n.Constant.App.Acknowledgement.Text.tca - ) - ]}() -} - -// MARK: LinkRow -private struct LinkRow: View { - private let urlString: String - private let text: String - - init(urlString: String, text: String) { - self.urlString = urlString - self.text = text - } - - var body: some View { - ZStack { - let text = Text(text).fontWeight(.medium) - if let url = URL(string: urlString) { - Link(destination: url) { - text.withArrow() - } - } else { - text - } - } - .foregroundColor(.primary) - } -} - -// 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 { - AboutView() - } - } -} diff --git a/EhPanda/View/Setting/Components/ReadingSettingView.swift b/EhPanda/View/Setting/Components/ReadingSettingView.swift deleted file mode 100644 index 82b39f839..000000000 --- a/EhPanda/View/Setting/Components/ReadingSettingView.swift +++ /dev/null @@ -1,135 +0,0 @@ -// -// ReadingSettingView.swift -// EhPanda -// - -import SwiftUI -import ComposableArchitecture - -struct ReadingSettingView: View { - @Binding private var readingDirection: ReadingDirection - @Binding private var prefetchLimit: Int - @Binding private var enablesLandscape: Bool - @Binding private var contentDividerHeight: Double - @Binding private var maximumScaleFactor: Double - @Binding private var doubleTapScaleFactor: Double - - 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 { - Picker(L10n.Localizable.ReadingSettingView.Title.direction, selection: $readingDirection) { - ForEach(ReadingDirection.allCases) { - Text($0.value).tag($0) - } - } - .pickerStyle(.menu) - Picker(L10n.Localizable.ReadingSettingView.Title.preloadLimit, selection: $prefetchLimit) { - ForEach(Array(stride(from: 6, through: 18, by: 4)), id: \.self) { value in - Text(L10n.Localizable.Common.Value.pages("\(value)")).tag(value) - } - } - .pickerStyle(.menu) - if !DeviceUtil.isPad { - Toggle(L10n.Localizable.ReadingSettingView.Title.enablesLandscape, isOn: $enablesLandscape) - } - } - Section(L10n.Localizable.ReadingSettingView.Section.Title.appearance) { - Picker( - L10n.Localizable.ReadingSettingView.Title.separatorHeight, - selection: $contentDividerHeight - ) { - ForEach(Array(stride(from: 0, through: 20, by: 5)), id: \.self) { value in - Text("\(value)pt").tag(Double(value)) - } - } - .pickerStyle(.menu) - .disabled(readingDirection != .vertical) - ScaleFactorRow( - scaleFactor: $maximumScaleFactor, - labelContent: L10n.Localizable.ReadingSettingView.Title.maximumScaleFactor, - minFactor: 1.5, maxFactor: 10 - ) - ScaleFactorRow( - scaleFactor: $doubleTapScaleFactor, - labelContent: L10n.Localizable.ReadingSettingView.Title.doubleTapScaleFactor, - minFactor: 1.5, maxFactor: 5 - ) - } - } - .navigationTitle(L10n.Localizable.ReadingSettingView.Title.reading) - } -} - -private struct ScaleFactorRow: View { - @Binding private var scaleFactor: Double - private let labelContent: String - private let minFactor: Double - private let maxFactor: Double - - init( - scaleFactor: Binding, labelContent: String, - minFactor: Double, maxFactor: Double - ) { - _scaleFactor = scaleFactor - self.labelContent = labelContent - self.minFactor = minFactor - self.maxFactor = maxFactor - } - - var body: some View { - VStack { - HStack { - Text(labelContent) - Spacer() - Text("\(scaleFactor.roundedString())x").foregroundStyle(.tint) - } - Slider( - value: $scaleFactor, in: minFactor...maxFactor, step: 0.5, - minimumValueLabel: Text("\(minFactor.roundedString())x") - .fontWeight(.medium).font(.callout), - maximumValueLabel: Text("\(maxFactor.roundedString())x") - .fontWeight(.medium).font(.callout), - label: EmptyView.init - ) - } - .padding(.vertical, 10) - } -} - -private extension Double { - func roundedString() -> String { - roundedString(with: 1) - } - - func roundedString(with places: Int) -> String { - 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/EhSetting/EhSettingView.swift b/EhPanda/View/Setting/EhSetting/EhSettingView.swift deleted file mode 100644 index e64686f29..000000000 --- a/EhPanda/View/Setting/EhSetting/EhSettingView.swift +++ /dev/null @@ -1,1022 +0,0 @@ -// -// EhSettingView.swift -// EhPanda -// - -import SwiftUI -import ComposableArchitecture - -struct EhSettingView: View { - @Bindable private var store: StoreOf - private let bypassesSNIFiltering: Bool - private let blurRadius: Double - - // Should make it an Environment value. - private var galleryHost: GalleryHost { AppUtil.galleryHost } - - init(store: StoreOf, bypassesSNIFiltering: Bool, blurRadius: Double) { - self.store = store - self.bypassesSNIFiltering = bypassesSNIFiltering - self.blurRadius = blurRadius - } - - // MARK: EhSettingView - var body: some View { - ZStack { - // Workaround: Stay if-else approach - if store.loadingState == .loading || store.submittingState == .loading { - LoadingView() - .tint(nil) - } else if case .failed(let error) = store.loadingState { - ErrorView(error: error, action: { store.send(.fetchEhSetting) }) - .tint(nil) - } - // Using `Binding.init` will crash the app - else if let ehSetting = Binding(unwrapping: $store.ehSetting), - let ehProfile = Binding(unwrapping: $store.ehProfile) - { - form(ehSetting: ehSetting, ehProfile: ehProfile) - .transition(.opacity.animation(.default)) - } - } - .onAppear { - if store.ehSetting == nil { - store.send(.fetchEhSetting) - } - } - .onDisappear { - if let profileSet = store.ehSetting?.ehpandaProfile?.value { - store.send(.setDefaultProfile(profileSet)) - } - } - .sheet(item: $store.route.sending(\.setNavigation).webView, id: \.absoluteString) { url in - WebView(url: url) - .ignoresSafeArea(edges: .bottom) - .autoBlur(radius: blurRadius) - } - .toolbar(content: toolbar) - .navigationTitle(L10n.Localizable.EhSettingView.Title.hostSettings(galleryHost.rawValue)) - } - // MARK: Form - private func form(ehSetting: Binding, ehProfile: Binding) -> some View { - Form { - Group { - EhProfileSection( - route: $store.route, - ehSetting: ehSetting, - ehProfile: ehProfile, - editingProfileName: $store.editingProfileName, - deleteAction: { - if let value = store.ehProfile?.value { - DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) { - store.send(.performAction(action: .delete, name: nil, set: value)) - } - } - }, - deleteDialogAction: { store.send(.setNavigation(.deleteProfile)) }, - performEhProfileAction: { store.send(.performAction(action: $0, name: $1, set: $2)) } - ) - - ImageLoadSettingsSection(ehSetting: ehSetting) - ImageSizeSettingsSection(ehSetting: ehSetting) - GalleryNameDisplaySection(ehSetting: ehSetting) - ArchiverSettingsSection(ehSetting: ehSetting) - FrontPageSettingsSection(ehSetting: ehSetting) - OptionalUIElementsSection(ehSetting: ehSetting) - FavoritesSection(ehSetting: ehSetting) - SearchResultCountSection(ehSetting: ehSetting) - ThumbnailSettingsSection(ehSetting: ehSetting) - } - Group { - CoverScalingSection(ehSetting: ehSetting) - RatingsSection(ehSetting: ehSetting) - TagWatchingThresholdSection(ehSetting: ehSetting) - TagFilteringThresholdSection(ehSetting: ehSetting) - FilteredRemovalCountSection(ehSetting: ehSetting) - ExcludedLanguagesSection(ehSetting: ehSetting) - ExcludedUploadersSection(ehSetting: ehSetting) - ViewportOverrideSection(ehSetting: ehSetting) - GalleryCommentsSection(ehSetting: ehSetting) - GalleryTagsSection(ehSetting: ehSetting) - } - Group { - GalleryPageThumbnailLabelingSection(ehSetting: ehSetting) - MultiplePageViewerSection(ehSetting: ehSetting) - } - } - } - // MARK: Toolbar - private func toolbar() -> some ToolbarContent { - Group { - ToolbarItem(placement: .navigationBarTrailing) { - Button { - store.send(.setNavigation(.webView(Defaults.URL.uConfig))) - } label: { - Image(systemSymbol: .globe) - } - .disabled(bypassesSNIFiltering) - } - - ToolbarItem(placement: .confirmationAction) { - Button { - store.send(.submitChanges) - } label: { - Image(systemSymbol: .icloudAndArrowUp) - } - .disabled(store.ehSetting == nil) - } - - ToolbarItem(placement: .keyboard) { - Button(L10n.Localizable.EhSettingView.ToolbarItem.Button.done) { - store.send(.setKeyboardHidden) - } - .frame(maxWidth: .infinity, alignment: .trailing) - } - } - } -} - -// MARK: EhProfileSection -private struct EhProfileSection: View { - @Binding private var route: EhSettingReducer.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(L10n.Localizable.EhSettingView.Section.Title.profileSettings) { - Picker(L10n.Localizable.EhSettingView.Title.selectedProfile, selection: $ehProfile) { - ForEach(ehSetting.ehProfiles) { ehProfile in - Text(ehProfile.name) - .tag(ehProfile) - } - } - .pickerStyle(.menu) - - if !ehProfile.isDefault { - Button(L10n.Localizable.EhSettingView.Button.setAsDefault) { - performEhProfileAction(.default, nil, ehProfile.value) - } - - Button( - L10n.Localizable.EhSettingView.Button.deleteProfile, - role: .destructive, - action: deleteDialogAction - ) - .confirmationDialog( - message: L10n.Localizable.ConfirmationDialog.Title.delete, - unwrapping: $route, - case: \.deleteProfile - ) { - Button( - L10n.Localizable.ConfirmationDialog.Button.delete, - role: .destructive, action: deleteAction - ) - } - } - } - .onChange(of: ehProfile) { _, newValue in - performEhProfileAction(nil, nil, newValue.value) - } - - Section { - SettingTextField(text: $editingProfileName, width: nil, alignment: .leading, background: .clear) - .focused($isFocused) - - Button(L10n.Localizable.EhSettingView.Button.rename) { - performEhProfileAction(.rename, editingProfileName, ehProfile.value) - } - .disabled(isFocused) - - if ehSetting.isCapableOfCreatingNewProfile { - Button(L10n.Localizable.EhSettingView.Button.createNew) { - performEhProfileAction(.create, editingProfileName, ehProfile.value) - } - .disabled(isFocused) - } - } - } -} - -// MARK: ImageLoadSettingsSection -private struct ImageLoadSettingsSection: View { - @Binding private var ehSetting: EhSetting - - init(ehSetting: Binding) { - _ehSetting = ehSetting - } - - var body: some View { - Section { - Picker( - L10n.Localizable.EhSettingView.Title.loadImagesThroughTheHathNetwork, - selection: $ehSetting.loadThroughHathSetting - ) { - ForEach(ehSetting.capableLoadThroughHathSettings) { setting in - Text(setting.value) - .tag(setting) - } - } - .pickerStyle(.menu) - } header: { - Text(L10n.Localizable.EhSettingView.Section.Title.imageLoadSettings) - } footer: { - Text(ehSetting.loadThroughHathSetting.description) - } - - Section( - L10n.Localizable.EhSettingView.Description.browsingCountry( - ehSetting.localizedLiteralBrowsingCountry ?? ehSetting.literalBrowsingCountry - ) - .localizedKey - ) { - Picker(L10n.Localizable.EhSettingView.Title.browsingCountry, selection: $ehSetting.browsingCountry) { - ForEach(EhSetting.BrowsingCountry.allCases) { country in - Text(country.name) - .tag(country) - .foregroundColor(country == ehSetting.browsingCountry ? .accentColor : .primary) - } - } - } - } -} - -// MARK: ImageSizeSettingsSection -private struct ImageSizeSettingsSection: View { - @Binding private var ehSetting: EhSetting - - init(ehSetting: Binding) { - _ehSetting = ehSetting - } - - var body: some View { - Section { - Picker(L10n.Localizable.EhSettingView.Title.imageResolution, selection: $ehSetting.imageResolution) { - ForEach(ehSetting.capableImageResolutions) { setting in - Text(setting.value) - .tag(setting) - } - } - .pickerStyle(.menu) - } header: { - Text(L10n.Localizable.EhSettingView.Section.Title.imageSizeSettings) - .newlineBold() - .appending(L10n.Localizable.EhSettingView.Description.imageResolution) - } - - if let useOriginalImagesBinding = Binding($ehSetting.useOriginalImages) { - Section(L10n.Localizable.EhSettingView.Section.Title.originalImages) { - Toggle( - L10n.Localizable.EhSettingView.Title.useOriginalImages, - isOn: useOriginalImagesBinding - ) - } - } - - Section(L10n.Localizable.EhSettingView.Description.imageSize) { - Text(L10n.Localizable.EhSettingView.Title.imageSize) - - ValuePicker( - title: L10n.Localizable.EhSettingView.Title.horizontal, - value: $ehSetting.imageSizeWidth, range: 0...65535, unit: "px" - ) - - ValuePicker( - title: L10n.Localizable.EhSettingView.Title.vertical, - value: $ehSetting.imageSizeHeight, range: 0...65535, unit: "px" - ) - } - } -} - -// MARK: GalleryNameDisplaySection -private struct GalleryNameDisplaySection: View { - @Binding private var ehSetting: EhSetting - - init(ehSetting: Binding) { - _ehSetting = ehSetting - } - - var body: some View { - Section { - Picker(L10n.Localizable.EhSettingView.Title.galleryName, selection: $ehSetting.galleryName) { - ForEach(EhSetting.GalleryName.allCases) { name in - Text(name.value) - .tag(name) - } - } - .pickerStyle(.menu) - } header: { - Text(L10n.Localizable.EhSettingView.Section.Title.galleryNameDisplay) - .newlineBold() - .appending(L10n.Localizable.EhSettingView.Description.galleryName) - } - } -} - -// MARK: ArchiverSettingsSection -private struct ArchiverSettingsSection: View { - @Binding private var ehSetting: EhSetting - - init(ehSetting: Binding) { - _ehSetting = ehSetting - } - - var body: some View { - Section { - Picker(L10n.Localizable.EhSettingView.Title.archiverBehavior, selection: $ehSetting.archiverBehavior) { - ForEach(EhSetting.ArchiverBehavior.allCases) { behavior in - Text(behavior.value) - .tag(behavior) - } - } - .pickerStyle(.menu) - } header: { - Text(L10n.Localizable.EhSettingView.Section.Title.archiverSettings) - .newlineBold() - .appending(L10n.Localizable.EhSettingView.Description.archiverBehavior) - } - } -} - -// MARK: FrontPageSettingsSection -private struct FrontPageSettingsSection: View { - @Binding private var ehSetting: EhSetting - - init(ehSetting: Binding) { - _ehSetting = ehSetting - } - - private var categoryBindings: [Binding] { - $ehSetting.disabledCategories.map({ $0 }) - } - - var body: some View { - Section { - CategoryView(bindings: categoryBindings) - } header: { - Text(L10n.Localizable.EhSettingView.Section.Title.frontPageSettings) - .newlineBold() - .appending(L10n.Localizable.EhSettingView.Description.galleryCategory) - } - - Section(L10n.Localizable.EhSettingView.Description.displayMode) { - Picker(L10n.Localizable.EhSettingView.Title.displayMode, selection: $ehSetting.displayMode) { - ForEach(EhSetting.DisplayMode.allCases) { mode in - Text(mode.value) - .tag(mode) - } - } - .pickerStyle(.menu) - } - - Section(L10n.Localizable.EhSettingView.Section.Title.showSearchRangeIndicator) { - Toggle( - L10n.Localizable.EhSettingView.Title.showSearchRangeIndicator, - isOn: $ehSetting.showSearchRangeIndicator - ) - } - } -} - -// MARK: OptionalUIElementsSection -private struct OptionalUIElementsSection: View { - @Binding private var ehSetting: EhSetting - - init(ehSetting: Binding) { - self._ehSetting = ehSetting - } - - var body: some View { - Section { - Toggle( - L10n.Localizable.EhSettingView.Title.enableGalleryThumbnailSelector, - isOn: $ehSetting.enableGalleryThumbnailSelector - ) - } header: { - Text(L10n.Localizable.EhSettingView.Section.Title.optionalUIElements) - .newlineBold() - .appending(L10n.Localizable.EhSettingView.Description.optionalUIElements) - } - } -} - -// MARK: FavoritesSection -private struct FavoritesSection: View { - @Binding private var ehSetting: EhSetting - @FocusState private var isFocused - - init(ehSetting: Binding) { - _ehSetting = ehSetting - } - - private var tuples: [(Category, Binding)] { - Category.allFavoritesCases.enumerated().map { index, category in - (category, $ehSetting.favoriteCategories[index]) - } - } - - var body: some View { - Section { - ForEach(tuples, id: \.0) { category, nameBinding in - HStack(spacing: 30) { - Circle() - .foregroundColor(category.color) - .frame(width: 10) - - SettingTextField(text: nameBinding, width: nil, alignment: .leading, background: .clear) - .focused($isFocused) - } - .padding(.leading) - } - } header: { - Text(L10n.Localizable.EhSettingView.Section.Title.favorites) - .newlineBold() - .appending(L10n.Localizable.EhSettingView.Description.favoriteCategories) - } - - Section(L10n.Localizable.EhSettingView.Description.favoritesSortOrder) { - Picker( - L10n.Localizable.EhSettingView.Title.favoritesSortOrder, - selection: $ehSetting.favoritesSortOrder - ) { - ForEach(EhSetting.FavoritesSortOrder.allCases) { order in - Text(order.value) - .tag(order) - } - } - .pickerStyle(.menu) - } - } -} - -// MARK: RatingsSection -private struct RatingsSection: View { - @Binding private var ehSetting: EhSetting - @FocusState var isFocused - - init(ehSetting: Binding) { - _ehSetting = ehSetting - } - - var body: some View { - Section { - LabeledContent(L10n.Localizable.EhSettingView.Title.ratingsColor) { - SettingTextField( - text: $ehSetting.ratingsColor, - promptText: L10n.Localizable.EhSettingView.Promt.ratingsColor, - width: 80 - ) - .focused($isFocused) - } - } header: { - Text(L10n.Localizable.EhSettingView.Section.Title.ratings) - .newlineBold() - .appending(L10n.Localizable.EhSettingView.Description.ratingsColor) - } - } -} - -// MARK: TagFilteringThresholdSection -private struct TagFilteringThresholdSection: View { - @Binding private var ehSetting: EhSetting - - init(ehSetting: Binding) { - _ehSetting = ehSetting - } - - var body: some View { - Section { - ValuePicker( - title: L10n.Localizable.EhSettingView.Title.tagFilteringThreshold, - value: $ehSetting.tagFilteringThreshold, range: -9999...0 - ) - } header: { - Text(L10n.Localizable.EhSettingView.Section.Title.tagFilteringThreshold) - .newlineBold() - .appending(L10n.Localizable.EhSettingView.Description.tagFilteringThreshold) - } - } -} - -// MARK: TagWatchingThresholdSection -private struct TagWatchingThresholdSection: View { - @Binding private var ehSetting: EhSetting - - init(ehSetting: Binding) { - _ehSetting = ehSetting - } - - var body: some View { - Section { - ValuePicker( - title: L10n.Localizable.EhSettingView.Title.tagWatchingThreshold, - value: $ehSetting.tagWatchingThreshold, range: 0...9999 - ) - } header: { - Text(L10n.Localizable.EhSettingView.Section.Title.tagWatchingThreshold) - .newlineBold() - .appending(L10n.Localizable.EhSettingView.Description.tagWatchingThreshold) - } - } -} - -// MARK: FilteredRemovalCountSection -private struct FilteredRemovalCountSection: View { - @Binding private var ehSetting: EhSetting - - init(ehSetting: Binding) { - _ehSetting = ehSetting - } - - var body: some View { - Section { - Toggle( - L10n.Localizable.EhSettingView.Title.showFilteredRemovalCount, - isOn: $ehSetting.showFilteredRemovalCount - ) - } header: { - Text(L10n.Localizable.EhSettingView.Section.Title.filteredRemovalCount).newlineBold() - + Text(L10n.Localizable.EhSettingView.Description.filteredRemovalCount) - } - } -} - -// MARK: ExcludedLanguagesSection -private struct ExcludedLanguagesSection: View { - @Binding private var ehSetting: EhSetting - - init(ehSetting: Binding) { - _ehSetting = ehSetting - } - - private let languages = Language.allExcludedCases.map(\.value) - private var languageBindings: [Binding] { - $ehSetting.excludedLanguages.map({ $0 }) - } - private func rowBindings(index: Int) -> [Binding] { - [-1, 0, 1].map { num in - let index = index * 3 + num - if index != -1 { - return languageBindings[index] - } else { - return .constant(false) - } - } - } - - var body: some View { - Section { - HStack { - Text("") - .frame(width: DeviceUtil.windowW * 0.25) - - ForEach(EhSetting.ExcludedLanguagesCategory.allCases) { category in - Color.clear - .overlay { - Text(category.value) - .lineLimit(1) - .font(.subheadline) - .fixedSize() - } - } - } - - ForEach(0..<(languageBindings.count / 3) + 1, id: \.self) { index in - ExcludeRow( - title: languages[index], - bindings: rowBindings(index: index), - isFirstRow: index == 0 - ) - } - } header: { - Text(L10n.Localizable.EhSettingView.Section.Title.excludedLanguages) - .newlineBold() - .appending(L10n.Localizable.EhSettingView.Description.excludedLanguages) - } - } -} - -private struct ExcludeRow: View { - private let title: String - private let bindings: [Binding] - private let isFirstRow: Bool - - init(title: String, bindings: [Binding], isFirstRow: Bool) { - self.title = title - self.bindings = bindings - self.isFirstRow = isFirstRow - } - - var body: some View { - HStack { - Text(title) - .lineLimit(1) - .font(.subheadline) - .fixedSize() - .frame(maxWidth: .infinity, alignment: .leading) - .frame(width: DeviceUtil.windowW * 0.25) - - ForEach(0..) { - _isOn = isOn - } - - var body: some View { - Color.clear - .overlay { - Image(systemSymbol: isOn ? .nosign : .circle) - .foregroundColor(isOn ? .red : .primary) - .font(.title) - } - .onTapGesture { - withAnimation { isOn.toggle() } - HapticsUtil.generateFeedback(style: .soft) - } - } -} - -// MARK: ExcludedUploadersSection -private struct ExcludedUploadersSection: View { - @Binding private var ehSetting: EhSetting - @FocusState var isFocused - - init(ehSetting: Binding) { - _ehSetting = ehSetting - } - - var body: some View { - Section { - TextEditor(text: $ehSetting.excludedUploaders) - .textInputAutocapitalization(.none) - .frame(maxHeight: DeviceUtil.windowH * 0.3) - .disableAutocorrection(true) - .focused($isFocused) - } header: { - Text(L10n.Localizable.EhSettingView.Section.Title.excludedUploaders) - .newlineBold() - .appending(L10n.Localizable.EhSettingView.Description.excludedUploaders) - } footer: { - Text( - L10n.Localizable.EhSettingView.Description.excludedUploadersCount( - "\(ehSetting.excludedUploaders.lineCount)", "\(1000)" - ) - .localizedKey - ) - } - } -} - -// MARK: SearchResultCountSection -private struct SearchResultCountSection: View { - @Binding private var ehSetting: EhSetting - - init(ehSetting: Binding) { - _ehSetting = ehSetting - } - - var body: some View { - Section { - Picker(L10n.Localizable.EhSettingView.Title.resultCount, selection: $ehSetting.searchResultCount) { - ForEach(ehSetting.capableSearchResultCounts) { count in - Text(String(count.value)) - .tag(count) - } - } - .pickerStyle(.menu) - } header: { - Text(L10n.Localizable.EhSettingView.Section.Title.searchResultCount) - .newlineBold() - .appending(L10n.Localizable.EhSettingView.Description.resultCount) - } - } -} - -// MARK: ThumbnailSettingsSection -private struct ThumbnailSettingsSection: View { - @Binding private var ehSetting: EhSetting - - init(ehSetting: Binding) { - self._ehSetting = ehSetting - } - - var body: some View { - Section { - Picker( - L10n.Localizable.EhSettingView.Title.thumbnailLoadTiming, - selection: $ehSetting.thumbnailLoadTiming - ) { - ForEach(EhSetting.ThumbnailLoadTiming.allCases) { timing in - Text(timing.value) - .tag(timing) - } - } - .pickerStyle(.menu) - } header: { - Text(L10n.Localizable.EhSettingView.Section.Title.thumbnailSettings) - .newlineBold() - .appending(L10n.Localizable.EhSettingView.Description.thumbnailLoadTiming) - } footer: { - Text(ehSetting.thumbnailLoadTiming.description) - } - - Section(L10n.Localizable.EhSettingView.Description.thumbnailConfiguration) { - LabeledContent(L10n.Localizable.EhSettingView.Title.thumbnailSize) { - Picker(selection: $ehSetting.thumbnailConfigSize) { - ForEach(ehSetting.capableThumbnailConfigSizes) { size in - Text(size.value) - .tag(size) - } - } label: { - Text(ehSetting.thumbnailConfigSize.value) - } - .pickerStyle(.segmented) - .frame(width: 200) - } - - LabeledContent(L10n.Localizable.EhSettingView.Title.thumbnailRowCount) { - Picker(selection: $ehSetting.thumbnailConfigRows) { - ForEach(ehSetting.capableThumbnailConfigRowCounts) { row in - Text(row.value) - .tag(row) - } - } label: { - Text(ehSetting.capableThumbnailConfigRowCount.value) - } - .pickerStyle(.segmented) - .frame(width: 200) - } - } - } -} - -// MARK: CoverScalingSection -private struct CoverScalingSection: View { - @Binding private var ehSetting: EhSetting - - init(ehSetting: Binding) { - self._ehSetting = ehSetting - } - - var body: some View { - Section { - ValuePicker( - title: L10n.Localizable.EhSettingView.Title.scaleFactor, - value: $ehSetting.coverScaleFactor, - range: 75...150, - unit: "%" - ) - } header: { - Text(L10n.Localizable.EhSettingView.Section.Title.coverScaling) - .newlineBold() - .appending(L10n.Localizable.EhSettingView.Description.coverScaleFactor) - } - } -} - -// MARK: ViewportOverrideSection -private struct ViewportOverrideSection: View { - @Binding private var ehSetting: EhSetting - - init(ehSetting: Binding) { - _ehSetting = ehSetting - } - - var body: some View { - Section { - ValuePicker( - title: L10n.Localizable.EhSettingView.Title.virtualWidth, - value: $ehSetting.viewportVirtualWidth, - range: 0...9999, - unit: "px" - ) - } header: { - Text(L10n.Localizable.EhSettingView.Section.Title.viewportOverride) - .newlineBold() - .appending(L10n.Localizable.EhSettingView.Description.virtualWidth) - } - } -} - -private struct ValuePicker: View { - private let title: String - @Binding private var value: Float - private let range: ClosedRange - private let unit: String - - init(title: String, value: Binding, range: ClosedRange, unit: String = "") { - self.title = title - _value = value - self.range = range - self.unit = unit - } - - var body: some View { - LabeledContent(title) { - Text(String(Int(value)) + unit) - .foregroundStyle(.tint) - } - - Slider( - value: $value, - in: range, - label: EmptyView.init, - minimumValueLabel: { - Text(String(Int(range.lowerBound)) + unit) - .fontWeight(.medium) - .font(.callout) - }, - maximumValueLabel: { - Text(String(Int(range.upperBound)) + unit) - .fontWeight(.medium) - .font(.callout) - } - ) - } -} - -// MARK: GalleryCommentsSection -private struct GalleryCommentsSection: View { - @Binding private var ehSetting: EhSetting - - init(ehSetting: Binding) { - _ehSetting = ehSetting - } - - var body: some View { - Section(L10n.Localizable.EhSettingView.Section.Title.galleryComments) { - Picker( - L10n.Localizable.EhSettingView.Title.commentsSortOrder, - selection: $ehSetting.commentsSortOrder - ) { - ForEach(EhSetting.CommentsSortOrder.allCases) { order in - Text(order.value) - .tag(order) - } - } - .pickerStyle(.menu) - - Picker( - L10n.Localizable.EhSettingView.Title.commentsVotesShowTiming, - selection: $ehSetting.commentVotesShowTiming - ) { - ForEach(EhSetting.CommentVotesShowTiming.allCases) { timing in - Text(timing.value) - .tag(timing) - } - } - .pickerStyle(.menu) - } - } -} - -// MARK: GalleryTagsSection -private struct GalleryTagsSection: View { - @Binding private var ehSetting: EhSetting - - init(ehSetting: Binding) { - _ehSetting = ehSetting - } - - var body: some View { - Section(L10n.Localizable.EhSettingView.Section.Title.galleryTags) { - Picker(L10n.Localizable.EhSettingView.Title.tagsSortOrder, selection: $ehSetting.tagsSortOrder) { - ForEach(EhSetting.TagsSortOrder.allCases) { order in - Text(order.value) - .tag(order) - } - } - .pickerStyle(.menu) - } - } -} - -// MARK: GalleryPageThumbnailLabelingSection -private struct GalleryPageThumbnailLabelingSection: View { - @Binding private var ehSetting: EhSetting - - init(ehSetting: Binding) { - self._ehSetting = ehSetting - } - - var body: some View { - Section(L10n.Localizable.EhSettingView.Section.Title.galleryPageThumbnailLabeling) { - Picker( - L10n.Localizable.EhSettingView.Title.showLabelBelowGalleryThumbnails, - selection: $ehSetting.galleryPageNumbering - ) { - ForEach(EhSetting.GalleryPageNumbering.allCases) { behavior in - Text(behavior.value) - .tag(behavior) - } - } - .pickerStyle(.menu) - } - } -} - -// MARK: MultiplePageViewerSection -private struct MultiplePageViewerSection: View { - @Binding private var ehSetting: EhSetting - - init(ehSetting: Binding) { - _ehSetting = ehSetting - } - - var body: some View { - if let useMultiplePageViewerBinding = Binding($ehSetting.useMultiplePageViewer), - let multiplePageViewerStyleBinding = Binding($ehSetting.multiplePageViewerStyle), - let multiplePageViewerShowPaneBinding = Binding($ehSetting.multiplePageViewerShowThumbnailPane) - { - Section(L10n.Localizable.EhSettingView.Section.Title.multiPageViewer) { - Toggle( - L10n.Localizable.EhSettingView.Title.useMultiPageViewer, - isOn: useMultiplePageViewerBinding - ) - - Picker( - L10n.Localizable.EhSettingView.Title.displayStyle, - selection: multiplePageViewerStyleBinding - ) { - ForEach(EhSetting.MultiplePageViewerStyle.allCases) { style in - Text(style.value) - .tag(style) - } - } - .pickerStyle(.menu) - - Toggle( - L10n.Localizable.EhSettingView.Title.showThumbnailPane, - isOn: multiplePageViewerShowPaneBinding - ) - } - } - } -} - -private extension String { - var lineCount: Int { - var count = 0 - enumerateLines { line, _ in - if !line.isEmpty { - count += 1 - } - } - return count - } -} - -private extension Text { - func newlineBold() -> Text { - bold() + Text("\n") - } - - func appending(_ string: some StringProtocol) -> Text { - self + Text(string) - } -} - -struct EhSettingView_Previews: PreviewProvider { - static var previews: some View { - NavigationView { - EhSettingView( - store: .init( - initialState: .init(ehSetting: .empty, ehProfile: .empty, loadingState: .idle), - reducer: EhSettingReducer.init - ), - bypassesSNIFiltering: false, - blurRadius: 0 - ) - } - } -} diff --git a/EhPanda/View/Setting/GeneralSetting/GeneralSettingReducer.swift b/EhPanda/View/Setting/GeneralSetting/GeneralSettingReducer.swift deleted file mode 100644 index d63026bd8..000000000 --- a/EhPanda/View/Setting/GeneralSetting/GeneralSettingReducer.swift +++ /dev/null @@ -1,110 +0,0 @@ -// -// GeneralSettingReducer.swift -// EhPanda -// - -import Kingfisher -import LocalAuthentication -import ComposableArchitecture - -@Reducer -struct GeneralSettingReducer { - @CasePathable - enum Route { - case logs - case clearCache - case removeCustomTranslations - } - - @ObservableState - struct State: Equatable { - var route: Route? - - var loadingState: LoadingState = .idle - var diskImageCacheSize = "0 KB" - var passcodeNotSet = false - - var logsState = LogsReducer.State() - } - - enum Action: BindableAction, Equatable { - case binding(BindingAction) - case setNavigation(Route?) - case clearSubStates - case onTranslationsFilePicked(URL) - case onRemoveCustomTranslations - - case clearWebImageCache - case checkPasscodeSetting - case navigateToSystemSetting - case calculateWebImageDiskCache - case calculateWebImageDiskCacheDone(UInt?) - - case logs(LogsReducer.Action) - } - - @Dependency(\.authorizationClient) private var authorizationClient - @Dependency(\.uiApplicationClient) private var uiApplicationClient - @Dependency(\.databaseClient) private var databaseClient - @Dependency(\.libraryClient) private var libraryClient - - var body: some Reducer { - BindingReducer() - .onChange(of: \.route) { _, newValue in - Reduce({ _, _ in newValue == nil ? .send(.clearSubStates) : .none }) - } - - Reduce { state, action in - switch action { - case .binding: - return .none - - case .setNavigation(let route): - state.route = route - return route == nil ? .send(.clearSubStates) : .none - - case .clearSubStates: - state.logsState = .init() - return .send(.logs(.teardown)) - - case .onTranslationsFilePicked: - return .none - - case .onRemoveCustomTranslations: - return .none - - case .clearWebImageCache: - return .merge( - .run(operation: { _ in libraryClient.clearWebImageDiskCache() }), - .run(operation: { _ in await databaseClient.removeImageURLs() }), - .send(.calculateWebImageDiskCache) - ) - - case .checkPasscodeSetting: - state.passcodeNotSet = authorizationClient.passcodeNotSet() - return .none - - case .navigateToSystemSetting: - return .run(operation: { _ in await uiApplicationClient.openSettings() }) - - case .calculateWebImageDiskCache: - return .run { send in - let size = await libraryClient.calculateWebImageDiskCacheSize() - await send(.calculateWebImageDiskCacheDone(size)) - } - - case .calculateWebImageDiskCacheDone(let bytes): - guard let bytes = bytes else { return .none } - let formatter = ByteCountFormatter() - formatter.allowedUnits = .useAll - state.diskImageCacheSize = formatter.string(fromByteCount: .init(bytes)) - return .none - - case .logs: - return .none - } - } - - Scope(state: \.logsState, action: \.logs, child: LogsReducer.init) - } -} diff --git a/EhPanda/View/Setting/GeneralSetting/GeneralSettingView.swift b/EhPanda/View/Setting/GeneralSetting/GeneralSettingView.swift deleted file mode 100644 index 017f2520c..000000000 --- a/EhPanda/View/Setting/GeneralSetting/GeneralSettingView.swift +++ /dev/null @@ -1,217 +0,0 @@ -// -// GeneralSettingView.swift -// EhPanda -// - -import SwiftUI -import FilePicker -import ComposableArchitecture - -struct GeneralSettingView: View { - @Bindable private var store: StoreOf - private let tagTranslatorLoadingState: LoadingState - private let tagTranslatorEmpty: Bool - private let tagTranslatorHasCustomTranslations: Bool - @Binding private var enablesTagsExtension: Bool - @Binding private var translatesTags: Bool - @Binding private var showsTagsSearchSuggestion: Bool - @Binding private var showsImagesInTags: Bool - @Binding private var redirectsLinksToSelectedHost: Bool - @Binding private var detectsLinksFromClipboard: Bool - @Binding private var backgroundBlurRadius: Double - @Binding private var autoLockPolicy: AutoLockPolicy - - init( - store: StoreOf, - tagTranslatorLoadingState: LoadingState, tagTranslatorEmpty: Bool, - tagTranslatorHasCustomTranslations: Bool, enablesTagsExtension: Binding, - translatesTags: Binding, showsTagsSearchSuggestion: Binding, - showsImagesInTags: Binding, redirectsLinksToSelectedHost: Binding, - detectsLinksFromClipboard: Binding, backgroundBlurRadius: Binding, - autoLockPolicy: Binding - ) { - self.store = store - self.tagTranslatorLoadingState = tagTranslatorLoadingState - self.tagTranslatorEmpty = tagTranslatorEmpty - self.tagTranslatorHasCustomTranslations = tagTranslatorHasCustomTranslations - _enablesTagsExtension = enablesTagsExtension - _translatesTags = translatesTags - _showsTagsSearchSuggestion = showsTagsSearchSuggestion - _showsImagesInTags = showsImagesInTags - _redirectsLinksToSelectedHost = redirectsLinksToSelectedHost - _detectsLinksFromClipboard = detectsLinksFromClipboard - _backgroundBlurRadius = backgroundBlurRadius - _autoLockPolicy = autoLockPolicy - } - - private var language: String { - Locale.current.language.languageCode.map(\.identifier).flatMap(Locale.current.localizedString(forLanguageCode:)) - ?? L10n.Localizable.GeneralSettingView.Value.defaultLanguageDescription - } - - var body: some View { - Form { - Section { - HStack { - Text(L10n.Localizable.GeneralSettingView.Title.language) - Spacer() - Button(language) { - store.send(.navigateToSystemSetting) - } - .foregroundStyle(.tint) - } - Button(L10n.Localizable.GeneralSettingView.Button.logs) { - store.send(.setNavigation(.logs)) - } - .foregroundColor(.primary).withArrow() - } - Section(L10n.Localizable.GeneralSettingView.Section.Title.tags) { - HStack { - Text(L10n.Localizable.GeneralSettingView.Title.enablesTagsExtension) - .frame(maxWidth: .infinity, alignment: .leading) - - ZStack { - Image(systemSymbol: .exclamationmarkTriangleFill) - .foregroundStyle(.yellow) - .opacity( - translatesTags && tagTranslatorEmpty - && tagTranslatorLoadingState != .loading ? 1 : 0 - ) - ProgressView() - .tint(nil) - .opacity(tagTranslatorLoadingState == .loading ? 1 : 0) - } - - Toggle("", isOn: $enablesTagsExtension) - .frame(width: 50) - .padding(.leading, 20) - } - if enablesTagsExtension && !tagTranslatorEmpty { - Toggle(L10n.Localizable.GeneralSettingView.Title.translatesTags, isOn: $translatesTags) - Toggle( - L10n.Localizable.GeneralSettingView.Title.showsTagsSearchSuggestion, - isOn: $showsTagsSearchSuggestion - ) - Toggle(L10n.Localizable.GeneralSettingView.Title.showsImagesInTags, isOn: $showsImagesInTags) - } - FilePicker( - types: [.json], allowMultiple: false, - title: L10n.Localizable.GeneralSettingView.Button.importCustomTranslations - ) { urls in - if let url = urls.first { - store.send(.onTranslationsFilePicked(url)) - } - } - if tagTranslatorHasCustomTranslations { - Button( - L10n.Localizable.GeneralSettingView.Button.removeCustomTranslations, - role: .destructive, action: { store.send(.setNavigation(.removeCustomTranslations)) } - ) - .confirmationDialog( - message: L10n.Localizable.ConfirmationDialog.Title.removeCustomTranslations, - unwrapping: $store.route, - case: \.removeCustomTranslations - ) { - Button(L10n.Localizable.ConfirmationDialog.Button.remove, role: .destructive) { - DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) { - store.send(.onRemoveCustomTranslations) - } - } - } - } - } - Section(L10n.Localizable.GeneralSettingView.Section.Title.navigation) { - Toggle( - L10n.Localizable.GeneralSettingView.Title.redirectsLinksToTheSelectedHost, - isOn: $redirectsLinksToSelectedHost - ) - Toggle( - L10n.Localizable.GeneralSettingView.Title.detectsLinksFromClipboard, - isOn: $detectsLinksFromClipboard - ) - } - Section(L10n.Localizable.GeneralSettingView.Section.Title.security) { - HStack { - Picker( - L10n.Localizable.GeneralSettingView.Title.autoLock, - selection: $autoLockPolicy - ) { - ForEach(AutoLockPolicy.allCases) { policy in - Text(policy.value).tag(policy) - } - } - .pickerStyle(.menu) - if store.passcodeNotSet && autoLockPolicy != .never { - Image(systemSymbol: .exclamationmarkTriangleFill).foregroundStyle(.yellow) - } - } - VStack(alignment: .leading) { - Text(L10n.Localizable.GeneralSettingView.Title.backgroundBlurRadius) - HStack { - Image(systemSymbol: .eye) - Slider(value: $backgroundBlurRadius, in: 0...100, step: 10) - Image(systemSymbol: .eyeSlash) - } - } - } - Section(L10n.Localizable.GeneralSettingView.Section.Title.caches) { - Button { - store.send(.setNavigation(.clearCache)) - } label: { - HStack { - Text(L10n.Localizable.GeneralSettingView.Button.clearImageCaches) - Spacer() - Text(store.diskImageCacheSize).foregroundStyle(.tint) - } - .foregroundColor(.primary) - } - .confirmationDialog( - message: L10n.Localizable.ConfirmationDialog.Title.clear, - unwrapping: $store.route, - case: \.clearCache - ) { - Button(L10n.Localizable.ConfirmationDialog.Button.clear, role: .destructive) { - store.send(.clearWebImageCache) - } - } - } - } - .animation(.default, value: tagTranslatorHasCustomTranslations) - .animation(.default, value: tagTranslatorLoadingState) - .animation(.default, value: enablesTagsExtension) - .animation(.default, value: tagTranslatorEmpty) - .onAppear { - store.send(.checkPasscodeSetting) - store.send(.calculateWebImageDiskCache) - } - .background(navigationLink) - .navigationTitle(L10n.Localizable.GeneralSettingView.Title.general) - } - - private var navigationLink: some View { - NavigationLink(unwrapping: $store.route, case: \.logs) { _ in - LogsView(store: store.scope(state: \.logsState, action: \.logs)) - } - } -} - -struct GeneralSettingView_Previews: PreviewProvider { - static var previews: some View { - NavigationView { - GeneralSettingView( - store: .init(initialState: .init(), reducer: GeneralSettingReducer.init), - tagTranslatorLoadingState: .idle, - tagTranslatorEmpty: false, - tagTranslatorHasCustomTranslations: false, - enablesTagsExtension: .constant(false), - translatesTags: .constant(false), - showsTagsSearchSuggestion: .constant(false), - showsImagesInTags: .constant(false), - redirectsLinksToSelectedHost: .constant(false), - detectsLinksFromClipboard: .constant(false), - backgroundBlurRadius: .constant(10), - autoLockPolicy: .constant(.never) - ) - } - } -} diff --git a/EhPanda/View/Setting/Logs/LogsReducer.swift b/EhPanda/View/Setting/Logs/LogsReducer.swift deleted file mode 100644 index 55df0a967..000000000 --- a/EhPanda/View/Setting/Logs/LogsReducer.swift +++ /dev/null @@ -1,92 +0,0 @@ -// -// LogsReducer.swift -// EhPanda -// - -import ComposableArchitecture - -@Reducer -struct LogsReducer { - @CasePathable - enum Route: Equatable { - case log(Log) - } - - private enum CancelID { - case fetchLogs - } - - @ObservableState - struct State: Equatable { - var route: Route? - var loadingState: LoadingState = .idle - var logs = [Log]() - } - - enum Action: BindableAction, Equatable { - case binding(BindingAction) - case setNavigation(Route?) - case navigateToFileApp - - case teardown - case fetchLogs - case fetchLogsDone(Result<[Log], AppError>) - case deleteLog(String) - case deleteLogDone(Result) - } - - @Dependency(\.uiApplicationClient) private var uiApplicationClient - @Dependency(\.fileClient) private var fileClient - - var body: some Reducer { - BindingReducer() - - Reduce { state, action in - switch action { - case .binding: - return .none - - case .setNavigation(let route): - state.route = route - return .none - - case .navigateToFileApp: - return .run(operation: { _ in await uiApplicationClient.openFileApp() }) - - case .teardown: - return .cancel(id: CancelID.fetchLogs) - - case .fetchLogs: - guard state.loadingState != .loading else { return .none } - state.loadingState = .loading - return .run { send in - let result = await fileClient.fetchLogs() - await send(.fetchLogsDone(result)) - } - .cancellable(id: CancelID.fetchLogs) - - case .fetchLogsDone(let result): - switch result { - case .success(let logs): - state.logs = logs - state.loadingState = .idle - case .failure(let error): - state.loadingState = .failed(error) - } - return .none - - case .deleteLog(let fileName): - return .run { send in - let result = await fileClient.deleteLog(fileName) - await send(.deleteLogDone(result)) - } - - case .deleteLogDone(let result): - if case .success(let fileName) = result { - state.logs = state.logs.filter({ $0.fileName != fileName }) - } - return .none - } - } - } -} diff --git a/EhPanda/View/Setting/Logs/LogsView.swift b/EhPanda/View/Setting/Logs/LogsView.swift deleted file mode 100644 index 30afa5c1c..000000000 --- a/EhPanda/View/Setting/Logs/LogsView.swift +++ /dev/null @@ -1,178 +0,0 @@ -// -// LogsView.swift -// EhPanda -// - -import SwiftUI -import ComposableArchitecture - -struct LogsView: View { - @Bindable private var store: StoreOf - - init(store: StoreOf) { - self.store = store - } - - var body: some View { - ZStack { - List(store.logs) { log in - Button { - store.send(.setNavigation(.log(log))) - } label: { - LogCell(log: log, isLatest: log == store.logs.first) - } - .swipeActions { - Button { - store.send(.deleteLog(log.fileName)) - } label: { - Image(systemSymbol: .trash) - } - .tint(.red) - } - .foregroundColor(.primary) - } - .opacity(store.logs.isEmpty ? 0 : 1) - - LoadingView().opacity(store.loadingState == .loading && store.logs.isEmpty ? 1 : 0) - - let error = store.loadingState.failed - ErrorView(error: error ?? .notFound) { - store.send(.fetchLogs) - } - .opacity(error != nil && store.logs.isEmpty ? 1 : 0) - } - .onAppear { - if store.logs.isEmpty { - DispatchQueue.main.async { - store.send(.fetchLogs) - } - } - } - .toolbar(content: toolbar) - .background(navigationLink) - .navigationTitle(L10n.Localizable.LogsView.Title.logs) - } - - private var navigationLink: some View { - NavigationLink(unwrapping: $store.route, case: \.log) { route in - LogView(log: route.wrappedValue) - } - } - private func toolbar() -> some ToolbarContent { - ToolbarItem(placement: .navigationBarTrailing) { - Button { - store.send(.navigateToFileApp) - } label: { - Image(systemSymbol: .folderBadgeGearshape) - } - } - } -} - -// MARK: LogCell -private struct LogCell: View { - private let log: Log - private let isLatest: Bool - - private var dateRangeString: String { - parseDate(string: log.contents.first) - + " - " + parseDate(string: log.contents.last) - } - - init(log: Log, isLatest: Bool) { - self.log = log - self.isLatest = isLatest - } - - var body: some View { - VStack(spacing: 5) { - HStack { - Text(log.fileName).font(.callout) - Spacer() - HStack(spacing: 2) { - Image(systemSymbol: .checkmarkCircle) - .foregroundColor(.green) - Text(L10n.Localizable.LogsView.Title.latest) - } - .opacity(isLatest ? 0.6 : 0) - .font(.caption) - } - HStack { - Text(dateRangeString).bold() - Spacer() - Text(L10n.Localizable.Common.Value.records("\(log.contents.count)")) - } - .foregroundColor(.secondary) - .font(.caption2).lineLimit(1) - } - .padding() - } - - private func parseDate(string: String?) -> String { - guard let string = string, - let range = string.range(of: " ") - else { return "" } - - return String(string[.. Bool { - lhs.fileName < rhs.fileName - } - - var id: String { fileName } - let fileName: String - let contents: [String] -} -extension Log: CustomStringConvertible { - var description: String { - let params = String( - describing: [ - "fileName": fileName, - "contentsCount": contents.count - ] - as [String: Any] - ) - return "Log(\(params))" - } -} - -struct LogsView_Previews: PreviewProvider { - static var previews: some View { - NavigationView { - LogsView(store: .init(initialState: .init(), reducer: LogsReducer.init)) - } - } -} diff --git a/EhPanda/View/Setting/SettingReducer.swift b/EhPanda/View/Setting/SettingReducer.swift deleted file mode 100644 index 34af255a2..000000000 --- a/EhPanda/View/Setting/SettingReducer.swift +++ /dev/null @@ -1,512 +0,0 @@ -// -// SettingReducer.swift -// EhPanda -// - -import Foundation -import ComposableArchitecture - -@Reducer -struct SettingReducer { - @CasePathable - enum Route: Int, Equatable, Hashable, Identifiable, CaseIterable { - var id: Int { rawValue } - - case account - case general - case appearance - case reading - case laboratory - case about - } - - @ObservableState - struct State: Equatable { - // AppEnvStorage - var setting = Setting() - var tagTranslator = TagTranslator() - var user = User() - - var hasLoadedInitialSetting = false - - var route: Route? - var tagTranslatorLoadingState: LoadingState = .idle - - var accountSettingState = AccountSettingReducer.State() - var generalSettingState = GeneralSettingReducer.State() - var appearanceSettingState = AppearanceSettingReducer.State() - - 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 Action: BindableAction, Equatable { - case binding(BindingAction) - case setNavigation(Route?) - case clearSubStates - - case syncAppIconType - case syncUserInterfaceStyle - case syncSetting - case syncTagTranslator - case syncUser - - case loadUserSettings - case onLoadUserSettings(AppEnv) - case loadUserSettingsDone - 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) - case fetchFavoriteCategories - case fetchFavoriteCategoriesDone(Result<[Int: String], AppError>) - - case account(AccountSettingReducer.Action) - case general(GeneralSettingReducer.Action) - case appearance(AppearanceSettingReducer.Action) - } - - @Dependency(\.uiApplicationClient) private var uiApplicationClient - @Dependency(\.userDefaultsClient) private var userDefaultsClient - @Dependency(\.appDelegateClient) private var appDelegateClient - @Dependency(\.databaseClient) private var databaseClient - @Dependency(\.libraryClient) private var libraryClient - @Dependency(\.hapticsClient) private var hapticsClient - @Dependency(\.loggerClient) private var loggerClient - @Dependency(\.cookieClient) private var cookieClient - @Dependency(\.deviceClient) private var deviceClient - @Dependency(\.fileClient) private var fileClient - @Dependency(\.dfClient) private var dfClient - - var body: some Reducer { - BindingReducer() - .onChange(of: \.setting) { _, _ in - Reduce({ _, _ in .send(.syncSetting) }) - } - .onChange(of: \.setting.galleryHost) { _, newValue in - Reduce { _, _ in - .merge( - .send(.syncSetting), - .run(operation: { _ in userDefaultsClient.setValue(newValue.rawValue, .galleryHost) }) - ) - } - } - .onChange(of: \.setting.enablesTagsExtension) { _, newValue in - Reduce { _, _ in - var effects: [Effect] = [ - .send(.syncSetting) - ] - if newValue { - effects.append(.send(.fetchTagTranslator)) - } - return .merge(effects) - } - } - .onChange(of: \.setting.preferredColorScheme) { _, _ in - Reduce { _, _ in - .merge( - .send(.syncSetting), - .send(.syncUserInterfaceStyle) - ) - } - } - .onChange(of: \.setting.appIconType) { _, newValue in - Reduce { _, _ in - .merge( - .send(.syncSetting), - .run { send in - _ = await uiApplicationClient.setAlternateIconName(newValue.filename) - await send(.syncAppIconType) - } - ) - } - } - .onChange(of: \.setting.autoLockPolicy) { _, newValue in - Reduce { state, _ in - if newValue != .never && state.setting.backgroundBlurRadius == 0 { - state.setting.backgroundBlurRadius = 10 - } - return .send(.syncSetting) - } - } - .onChange(of: \.setting.backgroundBlurRadius) { _, newValue in - Reduce { state, _ in - if state.setting.autoLockPolicy != .never && newValue == 0 { - state.setting.autoLockPolicy = .never - } - return .send(.syncSetting) - } - } - .onChange(of: \.setting.enablesLandscape) { _, newValue in - Reduce { _, _ in - var effects: [Effect] = [ - .send(.syncSetting) - ] - if !newValue && !deviceClient.isPad() { - effects.append(.run(operation: { _ in appDelegateClient.setPortraitOrientationMask() })) - } - return .merge(effects) - } - } - .onChange(of: \.setting.maximumScaleFactor) { _, newValue in - Reduce { state, _ in - if state.setting.doubleTapScaleFactor > newValue { - state.setting.doubleTapScaleFactor = newValue - } - return .send(.syncSetting) - } - } - .onChange(of: \.setting.doubleTapScaleFactor) { _, newValue in - Reduce { state, _ in - if state.setting.maximumScaleFactor < newValue { - state.setting.maximumScaleFactor = newValue - } - return .send(.syncSetting) - } - } - .onChange(of: \.setting.bypassesSNIFiltering) { _, newValue in - Reduce { _, _ in - .merge( - .send(.syncSetting), - .run(operation: { _ in hapticsClient.generateFeedback(.soft) }), - .run(operation: { _ in dfClient.setActive(newValue) }) - ) - } - } - - Reduce { state, action in - switch action { - case .binding: - return .merge( - .send(.syncUser), - .send(.syncSetting), - .send(.syncTagTranslator) - ) - - case .setNavigation(let route): - state.route = route - return .none - - case .clearSubStates: - state.accountSettingState = .init() - state.generalSettingState = .init() - state.appearanceSettingState = .init() - return .none - - case .syncAppIconType: - if let iconName = uiApplicationClient.alternateIconName() { - state.setting.appIconType = AppIconType.allCases.filter({ - iconName.contains($0.filename) - }).first ?? .default - } - return .none - - case .syncUserInterfaceStyle: - let style = state.setting.preferredColorScheme.userInterfaceStyle - return .run(operation: { _ in await uiApplicationClient.setUserInterfaceStyle(style) }) - - case .syncSetting: - return .run { [state] _ in - await databaseClient.updateSetting(state.setting) - } - case .syncTagTranslator: - return .run { [state] _ in - await databaseClient.updateTagTranslator(state.tagTranslator) - } - case .syncUser: - return .run { [state] _ in - await databaseClient.updateUser(state.user) - } - - case .loadUserSettings: - return .run { send in - let appEnv = await databaseClient.fetchAppEnv() - await send(.onLoadUserSettings(appEnv)) - } - - case .onLoadUserSettings(let appEnv): - state.setting = appEnv.setting - state.tagTranslator = appEnv.tagTranslator - state.user = appEnv.user - var effects: [Effect] = [ - .send(.syncAppIconType), - .send(.loadUserSettingsDone), - .send(.syncUserInterfaceStyle), - .run { [state] _ in - dfClient.setActive(state.setting.bypassesSNIFiltering) - } - ] - if let value: String = userDefaultsClient.getValue(.galleryHost), - let galleryHost = GalleryHost(rawValue: value) - { - state.setting.galleryHost = galleryHost - } - if cookieClient.shouldFetchIgneous { - effects.append(.send(.fetchIgneous)) - } - if cookieClient.didLogin { - effects.append(contentsOf: [ - .send(.fetchUserInfo), - .send(.fetchGreeting), - .send(.fetchFavoriteCategories), - .send(.fetchEhProfileIndex) - ]) - } - if state.setting.enablesTagsExtension { - effects.append(.send(.fetchTagTranslator)) - } - return .merge(effects) - - case .loadUserSettingsDone: - state.hasLoadedInitialSetting = true - return .none - - case .createDefaultEhProfile: - return .run { _ in - _ = await EhProfileRequest(action: .create, name: "EhPanda").response() - } - - case .fetchIgneous: - guard cookieClient.didLogin else { return .none } - return .run { send in - let response = await IgneousRequest().response() - await send(.fetchIgneousDone(response)) - } - - case .fetchIgneousDone(let result): - var effects = [Effect]() - if case .success(let response) = result { - effects.append(.run(operation: { _ in cookieClient.setCredentials(response: response) })) - } - effects.append(.send(.account(.loadCookies))) - return .merge(effects) - - case .fetchUserInfo: - guard cookieClient.didLogin else { return .none } - let uid = cookieClient - .getCookie(Defaults.URL.host, Defaults.Cookie.ipbMemberId).rawValue - if !uid.isEmpty { - return .run { send in - let response = await UserInfoRequest(uid: uid).response() - await send(.fetchUserInfoDone(response)) - } - } - return .none - - case .fetchUserInfoDone(let result): - if case .success(let user) = result { - state.updateUser(user) - return .send(.syncUser) - } - return .none - - case .fetchGreeting: - func verifyDate(with updateTime: Date?) -> Bool { - guard let updateTime = updateTime else { return true } - - let currentTime = Date() - let formatter = DateFormatter() - formatter.locale = Locale.current - formatter.timeZone = TimeZone(secondsFromGMT: 0) - formatter.dateFormat = Defaults.DateFormat.greeting - - let currentTimeString = formatter.string(from: currentTime) - if let currentDay = formatter.date(from: currentTimeString) { - return currentTime > currentDay && updateTime < currentDay - } - - return false - } - - guard cookieClient.didLogin, - state.setting.showsNewDawnGreeting - else { return .none } - let requestEffect = Effect.run { send in - let response = await GreetingRequest().response() - await send(Action.fetchGreetingDone(response)) - } - if let greeting = state.user.greeting { - if verifyDate(with: greeting.updateTime) { - return requestEffect - } - } else { - return requestEffect - } - return .none - - case .fetchGreetingDone(let result): - switch result { - case .success(let greeting): - state.setGreeting(greeting) - return .send(.syncUser) - case .failure(let error): - if case .parseFailed = error { - var greeting = Greeting() - greeting.updateTime = Date() - state.setGreeting(greeting) - return .send(.syncUser) - } - } - return .none - - case .fetchTagTranslator: - guard state.tagTranslatorLoadingState != .loading, - !state.tagTranslator.hasCustomTranslations, - let language = TranslatableLanguage.current - else { return .none } - state.tagTranslatorLoadingState = .loading - - var databaseEffect: Effect? - if state.tagTranslator.language != language { - state.tagTranslator = TagTranslator(language: language) - databaseEffect = .send(.syncTagTranslator) - } - let updatedDate = state.tagTranslator.updatedDate - let requestEffect = Effect.run { send in - let response = await TagTranslatorRequest(language: language, updatedDate: updatedDate).response() - await send(Action.fetchTagTranslatorDone(response)) - } - if let databaseEffect = databaseEffect { - return .merge(databaseEffect, requestEffect) - } else { - return requestEffect - } - - case .fetchTagTranslatorDone(let result): - state.tagTranslatorLoadingState = .idle - switch result { - case .success(let tagTranslator): - state.tagTranslator = tagTranslator - return .send(.syncTagTranslator) - case .failure(let error): - state.tagTranslatorLoadingState = .failed(error) - } - return .none - - case .fetchEhProfileIndex: - guard cookieClient.didLogin else { return .none } - return .run { send in - let response = await VerifyEhProfileRequest().response() - await send(.fetchEhProfileIndexDone(response)) - } - - case .fetchEhProfileIndexDone(let result): - var effects = [Effect]() - - if case .success(let response) = result { - if let profileValue = response.profileValue { - let hostURL = Defaults.URL.host - let profileValueString = String(profileValue) - let selectedProfileKey = Defaults.Cookie.selectedProfile - - let cookieValue = cookieClient.getCookie(hostURL, selectedProfileKey) - if cookieValue.rawValue != profileValueString { - effects.append( - .run { _ in - cookieClient.setOrEditCookie( - for: hostURL, key: selectedProfileKey, value: profileValueString - ) - } - ) - } - } else if response.isProfileNotFound { - effects.append(.send(.createDefaultEhProfile)) - } else { - let message = "Found profile but failed in parsing value." - effects.append(.run(operation: { _ in loggerClient.error(message, nil) })) - } - } - return effects.isEmpty ? .none : .merge(effects) - - case .fetchFavoriteCategories: - guard cookieClient.didLogin else { return .none } - return .run { send in - let response = await FavoriteCategoriesRequest().response() - await send(.fetchFavoriteCategoriesDone(response)) - } - - case .fetchFavoriteCategoriesDone(let result): - if case .success(let categories) = result { - state.user.favoriteCategories = categories - } - return .none - - case .account(.login(.loginDone)): - return .merge( - .run(operation: { _ in cookieClient.removeYay() }), - .run(operation: { _ in cookieClient.syncExCookies() }), - .run(operation: { _ in cookieClient.fulfillAnotherHostField() }), - .send(.fetchIgneous), - .send(.fetchUserInfo), - .send(.fetchFavoriteCategories), - .send(.fetchEhProfileIndex) - ) - - case .account(.onLogoutConfirmButtonTapped): - state.user = User() - return .merge( - .send(.syncUser), - .run(operation: { _ in cookieClient.clearAll() }), - .run(operation: { _ in await databaseClient.removeImageURLs() }), - .run(operation: { _ in libraryClient.clearWebImageDiskCache() }) - ) - - case .account: - return .none - - case .general(.onTranslationsFilePicked(let url)): - return .run { send in - let result = await fileClient.importTagTranslator(url) - await send(.fetchTagTranslatorDone(result)) - } - - case .general(.onRemoveCustomTranslations): - state.tagTranslator.hasCustomTranslations = false - state.tagTranslator.translations = .init() - return .send(.syncTagTranslator) - - case .general: - return .none - - case .appearance: - return .none - } - } - - Scope(state: \.accountSettingState, action: \.account, child: AccountSettingReducer.init) - Scope(state: \.generalSettingState, action: \.general, child: GeneralSettingReducer.init) - Scope(state: \.appearanceSettingState, action: \.appearance, child: AppearanceSettingReducer.init) - } -} diff --git a/EhPanda/View/Setting/SettingView.swift b/EhPanda/View/Setting/SettingView.swift deleted file mode 100644 index ccfcabc13..000000000 --- a/EhPanda/View/Setting/SettingView.swift +++ /dev/null @@ -1,186 +0,0 @@ -// -// SettingView.swift -// EhPanda -// - -import SwiftUI -import SFSafeSymbols -import ComposableArchitecture - -struct SettingView: View { - @Bindable private var store: StoreOf - private let blurRadius: Double - - init(store: StoreOf, blurRadius: Double) { - self.store = store - self.blurRadius = blurRadius - } - - // MARK: SettingView - var body: some View { - NavigationView { - ScrollView { - VStack(spacing: 0) { - ForEach(SettingReducer.Route.allCases) { route in - SettingRow(rowType: route) { - store.send(.setNavigation($0)) - } - } - } - .padding(.vertical, 40).padding(.horizontal) - } - .background(navigationLinks) - .navigationTitle(L10n.Localizable.SettingView.Title.setting) - } - } -} - -// MARK: NavigationLinks -private extension SettingView { - @ViewBuilder var navigationLinks: some View { - NavigationLink(unwrapping: $store.route, case: \.account) { _ in - AccountSettingView( - store: store.scope(state: \.accountSettingState, action: \.account), - galleryHost: $store.setting.galleryHost, - showsNewDawnGreeting: $store.setting.showsNewDawnGreeting, - bypassesSNIFiltering: store.setting.bypassesSNIFiltering, - blurRadius: blurRadius - ) - .tint(store.setting.accentColor) - } - NavigationLink(unwrapping: $store.route, case: \.general) { _ in - GeneralSettingView( - store: store.scope(state: \.generalSettingState, action: \.general), - tagTranslatorLoadingState: store.tagTranslatorLoadingState, - tagTranslatorEmpty: store.tagTranslator.translations.isEmpty, - tagTranslatorHasCustomTranslations: store.tagTranslator.hasCustomTranslations, - enablesTagsExtension: $store.setting.enablesTagsExtension, - translatesTags: $store.setting.translatesTags, - showsTagsSearchSuggestion: $store.setting.showsTagsSearchSuggestion, - showsImagesInTags: $store.setting.showsImagesInTags, - redirectsLinksToSelectedHost: $store.setting.redirectsLinksToSelectedHost, - detectsLinksFromClipboard: $store.setting.detectsLinksFromClipboard, - backgroundBlurRadius: $store.setting.backgroundBlurRadius, - autoLockPolicy: $store.setting.autoLockPolicy - ) - .tint(store.setting.accentColor) - } - NavigationLink(unwrapping: $store.route, case: \.appearance) { _ in - AppearanceSettingView( - store: store.scope(state: \.appearanceSettingState, action: \.appearance), - preferredColorScheme: $store.setting.preferredColorScheme, - accentColor: $store.setting.accentColor, - appIconType: $store.setting.appIconType, - listDisplayMode: $store.setting.listDisplayMode, - showsTagsInList: $store.setting.showsTagsInList, - listTagsNumberMaximum: $store.setting.listTagsNumberMaximum, - displaysJapaneseTitle: $store.setting.displaysJapaneseTitle - ) - .tint(store.setting.accentColor) - } - NavigationLink(unwrapping: $store.route, case: \.reading) { _ in - ReadingSettingView( - readingDirection: $store.setting.readingDirection, - prefetchLimit: $store.setting.prefetchLimit, - enablesLandscape: $store.setting.enablesLandscape, - contentDividerHeight: $store.setting.contentDividerHeight, - maximumScaleFactor: $store.setting.maximumScaleFactor, - doubleTapScaleFactor: $store.setting.doubleTapScaleFactor - ) - .tint(store.setting.accentColor) - } - NavigationLink(unwrapping: $store.route, case: \.laboratory) { _ in - LaboratorySettingView( - bypassesSNIFiltering: $store.setting.bypassesSNIFiltering - ) - .tint(store.setting.accentColor) - } - NavigationLink(unwrapping: $store.route, case: \.about) { _ in - AboutView().tint(store.setting.accentColor) - } - } -} - -// MARK: SettingRow -private struct SettingRow: View { - @Environment(\.colorScheme) private var colorScheme - @State private var isPressing = false - - private let rowType: SettingReducer.Route - private let tapAction: (SettingReducer.Route) -> Void - - private var color: Color { - colorScheme == .light ? Color(.darkGray) : Color(.lightGray) - } - private var backgroundColor: Color { - isPressing ? color.opacity(0.1) : .clear - } - - init(rowType: SettingReducer.Route, tapAction: @escaping (SettingReducer.Route) -> Void) { - self.rowType = rowType - self.tapAction = tapAction - } - - var body: some View { - HStack { - Image(systemSymbol: rowType.symbol) - .font(.largeTitle).foregroundColor(color) - .padding(.trailing, 20).frame(width: 45) - Text(rowType.value).fontWeight(.medium) - .font(.title3).foregroundColor(color) - Spacer() - } - .contentShape(Rectangle()).padding(.vertical, 10) - .padding(.horizontal, 20).background(backgroundColor) - .cornerRadius(10).onTapGesture { tapAction(rowType) } - .onLongPressGesture( - minimumDuration: .infinity, maximumDistance: 50, - pressing: { isPressing = $0 }, perform: {} - ) - } -} - -// MARK: Definition -extension SettingReducer.Route { - var value: String { - switch self { - case .account: - return L10n.Localizable.Enum.SettingStateRoute.Value.account - case .general: - return L10n.Localizable.Enum.SettingStateRoute.Value.general - case .appearance: - return L10n.Localizable.Enum.SettingStateRoute.Value.appearance - case .reading: - return L10n.Localizable.Enum.SettingStateRoute.Value.reading - case .laboratory: - return L10n.Localizable.Enum.SettingStateRoute.Value.laboratory - case .about: - return L10n.Localizable.Enum.SettingStateRoute.Value.about - } - } - 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 .about: - return .pCircleFill - } - } -} - -struct SettingView_Previews: PreviewProvider { - static var previews: some View { - SettingView( - store: .init(initialState: .init(), reducer: SettingReducer.init), - blurRadius: 0 - ) - } -} diff --git a/EhPanda/View/Support/Components/ActivityView.swift b/EhPanda/View/Support/Components/ActivityView.swift deleted file mode 100644 index 696ff6469..000000000 --- a/EhPanda/View/Support/Components/ActivityView.swift +++ /dev/null @@ -1,19 +0,0 @@ -// -// ActivityView.swift -// EhPanda -// - -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/Cells/GalleryDetailCell.swift b/EhPanda/View/Support/Components/Cells/GalleryDetailCell.swift deleted file mode 100644 index 95948467b..000000000 --- a/EhPanda/View/Support/Components/Cells/GalleryDetailCell.swift +++ /dev/null @@ -1,79 +0,0 @@ -// -// GalleryDetailCell.swift -// EhPanda -// - -import SwiftUI -import Kingfisher - -struct GalleryDetailCell: View { - @Environment(\.colorScheme) private var colorScheme - - private let gallery: Gallery - private let setting: Setting - private let translateAction: ((String) -> (String, TagTranslation?))? - - init(gallery: Gallery, setting: Setting, translateAction: ((String) -> (String, TagTranslation?))? = nil) { - self.gallery = gallery - self.setting = setting - self.translateAction = translateAction - } - - private var tagColor: Color { - colorScheme == .light ? Color(.systemGray5) : Color(.systemGray4) - } - - var body: some View { - HStack(spacing: 10) { - 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, spacing: 5) { - Text(gallery.title).lineLimit(3).font(.headline).foregroundStyle(.primary) - .fixedSize(horizontal: false, vertical: true) - Text(gallery.uploader ?? "").lineLimit(1).font(.subheadline).foregroundStyle(.secondary) - let tagContents = gallery.tagContents(maximum: setting.listTagsNumberMaximum) - if setting.showsTagsInList, !tagContents.isEmpty { - TagCloudView(data: tagContents) { content in - let translation = translateAction?(content.rawNamespace + content.text).1 - TagCloudCell( - text: translation?.displayValue ?? content.text, - imageURL: translation?.valueImageURL, - showsImages: setting.showsImagesInTags, - font: .caption2, padding: .init(top: 2, leading: 4, bottom: 2, trailing: 4), - textColor: content.backgroundColor != nil ? content.textColor ?? .secondary : .secondary, - backgroundColor: content.backgroundColor ?? tagColor - ) - } - } - HStack { - RatingView(rating: gallery.rating).font(.caption).foregroundStyle(.yellow) - Spacer() - HStack(spacing: 10) { - Text(gallery.language?.value ?? "") - HStack(spacing: 2) { - Image(systemSymbol: .photoOnRectangleAngled) - Text(String(gallery.pageCount)) - } - } - .lineLimit(1).font(.footnote).foregroundStyle(.secondary).minimumScaleFactor(0.75) - } - HStack(alignment: .bottom) { - CategoryLabel(text: gallery.category.value, color: gallery.color) - Spacer() - Text(gallery.formattedDateString).lineLimit(1).font(.footnote) - .foregroundStyle(.secondary).minimumScaleFactor(0.75) - } - .padding(.top, 1) - } - .drawingGroup() - } - .padding(.vertical, 5).padding(.leading, -10).padding(.trailing, -5) - } -} - -struct GalleryDetailCell_Previews: PreviewProvider { - static var previews: some View { - GalleryDetailCell(gallery: .preview, setting: Setting()) - } -} diff --git a/EhPanda/View/Support/Components/Placeholder.swift b/EhPanda/View/Support/Components/Placeholder.swift deleted file mode 100644 index 89fea30e8..000000000 --- a/EhPanda/View/Support/Components/Placeholder.swift +++ /dev/null @@ -1,41 +0,0 @@ -// -// Placeholder.swift -// EhPanda -// - -import SwiftUI - -struct Placeholder: View { - @Environment(\.inSheet) private var inSheet - private let style: PlaceholderStyle - - init(style: PlaceholderStyle) { - self.style = style - } - - var body: some View { - switch style { - case .activity(let ratio, let cornerRadius): - ZStack { - Color(inSheet ? .systemGray4 : .systemGray5) - ProgressView() - } - .aspectRatio(ratio, contentMode: .fill).cornerRadius(cornerRadius) - case .progress(let pageNumber, let progress, let isDualPage, let backgroundColor): - ZStack { - backgroundColor - VStack { - Text(String(pageNumber)).font(.largeTitle.bold()) - .foregroundColor(.gray).padding(.bottom, 30) - ProgressView(progress).progressViewStyle(.plainLinear) - .frame(width: DeviceUtil.absWindowW * (isDualPage ? 0.25 : 0.5)) - } - } - } - } -} - -enum PlaceholderStyle { - case activity(ratio: CGFloat, cornerRadius: CGFloat = 5) - case progress(pageNumber: Int, progress: Progress, isDualPage: Bool = false, backgroundColor: Color) -} diff --git a/EhPanda/View/Support/Components/SettingTextField.swift b/EhPanda/View/Support/Components/SettingTextField.swift deleted file mode 100644 index 849012073..000000000 --- a/EhPanda/View/Support/Components/SettingTextField.swift +++ /dev/null @@ -1,42 +0,0 @@ -// -// SettingTextField.swift -// SettingTextField -// - -import SwiftUI - -struct SettingTextField: View { - @Environment(\.colorScheme) private var colorScheme - - @Binding private var text: String - private let promptText: String? - private let width: CGFloat? - private let alignment: TextAlignment - private let background: Color? - - private var color: Color { - if let background = background { return background } - return colorScheme == .light ? Color(.systemGray6) : Color(.systemGray3) - } - private var prompt: Text? { - guard let text = promptText else { return nil } - return Text(text) - } - - init( - text: Binding, promptText: String? = nil, width: CGFloat? = 50, - alignment: TextAlignment = .center, background: Color? = nil - ) { - _text = text - self.promptText = promptText - self.width = width - self.alignment = alignment - self.background = background - } - - var body: some View { - TextField("", text: $text, prompt: prompt).keyboardType(.numbersAndPunctuation) - .textInputAutocapitalization(.none).multilineTextAlignment(alignment) - .disableAutocorrection(true).background(color).frame(width: width).cornerRadius(5) - } -} diff --git a/EhPanda/View/Support/Components/TagCloudView.swift b/EhPanda/View/Support/Components/TagCloudView.swift deleted file mode 100644 index ba0158ef7..000000000 --- a/EhPanda/View/Support/Components/TagCloudView.swift +++ /dev/null @@ -1,117 +0,0 @@ -// -// TagCloudView.swift -// EhPanda -// -// Copied from https://stackoverflow.com/questions/62102647/ -// - -import SwiftUI -import Kingfisher - -struct TagCloudView: View -where TagCell: View, Element: Equatable & Identifiable, ID == Element.ID { - private let data: [Element] - private let id: KeyPath - private let spacing: Double - private let content: (Element) -> TagCell - - @State private var totalHeight = CGFloat.zero - - init( - data: Data, id: KeyPath = \Element.id, spacing: Double = 4, - @ViewBuilder content: @escaping (Element) -> TagCell - ) where Data.Index == Int, Data.Element == Element { - self.data = .init(data) - self.id = id - self.spacing = spacing - self.content = content - } - - var body: some View { - VStack { - GeometryReader { geometry in - generateContent(in: geometry) - } - } - .frame(height: totalHeight) - } -} - -private extension TagCloudView { - func generateContent(in proxy: GeometryProxy) -> some View { - ZStack(alignment: .topLeading) { - var width = CGFloat.zero - var height = CGFloat.zero - ForEach(data, id: id) { content in - self.content(content) - .padding([.trailing, .bottom], spacing) - .alignmentGuide(.leading, computeValue: { [proxyWidth = proxy.size.width] dimensions in - if abs(width - dimensions.width) > proxyWidth { - width = 0 - height -= dimensions.height - } - let result = width - if content == data.last { - width = 0 // last item - } else { - width -= dimensions.width - } - return result - }) - .alignmentGuide(.top, computeValue: { _ in - let result = height - if content == data.last { - height = 0 // last item - } - return result - }) - } - } - .background(viewHeightReader(binding: $totalHeight)) - } - - func viewHeightReader(binding: Binding) -> some View { - GeometryReader { geometry -> Color in - let rect = geometry.frame(in: .local) - DispatchQueue.main.async { - binding.wrappedValue = rect.size.height - } - return .clear - } - } -} - -struct TagCloudCell: View { - private let text: String - private let imageURL: URL? - private let showsImages: Bool - private let font: Font - private let padding: EdgeInsets - private let textColor: Color - private let backgroundColor: Color - - init( - text: String, imageURL: URL?, showsImages: Bool, font: Font, - padding: EdgeInsets, textColor: Color, backgroundColor: Color - ) { - self.text = text - self.imageURL = imageURL - self.showsImages = showsImages - self.font = font - self.padding = padding - self.textColor = textColor - self.backgroundColor = backgroundColor - } - - var body: some View { - HStack(spacing: 2) { - Text(showsImages ? text : text.emojisRipped) - if let imageURL = imageURL, showsImages { - Image(systemSymbol: .photo).opacity(0) - .overlay(KFImage(imageURL).resizable().scaledToFit()) - } - } - .font(font.bold()).lineLimit(1).foregroundColor(textColor) - .padding(padding).background(backgroundColor).cornerRadius(5) - } -} diff --git a/EhPanda/View/Support/FiltersReducer.swift b/EhPanda/View/Support/FiltersReducer.swift deleted file mode 100644 index 12a960f91..000000000 --- a/EhPanda/View/Support/FiltersReducer.swift +++ /dev/null @@ -1,124 +0,0 @@ -// -// FiltersReducer.swift -// EhPanda -// - -import ComposableArchitecture - -@Reducer -struct FiltersReducer { - @CasePathable - enum Route { - case resetFilters - } - - enum FocusedBound { - case lower - case upper - } - - @ObservableState - struct State: Equatable { - var route: Route? - var filterRange: FilterRange = .search - var focusedBound: FocusedBound? - - var searchFilter = Filter() - var globalFilter = Filter() - var watchedFilter = Filter() - } - - enum Action: BindableAction, Equatable { - case binding(BindingAction) - case setNavigation(Route?) - case onTextFieldSubmitted - - case syncFilter(FilterRange) - case resetFilters - case fetchFilters - case fetchFiltersDone(AppEnv) - } - - @Dependency(\.databaseClient) private var databaseClient - - var body: some Reducer { - BindingReducer() - .onChange(of: \.searchFilter) { _, _ in - Reduce { state, _ in - state.searchFilter.fixInvalidData() - return .send(.syncFilter(.search)) - } - } - .onChange(of: \.globalFilter) { _, _ in - Reduce { state, _ in - state.globalFilter.fixInvalidData() - return .send(.syncFilter(.global)) - } - } - .onChange(of: \.watchedFilter) { _, _ in - Reduce { state, _ in - state.watchedFilter.fixInvalidData() - return .send(.syncFilter(.watched)) - } - } - - Reduce { state, action in - switch action { - 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 .run(operation: { _ in await databaseClient.updateFilter(filter, range: range) }) - - case .resetFilters: - switch state.filterRange { - case .search: - state.searchFilter = .init() - return .send(.syncFilter(.search)) - case .global: - state.globalFilter = .init() - return .send(.syncFilter(.global)) - case .watched: - state.watchedFilter = .init() - return .send(.syncFilter(.watched)) - } - - case .fetchFilters: - return .run { send in - let appEnv = await databaseClient.fetchAppEnv() - await send(.fetchFiltersDone(appEnv)) - } - - case .fetchFiltersDone(let appEnv): - state.searchFilter = appEnv.searchFilter - state.globalFilter = appEnv.globalFilter - state.watchedFilter = appEnv.watchedFilter - return .none - } - } - } -} diff --git a/EhPandaTests/Resources/Utility/Extensions.swift b/EhPandaTests/Resources/Utility/Extensions.swift deleted file mode 100644 index 1b21cf70e..000000000 --- a/EhPandaTests/Resources/Utility/Extensions.swift +++ /dev/null @@ -1,12 +0,0 @@ -// -// Extensions.swift -// EhPandaTests -// - -import XCTest - -extension XCTWaiter { - static func wait(timeout: TimeInterval) { - _ = Self.wait(for: [.init()], timeout: timeout) - } -} diff --git a/EhPandaTests/Resources/Utility/TestHelper.swift b/EhPandaTests/Resources/Utility/TestHelper.swift deleted file mode 100644 index a26a72de7..000000000 --- a/EhPandaTests/Resources/Utility/TestHelper.swift +++ /dev/null @@ -1,18 +0,0 @@ -// -// TestHelper.swift -// TestHelper -// - -import Kanna -import XCTest - -protocol TestHelper {} - -extension TestHelper where Self: XCTestCase { - func htmlDocument(filename: HTMLFilename) throws -> HTMLDocument { - guard let url = Bundle(for: Self.self).url(forResource: filename.rawValue, withExtension: "html") else { - throw TestError.htmlDocumentNotFound(filename) - } - return try Kanna.HTML(url: url, encoding: .utf8) - } -} diff --git a/EhPandaTests/Tests/Parser/Gallery/GalleryDetailParserTests.swift b/EhPandaTests/Tests/Parser/Gallery/GalleryDetailParserTests.swift deleted file mode 100644 index 50d83112b..000000000 --- a/EhPandaTests/Tests/Parser/Gallery/GalleryDetailParserTests.swift +++ /dev/null @@ -1,38 +0,0 @@ -// -// GalleryDetailParserTests.swift -// EhPandaTests -// - -import Kanna -import XCTest -@testable import EhPanda - -class GalleryDetailParserTests: XCTestCase, TestHelper { - func testExample() throws { - let document = try htmlDocument(filename: .galleryDetail) - let (detail, state) = try Parser.parseGalleryDetail(doc: document, gid: "2725078") - XCTAssertEqual(detail.gid, "2725078") - XCTAssertEqual(detail.title, "[Artist] mks") - XCTAssertEqual(detail.jpnTitle, "[アーティスト] mks") - XCTAssertFalse(detail.isFavorited) - XCTAssertEqual(detail.visibility, .yes) - XCTAssertEqual(detail.rating, 4.5) - XCTAssertEqual(detail.userRating, 0) - XCTAssertEqual(detail.ratingCount, 110) - XCTAssertEqual(detail.category, .nonH) - XCTAssertEqual(detail.language, .japanese) - XCTAssertEqual(detail.uploader, "Pokom") - XCTAssertEqual(detail.coverURL?.absoluteString, "https://ehgt.org/03/08/0308268821e99628b05a19fa54e2fc0fa9ad8f4b-1705560-1012-1470-png_250.jpg") - XCTAssertEqual(detail.archiveURL?.absoluteString, "https://e-hentai.org/archiver.php?gid=3103480&token=0000000000") - XCTAssertEqual(detail.parentURL?.absoluteString, "https://e-hentai.org/g/2930572/daf4b9880d/") - XCTAssertEqual(detail.favoritedCount, 591) - XCTAssertEqual(detail.pageCount, 156) - XCTAssertEqual(detail.sizeCount, 314.3) - XCTAssertEqual(detail.sizeType, "MiB") - XCTAssertEqual(detail.torrentCount, 1) - XCTAssertEqual(state.tags.count, 1) - XCTAssertEqual(state.previewURLs.count, 40) - XCTAssertEqual(state.previewConfig, .normal(rows: 4)) - XCTAssertEqual(state.comments.count, 10) - } -} diff --git a/EhPandaTests/Tests/Parser/Gallery/GalleryImageURLParserTests.swift b/EhPandaTests/Tests/Parser/Gallery/GalleryImageURLParserTests.swift deleted file mode 100644 index 6f4d2a128..000000000 --- a/EhPandaTests/Tests/Parser/Gallery/GalleryImageURLParserTests.swift +++ /dev/null @@ -1,29 +0,0 @@ -// -// GalleryImageURLParserTests.swift -// EhPandaTests -// - -import Kanna -import XCTest -@testable import EhPanda - -class GalleryImageURLParserTests: XCTestCase, TestHelper { - func testExample() throws { - let document = try htmlDocument(filename: .galleryNormalImageURL) - try testGalleryNormalImageURLParser(doc: document) - try testSkipServerIdentifierParser(doc: document) - } - - func testGalleryNormalImageURLParser(doc: HTMLDocument) throws { - let inputIndex = 1 - let (index, imageURL, originalImageURL) = try Parser.parseGalleryNormalImageURL(doc: doc, index: inputIndex) - XCTAssertEqual(index, inputIndex) - XCTAssertEqual(imageURL.absoluteString, "https://akrtazd.spuqplybaxmf.hath.network:65000/h/ea42b28bceeae68f1f6adb414da61d186b3d126b-311480-1280-1920-jpg/keystamp=1694132700-fd778f8260;fileindex=132044713;xres=1280/87052610_5090394_0.jpg") - XCTAssertEqual(originalImageURL?.absoluteString, "https://e-hentai.org/fullimg.php?gid=0000000&page=1&key=000000000") - } - func testSkipServerIdentifierParser(doc: HTMLDocument) throws { - let identifier = try Parser.parseSkipServerIdentifier(doc: doc) - XCTAssertEqual(identifier, "00000-000000") - } -} - diff --git a/EhPandaTests/Tests/Parser/Gallery/GalleryMPVKeysParserTests.swift b/EhPandaTests/Tests/Parser/Gallery/GalleryMPVKeysParserTests.swift deleted file mode 100644 index 215f1295c..000000000 --- a/EhPandaTests/Tests/Parser/Gallery/GalleryMPVKeysParserTests.swift +++ /dev/null @@ -1,17 +0,0 @@ -// -// GalleryMPVKeysParserTests.swift -// EhPandaTests -// - -import Kanna -import XCTest -@testable import EhPanda - -class GalleryMPVKeysParserTests: XCTestCase, TestHelper { - func testExample() throws { - let document = try htmlDocument(filename: .galleryMPVKeys) - let (mpvKey, mpvImageKeys) = try Parser.parseMPVKeys(doc: document) - XCTAssertEqual(mpvKey, "00000000000") - XCTAssertEqual(mpvImageKeys.count, 194) - } -} diff --git a/EhPandaTests/Tests/Parser/List/ListParserTests.swift b/EhPandaTests/Tests/Parser/List/ListParserTests.swift deleted file mode 100644 index c03e8a2cc..000000000 --- a/EhPandaTests/Tests/Parser/List/ListParserTests.swift +++ /dev/null @@ -1,26 +0,0 @@ -// -// ListParserTests.swift -// EhPandaTests -// - -import Kanna -import XCTest -@testable import EhPanda - -class ListParserTests: XCTestCase, TestHelper { - func testExample() throws { - let tuples: [(ListParserTestType, HTMLDocument)] = try ListParserTestType.allCases.compactMap { type in - (type, try htmlDocument(filename: type.filename)) - } - XCTAssertEqual(tuples.count, ListParserTestType.allCases.count) - - try tuples.forEach { type, document in - let galleries = try Parser.parseGalleries(doc: document) - let uploaders = galleries.compactMap(\.uploader).filter(\.notEmpty) - XCTAssertEqual(galleries.count, type.assertCount, .init(describing: type)) - if type.hasUploader { - XCTAssertEqual(uploaders.count, type.assertCount, .init(describing: type)) - } - } - } -} diff --git a/EhPandaTests/Tests/Parser/Other/BanIntervalParserTests.swift b/EhPandaTests/Tests/Parser/Other/BanIntervalParserTests.swift deleted file mode 100644 index 5a4672ba9..000000000 --- a/EhPandaTests/Tests/Parser/Other/BanIntervalParserTests.swift +++ /dev/null @@ -1,16 +0,0 @@ -// -// BanIntervalParserTests.swift -// EhPandaTests -// - -import Kanna -import XCTest -@testable import EhPanda - -class BanIntervalParserTests: XCTestCase, TestHelper { - func testExample() throws { - let document = try htmlDocument(filename: .ipBanned) - let banInterval = Parser.parseBanInterval(doc: document) - XCTAssertEqual(banInterval, .minutes(59, seconds: 48)) - } -} diff --git a/EhPandaTests/Tests/Parser/Other/EhSettingParserTests.swift b/EhPandaTests/Tests/Parser/Other/EhSettingParserTests.swift deleted file mode 100644 index aa9c67759..000000000 --- a/EhPandaTests/Tests/Parser/Other/EhSettingParserTests.swift +++ /dev/null @@ -1,90 +0,0 @@ -// -// EhSettingParserTests.swift -// EhPandaTests -// - -import Kanna -import XCTest -@testable import EhPanda - -class EhSettingParserTests: XCTestCase, TestHelper { - func testExample() throws { - let document = try htmlDocument(filename: .ehSetting) - let ehSetting = try Parser.parseEhSetting(doc: document) - testEhProfiles(ehSetting.ehProfiles) - testCapability(ehSetting: ehSetting) - testRemainingStuff(ehSetting: ehSetting) - } - - func testEhProfiles(_ profiles: [EhProfile]) { - XCTAssertEqual(profiles.count, 3) - - let ehProfile1 = profiles[0] - XCTAssertEqual(ehProfile1.value, 1) - XCTAssertEqual(ehProfile1.name, "Default Profile") - XCTAssertEqual(ehProfile1.isSelected, true) - XCTAssertEqual(ehProfile1.isDefault, true) - - let ehProfile2 = profiles[1] - XCTAssertEqual(ehProfile2.value, 2) - XCTAssertEqual(ehProfile2.name, "EhPanda") - XCTAssertEqual(ehProfile2.isSelected, false) - XCTAssertEqual(ehProfile2.isDefault, false) - XCTAssertTrue(EhSetting.verifyEhPandaProfileName(with: ehProfile2.name)) - } - - func testCapability(ehSetting: EhSetting) { - XCTAssertEqual(ehSetting.capableLoadThroughHathSetting, .legacyNo) - XCTAssertEqual(ehSetting.capableLoadThroughHathSettings, EhSetting.LoadThroughHathSetting.allCases) - - XCTAssertEqual(ehSetting.capableImageResolution, .x2400) - XCTAssertEqual(ehSetting.capableImageResolutions, EhSetting.ImageResolution.allCases) - - XCTAssertEqual(ehSetting.capableSearchResultCount, .oneHundred) - XCTAssertEqual(ehSetting.capableSearchResultCounts, [.twentyFive, .fifty, .oneHundred]) - - XCTAssertEqual(ehSetting.capableThumbnailConfigSizes, [.auto, .small, .normal]) - - XCTAssertEqual(ehSetting.capableThumbnailConfigRowCount, .forty) - XCTAssertEqual(ehSetting.capableThumbnailConfigRowCounts, EhSetting.ThumbnailRowCount.allCases) - } - - func testRemainingStuff(ehSetting: EhSetting) { - XCTAssertEqual(ehSetting.loadThroughHathSetting, .anyClient) - XCTAssertEqual(ehSetting.browsingCountry, .autoDetect) - XCTAssertEqual(ehSetting.literalBrowsingCountry, "Japan") - XCTAssertEqual(ehSetting.imageResolution, .auto) - XCTAssertEqual(ehSetting.imageSizeWidth, 0) - XCTAssertEqual(ehSetting.imageSizeHeight, 0) - XCTAssertEqual(ehSetting.galleryName, .japanese) - XCTAssertEqual(ehSetting.archiverBehavior, .manualSelectManualStart) - XCTAssertEqual(ehSetting.displayMode, .compact) - XCTAssertEqual(ehSetting.showSearchRangeIndicator, true) - XCTAssertEqual(ehSetting.disabledCategories, .init(repeating: false, count: 10)) - XCTAssertEqual(ehSetting.favoriteCategories, [ - "Favorites 0", "Favorites 1", "Favorites 2", "Favorites 3", "Favorites 4", - "Favorites 5", "Favorites 6", "Favorites 7", "Favorites 8", "Favorites 9" - ]) - XCTAssertEqual(ehSetting.favoritesSortOrder, .favoritedTime) - XCTAssertEqual(ehSetting.ratingsColor, "") - XCTAssertEqual(ehSetting.tagFilteringThreshold, 0) - XCTAssertEqual(ehSetting.tagWatchingThreshold, 0) - XCTAssertEqual(ehSetting.showFilteredRemovalCount, true) - XCTAssertEqual(ehSetting.excludedLanguages, .init(repeating: false, count: 50)) - XCTAssertEqual(ehSetting.excludedUploaders, "") - XCTAssertEqual(ehSetting.searchResultCount, .oneHundred) - XCTAssertEqual(ehSetting.thumbnailLoadTiming, .onMouseOver) - XCTAssertEqual(ehSetting.thumbnailConfigSize, .auto) - XCTAssertEqual(ehSetting.thumbnailConfigRows, .four) - XCTAssertEqual(ehSetting.coverScaleFactor, 100) - XCTAssertEqual(ehSetting.viewportVirtualWidth, 0) - XCTAssertEqual(ehSetting.commentsSortOrder, .oldest) - XCTAssertEqual(ehSetting.commentVotesShowTiming, .onHoverOrClick) - XCTAssertEqual(ehSetting.tagsSortOrder, .alphabetical) - XCTAssertEqual(ehSetting.galleryPageNumbering, .none) - XCTAssertEqual(ehSetting.useOriginalImages, false) - XCTAssertEqual(ehSetting.useMultiplePageViewer, true) - XCTAssertEqual(ehSetting.multiplePageViewerStyle, .alignLeftScaleIfOverWidth) - XCTAssertEqual(ehSetting.multiplePageViewerShowThumbnailPane, true) - } -} diff --git a/EhPandaTests/Tests/Parser/Other/GreetingParserTests.swift b/EhPandaTests/Tests/Parser/Other/GreetingParserTests.swift deleted file mode 100644 index 6da760198..000000000 --- a/EhPandaTests/Tests/Parser/Other/GreetingParserTests.swift +++ /dev/null @@ -1,22 +0,0 @@ -// -// GreetingParserTests.swift -// EhPandaTests -// - -import Kanna -import XCTest -@testable import EhPanda - -class GreetingParserTests: XCTestCase, TestHelper { - func testExample() throws { - let document = try htmlDocument(filename: .galleryDetailWithGreeting) - let greeting = try Parser.parseGreeting(doc: document) - XCTAssertEqual(greeting.gainedEXP, 30) - XCTAssertEqual(greeting.gainedCredits, 329) - XCTAssertNil(greeting.gainedGP) - XCTAssertNil(greeting.gainedHath) - XCTAssertNotNil(greeting.updateTime) - XCTAssertFalse(greeting.gainedNothing) - XCTAssertNotNil(greeting.gainContent) - } -} diff --git a/README.md b/README.md index ca0249a8e..cf217f38b 100644 --- a/README.md +++ b/README.md @@ -15,15 +15,6 @@ 简体中文

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

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

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

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

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

-## 📢 번역 수요 📢 -번역을 돕고 싶으면 Pull Request를 제출해주세요. - -App Strings: [{lang}.lproj](/EhPanda/App) - -GitHub Readme: [README.{lang}.md](/READMEs) - -https://ehpanda.app: [main.js](https://github.com/EhPanda-Team/ehpanda-website/blob/main/src/main.js) - ## 다운로드 1. [Releases](https://github.com/EhPanda-Team/EhPanda/releases)에서 ipa 파일을 다운로드 받으세요. 2. [AltStore](https://altstore.io)를 사용해서 ipa 파일을 설치할 수 있습니다. diff --git a/Scripts/bump-version.sh b/Scripts/bump-version.sh new file mode 100755 index 000000000..54fe19e72 --- /dev/null +++ b/Scripts/bump-version.sh @@ -0,0 +1,180 @@ +#!/usr/bin/env bash +# +# Bumps MARKETING_VERSION and CURRENT_PROJECT_VERSION for every non-test +# target in EhPanda.xcodeproj at once. +# +# Usage: ./Scripts/bump-version.sh -v [-b ] +# If -b is omitted, the current build number is auto-incremented by 1. +# Example: ./Scripts/bump-version.sh -v 2.8.1 -b 158 +# ./Scripts/bump-version.sh -v 2.8.1 + +set -euo pipefail + +PROG="$(basename "$0")" + +usage() { + echo "Usage: $PROG -v [-b ]" >&2 + exit 1 +} + +help() { + cat < [-b ] + $PROG -h | --help + +Options: + -v Marketing version in semantic format (required). + -b Build number (non-negative integer). If omitted, the + current build number is detected from the project and + incremented by 1. + -h, --help Show this help and exit. + +Examples: + $PROG -v 2.8.1 -b 158 # set version 2.8.1, build 158 + $PROG -v 2.8.1 # set version 2.8.1, auto-increment build +EOF + exit 0 +} + +# Translate long --help before getopts (getopts is short-opt only). +for arg in "$@"; do + case "$arg" in + --help) help ;; + esac +done + +VERSION="" +BUILD="" + +while getopts ":v:b:h" opt; do + case "$opt" in + v) VERSION="$OPTARG" ;; + b) BUILD="$OPTARG" ;; + h) help ;; + \?) echo "Error: unknown option -$OPTARG" >&2; usage ;; + :) echo "Error: option -$OPTARG requires an argument" >&2; usage ;; + esac +done + +if [ -z "$VERSION" ]; then + echo "Error: -v is required" >&2 + usage +fi + +if ! [[ "$VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then + echo "Error: version must be in semantic x.y.z format (got '$VERSION')" >&2 + exit 1 +fi + +if [ -n "$BUILD" ] && ! [[ "$BUILD" =~ ^[0-9]+$ ]]; then + echo "Error: build number must be a non-negative integer (got '$BUILD')" >&2 + exit 1 +fi + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PBXPROJ="$SCRIPT_DIR/../EhPanda.xcodeproj/project.pbxproj" + +if [ ! -f "$PBXPROJ" ]; then + echo "Error: project.pbxproj not found at $PBXPROJ" >&2 + exit 1 +fi + +TEST_BUNDLE_ID="app.ehpanda.tests" + +if [ -z "$BUILD" ]; then + # Pick the highest CURRENT_PROJECT_VERSION across non-test build configs and add 1. + CURRENT_BUILD="$(awk -v test_id="$TEST_BUNDLE_ID" ' + function flush( i, is_test, m) { + is_test = 0 + for (i = 1; i <= n; i++) { + if (block[i] ~ ("PRODUCT_BUNDLE_IDENTIFIER = " test_id ";")) { is_test = 1; break } + } + if (!is_test) { + for (i = 1; i <= n; i++) { + if (match(block[i], /CURRENT_PROJECT_VERSION = [0-9]+;/)) { + m = substr(block[i], RSTART + 26, RLENGTH - 27) + if (m + 0 > max + 0) max = m + 0 + } + } + } + n = 0; in_block = 0 + } + { + if (in_block) { + block[++n] = $0 + if ($0 ~ /^\t\t};[[:space:]]*$/) flush() + next + } + if ($0 ~ /isa = XCBuildConfiguration;/) { in_block = 1; n = 1; block[n] = $0; next } + } + END { if (in_block) flush(); print max + 0 } + ' "$PBXPROJ")" + + if [ -z "$CURRENT_BUILD" ] || [ "$CURRENT_BUILD" = "0" ]; then + echo "Error: could not detect current build number from $PBXPROJ" >&2 + exit 1 + fi + BUILD=$((CURRENT_BUILD + 1)) + echo "Auto-detected current build $CURRENT_BUILD, bumping to $BUILD." +fi + +TMP="$(mktemp)" +trap 'rm -f "$TMP"' EXIT + +awk -v v="$VERSION" -v b="$BUILD" -v test_id="$TEST_BUNDLE_ID" ' +function flush_block( i, is_test, line) { + is_test = 0 + for (i = 1; i <= n; i++) { + if (block[i] ~ ("PRODUCT_BUNDLE_IDENTIFIER = " test_id ";")) { + is_test = 1 + break + } + } + for (i = 1; i <= n; i++) { + line = block[i] + if (!is_test) { + if (line ~ /MARKETING_VERSION = /) { + sub(/MARKETING_VERSION = [^;]+;/, "MARKETING_VERSION = " v ";", line) + marketing_hits++ + } else if (line ~ /CURRENT_PROJECT_VERSION = /) { + sub(/CURRENT_PROJECT_VERSION = [^;]+;/, "CURRENT_PROJECT_VERSION = " b ";", line) + build_hits++ + } + } + print line + } + n = 0 + in_block = 0 +} + +{ + if (in_block) { + block[++n] = $0 + # XCBuildConfiguration block ends at 2-tab indented "};" + if ($0 ~ /^\t\t};[[:space:]]*$/) { + flush_block() + } + next + } + if ($0 ~ /isa = XCBuildConfiguration;/) { + in_block = 1 + n = 1 + block[n] = $0 + next + } + print +} +END { + if (in_block) flush_block() + printf("Updated %d MARKETING_VERSION and %d CURRENT_PROJECT_VERSION entries.\n", marketing_hits, build_hits) > "/dev/stderr" +} +' "$PBXPROJ" > "$TMP" + +mv "$TMP" "$PBXPROJ" +trap - EXIT + +echo "Bumped non-test targets to version $VERSION (build $BUILD)." diff --git a/ShareExtension/Info.plist b/ShareExtension/Info.plist index 4c8c1c00c..ea3b19ad9 100644 --- a/ShareExtension/Info.plist +++ b/ShareExtension/Info.plist @@ -2,8 +2,6 @@ - CFBundleIdentifier - $(PRODUCT_BUNDLE_IDENTIFIER) NSExtension NSExtensionAttributes diff --git a/ShareExtension/ShareViewController.swift b/ShareExtension/ShareViewController.swift index d9df2d995..c51569683 100644 --- a/ShareExtension/ShareViewController.swift +++ b/ShareExtension/ShareViewController.swift @@ -1,8 +1,4 @@ -// -// ShareViewController.swift -// ShareExtension -// - +import AppIntents import UIKit class ShareViewController: UIViewController { @@ -21,21 +17,25 @@ class ShareViewController: UIViewController { return } - itemProvider.loadItem(forTypeIdentifier: "public.url") { (item, _) in + itemProvider.loadItem(forTypeIdentifier: "public.url") { [weak self] (item, _) in if let shareURL = item as? URL, let scheme = shareURL.scheme, let replacedURL = URL(string: shareURL.absoluteString - .replacingOccurrences(of: scheme, with: "ehpanda")) - { - self.openMainApp(url: replacedURL) + .replacingOccurrences(of: scheme, with: "ehpanda")) { + Task { @MainActor in + self?.openMainApp(url: replacedURL) + } } } } + @MainActor private func openMainApp(url: URL) { extensionContext?.completeRequest( returningItems: nil, completionHandler: { [weak self] _ in - self?.openURL(url) + Task { @MainActor in + self?.openURL(url) + } } ) } diff --git a/swiftgen.yml b/swiftgen.yml deleted file mode 100644 index f8c582f83..000000000 --- a/swiftgen.yml +++ /dev/null @@ -1,7 +0,0 @@ -output_dir: EhPanda/App/Generated - -strings: - inputs: EhPanda/App/en.lproj - outputs: - - templateName: structured-swift5 - output: Strings.swift