diff --git a/EhPanda/App/Generated/Strings.swift b/EhPanda/App/Generated/Strings.swift index 1af4a177..92ead8da 100644 --- a/EhPanda/App/Generated/Strings.swift +++ b/EhPanda/App/Generated/Strings.swift @@ -1892,6 +1892,24 @@ internal enum L10n { internal static let success = L10n.tr("Localizable", "hud.title.success", fallback: "Success") } } + internal enum DateJumpView { + internal enum Button { + /// Seek Newer + internal static let seekNewer = L10n.tr("Localizable", "date_jump_view.button.seek_newer", fallback: "Seek Newer") + /// Seek Older + internal static let seekOlder = L10n.tr("Localizable", "date_jump_view.button.seek_older", fallback: "Seek Older") + } + internal enum Footer { + /// Seek to galleries around the selected date. + internal static let seekAroundDate = L10n.tr("Localizable", "date_jump_view.footer.seek_around_date", fallback: "Seek to galleries around the selected date.") + } + internal enum Title { + /// Date + internal static let date = L10n.tr("Localizable", "date_jump_view.title.date", fallback: "Date") + /// Date Jump + internal static let dateJump = L10n.tr("Localizable", "date_jump_view.title.date_jump", fallback: "Date Jump") + } + } internal enum JumpPageView { internal enum Button { /// Confirm @@ -2160,6 +2178,8 @@ internal enum L10n { } internal enum ToolbarItem { internal enum Button { + /// Date Jump + internal static let dateJump = L10n.tr("Localizable", "toolbar_item.button.date_jump", fallback: "Date Jump") /// Filters internal static let filters = L10n.tr("Localizable", "toolbar_item.button.filters", fallback: "Filters") /// Jump page diff --git a/EhPanda/App/Tools/Extensions/AlertKit_Extension.swift b/EhPanda/App/Tools/Extensions/AlertKit_Extension.swift index 16574b85..ed44b248 100644 --- a/EhPanda/App/Tools/Extensions/AlertKit_Extension.swift +++ b/EhPanda/App/Tools/Extensions/AlertKit_Extension.swift @@ -77,3 +77,64 @@ private struct JumpPageAlert: View { .synchronize($isPresented, $manager.isPresented) } } + +struct DateJumpView: View { + let pageNumber: PageNumber + @Binding var selectedDate: Date + let jumpAction: (PageJumpDirection) -> Void + + private var navigation: PageJumpNavigation? { + pageNumber.jumpNavigation + } + private var dateRange: ClosedRange { + navigation?.dateRange ?? Date.distantPast...Date.distantFuture + } + private var showsNewerButton: Bool { + navigation?.previousURL != nil + } + private var showsOlderButton: Bool { + navigation?.nextURL != nil + } + + var body: some View { + NavigationView { + Form { + Section { + DatePicker( + L10n.Localizable.DateJumpView.Title.date, + selection: $selectedDate, + in: dateRange, + displayedComponents: .date + ) + .datePickerStyle(.graphical) + } footer: { + Text(L10n.Localizable.DateJumpView.Footer.seekAroundDate) + } + + Section { + if showsNewerButton { + Button { + jumpAction(.newer) + } label: { + Label(L10n.Localizable.DateJumpView.Button.seekNewer, systemImage: "chevron.left") + } + } + if showsOlderButton { + Button { + jumpAction(.older) + } label: { + Label(L10n.Localizable.DateJumpView.Button.seekOlder, systemImage: "chevron.right") + } + } + } + } + .navigationTitle(L10n.Localizable.DateJumpView.Title.dateJump) + .navigationBarTitleDisplayMode(.inline) + } + .onAppear { + if let navigation { + selectedDate = navigation.clampedDate(selectedDate) + } + } + } +} diff --git a/EhPanda/App/Tools/Parser.swift b/EhPanda/App/Tools/Parser.swift index 7782db41..ac170f74 100644 --- a/EhPanda/App/Tools/Parser.swift +++ b/EhPanda/App/Tools/Parser.swift @@ -1378,6 +1378,73 @@ extension Parser { // MARK: PageNumber static func parsePageNum(doc: HTMLDocument) -> PageNumber { + func parseScriptVariable(name: String) -> 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 + } + func parseScriptURL(name: String) -> URL? { + guard var value = parseScriptVariable(name: name)?.trimmingCharacters(in: .whitespacesAndNewlines), + !value.isEmpty + else { return nil } + value = value + .replacingOccurrences(of: "&", with: "&") + .replacingOccurrences(of: "\\u0026", with: "&") + + let baseURL = Defaults.URL.host + let parsedURL: URL? + if let url = URL(string: value), url.scheme != nil { + parsedURL = url + } else { + parsedURL = URL(string: value, relativeTo: baseURL)?.absoluteURL + } + + guard let parsedURL else { return nil } + guard var components = URLComponents(url: parsedURL, resolvingAgainstBaseURL: false) else { + return parsedURL + } + + let knownGalleryHosts = [ + Defaults.URL.ehentai.host, + Defaults.URL.exhentai.host, + Defaults.URL.sexhentai.host + ] + .compactMap { $0?.lowercased() } + + if let host = components.host?.lowercased(), + knownGalleryHosts.contains(host), + let baseComponents = URLComponents(url: baseURL, resolvingAgainstBaseURL: false) + { + components.scheme = baseComponents.scheme + components.host = baseComponents.host + } + return components.url + } + func parseScriptDate(name: String) -> Date? { + guard let value = parseScriptVariable(name: name), !value.isEmpty else { return nil } + return try? parseDate(time: value, format: "yyyy-MM-dd") + } + func parsePageJumpNavigation() -> PageJumpNavigation? { + let navigation = PageJumpNavigation( + previousURL: parseScriptURL(name: "prevurl"), + nextURL: parseScriptURL(name: "nexturl"), + minimumDate: parseScriptDate(name: "mindate"), + maximumDate: parseScriptDate(name: "maxdate") + ) + return navigation.isEnabled ? navigation : nil + } + var current = 0 var maximum = 0 @@ -1402,9 +1469,16 @@ extension Parser { break } - return PageNumber(lastItemTimestamp: timestamp, isNextButtonEnabled: isEnabled) + return PageNumber( + lastItemTimestamp: timestamp, + isNextButtonEnabled: isEnabled, + jumpNavigation: parsePageJumpNavigation() + ) } else { - return PageNumber(isNextButtonEnabled: false) + return PageNumber( + isNextButtonEnabled: false, + jumpNavigation: parsePageJumpNavigation() + ) } } @@ -1418,7 +1492,11 @@ extension Parser { maximum = num - 1 } } - return PageNumber(current: current, maximum: maximum) + return PageNumber( + current: current, + maximum: maximum, + jumpNavigation: parsePageJumpNavigation() + ) } // MARK: SortOrder diff --git a/EhPanda/App/de.lproj/Localizable.strings b/EhPanda/App/de.lproj/Localizable.strings index b505d8d4..c6b4f46a 100644 --- a/EhPanda/App/de.lproj/Localizable.strings +++ b/EhPanda/App/de.lproj/Localizable.strings @@ -51,8 +51,15 @@ // MARK: ToolbarItem "toolbar_item.button.filters" = "Filters"; "toolbar_item.button.jump_page" = "Jump page"; +"toolbar_item.button.date_jump" = "Datumssprung"; "toolbar_item.button.quick_search" = "Quick search"; +// MARK: DateJump +"date_jump_view.title.date_jump" = "Datumssprung"; +"date_jump_view.title.date" = "Datum"; +"date_jump_view.footer.seek_around_date" = "Zu Galerien um das ausgewählte Datum springen."; +"date_jump_view.button.seek_newer" = "Zu neueren springen"; +"date_jump_view.button.seek_older" = "Zu älteren springen"; // MARK: JumpPage "jump_page_view.title.jump_page" = "Jump page"; "jump_page_view.button.confirm" = "Confirm"; diff --git a/EhPanda/App/en.lproj/Localizable.strings b/EhPanda/App/en.lproj/Localizable.strings index a3ca9bac..4b30bd13 100644 --- a/EhPanda/App/en.lproj/Localizable.strings +++ b/EhPanda/App/en.lproj/Localizable.strings @@ -51,8 +51,15 @@ // MARK: ToolbarItem "toolbar_item.button.filters" = "Filters"; "toolbar_item.button.jump_page" = "Jump page"; +"toolbar_item.button.date_jump" = "Date Jump"; "toolbar_item.button.quick_search" = "Quick search"; +// MARK: DateJump +"date_jump_view.title.date_jump" = "Date Jump"; +"date_jump_view.title.date" = "Date"; +"date_jump_view.footer.seek_around_date" = "Seek to galleries around the selected date."; +"date_jump_view.button.seek_newer" = "Seek Newer"; +"date_jump_view.button.seek_older" = "Seek Older"; // MARK: JumpPage "jump_page_view.title.jump_page" = "Jump page"; "jump_page_view.button.confirm" = "Confirm"; diff --git a/EhPanda/App/ja.lproj/Localizable.strings b/EhPanda/App/ja.lproj/Localizable.strings index fa68dba1..a71954ac 100644 --- a/EhPanda/App/ja.lproj/Localizable.strings +++ b/EhPanda/App/ja.lproj/Localizable.strings @@ -51,8 +51,15 @@ // MARK: ToolbarItem "toolbar_item.button.filters" = "フィルター"; "toolbar_item.button.jump_page" = "ページジャンプ"; +"toolbar_item.button.date_jump" = "日付ジャンプ"; "toolbar_item.button.quick_search" = "クイック検索"; +// MARK: DateJump +"date_jump_view.title.date_jump" = "日付ジャンプ"; +"date_jump_view.title.date" = "日付"; +"date_jump_view.footer.seek_around_date" = "選択した日付付近のギャラリーへ移動します。"; +"date_jump_view.button.seek_newer" = "新しい方へ移動"; +"date_jump_view.button.seek_older" = "古い方へ移動"; // MARK: JumpPage "jump_page_view.title.jump_page" = "ページジャンプ"; "jump_page_view.button.confirm" = "確認"; diff --git a/EhPanda/App/ko.lproj/Localizable.strings b/EhPanda/App/ko.lproj/Localizable.strings index b9266898..d7815a8f 100644 --- a/EhPanda/App/ko.lproj/Localizable.strings +++ b/EhPanda/App/ko.lproj/Localizable.strings @@ -51,8 +51,15 @@ // MARK: ToolbarItem "toolbar_item.button.filters" = "필터"; "toolbar_item.button.jump_page" = "페이지 이동"; +"toolbar_item.button.date_jump" = "날짜 이동"; "toolbar_item.button.quick_search" = "빠른 검색"; +// MARK: DateJump +"date_jump_view.title.date_jump" = "날짜 이동"; +"date_jump_view.title.date" = "날짜"; +"date_jump_view.footer.seek_around_date" = "선택한 날짜 근처의 갤러리로 이동합니다."; +"date_jump_view.button.seek_newer" = "새 항목으로 이동"; +"date_jump_view.button.seek_older" = "오래된 항목으로 이동"; // MARK: JumpPage "jump_page_view.title.jump_page" = "페이지 이동"; "jump_page_view.button.confirm" = "확인"; diff --git a/EhPanda/App/zh-Hans.lproj/Localizable.strings b/EhPanda/App/zh-Hans.lproj/Localizable.strings index 19a4d6b0..6092287e 100644 --- a/EhPanda/App/zh-Hans.lproj/Localizable.strings +++ b/EhPanda/App/zh-Hans.lproj/Localizable.strings @@ -51,8 +51,15 @@ // MARK: ToolbarItem "toolbar_item.button.filters" = "筛选"; "toolbar_item.button.jump_page" = "页码跳转"; +"toolbar_item.button.date_jump" = "日期跳转"; "toolbar_item.button.quick_search" = "快速搜索"; +// MARK: DateJump +"date_jump_view.title.date_jump" = "日期跳转"; +"date_jump_view.title.date" = "日期"; +"date_jump_view.footer.seek_around_date" = "跳转到所选日期附近的画廊。"; +"date_jump_view.button.seek_newer" = "跳到较新"; +"date_jump_view.button.seek_older" = "跳到较旧"; // MARK: JumpPage "jump_page_view.title.jump_page" = "页码跳转"; "jump_page_view.button.confirm" = "确认"; diff --git a/EhPanda/App/zh-Hant-HK.lproj/Localizable.strings b/EhPanda/App/zh-Hant-HK.lproj/Localizable.strings index 948d5eb2..2ad5e0ed 100644 --- a/EhPanda/App/zh-Hant-HK.lproj/Localizable.strings +++ b/EhPanda/App/zh-Hant-HK.lproj/Localizable.strings @@ -51,8 +51,15 @@ // MARK: ToolbarItem "toolbar_item.button.filters" = "過濾"; "toolbar_item.button.jump_page" = "跳到..."; +"toolbar_item.button.date_jump" = "日期跳轉"; "toolbar_item.button.quick_search" = "快速搜尋"; +// MARK: DateJump +"date_jump_view.title.date_jump" = "日期跳轉"; +"date_jump_view.title.date" = "日期"; +"date_jump_view.footer.seek_around_date" = "跳轉到所選日期附近的畫廊。"; +"date_jump_view.button.seek_newer" = "跳到較新"; +"date_jump_view.button.seek_older" = "跳到較舊"; // MARK: JumpPage "jump_page_view.title.jump_page" = "跳到..."; "jump_page_view.button.confirm" = "確定"; diff --git a/EhPanda/App/zh-Hant-TW.lproj/Localizable.strings b/EhPanda/App/zh-Hant-TW.lproj/Localizable.strings index 0c4f150d..68121aab 100644 --- a/EhPanda/App/zh-Hant-TW.lproj/Localizable.strings +++ b/EhPanda/App/zh-Hant-TW.lproj/Localizable.strings @@ -51,8 +51,15 @@ // MARK: ToolbarItem "toolbar_item.button.filters" = "過濾"; "toolbar_item.button.jump_page" = "跳到..."; +"toolbar_item.button.date_jump" = "日期跳轉"; "toolbar_item.button.quick_search" = "快速搜尋"; +// MARK: DateJump +"date_jump_view.title.date_jump" = "日期跳轉"; +"date_jump_view.title.date" = "日期"; +"date_jump_view.footer.seek_around_date" = "跳轉到所選日期附近的畫廊。"; +"date_jump_view.button.seek_newer" = "跳到較新"; +"date_jump_view.button.seek_older" = "跳到較舊"; // MARK: JumpPage "jump_page_view.title.jump_page" = "跳到..."; "jump_page_view.button.confirm" = "確定"; diff --git a/EhPanda/App/zh-Hant.lproj/Localizable.strings b/EhPanda/App/zh-Hant.lproj/Localizable.strings index ad77e167..5cfe58bf 100644 --- a/EhPanda/App/zh-Hant.lproj/Localizable.strings +++ b/EhPanda/App/zh-Hant.lproj/Localizable.strings @@ -51,8 +51,15 @@ // MARK: ToolbarItem "toolbar_item.button.filters" = "過濾"; "toolbar_item.button.jump_page" = "跳到..."; +"toolbar_item.button.date_jump" = "日期跳轉"; "toolbar_item.button.quick_search" = "快速搜尋"; +// MARK: DateJump +"date_jump_view.title.date_jump" = "日期跳轉"; +"date_jump_view.title.date" = "日期"; +"date_jump_view.footer.seek_around_date" = "跳轉到所選日期附近的畫廊。"; +"date_jump_view.button.seek_newer" = "跳到較新"; +"date_jump_view.button.seek_older" = "跳到較舊"; // MARK: JumpPage "jump_page_view.title.jump_page" = "跳到..."; "jump_page_view.button.confirm" = "確定"; diff --git a/EhPanda/Models/Support/Misc.swift b/EhPanda/Models/Support/Misc.swift index 46d14119..fe113ebb 100644 --- a/EhPanda/Models/Support/Misc.swift +++ b/EhPanda/Models/Support/Misc.swift @@ -10,6 +10,54 @@ import SwiftyBeaver typealias Logger = SwiftyBeaver typealias FavoritesSortOrder = EhSetting.FavoritesSortOrder +enum PageJumpDirection: Equatable { + case newer + case older +} + +struct PageJumpNavigation: Equatable { + var previousURL: URL? + var nextURL: URL? + var minimumDate: Date? + var maximumDate: Date? + + var isEnabled: Bool { + previousURL != nil || nextURL != nil + } + var dateRange: ClosedRange { + (minimumDate ?? .distantPast)...(maximumDate ?? .distantFuture) + } + + func clampedDate(_ date: Date = Date()) -> Date { + if let maximumDate, date > maximumDate { + return maximumDate + } + if let minimumDate, date < minimumDate { + return minimumDate + } + return date + } + + func seekURL(date: Date, direction: PageJumpDirection) -> URL? { + let baseURL: URL? + switch direction { + case .newer: + baseURL = previousURL + case .older: + baseURL = nextURL + } + return baseURL?.appending(queryItems: ["seek": Self.dateFormatter.string(from: date)]) + } + + private static let dateFormatter: DateFormatter = { + let formatter = DateFormatter() + formatter.dateFormat = "yyyy-MM-dd" + formatter.timeZone = TimeZone(secondsFromGMT: 0) + formatter.locale = Locale(identifier: "en_US_POSIX") + return formatter + }() +} + protocol DateFormattable { var originalDate: Date { get } } @@ -29,6 +77,7 @@ struct PageNumber: Equatable { var maximum = 0 var lastItemTimestamp: String? var isNextButtonEnabled = false + var jumpNavigation: PageJumpNavigation? var isSinglePage: Bool { current == 0 && maximum == 0 diff --git a/EhPanda/Network/Request.swift b/EhPanda/Network/Request.swift index f70b746a..29b95159 100644 --- a/EhPanda/Network/Request.swift +++ b/EhPanda/Network/Request.swift @@ -188,6 +188,19 @@ struct MoreSearchGalleriesRequest: Request { } } +struct JumpGalleriesRequest: Request { + let url: URL + + var publisher: AnyPublisher<(PageNumber, [Gallery]), AppError> { + URLSession.shared.dataTaskPublisher(for: url) + .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 diff --git a/EhPanda/View/Home/Frontpage/FrontpageReducer.swift b/EhPanda/View/Home/Frontpage/FrontpageReducer.swift index f27c5aee..3403e857 100644 --- a/EhPanda/View/Home/Frontpage/FrontpageReducer.swift +++ b/EhPanda/View/Home/Frontpage/FrontpageReducer.swift @@ -4,6 +4,7 @@ // import ComposableArchitecture +import Foundation @Reducer struct FrontpageReducer { @@ -14,7 +15,7 @@ struct FrontpageReducer { } private enum CancelID: CaseIterable { - case fetchGalleries, fetchMoreGalleries + case fetchGalleries, fetchMoreGalleries, fetchJumpGalleries } @ObservableState @@ -28,6 +29,8 @@ struct FrontpageReducer { } var galleries = [Gallery]() var pageNumber = PageNumber() + var dateJumpDate = Date() + var dateJumpSheetPresented = false var loadingState: LoadingState = .idle var footerLoadingState: LoadingState = .idle @@ -57,6 +60,9 @@ struct FrontpageReducer { case fetchGalleriesDone(Result<(PageNumber, [Gallery]), AppError>) case fetchMoreGalleries case fetchMoreGalleriesDone(Result<(PageNumber, [Gallery]), AppError>) + case presentDateJump + case jumpToDate(PageJumpDirection) + case jumpToDateDone(Result<(PageNumber, [Gallery]), AppError>) case filters(FiltersReducer.Action) case detail(DetailReducer.Action) @@ -152,6 +158,51 @@ struct FrontpageReducer { } return .none + case .presentDateJump: + guard let navigation = state.pageNumber.jumpNavigation, navigation.isEnabled else { + return .run(operation: { _ in hapticsClient.generateNotificationFeedback(.error) }) + } + state.dateJumpDate = navigation.clampedDate(state.dateJumpDate) + state.dateJumpSheetPresented = true + return .run(operation: { _ in hapticsClient.generateFeedback(.light) }) + + case .jumpToDate(let direction): + guard state.loadingState != .loading, + let url = state.pageNumber.jumpNavigation?.seekURL( + date: state.dateJumpDate, direction: direction + ) + else { return .run(operation: { _ in hapticsClient.generateNotificationFeedback(.error) }) } + + state.dateJumpSheetPresented = false + state.loadingState = .loading + state.footerLoadingState = .idle + state.pageNumber.resetPages() + return .run { send in + let response = await JumpGalleriesRequest(url: url).response() + await send(.jumpToDateDone(response)) + } + .cancellable(id: CancelID.fetchJumpGalleries) + + case .jumpToDateDone(let result): + state.loadingState = .idle + switch result { + case .success(let (pageNumber, galleries)): + guard !galleries.isEmpty else { + state.loadingState = .failed(.notFound) + guard pageNumber.hasNextPage() else { return .none } + return .send(.fetchMoreGalleries) + } + state.pageNumber = pageNumber + if let navigation = pageNumber.jumpNavigation { + state.dateJumpDate = navigation.clampedDate(state.dateJumpDate) + } + state.galleries = galleries + return .run(operation: { _ in await databaseClient.cacheGalleries(galleries) }) + case .failure(let error): + state.loadingState = .failed(error) + } + return .none + case .filters: return .none diff --git a/EhPanda/View/Home/Frontpage/FrontpageView.swift b/EhPanda/View/Home/Frontpage/FrontpageView.swift index 571b15c7..a3239ac0 100644 --- a/EhPanda/View/Home/Frontpage/FrontpageView.swift +++ b/EhPanda/View/Home/Frontpage/FrontpageView.swift @@ -44,6 +44,15 @@ struct FrontpageView: View { FiltersView(store: store.scope(state: \.filtersState, action: \.filters)) .autoBlur(radius: blurRadius).environment(\.inSheet, true) } + .sheet(isPresented: $store.dateJumpSheetPresented) { + DateJumpView( + pageNumber: store.pageNumber, + selectedDate: $store.dateJumpDate, + jumpAction: { store.send(.jumpToDate($0)) } + ) + .accentColor(setting.accentColor) + .autoBlur(radius: blurRadius) + } .searchable(text: $store.keyword, prompt: L10n.Localizable.Searchable.Prompt.filter) .onAppear { if store.galleries.isEmpty { @@ -86,6 +95,9 @@ struct FrontpageView: View { } private func toolbar() -> some ToolbarContent { CustomToolbarItem { + DateJumpButton(pageNumber: store.pageNumber, hideText: true) { + store.send(.presentDateJump) + } FiltersButton(hideText: true) { store.send(.setNavigation(.filters())) } diff --git a/EhPanda/View/Search/SearchReducer.swift b/EhPanda/View/Search/SearchReducer.swift index 79604aa6..234bb73e 100644 --- a/EhPanda/View/Search/SearchReducer.swift +++ b/EhPanda/View/Search/SearchReducer.swift @@ -4,6 +4,7 @@ // import ComposableArchitecture +import Foundation @Reducer struct SearchReducer { @@ -15,7 +16,7 @@ struct SearchReducer { } private enum CancelID: CaseIterable { - case fetchGalleries, fetchMoreGalleries + case fetchGalleries, fetchMoreGalleries, fetchJumpGalleries } @ObservableState @@ -26,6 +27,8 @@ struct SearchReducer { var galleries = [Gallery]() var pageNumber = PageNumber() + var dateJumpDate = Date() + var dateJumpSheetPresented = false var loadingState: LoadingState = .idle var footerLoadingState: LoadingState = .idle @@ -56,6 +59,9 @@ struct SearchReducer { case fetchGalleriesDone(Result<(PageNumber, [Gallery]), AppError>) case fetchMoreGalleries case fetchMoreGalleriesDone(Result<(PageNumber, [Gallery]), AppError>) + case presentDateJump + case jumpToDate(PageJumpDirection) + case jumpToDateDone(Result<(PageNumber, [Gallery]), AppError>) case detail(DetailReducer.Action) case filters(FiltersReducer.Action) @@ -171,6 +177,51 @@ struct SearchReducer { } return .none + case .presentDateJump: + guard let navigation = state.pageNumber.jumpNavigation, navigation.isEnabled else { + return .run(operation: { _ in hapticsClient.generateNotificationFeedback(.error) }) + } + state.dateJumpDate = navigation.clampedDate(state.dateJumpDate) + state.dateJumpSheetPresented = true + return .run(operation: { _ in hapticsClient.generateFeedback(.light) }) + + case .jumpToDate(let direction): + guard state.loadingState != .loading, + let url = state.pageNumber.jumpNavigation?.seekURL( + date: state.dateJumpDate, direction: direction + ) + else { return .run(operation: { _ in hapticsClient.generateNotificationFeedback(.error) }) } + + state.dateJumpSheetPresented = false + state.loadingState = .loading + state.footerLoadingState = .idle + state.pageNumber.resetPages() + return .run { send in + let response = await JumpGalleriesRequest(url: url).response() + await send(.jumpToDateDone(response)) + } + .cancellable(id: CancelID.fetchJumpGalleries) + + case .jumpToDateDone(let result): + state.loadingState = .idle + switch result { + case .success(let (pageNumber, galleries)): + guard !galleries.isEmpty else { + state.loadingState = .failed(.notFound) + guard pageNumber.hasNextPage() else { return .none } + return .send(.fetchMoreGalleries) + } + state.pageNumber = pageNumber + if let navigation = pageNumber.jumpNavigation { + state.dateJumpDate = navigation.clampedDate(state.dateJumpDate) + } + state.galleries = galleries + return .run(operation: { _ in await databaseClient.cacheGalleries(galleries) }) + case .failure(let error): + state.loadingState = .failed(error) + } + return .none + case .detail: return .none diff --git a/EhPanda/View/Search/SearchView.swift b/EhPanda/View/Search/SearchView.swift index 0ba8c1d2..f06f4597 100644 --- a/EhPanda/View/Search/SearchView.swift +++ b/EhPanda/View/Search/SearchView.swift @@ -55,6 +55,15 @@ struct SearchView: View { FiltersView(store: store.scope(state: \.filtersState, action: \.filters)) .accentColor(setting.accentColor).autoBlur(radius: blurRadius) } + .sheet(isPresented: $store.dateJumpSheetPresented) { + DateJumpView( + pageNumber: store.pageNumber, + selectedDate: $store.dateJumpDate, + jumpAction: { store.send(.jumpToDate($0)) } + ) + .accentColor(setting.accentColor) + .autoBlur(radius: blurRadius) + } .searchable(text: $store.keyword) .searchSuggestions { TagSuggestionView( @@ -107,6 +116,9 @@ struct SearchView: View { private func toolbar() -> some ToolbarContent { CustomToolbarItem { ToolbarFeaturesMenu { + DateJumpButton(pageNumber: store.pageNumber) { + store.send(.presentDateJump) + } FiltersButton { store.send(.setNavigation(.filters())) } diff --git a/EhPanda/View/Support/Components/ToolbarItems.swift b/EhPanda/View/Support/Components/ToolbarItems.swift index ef4d850e..5c64dacd 100644 --- a/EhPanda/View/Support/Components/ToolbarItems.swift +++ b/EhPanda/View/Support/Components/ToolbarItems.swift @@ -111,6 +111,28 @@ struct JumpPageButton: View { } } +struct DateJumpButton: View { + private let pageNumber: PageNumber + private let hideText: Bool + private let action: () -> Void + + init(pageNumber: PageNumber, hideText: Bool = false, action: @escaping () -> Void) { + self.pageNumber = pageNumber + self.hideText = hideText + self.action = action + } + + var body: some View { + Button(action: action) { + Image(systemName: "calendar") + if !hideText { + Text(L10n.Localizable.ToolbarItem.Button.dateJump) + } + } + .disabled(pageNumber.jumpNavigation?.isEnabled != true) + } +} + struct FavoritesIndexMenu: View { private let user: User private let index: Int diff --git a/EhPandaTests/Tests/Parser/List/ListParserTests.swift b/EhPandaTests/Tests/Parser/List/ListParserTests.swift index c03e8a2c..5f7d3f7c 100644 --- a/EhPandaTests/Tests/Parser/List/ListParserTests.swift +++ b/EhPandaTests/Tests/Parser/List/ListParserTests.swift @@ -23,4 +23,113 @@ class ListParserTests: XCTestCase, TestHelper { } } } + + func testPageJumpNavigation() throws { + let document = try htmlDocument(filename: .frontPageMinimalList) + let pageNumber = Parser.parsePageNum(doc: document) + let navigation = try XCTUnwrap(pageNumber.jumpNavigation) + + XCTAssertTrue(pageNumber.hasNextPage()) + XCTAssertEqual(pageNumber.lastItemTimestamp, "2668517") + XCTAssertNil(navigation.previousURL) + XCTAssertEqual(navigation.nextURL?.absoluteString, "https://e-hentai.org/?next=2668517") + XCTAssertEqual(Self.dateFormatter.string(from: try XCTUnwrap(navigation.minimumDate)), "2007-03-20") + XCTAssertEqual(Self.dateFormatter.string(from: try XCTUnwrap(navigation.maximumDate)), "2023-09-08") + } + + func testPageJumpSeekURL() throws { + let document = try htmlDocument(filename: .frontPageMinimalList) + let pageNumber = Parser.parsePageNum(doc: document) + let navigation = try XCTUnwrap(pageNumber.jumpNavigation) + let maximumDate = try XCTUnwrap(navigation.maximumDate) + let url = try XCTUnwrap(navigation.seekURL(date: maximumDate, direction: .older)) + let queryItems = URLComponents(url: url, resolvingAgainstBaseURL: false)?.queryItems + + XCTAssertEqual(queryItems?.first(where: { $0.name == "next" })?.value, "2668517") + XCTAssertEqual(queryItems?.first(where: { $0.name == "seek" })?.value, "2023-09-08") + XCTAssertNil(navigation.seekURL(date: maximumDate, direction: .newer)) + } + + func testPageJumpNavigationNormalizesExHentaiHost() throws { + let originalHost: String? = UserDefaultsUtil.value(forKey: .galleryHost) + UserDefaults.standard.set(GalleryHost.exhentai.rawValue, forKey: AppUserDefaults.galleryHost.rawValue) + defer { + if let originalHost { + UserDefaults.standard.set(originalHost, forKey: AppUserDefaults.galleryHost.rawValue) + } else { + UserDefaults.standard.removeObject(forKey: AppUserDefaults.galleryHost.rawValue) + } + } + + let document = try Kanna.HTML(html: """ + + + + + + + """, encoding: .utf8) + + let navigation = try XCTUnwrap(Parser.parsePageNum(doc: document).jumpNavigation) + + XCTAssertEqual(navigation.previousURL?.host, "exhentai.org") + XCTAssertEqual(navigation.nextURL?.host, "exhentai.org") + XCTAssertEqual( + URLComponents(url: try XCTUnwrap(navigation.previousURL), resolvingAgainstBaseURL: false)? + .queryItems? + .first(where: { $0.name == "page" })? + .value, + "1" + ) + XCTAssertEqual( + URLComponents(url: try XCTUnwrap(navigation.nextURL), resolvingAgainstBaseURL: false)? + .queryItems? + .first(where: { $0.name == "next" })? + .value, + "456" + ) + } + + func testPageJumpNavigationIsPreservedWithNumericPager() throws { + let document = try Kanna.HTML(html: """ + + + + + + + + + +
123
+ + + """, encoding: .utf8) + + let pageNumber = Parser.parsePageNum(doc: document) + let navigation = try XCTUnwrap(pageNumber.jumpNavigation) + + XCTAssertEqual(pageNumber.current, 1) + XCTAssertEqual(pageNumber.maximum, 2) + XCTAssertEqual(navigation.previousURL?.absoluteString, "https://e-hentai.org/?prev=123") + XCTAssertEqual(navigation.nextURL?.absoluteString, "https://e-hentai.org/?next=456") + } + + private static let dateFormatter: DateFormatter = { + let formatter = DateFormatter() + formatter.dateFormat = "yyyy-MM-dd" + formatter.timeZone = TimeZone(secondsFromGMT: 0) + formatter.locale = Locale(identifier: "en_US_POSIX") + return formatter + }() }