From 48ea3c40a13276c84c5427b7c7ce5c9235437e75 Mon Sep 17 00:00:00 2001 From: Vegard Bieker Matthey Date: Fri, 31 Jul 2026 20:52:41 +0200 Subject: [PATCH 1/6] feat: anilist --- ImageViewer/ViewController.swift | 552 ++++++++++++++++++++++++--- ImageViewer/metadata.fbs | 1 + ImageViewer/metadata_generated.swift | 12 +- 3 files changed, 499 insertions(+), 66 deletions(-) diff --git a/ImageViewer/ViewController.swift b/ImageViewer/ViewController.swift index 9376e1b..b886e45 100644 --- a/ImageViewer/ViewController.swift +++ b/ImageViewer/ViewController.swift @@ -1,6 +1,4 @@ -// TODO: Anilist support? // TODO: Properly avoid swallowing of input from UICollectionView used for scrolling -// TODO: Convert between state for normal and scrolling page turn import FlatBuffers import Foundation @@ -29,7 +27,7 @@ struct ProgressIndices { var i: Int } -enum PageTurnMode: Codable { +enum PageTurnMode: Codable, CaseIterable { case leftToRight case rightToLeft case scroll @@ -81,6 +79,7 @@ final class ViewController: UIViewController, UIGestureRecognizerDelegate { var progress = ProgressIndices(v: 0, c: 0, i: 0) var currentPath: URL! var lastLayoutSize: CGSize = .zero + var aniListToken: String? = getAniListToken() var leftTap: UITapGestureRecognizer! var rightTap: UITapGestureRecognizer! @@ -906,7 +905,7 @@ final class ViewController: UIViewController, UIGestureRecognizerDelegate { super.viewDidLayoutSubviews() guard view.bounds.size != lastLayoutSize else { return } lastLayoutSize = UIScreen.main.bounds.size - if scrollingCollectionView.isHidden == true + if scrollingCollectionView.isHidden || mode != .scroll || scrollingCollectionView.contentSize == .zero { @@ -1183,7 +1182,7 @@ final class ViewController: UIViewController, UIGestureRecognizerDelegate { let point = gesture.location(in: comicCollectionView) guard let indexPath = comicCollectionView.indexPathForItem(at: point) else { return } - showComicOptions(for: indexPath) + comicOptionLongPressed(indexPath) } @objc func handleTopTap() { @@ -1289,39 +1288,6 @@ final class ViewController: UIViewController, UIGestureRecognizerDelegate { saveLocalState() } - func showComicOptions(for indexPath: IndexPath) { - let alert = UIAlertController(title: "Options", message: nil, preferredStyle: .actionSheet) - alert.addAction(UIAlertAction(title: "View Metadata", style: .default) { _ in - let metadataVC = ComicMetadataViewController(comic: self.comics[indexPath.item].metadata) - let nav = UINavigationController(rootViewController: metadataVC) - nav.modalPresentationStyle = .formSheet - self.present(nav, animated: false) - }) - alert.addAction(UIAlertAction(title: "Delete", style: .destructive) { _ in - do { - try self.fileManager.removeItem(at: self.comics[indexPath.item].path) - self.comics.remove(at: indexPath.item) - self.comicCollectionView.reloadData() - } catch { - let failureAlert = UIAlertController( - title: "Failed to remove directory.", - message: "Failed to recursively remove \(self.comics[indexPath.item].path)", - preferredStyle: .alert - ) - self.present(failureAlert, animated: false) - } - }) - alert.addAction(UIAlertAction(title: "Cancel", style: .cancel)) - - // Required for iPad, harmless on iPhone - if let cell = comicCollectionView.cellForItem(at: indexPath) { - alert.popoverPresentationController?.sourceView = cell - alert.popoverPresentationController?.sourceRect = cell.bounds - } - - present(alert, animated: false) - } - func metaValueToString(m: MetaValue) -> String { var r = "" if m.bonus != 0 { @@ -1398,9 +1364,6 @@ final class ViewController: UIViewController, UIGestureRecognizerDelegate { func getImagePath(_ progress: ProgressIndices) -> String { let (v, c, i) = (progress.v, progress.c, progress.i) return metadata.volumes[v].chapters[c].images[i].filename! - // if fileManager.fileExists(atPath: modernPath.path) { - // } - // return nil } func getProgressIndicesFromTurn(turn: PageTurn, progress: ProgressIndices) -> ProgressIndices { @@ -1437,13 +1400,6 @@ final class ViewController: UIViewController, UIGestureRecognizerDelegate { return ProgressIndices(v: v, c: c, i: i) } - func getMetadata(path: URL) -> Metadata? { - guard let data = try? Data(contentsOf: path.appendingPathComponent(metadataFilename)) else { return nil } - var byteBuffer = ByteBuffer(data: data) - guard let metadata: Metadata = try? getCheckedRoot(byteBuffer: &byteBuffer) else { return nil } - return metadata - } - func setImages() { let scaling: UIView.ContentMode = mode == .scroll ? .scaleAspectFill : .scaleAspectFit imageLoader.loadImage( @@ -1476,6 +1432,22 @@ final class ViewController: UIViewController, UIGestureRecognizerDelegate { } } +func getAniListToken() -> String? { + let fileManager = FileManager.default + if let documentsURL = fileManager.urls(for: .documentDirectory, in: .userDomainMask).first { + let path = documentsURL.appendingPathComponent("anilist_token.txt").path + if !fileManager.fileExists(atPath: path) { + return nil + } + do { + return try String(contentsOfFile: path).trimmingCharacters(in: CharacterSet.whitespacesAndNewlines) + } catch { + print("Error reading directory contents: \(error)") + } + } + return nil +} + func getGlobalState() -> GlobalState { let fileManager = FileManager.default if let documentsURL = fileManager.urls(for: .documentDirectory, in: .userDomainMask).first { @@ -2000,21 +1972,26 @@ class ComicMetadataViewController: UITableViewController { cell.detailTextLabel?.numberOfLines = 0 return cell } + var anId = "" + if let id = comic.anilistId { + anId = String(id) + } let cell = UITableViewCell(style: .value1, reuseIdentifier: nil) switch indexPath.row { case 0: cell.textLabel?.text = "Title"; cell.detailTextLabel?.text = comic.title - case 1: cell.textLabel?.text = "Format"; cell.detailTextLabel?.text = "\(comic.format)" - case 2: cell.textLabel?.text = "Original Language"; cell.detailTextLabel?.text = comic.originalLanguage - case 3: cell.textLabel?.text = "Publication Demographic" + case 1: cell.textLabel?.text = "AniList ID"; cell.detailTextLabel?.text = anId + case 2: cell.textLabel?.text = "Format"; cell.detailTextLabel?.text = "\(comic.format)" + case 3: cell.textLabel?.text = "Original Language"; cell.detailTextLabel?.text = comic.originalLanguage + case 4: cell.textLabel?.text = "Publication Demographic" cell.detailTextLabel?.text = "\(comic.publicationDemographic)" - case 4: cell.textLabel?.text = "Country of Origin"; cell.detailTextLabel?.text = comic.countryOfOrigin - case 5: cell.textLabel?.text = "Status"; cell.detailTextLabel?.text = "\(comic.status)" - case 6: cell.textLabel?.text = "Content Rating"; cell.detailTextLabel?.text = "\(comic.contentRating)" - case 7: cell.textLabel?.text = "First Release" + case 5: cell.textLabel?.text = "Country of Origin"; cell.detailTextLabel?.text = comic.countryOfOrigin + case 6: cell.textLabel?.text = "Status"; cell.detailTextLabel?.text = "\(comic.status)" + case 7: cell.textLabel?.text = "Content Rating"; cell.detailTextLabel?.text = "\(comic.contentRating)" + case 8: cell.textLabel?.text = "First Release" cell.detailTextLabel?.text = "\(dateToString(comic.startReleaseDate))" - case 8: cell.textLabel?.text = "Last Release" + case 9: cell.textLabel?.text = "Last Release" cell.detailTextLabel?.text = "\(dateToString(comic.endReleaseDate))" - case 9: cell.textLabel?.text = "Tags" + case 10: cell.textLabel?.text = "Tags" cell.detailTextLabel?.text = comic.tags.map { $0.value ?? "" }.joined(separator: ", ") default: break } @@ -2106,7 +2083,7 @@ class SettingsMenuViewController: UITableViewController { override func tableView(_: UITableView, willSelectRowAt indexPath: IndexPath) -> IndexPath? { let item = items[indexPath.row] - return (item.selected?() == true) ? nil : indexPath + return item.selected?() == true ? nil : indexPath } override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { @@ -2121,6 +2098,84 @@ class SettingsMenuViewController: UITableViewController { } } +struct ComicOptionItem { + let title: String + let children: [ComicOptionItem]? + let destructive: Bool + let available: () -> Bool + let action: (() -> Void)? +} + +class ComicOptionViewController: UITableViewController { + private let items: [ComicOptionItem] + + init(title: String, items: [ComicOptionItem]) { + self.items = items + super.init(style: .plain) + self.title = title + } + + @available(*, unavailable) + required init?(coder _: NSCoder) { + fatalError("init(coder:) not implemented") + } + + override func viewDidLoad() { + super.viewDidLoad() + tableView.register(UITableViewCell.self, forCellReuseIdentifier: "Cell") + preferredContentSize = CGSize(width: 330, height: items.count * 44) + } + + override func viewWillAppear(_ animated: Bool) { + super.viewWillAppear(animated) + tableView.reloadData() + } + + override func tableView(_: UITableView, numberOfRowsInSection _: Int) -> Int { + return items.count + } + + override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { + let cell = tableView.dequeueReusableCell(withIdentifier: "Cell", for: indexPath) + let item = items[indexPath.row] + cell.textLabel?.text = item.title + cell.accessoryType = item.children != nil ? .disclosureIndicator : .none + + let available = item.available() + + if available { + cell.accessoryType = item.children != nil ? .disclosureIndicator : .none + cell.selectionStyle = .default + if item.destructive { + cell.textLabel?.textColor = .red + } else { + cell.textLabel?.textColor = .white + } + } else { + cell.textLabel?.textColor = .gray + cell.selectionStyle = .none + } + + return cell + } + + override func tableView(_: UITableView, willSelectRowAt indexPath: IndexPath) -> IndexPath? { + let item = items[indexPath.row] + return (item.available()) ? indexPath : nil + } + + override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { + let item = items[indexPath.row] + if let children = item.children { + let next = ComicOptionViewController(title: item.title, items: children) + navigationController?.pushViewController(next, animated: false) + } else { + tableView.deselectRow(at: indexPath, animated: false) + item.action?() + } + } +} + func convertPageTurnModeToString(_ pageTurnMode: PageTurnMode) -> String { switch pageTurnMode { case .leftToRight: return "Left to Right" @@ -2129,6 +2184,12 @@ func convertPageTurnModeToString(_ pageTurnMode: PageTurnMode) -> String { } } +enum AniListAction: String, CaseIterable { + case track = "Track" + case updateStatus = "Update Status" + case fetchMetadata = "Fetch Metadata" +} + extension ViewController { private func colorItems( previous: UIColor, @@ -2168,7 +2229,7 @@ extension ViewController { } private func pageTurnItems(applyTo pageTurnHandler: @escaping (PageTurnMode) -> Void) -> [SettingsMenuItem] { - let items: [PageTurnMode] = [.leftToRight, .rightToLeft, .scroll] + let items = PageTurnMode.allCases return items.map { pageTurnMode in SettingsMenuItem( title: convertPageTurnModeToString(pageTurnMode), @@ -2185,7 +2246,7 @@ extension ViewController { } private func pageTurnDefaultItems(applyTo pageTurnHandler: @escaping (PageTurnMode) -> Void) -> [SettingsMenuItem] { - let items: [PageTurnMode?] = [.leftToRight, .rightToLeft, .scroll, nil] + let items = PageTurnMode.allCases + [nil] return items.map { pageTurnMode in var title = "" if let mode = pageTurnMode { @@ -2273,9 +2334,119 @@ extension ViewController { } private func makeBackgroundColorTree() -> [SettingsMenuItem] { - return colorDefaultItems { [weak self] color in - self?.readerView.backgroundColor = color - self?.saveLocalState() + return colorDefaultItems { [self] color in + self.readerView.backgroundColor = color + self.saveLocalState() + } + } + + private func makeComicOptionTree(_ indexPath: IndexPath) -> [ComicOptionItem] { + let viewMetadataOption = ComicOptionItem( + title: "View Metadata", + children: nil, + destructive: false, + available: { true }, + action: { [self] in + let metadataVC = ComicMetadataViewController(comic: self.comics[indexPath.item].metadata) + let nav = UINavigationController(rootViewController: metadataVC) + nav.modalPresentationStyle = .formSheet + self.dismiss(animated: false) + self.present(nav, animated: false) + } + ) + let aniListOptionItems = aniListOptionItems(indexPath) + let aniListOption = ComicOptionItem( + title: "AniList", + children: aniListOptionItems, + destructive: false, + available: { [self] in return metadata.anilistId != nil && aniListToken != nil }, + action: { [weak self] in + self?.dismiss(animated: false) + } + ) + let deleteOption = ComicOptionItem( + title: "Delete", + children: nil, + destructive: true, + available: { true }, + action: { [self] in + do { + try self.fileManager.removeItem(at: self.comics[indexPath.item].path) + self.comics.remove(at: indexPath.item) + self.comicCollectionView.reloadData() + } catch { + let failureAlert = UIAlertController( + title: "Failed to remove directory.", + message: "Failed to recursively remove \(self.comics[indexPath.item].path)", + preferredStyle: .alert + ) + self.present(failureAlert, animated: false) + } + self.dismiss(animated: false) + } + ) + return [viewMetadataOption, aniListOption, deleteOption] + } + + private func aniListOptionItems(_ indexPath: IndexPath) -> [ComicOptionItem] { + let items = AniListAction.allCases + return items.map { action in + var children: [ComicOptionItem]? + switch action { + case .updateStatus: children = aniListUpdateStatusItems(indexPath) + case .fetchMetadata: print("") + case .track: print("") + } + return ComicOptionItem( + title: action.rawValue, children: children, + destructive: false, available: { true }, + action: { [weak self] in + self?.dismiss(animated: false) + } + ) + } + } + + private func aniListUpdateStatusItems(_ indexPath: IndexPath) -> [ComicOptionItem] { + let items = MediaListStatus.allCases + guard let mediaId = comics[indexPath.item].metadata.anilistId else { return [] } + return items.map { status in + ComicOptionItem( + title: status.rawValue, children: nil, + destructive: false, available: { true }, + action: { [weak self] in + self?.dismiss(animated: false) + guard let token = self?.aniListToken else { return } + AniListClient.mutateMediaListStatus( + accessToken: token, mediaId: UInt(mediaId), mediaStatus: status, completion: { _ in } + ) + } + ) + } + } + + private func getAnilistMediaStatus(item _: ComicOptionItem) { + guard let token = aniListToken else { return } + AniListClient.fetchViewer(accessToken: token) { result in + switch result { + case let .success(viewer): + print("Logged in as \(viewer.name) (id: \(viewer.id))") + guard let id = self.metadata.anilistId else { return } + AniListClient.fetchMediaStatus( + accessToken: token, id: UInt(id), + userId: UInt(viewer.id) + ) { result in + print("Tried to fetch for userId: \(viewer.id) and id: \(id)") + switch result { + case let .success(mediaStatus): + print("mediaStatus: \(mediaStatus)") + case let .failure(error): + print("Error: \(error)") + } + } + case let .failure(error): + print("Error: \(error)") + } } } } @@ -2326,6 +2497,21 @@ extension ViewController: UIPopoverPresentationControllerDelegate { present(nav, animated: false) } + @objc private func comicOptionLongPressed(_ indexPath: IndexPath) { + let root = ComicOptionViewController(title: "Comic", items: makeComicOptionTree(indexPath)) + let nav = UINavigationController(rootViewController: root) + nav.modalPresentationStyle = .popover + + if let popover = nav.popoverPresentationController, let cell = comicCollectionView.cellForItem(at: indexPath) { + popover.sourceView = cell + popover.sourceRect = cell.bounds + popover.permittedArrowDirections = .up + popover.delegate = self + } + + present(nav, animated: false) + } + func adaptivePresentationStyle(for _: UIPresentationController) -> UIModalPresentationStyle { return .none // keeps it a real popover on iPhone instead of a full-screen sheet } @@ -2335,3 +2521,243 @@ extension ViewController: UIPopoverPresentationControllerDelegate { return true } } + +struct AniListResponse: Decodable { + let data: T +} + +struct ViewerData: Decodable { + // swiftlint:disable identifier_name + let Viewer: Viewer + // swiftlint:enable identifier_name +} + +struct Viewer: Decodable { + let id: Int + let name: String +} + +struct MediaListData: Decodable { + // swiftlint:disable identifier_name + let MediaList: MediaList + // swiftlint:enable identifier_name +} + +struct MediaList: Decodable { + let status: MediaListStatus +} + +struct SaveMediaListEntryData: Decodable { + // swiftlint:disable identifier_name + let SaveMediaListEntry: SaveMediaListEntry + // swiftlint:enable identifier_name +} + +struct SaveMediaListEntry: Decodable { + let id: Int + let status: MediaListStatus +} + +enum MediaListStatus: String, Decodable, CaseIterable { + case current = "CURRENT" + case planning = "PLANNING" + case completed = "COMPLETED" + case dropped = "DROPPED" + case paused = "PAUSED" +} + +struct GraphQLBody: Encodable { + let query: String +} + +enum AniListClient { + static let endpoint = URL(string: "https://graphql.aniList.co")! + + enum AniListError: Error { + case badResponse + case requestFailed(status: Int, body: String) + case noData + } + + static func mutateMediaListStatus( + accessToken: String, + mediaId: UInt, + mediaStatus: MediaListStatus, + completion: @escaping (Result) -> Void + ) { + let mutation = """ + mutation ($mediaId: Int = \(mediaId), $status: MediaListStatus = \(mediaStatus.rawValue)) { + SaveMediaListEntry(mediaId: $mediaId, status: $status) { + id + status + } + } + """ + mutate( + accessToken: accessToken, mutation: mutation + ) { (result: Result, Error>) in + switch result { + case let .success(m): + let status = m.data.SaveMediaListEntry + completion(.success(status)) + case let .failure(error): + completion(.failure(error)) + print("Error: \(error)") + } + } + } + + static func fetchMediaStatus( + accessToken: String, + id: UInt, + userId: UInt, + completion: @escaping (Result) -> Void + ) { + let query = """ + query ($id: Int = \(id), $userId: Int = \(userId)) { + MediaList(mediaId: $id, userId: $userId) { + status + } + } + """ + fetchAuthenticated( + accessToken: accessToken, query: query + ) { (result: Result, Error>) in + switch result { + case let .success(m): + let status = m.data.MediaList.status + completion(.success(status)) + case let .failure(error): + completion(.failure(error)) + print("Error: \(error)") + } + } + } + + static func fetchViewer( + accessToken: String, + completion: @escaping (Result) -> Void + ) { + let query = """ + query { + Viewer { + id + name + } + } + """ + fetchAuthenticated( + accessToken: accessToken, query: query + ) { (result: Result, Error>) in + switch result { + case let .success(v): + let viewer = v.data.Viewer + completion(.success(viewer)) + case let .failure(error): + print("Error: \(error)") + } + } + } + + static func fetchAuthenticated( + accessToken: String, + query: String, + completion: @escaping (Result, Error>) -> Void + ) { + var request = URLRequest(url: endpoint) + request.httpMethod = "POST" + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + request.setValue("application/json", forHTTPHeaderField: "Accept") + request.setValue("Bearer \(accessToken)", forHTTPHeaderField: "Authorization") + + do { + request.httpBody = try JSONEncoder().encode(GraphQLBody(query: query)) + } catch { + completion(.failure(error)) + return + } + + let task = URLSession.shared.dataTask(with: request) { data, response, error in + if let error = error { + completion(.failure(error)) + return + } + + guard let http = response as? HTTPURLResponse else { + completion(.failure(AniListError.badResponse)) + return + } + + guard let data = data else { + completion(.failure(AniListError.noData)) + return + } + + guard (200 ... 299).contains(http.statusCode) else { + let body = String(data: data, encoding: .utf8) ?? "" + completion(.failure(AniListError.requestFailed(status: http.statusCode, body: body))) + return + } + + do { + let decoded = try JSONDecoder().decode(AniListResponse.self, from: data) + completion(.success(decoded)) + } catch { + completion(.failure(error)) + } + } + + task.resume() + } + + static func mutate( + accessToken: String, + mutation: String, + completion: @escaping (Result, Error>) -> Void + ) { + var request = URLRequest(url: endpoint) + request.httpMethod = "POST" + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + request.setValue("application/json", forHTTPHeaderField: "Accept") + request.setValue("Bearer \(accessToken)", forHTTPHeaderField: "Authorization") + + do { + request.httpBody = try JSONEncoder().encode(GraphQLBody(query: mutation)) + } catch { + completion(.failure(error)) + return + } + + let task = URLSession.shared.dataTask(with: request) { data, response, error in + if let error = error { + completion(.failure(error)) + return + } + + guard let http = response as? HTTPURLResponse else { + completion(.failure(AniListError.badResponse)) + return + } + + guard let data = data else { + completion(.failure(AniListError.noData)) + return + } + + guard (200 ... 299).contains(http.statusCode) else { + let body = String(data: data, encoding: .utf8) ?? "" + completion(.failure(AniListError.requestFailed(status: http.statusCode, body: body))) + return + } + + do { + let decoded = try JSONDecoder().decode(AniListResponse.self, from: data) + completion(.success(decoded)) + } catch { + completion(.failure(error)) + } + } + + task.resume() + } +} diff --git a/ImageViewer/metadata.fbs b/ImageViewer/metadata.fbs index 15e3aa4..c3ef1ac 100644 --- a/ImageViewer/metadata.fbs +++ b/ImageViewer/metadata.fbs @@ -60,6 +60,7 @@ table Metadata { end_release_date:Date; tags:[Tag]; description:string; + anilist_id:uint = null; volumes:[VolumeMetadata]; image_count:uint; } diff --git a/ImageViewer/metadata_generated.swift b/ImageViewer/metadata_generated.swift index cf529b3..de4c597 100644 --- a/ImageViewer/metadata_generated.swift +++ b/ImageViewer/metadata_generated.swift @@ -448,8 +448,9 @@ public struct Metadata: FlatBufferTable, FlatbuffersVectorInitializable, Verifia static let endReleaseDate: VOffset = 22 static let tags: VOffset = 24 static let description: VOffset = 26 - static let volumes: VOffset = 28 - static let imageCount: VOffset = 30 + static let anilistId: VOffset = 28 + static let volumes: VOffset = 30 + static let imageCount: VOffset = 32 } public var title: String! { let o = _accessor.offset(VT.title); return _accessor.string(at: o) } @@ -470,9 +471,10 @@ public struct Metadata: FlatBufferTable, FlatbuffersVectorInitializable, Verifia public var tags: FlatbufferVector { return _accessor.vector(at: VT.tags, byteSize: 4) } public var description: String? { let o = _accessor.offset(VT.description); return o == 0 ? nil : _accessor.string(at: o) } public var descriptionSegmentArray: [UInt8]? { return _accessor.getVector(at: VT.description) } + public var anilistId: UInt32? { let o = _accessor.offset(VT.anilistId); return o == 0 ? nil : _accessor.readBuffer(of: UInt32.self, at: o) } public var volumes: FlatbufferVector { return _accessor.vector(at: VT.volumes, byteSize: 4) } public var imageCount: UInt32 { let o = _accessor.offset(VT.imageCount); return o == 0 ? 0 : _accessor.readBuffer(of: UInt32.self, at: o) } - public static func startMetadata(_ fbb: inout FlatBufferBuilder) -> UOffset { fbb.startTable(with: 14) } + public static func startMetadata(_ fbb: inout FlatBufferBuilder) -> UOffset { fbb.startTable(with: 15) } public static func add(title: Offset, _ fbb: inout FlatBufferBuilder) { fbb.add(offset: title, at: VT.title) } public static func add(format: Format, _ fbb: inout FlatBufferBuilder) { fbb.add(element: format.rawValue, def: 0, at: VT.format) } public static func add(originalLanguage: Offset, _ fbb: inout FlatBufferBuilder) { fbb.add(offset: originalLanguage, at: VT.originalLanguage) } @@ -485,6 +487,7 @@ public struct Metadata: FlatBufferTable, FlatbuffersVectorInitializable, Verifia public static func add(endReleaseDate: Date?, _ fbb: inout FlatBufferBuilder) { guard let endReleaseDate = endReleaseDate else { return }; fbb.create(struct: endReleaseDate, position: VT.endReleaseDate) } public static func addVectorOf(tags: Offset, _ fbb: inout FlatBufferBuilder) { fbb.add(offset: tags, at: VT.tags) } public static func add(description: Offset, _ fbb: inout FlatBufferBuilder) { fbb.add(offset: description, at: VT.description) } + public static func add(anilistId: UInt32?, _ fbb: inout FlatBufferBuilder) { fbb.add(element: anilistId, at: VT.anilistId) } public static func addVectorOf(volumes: Offset, _ fbb: inout FlatBufferBuilder) { fbb.add(offset: volumes, at: VT.volumes) } public static func add(imageCount: UInt32, _ fbb: inout FlatBufferBuilder) { fbb.add(element: imageCount, def: 0, at: VT.imageCount) } public static func endMetadata(_ fbb: inout FlatBufferBuilder, start: UOffset) -> Offset { let end = Offset(offset: fbb.endTable(at: start)); fbb.require(table: end, fields: [4]); return end } @@ -502,6 +505,7 @@ public struct Metadata: FlatBufferTable, FlatbuffersVectorInitializable, Verifia endReleaseDate: Date? = nil, tagsVectorOffset tags: Offset = Offset(), descriptionOffset description: Offset = Offset(), + anilistId: UInt32? = nil, volumesVectorOffset volumes: Offset = Offset(), imageCount: UInt32 = 0 ) -> Offset { @@ -518,6 +522,7 @@ public struct Metadata: FlatBufferTable, FlatbuffersVectorInitializable, Verifia Metadata.add(endReleaseDate: endReleaseDate, &fbb) Metadata.addVectorOf(tags: tags, &fbb) Metadata.add(description: description, &fbb) + Metadata.add(anilistId: anilistId, &fbb) Metadata.addVectorOf(volumes: volumes, &fbb) Metadata.add(imageCount: imageCount, &fbb) return Metadata.endMetadata(&fbb, start: __start) @@ -537,6 +542,7 @@ public struct Metadata: FlatBufferTable, FlatbuffersVectorInitializable, Verifia try _v.visit(field: VT.endReleaseDate, fieldName: "endReleaseDate", required: false, type: Date.self) try _v.visit(field: VT.tags, fieldName: "tags", required: false, type: ForwardOffset, Tag>>.self) try _v.visit(field: VT.description, fieldName: "description", required: false, type: ForwardOffset.self) + try _v.visit(field: VT.anilistId, fieldName: "anilistId", required: false, type: UInt32.self) try _v.visit(field: VT.volumes, fieldName: "volumes", required: false, type: ForwardOffset, VolumeMetadata>>.self) try _v.visit(field: VT.imageCount, fieldName: "imageCount", required: false, type: UInt32.self) _v.finish() -- 2.54.0 From 3a0d0940700409f2fdfaf363f89af12a1d251819 Mon Sep 17 00:00:00 2001 From: Vegard Bieker Matthey Date: Mon, 3 Aug 2026 00:01:28 +0200 Subject: [PATCH 2/6] feat: AniList tracking --- ImageViewer/ViewController.swift | 85 ++++++++++++++++++++++++++++++-- 1 file changed, 82 insertions(+), 3 deletions(-) diff --git a/ImageViewer/ViewController.swift b/ImageViewer/ViewController.swift index b886e45..dec394e 100644 --- a/ImageViewer/ViewController.swift +++ b/ImageViewer/ViewController.swift @@ -47,6 +47,7 @@ struct LocalState: Codable { var progress: ReadProgress var backgroundColor: String? var useDefaultPageTurnMode: Bool + var aniListTracking: Bool } struct Settings: Codable { @@ -80,6 +81,7 @@ final class ViewController: UIViewController, UIGestureRecognizerDelegate { var currentPath: URL! var lastLayoutSize: CGSize = .zero var aniListToken: String? = getAniListToken() + var aniListTracking = false var leftTap: UITapGestureRecognizer! var rightTap: UITapGestureRecognizer! @@ -798,7 +800,8 @@ final class ViewController: UIViewController, UIGestureRecognizerDelegate { try JSONEncoder().encode( LocalState( progress: newProgress, backgroundColor: self.convertColorToString(color), - useDefaultPageTurnMode: self.useDefaultPageTurnMode + useDefaultPageTurnMode: self.useDefaultPageTurnMode, + aniListTracking: self.aniListTracking ) ).write( to: self.currentPath.appendingPathComponent("state.json") @@ -820,6 +823,7 @@ final class ViewController: UIViewController, UIGestureRecognizerDelegate { let json = try Data(String(contentsOfFile: path).utf8) let local = try JSONDecoder().decode(LocalState.self, from: json) useDefaultPageTurnMode = local.useDefaultPageTurnMode + aniListTracking = local.aniListTracking switch local.progress { case let .leftToRight(volumeIndex, chapterIndex, imageIndex): progress.v = volumeIndex @@ -894,6 +898,7 @@ final class ViewController: UIViewController, UIGestureRecognizerDelegate { for _ in 0 ..< indexPath.item { newProgress = getProgressIndicesFromTurn(turn: .next, progress: newProgress) } + aniListProgressUpdate(progress: progress, newProgress: newProgress) progress = newProgress } if scrollingCollectionView.isHidden == false { @@ -1253,6 +1258,25 @@ final class ViewController: UIViewController, UIGestureRecognizerDelegate { ]) } + func aniListProgressUpdate(progress: ProgressIndices, newProgress: ProgressIndices) { + if progress.v > newProgress.v || progress.c > newProgress.c { + if let token = aniListToken, let id = metadata.anilistId { + var cProgress = 0 + var vProgress = 0 + while progress.v > vProgress { + cProgress += metadata.volumes[vProgress].chapters.count + vProgress += 1 + } + cProgress += progress.c + 1 + vProgress += 1 + AniListClient.mutateProgress( + accessToken: token, mediaId: UInt(id), + progress: UInt(cProgress), progressVolumes: UInt(vProgress), completion: { _ in } + ) + } + } + } + func changeImage(turn: PageTurn) { let scaling = UIView.ContentMode.scaleAspectFit var newProgress = progress @@ -1260,6 +1284,7 @@ final class ViewController: UIViewController, UIGestureRecognizerDelegate { if newProgress.v == progress.v, newProgress.c == progress.c, newProgress.i == progress.i { return } + aniListProgressUpdate(progress: progress, newProgress: newProgress) progress = newProgress updateInfo() let path = getImagePath(progress) @@ -2392,15 +2417,25 @@ extension ViewController { let items = AniListAction.allCases return items.map { action in var children: [ComicOptionItem]? + var title = action.rawValue switch action { case .updateStatus: children = aniListUpdateStatusItems(indexPath) case .fetchMetadata: print("") - case .track: print("") + case .track: + if aniListTracking { + title += " (Enabled)" + } else { + title += " (Disabled)" + } } return ComicOptionItem( - title: action.rawValue, children: children, + title: title, children: children, destructive: false, available: { true }, action: { [weak self] in + if action == .track { + self?.aniListTracking.toggle() + self?.saveLocalState() + } self?.dismiss(animated: false) } ) @@ -2558,6 +2593,18 @@ struct SaveMediaListEntry: Decodable { let status: MediaListStatus } +struct SaveMediaListProgressEntryData: Decodable { + // swiftlint:disable identifier_name + let SaveMediaListEntry: SaveMediaListProgressEntry + // swiftlint:enable identifier_name +} + +struct SaveMediaListProgressEntry: Decodable { + let id: Int + let progress: Int + let progressVolumes: Int +} + enum MediaListStatus: String, Decodable, CaseIterable { case current = "CURRENT" case planning = "PLANNING" @@ -2579,6 +2626,38 @@ enum AniListClient { case noData } + static func mutateProgress( + accessToken: String, + mediaId: UInt, + progress: UInt, + progressVolumes: UInt, + completion: @escaping (Result) -> Void + ) { + let mutation = """ + mutation ( + $mediaId: Int = \(mediaId), $progress: Int = \(progress), $progressVolumes: Int = \(progressVolumes) + ) { + SaveMediaListEntry(mediaId: $mediaId, progress: $progress, progressVolumes: $progressVolumes) { + id + progress + progressVolumes + } + } + """ + mutate( + accessToken: accessToken, mutation: mutation + ) { (result: Result, Error>) in + switch result { + case let .success(m): + let status = m.data.SaveMediaListEntry + completion(.success(status)) + case let .failure(error): + completion(.failure(error)) + print("Error: \(error)") + } + } + } + static func mutateMediaListStatus( accessToken: String, mediaId: UInt, -- 2.54.0 From 5dd596f0fbd30bfbc3d6fb65720aaaf44246fff8 Mon Sep 17 00:00:00 2001 From: Vegard Bieker Matthey Date: Mon, 3 Aug 2026 01:50:10 +0200 Subject: [PATCH 3/6] tracking further fixes --- ImageViewer/ViewController.swift | 28 +++++++++++++++++----------- 1 file changed, 17 insertions(+), 11 deletions(-) diff --git a/ImageViewer/ViewController.swift b/ImageViewer/ViewController.swift index dec394e..cfac009 100644 --- a/ImageViewer/ViewController.swift +++ b/ImageViewer/ViewController.swift @@ -81,7 +81,8 @@ final class ViewController: UIViewController, UIGestureRecognizerDelegate { var currentPath: URL! var lastLayoutSize: CGSize = .zero var aniListToken: String? = getAniListToken() - var aniListTracking = false + var aniListTracking: [Int: Bool] = [:] + var currentItemIndex = 0 var leftTap: UITapGestureRecognizer! var rightTap: UITapGestureRecognizer! @@ -801,7 +802,7 @@ final class ViewController: UIViewController, UIGestureRecognizerDelegate { LocalState( progress: newProgress, backgroundColor: self.convertColorToString(color), useDefaultPageTurnMode: self.useDefaultPageTurnMode, - aniListTracking: self.aniListTracking + aniListTracking: self.aniListTracking[self.currentItemIndex] ?? false ) ).write( to: self.currentPath.appendingPathComponent("state.json") @@ -823,7 +824,7 @@ final class ViewController: UIViewController, UIGestureRecognizerDelegate { let json = try Data(String(contentsOfFile: path).utf8) let local = try JSONDecoder().decode(LocalState.self, from: json) useDefaultPageTurnMode = local.useDefaultPageTurnMode - aniListTracking = local.aniListTracking + aniListTracking[currentItemIndex] = local.aniListTracking switch local.progress { case let .leftToRight(volumeIndex, chapterIndex, imageIndex): progress.v = volumeIndex @@ -1624,6 +1625,7 @@ extension ViewController: UICollectionViewDataSource, UICollectionViewDelegateFl ) { if collectionView == comicCollectionView { let selectedComic = comics[indexPath.item] + currentItemIndex = indexPath.item readComic(name: selectedComic.metadata.title!) } @@ -2384,7 +2386,9 @@ extension ViewController { title: "AniList", children: aniListOptionItems, destructive: false, - available: { [self] in return metadata.anilistId != nil && aniListToken != nil }, + available: { [self] in + return comics[indexPath.item].metadata.anilistId != nil && aniListToken != nil || true + }, action: { [weak self] in self?.dismiss(animated: false) } @@ -2420,20 +2424,22 @@ extension ViewController { var title = action.rawValue switch action { case .updateStatus: children = aniListUpdateStatusItems(indexPath) - case .fetchMetadata: print("") + case .fetchMetadata: _ = 0 case .track: - if aniListTracking { - title += " (Enabled)" - } else { - title += " (Disabled)" - } + if aniListTracking[indexPath.item] ?? false { + title += " (Enabled)" + } else { + title += " (Disabled)" + } } return ComicOptionItem( title: title, children: children, destructive: false, available: { true }, action: { [weak self] in if action == .track { - self?.aniListTracking.toggle() + var v = self?.aniListTracking[indexPath.item] ?? false + v.toggle() + self?.aniListTracking[indexPath.item] = v self?.saveLocalState() } self?.dismiss(animated: false) -- 2.54.0 From 9a34d4e95299a337806c2234b40074410efbc515 Mon Sep 17 00:00:00 2001 From: Vegard Bieker Matthey Date: Mon, 3 Aug 2026 21:31:35 +0200 Subject: [PATCH 4/6] fix: better local state handling --- ImageViewer/ViewController.swift | 184 ++++++++++++++----------------- 1 file changed, 81 insertions(+), 103 deletions(-) diff --git a/ImageViewer/ViewController.swift b/ImageViewer/ViewController.swift index cfac009..ea97ff8 100644 --- a/ImageViewer/ViewController.swift +++ b/ImageViewer/ViewController.swift @@ -21,10 +21,10 @@ enum ReadProgress: Codable { case scroll(CGPoint) } -struct ProgressIndices { - var v: Int - var c: Int - var i: Int +struct ProgressIndices: Codable { + var v: Int = 0 + var c: Int = 0 + var i: Int = 0 } enum PageTurnMode: Codable, CaseIterable { @@ -43,11 +43,13 @@ struct GlobalState: Codable { var comicName: String? } +/// nil indicates a default should be used, aka what is in settings. struct LocalState: Codable { - var progress: ReadProgress + var progress: ProgressIndices = ProgressIndices() + var mode: PageTurnMode? var backgroundColor: String? - var useDefaultPageTurnMode: Bool - var aniListTracking: Bool + var aniListTracking: Bool = false + var scrollPos: CGPoint = CGPoint() } struct Settings: Codable { @@ -63,7 +65,6 @@ final class ViewController: UIViewController, UIGestureRecognizerDelegate { var readerView = UIView() @IBOutlet var scrollingCollectionView: UICollectionView! - var scrollPos: CGPoint! var hasSetContentOffset = false var pendingAlert: UIAlertController? @@ -72,16 +73,13 @@ final class ViewController: UIViewController, UIGestureRecognizerDelegate { let dragRangeForFullBrightness: CGFloat = 300 var imageView = UIImageView() - var mode = PageTurnMode.leftToRight - var useDefaultPageTurnMode = true - var useDefaultBackgroundColor = true var metadataList: [URL: Metadata] = [:] + // directory name is key, indexPath.item can change. + var state: [String: LocalState] = [:] var metadata: Metadata! - var progress = ProgressIndices(v: 0, c: 0, i: 0) var currentPath: URL! var lastLayoutSize: CGSize = .zero var aniListToken: String? = getAniListToken() - var aniListTracking: [Int: Bool] = [:] var currentItemIndex = 0 var leftTap: UITapGestureRecognizer! @@ -126,9 +124,9 @@ final class ViewController: UIViewController, UIGestureRecognizerDelegate { if let alert = pendingAlert { present(alert, animated: false, completion: nil) } - if !hasSetContentOffset, scrollPos != nil { + if !hasSetContentOffset, let scrollPos = state[currentPath.lastPathComponent]?.scrollPos { let screenSize = UIScreen.main.bounds.size - var offset = scrollPos! + var offset = scrollPos if screenSize.width > screenSize.height { offset.y *= (screenSize.width / screenSize.height) } @@ -142,6 +140,20 @@ final class ViewController: UIViewController, UIGestureRecognizerDelegate { } } + func getMode() -> PageTurnMode { + if let state = state[currentPath.lastPathComponent] { + return state.mode ?? settings.defaultPageTurnMode + } + return settings.defaultPageTurnMode + } + + func getProgress() -> ProgressIndices { + if let state = state[currentPath.lastPathComponent] { + return state.progress + } + return ProgressIndices() + } + func setup() { do { if try fileManager.contentsOfDirectory(atPath: documentsURL.path).isEmpty { @@ -170,7 +182,6 @@ final class ViewController: UIViewController, UIGestureRecognizerDelegate { readerView.bottomAnchor.constraint(equalTo: view.bottomAnchor), readerView.widthAnchor.constraint(equalTo: view.widthAnchor), ]) - mode = settings.defaultPageTurnMode setupScrollingCollectionView() loadComics() setupImageView() @@ -285,7 +296,7 @@ final class ViewController: UIViewController, UIGestureRecognizerDelegate { } metadataList[dir] = metadata loadLocalState() - let volCovP = ProgressIndices(v: progress.v, c: 0, i: 0) + let volCovP = ProgressIndices(v: state[currentPath.lastPathComponent]!.progress.v, c: 0, i: 0) let archivePath = getArchiveURL(volCovP.v) let archive = try Archive(url: archivePath, accessMode: .read) let filename = metadata.volumes[volCovP.v].chapters[volCovP.c].images[volCovP.i].filename! @@ -772,37 +783,30 @@ final class ViewController: UIViewController, UIGestureRecognizerDelegate { } func saveLocalState() { + guard let currentDir = currentPath?.lastPathComponent else { print("did not save currentPath nil"); return } + guard let currentState = state[currentDir] else { print("did not save currentState nil"); return } + let progress = currentState.progress + let mode = currentState.mode let color = readerView.backgroundColor ?? .black + let aniListTracking = currentState.aniListTracking + let screenSize = UIScreen.main.bounds.size var scrollOffset = scrollingCollectionView.contentOffset if screenSize.width > screenSize.height { scrollOffset.y *= (screenSize.height / screenSize.width) } - var newProgress = - ReadProgress.leftToRight( - volumeIndex: progress.v, chapterIndex: progress.c, - imageIndex: progress.i - ) + // TODO: Calucate the offset based on progress, to allow seamless switching. + if mode != .scroll {} - switch mode { - case .leftToRight: - newProgress = ReadProgress.leftToRight( - volumeIndex: progress.v, chapterIndex: progress.c, - imageIndex: progress.i - ) - case .rightToLeft: newProgress = ReadProgress.rightToLeft( - volumeIndex: progress.v, chapterIndex: progress.c, - imageIndex: progress.i - ) - case .scroll: newProgress = ReadProgress.scroll(scrollOffset) - } queue.async { do { try JSONEncoder().encode( LocalState( - progress: newProgress, backgroundColor: self.convertColorToString(color), - useDefaultPageTurnMode: self.useDefaultPageTurnMode, - aniListTracking: self.aniListTracking[self.currentItemIndex] ?? false + progress: progress, + mode: mode, + backgroundColor: self.convertColorToString(color), + aniListTracking: aniListTracking, + scrollPos: scrollOffset ) ).write( to: self.currentPath.appendingPathComponent("state.json") @@ -814,59 +818,22 @@ final class ViewController: UIViewController, UIGestureRecognizerDelegate { } func loadLocalState() { + if state[currentPath.lastPathComponent] != nil { return } do { let path = currentPath.appendingPathComponent("state.json").path if !fileManager.fileExists(atPath: path) { - progress = ProgressIndices(v: 0, c: 0, i: 0) - mode = settings.defaultPageTurnMode + state[currentPath.lastPathComponent] = LocalState() return } let json = try Data(String(contentsOfFile: path).utf8) let local = try JSONDecoder().decode(LocalState.self, from: json) - useDefaultPageTurnMode = local.useDefaultPageTurnMode - aniListTracking[currentItemIndex] = local.aniListTracking - switch local.progress { - case let .leftToRight(volumeIndex, chapterIndex, imageIndex): - progress.v = volumeIndex - progress.c = chapterIndex - progress.i = imageIndex - mode = useDefaultPageTurnMode ? settings.defaultPageTurnMode : .leftToRight - case let .rightToLeft(volumeIndex, chapterIndex, imageIndex): - progress.v = volumeIndex - progress.c = chapterIndex - progress.i = imageIndex - mode = useDefaultPageTurnMode ? settings.defaultPageTurnMode : .rightToLeft - case let .scroll(point): - if scrollPos == nil { - scrollPos = point - let screenSize = UIScreen.main.bounds.size - var scrollOffset = point - if screenSize.width > screenSize.height { - scrollOffset.y *= (screenSize.width / screenSize.height) - } - if let indexPath = scrollingCollectionView.indexPathForItem(at: scrollOffset) { - var theProgress = ProgressIndices(v: 0, c: 0, i: 0) - for _ in 0 ..< indexPath.item { - theProgress = getProgressIndicesFromTurn( - turn: .next, progress: theProgress - ) - } - progress = theProgress - } - } - mode = useDefaultPageTurnMode ? settings.defaultPageTurnMode : .scroll - } - if let color = local.backgroundColor { - readerView.backgroundColor = convertStringToColor(color) - useDefaultBackgroundColor = false - } else { - readerView.backgroundColor = convertStringToColor(settings.defaultReadingBackgroundColor) - useDefaultBackgroundColor = true - } + state[currentPath.lastPathComponent] = local } catch let decodingError as DecodingError { - print(decodingError.errorDescription!) + print("Failed to decode state.json, setting default: \(decodingError.errorDescription ?? "")") + state[currentPath.lastPathComponent] = LocalState() } catch { - print("Unexpected error: \(error)") + print("Failed to decode state.json, setting default: \(error)") + state[currentPath.lastPathComponent] = LocalState() } } @@ -899,8 +866,9 @@ final class ViewController: UIViewController, UIGestureRecognizerDelegate { for _ in 0 ..< indexPath.item { newProgress = getProgressIndicesFromTurn(turn: .next, progress: newProgress) } + guard let progress = state[currentPath.lastPathComponent]?.progress else { return } aniListProgressUpdate(progress: progress, newProgress: newProgress) - progress = newProgress + state[currentPath.lastPathComponent]!.progress = newProgress } if scrollingCollectionView.isHidden == false { saveLocalState() @@ -912,10 +880,11 @@ final class ViewController: UIViewController, UIGestureRecognizerDelegate { guard view.bounds.size != lastLayoutSize else { return } lastLayoutSize = UIScreen.main.bounds.size if scrollingCollectionView.isHidden - || mode != .scroll + || getMode() != .scroll || scrollingCollectionView.contentSize == .zero { if metadata == nil { return } + let progress = getProgress() imageLoader.loadImage( archiveURL: getArchiveURL(progress.v), filename: getImagePath(progress), @@ -936,6 +905,7 @@ final class ViewController: UIViewController, UIGestureRecognizerDelegate { if let path = getPathFromComicName(name: name) { currentPath = path metadata = metadataList[path] + let mode = getMode() globalState.comicName = metadata.title saveGlobalState() @@ -1187,7 +1157,9 @@ final class ViewController: UIViewController, UIGestureRecognizerDelegate { let point = gesture.location(in: comicCollectionView) guard let indexPath = comicCollectionView.indexPathForItem(at: point) else { return } + currentPath = comics[indexPath.item].path + loadLocalState() comicOptionLongPressed(indexPath) } @@ -1196,7 +1168,7 @@ final class ViewController: UIViewController, UIGestureRecognizerDelegate { } @objc func handleLeftTap() { - switch mode { + switch getMode() { case .rightToLeft: changeImage(turn: .next) case .leftToRight: changeImage(turn: .previous) case .scroll: break @@ -1204,7 +1176,7 @@ final class ViewController: UIViewController, UIGestureRecognizerDelegate { } @objc func handleRightTap() { - switch mode { + switch getMode() { case .rightToLeft: changeImage(turn: .previous) case .leftToRight: changeImage(turn: .next) case .scroll: break @@ -1214,13 +1186,13 @@ final class ViewController: UIViewController, UIGestureRecognizerDelegate { @objc func handleSwipe(_ gesture: UISwipeGestureRecognizer) { switch gesture.direction { case .left: - switch mode { + switch getMode() { case .rightToLeft: changeImage(turn: .previous) case .leftToRight: changeImage(turn: .next) case .scroll: break } case .right: - switch mode { + switch getMode() { case .rightToLeft: changeImage(turn: .next) case .leftToRight: changeImage(turn: .previous) case .scroll: break @@ -1280,13 +1252,14 @@ final class ViewController: UIViewController, UIGestureRecognizerDelegate { func changeImage(turn: PageTurn) { let scaling = UIView.ContentMode.scaleAspectFit + let progress = getProgress() var newProgress = progress newProgress = getProgressIndicesFromTurn(turn: turn, progress: newProgress) if newProgress.v == progress.v, newProgress.c == progress.c, newProgress.i == progress.i { return } aniListProgressUpdate(progress: progress, newProgress: newProgress) - progress = newProgress + state[currentPath.lastPathComponent]!.progress = newProgress updateInfo() let path = getImagePath(progress) @@ -1326,6 +1299,7 @@ final class ViewController: UIViewController, UIGestureRecognizerDelegate { func updateInfo() { if metadata == nil { return } + let progress = getProgress() var text = "\(metadata.title ?? "")\n" @@ -1427,6 +1401,8 @@ final class ViewController: UIViewController, UIGestureRecognizerDelegate { } func setImages() { + let mode = getMode() + let progress = getProgress() let scaling: UIView.ContentMode = mode == .scroll ? .scaleAspectFill : .scaleAspectFit imageLoader.loadImage( archiveURL: getArchiveURL(progress.v), @@ -1560,7 +1536,7 @@ extension ViewController: UICollectionViewDataSource, UICollectionViewDelegateFl else { return ScrollingImageCell() } - if mode != .scroll { return cell } + if getMode() != .scroll { return cell } if metadata == nil { print("metadata is nil, should probably not be the case") return cell @@ -1571,6 +1547,7 @@ extension ViewController: UICollectionViewDataSource, UICollectionViewDelegateFl newProgress = getProgressIndicesFromTurn(turn: .next, progress: newProgress) } } + let progress = getProgress() imageLoader.loadImage( archiveURL: getArchiveURL(progress.v), filename: getImagePath(newProgress), @@ -2244,11 +2221,12 @@ extension ViewController { return SettingsMenuItem( title: title, children: nil, action: { [self] in - self.useDefaultBackgroundColor = color == nil + self.state[currentPath.lastPathComponent]!.backgroundColor = color == nil + ? nil : convertColorToString(color!) colorHandler(color ?? self.convertStringToColor(settings.defaultReadingBackgroundColor)) self.dismiss(animated: false) }, selected: { [self] in - if self.useDefaultBackgroundColor { return color == nil } + if self.state[currentPath.lastPathComponent]!.backgroundColor == nil { return color == nil } return color == self.readerView.backgroundColor && color != nil } ) @@ -2266,7 +2244,7 @@ extension ViewController { self.dismiss(animated: false) }, selected: { [self] in - return self.mode == pageTurnMode + return self.getMode() == pageTurnMode } ) } @@ -2284,14 +2262,12 @@ extension ViewController { return SettingsMenuItem( title: title, children: nil, action: { [self] in - self.useDefaultPageTurnMode = pageTurnMode == nil - let mode = pageTurnMode ?? self.settings.defaultPageTurnMode + let mode = getMode() pageTurnHandler(mode) self.dismiss(animated: false) }, selected: { [self] in - if self.useDefaultPageTurnMode { return pageTurnMode == nil } - return self.mode == pageTurnMode && pageTurnMode != nil + return self.getMode() == pageTurnMode } ) } @@ -2334,9 +2310,11 @@ extension ViewController { private func makePageTurnTree() -> [SettingsMenuItem] { return pageTurnDefaultItems { [self] pageTurnMode in + let mode = getMode() + let progress = getProgress() if mode == pageTurnMode { return } let scrollSwitch = mode != .scroll && pageTurnMode != .scroll - mode = pageTurnMode + state[currentPath.lastPathComponent]!.mode = pageTurnMode saveLocalState() if scrollSwitch { return } let archiveURL = getArchiveURL(progress.v) @@ -2426,7 +2404,7 @@ extension ViewController { case .updateStatus: children = aniListUpdateStatusItems(indexPath) case .fetchMetadata: _ = 0 case .track: - if aniListTracking[indexPath.item] ?? false { + if state[currentPath.lastPathComponent]!.aniListTracking { title += " (Enabled)" } else { title += " (Disabled)" @@ -2435,14 +2413,14 @@ extension ViewController { return ComicOptionItem( title: title, children: children, destructive: false, available: { true }, - action: { [weak self] in + action: { [self] in if action == .track { - var v = self?.aniListTracking[indexPath.item] ?? false + var v = self.state[currentPath.lastPathComponent]!.aniListTracking v.toggle() - self?.aniListTracking[indexPath.item] = v - self?.saveLocalState() + self.state[self.currentPath.lastPathComponent]!.aniListTracking = v + self.saveLocalState() } - self?.dismiss(animated: false) + self.dismiss(animated: false) } ) } -- 2.54.0 From c3b04e0a6bb5fcdee72fbeff93235ef0ebde48ee Mon Sep 17 00:00:00 2001 From: Vegard Bieker Matthey Date: Tue, 4 Aug 2026 01:48:03 +0200 Subject: [PATCH 5/6] feat: anilist metadata fetch --- .swiftlint.yml | 4 +- ImageViewer/ViewController.swift | 323 ++++++++++++++++++++++++++- ImageViewer/metadata.fbs | 10 +- ImageViewer/metadata_generated.swift | 30 +-- 4 files changed, 338 insertions(+), 29 deletions(-) diff --git a/.swiftlint.yml b/.swiftlint.yml index a4e2ac1..e06f801 100644 --- a/.swiftlint.yml +++ b/.swiftlint.yml @@ -10,8 +10,8 @@ identifier_name: min_length: 1 allowed_symbols: ["_"] file_length: - error: 10000 - warning: 3000 + error: 100000 + warning: 10000 cyclomatic_complexity: warning: 50 error: 100 diff --git a/ImageViewer/ViewController.swift b/ImageViewer/ViewController.swift index ea97ff8..55c6591 100644 --- a/ImageViewer/ViewController.swift +++ b/ImageViewer/ViewController.swift @@ -45,11 +45,11 @@ struct GlobalState: Codable { /// nil indicates a default should be used, aka what is in settings. struct LocalState: Codable { - var progress: ProgressIndices = ProgressIndices() + var progress: ProgressIndices = .init() var mode: PageTurnMode? var backgroundColor: String? var aniListTracking: Bool = false - var scrollPos: CGPoint = CGPoint() + var scrollPos: CGPoint = .init() } struct Settings: Codable { @@ -1984,13 +1984,17 @@ class ComicMetadataViewController: UITableViewController { switch indexPath.row { case 0: cell.textLabel?.text = "Title"; cell.detailTextLabel?.text = comic.title case 1: cell.textLabel?.text = "AniList ID"; cell.detailTextLabel?.text = anId - case 2: cell.textLabel?.text = "Format"; cell.detailTextLabel?.text = "\(comic.format)" + case 2: cell.textLabel?.text = "Format"; cell.detailTextLabel?.text = comic.format != nil + ? "\(comic.format!)" : "" case 3: cell.textLabel?.text = "Original Language"; cell.detailTextLabel?.text = comic.originalLanguage case 4: cell.textLabel?.text = "Publication Demographic" - cell.detailTextLabel?.text = "\(comic.publicationDemographic)" + cell.detailTextLabel?.text = comic.publicationDemographic != nil + ? "\(comic.publicationDemographic!)" : "" case 5: cell.textLabel?.text = "Country of Origin"; cell.detailTextLabel?.text = comic.countryOfOrigin - case 6: cell.textLabel?.text = "Status"; cell.detailTextLabel?.text = "\(comic.status)" - case 7: cell.textLabel?.text = "Content Rating"; cell.detailTextLabel?.text = "\(comic.contentRating)" + case 6: cell.textLabel?.text = "Status"; cell.detailTextLabel?.text = comic.status != nil + ? "\(comic.status!)" : "" + case 7: cell.textLabel?.text = "Content Rating"; cell.detailTextLabel?.text = comic.contentRating != nil + ? "\(comic.contentRating!)" : "" case 8: cell.textLabel?.text = "First Release" cell.detailTextLabel?.text = "\(dateToString(comic.startReleaseDate))" case 9: cell.textLabel?.text = "Last Release" @@ -2365,7 +2369,7 @@ extension ViewController { children: aniListOptionItems, destructive: false, available: { [self] in - return comics[indexPath.item].metadata.anilistId != nil && aniListToken != nil || true + return comics[indexPath.item].metadata.anilistId != nil && aniListToken != nil }, action: { [weak self] in self?.dismiss(animated: false) @@ -2420,6 +2424,16 @@ extension ViewController { self.state[self.currentPath.lastPathComponent]!.aniListTracking = v self.saveLocalState() } + if action == .fetchMetadata { + if let id = comics[indexPath.item].metadata.anilistId { + AniListClient.fetchMetadata(id: UInt(id)) { result in + switch result { + case let .success(m): self.rebuildMetadata(m, indexPath: indexPath) + case .failure: break + } + } + } + } self.dismiss(animated: false) } ) @@ -2468,6 +2482,123 @@ extension ViewController { } } } + + private func rebuildMetadata(_ m: MediaMetadata, indexPath: IndexPath) { + let alert = UIAlertController( + title: "Failed to rebuild metadata", + message: "This is an alert.", + preferredStyle: .alert + ) + alert.addAction(UIAlertAction(title: "OK", style: .default, handler: nil)) + + let endDate: Date! + if let d = m.endDate, let year = d.year, let month = d.month, let day = d.day { + endDate = Date(year: UInt16(year), month: UInt8(month), day: UInt8(day)) + } else { endDate = Date(year: 0, month: 0, day: 0) } + let startDate: Date! + if let d = m.startDate, let year = d.year, let month = d.month, let day = d.day { + startDate = Date(year: UInt16(year), month: UInt8(month), day: UInt8(day)) + } else { startDate = Date(year: 0, month: 0, day: 0) } + + let nSource = Source(from: m.source) + let nFormat = Format(from: m.format) + let nStatus = Status(from: m.status) + let pM = comics[indexPath.item].metadata + var volumes: [Offset] = [] + var currentChapters: [Offset] = [] + var currentImages: [Offset] = [] + var builder = FlatBufferBuilder(initialSize: 1024) + for volume in pM.volumes { + for chapter in volume.chapters { + for image in chapter.images { + let m_filename = builder.create(string: image.filename) + let imageMetadata = ImageMetadata.createImageMetadata( + &builder, + doublePage: image.doublePage, + filenameOffset: m_filename, + firstPage: image.firstPage, + size: image.size + ) + currentImages.append(imageMetadata) + } + let chapterName = builder.create(string: chapter.name) + let imagesOffset = builder.createVector(ofOffsets: currentImages) + let chapterMetadata = ChapterMetadata.createChapterMetadata( + &builder, + chapter: chapter.chapter, + nameOffset: chapterName, + imagesVectorOffset: imagesOffset + ) + currentChapters.append(chapterMetadata) + currentImages = [] + } + let volumeTitle = builder.create(string: volume.title) + let volumeURLOffset = builder.create(string: volume.archive) + let chaptersOffset = builder.createVector(ofOffsets: currentChapters) + let volumeMetadata = VolumeMetadata.createVolumeMetadata( + &builder, + volume: volume.volume, + titleOffset: volumeTitle, + archiveOffset: volumeURLOffset, + chaptersVectorOffset: chaptersOffset + ) + + volumes.append(volumeMetadata) + currentChapters = [] + } + + let title = builder.create(string: m.title?.english) + let originalLanguage = builder.create(string: pM.originalLanguage) + let countryOfOrigin = builder.create(string: pM.countryOfOrigin) + let description = builder.create(string: m.description) + let tags = builder.createVector(ofOffsets: []) + let volumeOffsets = builder.createVector(ofOffsets: volumes) + let metadata = Metadata.createMetadata( + &builder, + titleOffset: title, + format: nFormat, + originalLanguageOffset: originalLanguage, + countryOfOriginOffset: countryOfOrigin, + publicationDemographic: pM.publicationDemographic, + status: nStatus, + contentRating: pM.contentRating, + source: nSource, + startReleaseDate: startDate ?? Date(year: 0, month: 0, day: 0), + endReleaseDate: endDate ?? Date(year: 0, month: 0, day: 0), + tagsVectorOffset: tags, + descriptionOffset: description, + anilistId: pM.anilistId, + volumesVectorOffset: volumeOffsets, + imageCount: pM.imageCount, + ) + + builder.finish(offset: metadata) + do { + try builder.data.write(to: currentPath.appendingPathComponent(metadataFilename)) + } catch { + alert.message = String(format: "Failed to write metadata file at: \(metadataFilename)") + DispatchQueue.main.async { + self.present(alert, animated: false) + } + } + let metadataPath = currentPath.appendingPathComponent(metadataFilename) + guard let data = try? Data(contentsOf: metadataPath) else { + alert.message = String(format: "Failed to read metadata file at: \(metadataPath)") + DispatchQueue.main.async { + self.present(alert, animated: false) + } + return + } + var byteBuffer = ByteBuffer(data: data) + guard let newMetadata: Metadata = try? getCheckedRoot(byteBuffer: &byteBuffer) else { + alert.message = String(format: "Failed to load flatbuffer file at: \(metadataPath)") + DispatchQueue.main.async { + self.present(alert, animated: false) + } + return + } + comics[indexPath.item].metadata = newMetadata + } } extension ViewController: UIPopoverPresentationControllerDelegate { @@ -2545,6 +2676,32 @@ struct AniListResponse: Decodable { let data: T } +struct MediaMetadataData: Decodable { + // swiftlint:disable identifier_name + let Media: MediaMetadata + // swiftlint:enable identifier_name +} + +struct MediaDate: Decodable { + let year: Int? + let month: Int? + let day: Int? +} + +struct MediaMetadata: Decodable { + let description: String? + let endDate: MediaDate? + let startDate: MediaDate? + let format: String? + let title: MediaTitle? + let source: String? + let status: String? +} + +struct MediaTitle: Decodable { + let english: String? +} + struct ViewerData: Decodable { // swiftlint:disable identifier_name let Viewer: Viewer @@ -2670,6 +2827,96 @@ enum AniListClient { } } + static func fetchMetadata( + id: UInt, + completion: @escaping (Result) -> Void + ) { + let query = """ + query($mediaId: Int = \(id)) { + Media(id: $mediaId) { + description + endDate { + day + month + year + } + format + startDate { + day + month + year + } + title { + english + } + source + status + } + } + """ + fetch( + query: query + ) { (result: Result, Error>) in + switch result { + case let .success(m): + let metadata = m.data.Media + completion(.success(metadata)) + case let .failure(error): + completion(.failure(error)) + print("Error: \(error)") + } + } + } + + static func fetch( + query: String, + completion: @escaping (Result, Error>) -> Void + ) { + var request = URLRequest(url: endpoint) + request.httpMethod = "POST" + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + request.setValue("application/json", forHTTPHeaderField: "Accept") + + do { + request.httpBody = try JSONEncoder().encode(GraphQLBody(query: query)) + } catch { + completion(.failure(error)) + return + } + + let task = URLSession.shared.dataTask(with: request) { data, response, error in + if let error = error { + completion(.failure(error)) + return + } + + guard let http = response as? HTTPURLResponse else { + completion(.failure(AniListError.badResponse)) + return + } + + guard let data = data else { + completion(.failure(AniListError.noData)) + return + } + + guard (200 ... 299).contains(http.statusCode) else { + let body = String(data: data, encoding: .utf8) ?? "" + completion(.failure(AniListError.requestFailed(status: http.statusCode, body: body))) + return + } + + do { + let decoded = try JSONDecoder().decode(AniListResponse.self, from: data) + completion(.success(decoded)) + } catch { + completion(.failure(error)) + } + } + + task.resume() + } + static func fetchMediaStatus( accessToken: String, id: UInt, @@ -2824,3 +3071,65 @@ enum AniListClient { task.resume() } } + +extension Source { + init?(from string: String?) { + guard let string = string else { return nil } + guard let source = Source(from: string) else { return nil } + self = source + } + init?(from string: String) { + switch string.lowercased() { + case "ORIGINAL": self = .original + case "MANGA": self = .manga + case "LIGHT_NOVEL": self = .lightnovel + case "VISUAL_NOVEL": self = .visualnovel + case "VIDEO_GAME": self = .videogame + case "OTHER": self = .other + case "NOVEL": self = .novel + case "DOUJINSHI": self = .doujinshi + case "ANIME": self = .anime + case "WEB_NOVEL": self = .webnovel + case "LIVE_ACTION": self = .liveaction + case "GAME": self = .game + case "COMIC": self = .comic + case "MULTIMEDIA_PROJECT": self = .multimediaproject + case "PICTUREBOOK": self = .picturebook + default: return nil + } + } +} + +extension Format { + init?(from string: String?) { + guard let string = string else { return nil } + guard let format = Format(from: string) else { return nil } + self = format + } + init?(from string: String) { + switch string { + case "MANGA": self = .manga + case "NOVEL": self = .novel + case "ONE_SHOT": self = .oneshot + default: return nil + } + } +} + +extension Status { + init?(from string: String?) { + guard let string = string else { return nil } + guard let status = Status(from: string) else { return nil } + self = status + } + init?(from string: String) { + switch string { + case "FINISHED": self = .finished + case "RELEASING": self = .releasing + case "HIATUS": self = .hiatus + case "NOT_YET_RELEASED": self = .notyetreleased + case "CANCELLED": self = .cancelled + default: return nil + } + } +} diff --git a/ImageViewer/metadata.fbs b/ImageViewer/metadata.fbs index c3ef1ac..71c3692 100644 --- a/ImageViewer/metadata.fbs +++ b/ImageViewer/metadata.fbs @@ -49,13 +49,13 @@ table VolumeMetadata { table Metadata { title:string (required); - format:Format; + format:Format = null; original_language:string; country_of_origin:string; - publication_demographic:PublicationDemographic; - status:Status; - content_rating:ContentRating; - source:Source; + publication_demographic:PublicationDemographic = null; + status:Status = null; + content_rating:ContentRating = null; + source:Source = null; start_release_date:Date; end_release_date:Date; tags:[Tag]; diff --git a/ImageViewer/metadata_generated.swift b/ImageViewer/metadata_generated.swift index de4c597..237ebf0 100644 --- a/ImageViewer/metadata_generated.swift +++ b/ImageViewer/metadata_generated.swift @@ -455,15 +455,15 @@ public struct Metadata: FlatBufferTable, FlatbuffersVectorInitializable, Verifia public var title: String! { let o = _accessor.offset(VT.title); return _accessor.string(at: o) } public var titleSegmentArray: [UInt8]! { return _accessor.getVector(at: VT.title) } - public var format: Format { let o = _accessor.offset(VT.format); return o == 0 ? .manga : Format(rawValue: _accessor.readBuffer(of: Int8.self, at: o)) ?? .manga } + public var format: Format? { let o = _accessor.offset(VT.format); return o == 0 ? nil : Format(rawValue: _accessor.readBuffer(of: Int8.self, at: o)) ?? nil } public var originalLanguage: String? { let o = _accessor.offset(VT.originalLanguage); return o == 0 ? nil : _accessor.string(at: o) } public var originalLanguageSegmentArray: [UInt8]? { return _accessor.getVector(at: VT.originalLanguage) } public var countryOfOrigin: String? { let o = _accessor.offset(VT.countryOfOrigin); return o == 0 ? nil : _accessor.string(at: o) } public var countryOfOriginSegmentArray: [UInt8]? { return _accessor.getVector(at: VT.countryOfOrigin) } - public var publicationDemographic: PublicationDemographic { let o = _accessor.offset(VT.publicationDemographic); return o == 0 ? .shounen : PublicationDemographic(rawValue: _accessor.readBuffer(of: Int8.self, at: o)) ?? .shounen } - public var status: Status { let o = _accessor.offset(VT.status); return o == 0 ? .finished : Status(rawValue: _accessor.readBuffer(of: Int8.self, at: o)) ?? .finished } - public var contentRating: ContentRating { let o = _accessor.offset(VT.contentRating); return o == 0 ? .safe : ContentRating(rawValue: _accessor.readBuffer(of: Int8.self, at: o)) ?? .safe } - public var source: Source { let o = _accessor.offset(VT.source); return o == 0 ? .original : Source(rawValue: _accessor.readBuffer(of: Int8.self, at: o)) ?? .original } + public var publicationDemographic: PublicationDemographic? { let o = _accessor.offset(VT.publicationDemographic); return o == 0 ? nil : PublicationDemographic(rawValue: _accessor.readBuffer(of: Int8.self, at: o)) ?? nil } + public var status: Status? { let o = _accessor.offset(VT.status); return o == 0 ? nil : Status(rawValue: _accessor.readBuffer(of: Int8.self, at: o)) ?? nil } + public var contentRating: ContentRating? { let o = _accessor.offset(VT.contentRating); return o == 0 ? nil : ContentRating(rawValue: _accessor.readBuffer(of: Int8.self, at: o)) ?? nil } + public var source: Source? { let o = _accessor.offset(VT.source); return o == 0 ? nil : Source(rawValue: _accessor.readBuffer(of: Int8.self, at: o)) ?? nil } public var startReleaseDate: Date? { let o = _accessor.offset(VT.startReleaseDate); return o == 0 ? nil : _accessor.readBuffer(of: Date.self, at: o) } public var mutableStartReleaseDate: Date_Mutable? { let o = _accessor.offset(VT.startReleaseDate); return o == 0 ? nil : Date_Mutable(_accessor.bb, o: o + _accessor.position) } public var endReleaseDate: Date? { let o = _accessor.offset(VT.endReleaseDate); return o == 0 ? nil : _accessor.readBuffer(of: Date.self, at: o) } @@ -476,13 +476,13 @@ public struct Metadata: FlatBufferTable, FlatbuffersVectorInitializable, Verifia public var imageCount: UInt32 { let o = _accessor.offset(VT.imageCount); return o == 0 ? 0 : _accessor.readBuffer(of: UInt32.self, at: o) } public static func startMetadata(_ fbb: inout FlatBufferBuilder) -> UOffset { fbb.startTable(with: 15) } public static func add(title: Offset, _ fbb: inout FlatBufferBuilder) { fbb.add(offset: title, at: VT.title) } - public static func add(format: Format, _ fbb: inout FlatBufferBuilder) { fbb.add(element: format.rawValue, def: 0, at: VT.format) } + public static func add(format: Format?, _ fbb: inout FlatBufferBuilder) { fbb.add(element: format?.rawValue, at: VT.format) } public static func add(originalLanguage: Offset, _ fbb: inout FlatBufferBuilder) { fbb.add(offset: originalLanguage, at: VT.originalLanguage) } public static func add(countryOfOrigin: Offset, _ fbb: inout FlatBufferBuilder) { fbb.add(offset: countryOfOrigin, at: VT.countryOfOrigin) } - public static func add(publicationDemographic: PublicationDemographic, _ fbb: inout FlatBufferBuilder) { fbb.add(element: publicationDemographic.rawValue, def: 0, at: VT.publicationDemographic) } - public static func add(status: Status, _ fbb: inout FlatBufferBuilder) { fbb.add(element: status.rawValue, def: 0, at: VT.status) } - public static func add(contentRating: ContentRating, _ fbb: inout FlatBufferBuilder) { fbb.add(element: contentRating.rawValue, def: 0, at: VT.contentRating) } - public static func add(source: Source, _ fbb: inout FlatBufferBuilder) { fbb.add(element: source.rawValue, def: 0, at: VT.source) } + public static func add(publicationDemographic: PublicationDemographic?, _ fbb: inout FlatBufferBuilder) { fbb.add(element: publicationDemographic?.rawValue, at: VT.publicationDemographic) } + public static func add(status: Status?, _ fbb: inout FlatBufferBuilder) { fbb.add(element: status?.rawValue, at: VT.status) } + public static func add(contentRating: ContentRating?, _ fbb: inout FlatBufferBuilder) { fbb.add(element: contentRating?.rawValue, at: VT.contentRating) } + public static func add(source: Source?, _ fbb: inout FlatBufferBuilder) { fbb.add(element: source?.rawValue, at: VT.source) } public static func add(startReleaseDate: Date?, _ fbb: inout FlatBufferBuilder) { guard let startReleaseDate = startReleaseDate else { return }; fbb.create(struct: startReleaseDate, position: VT.startReleaseDate) } public static func add(endReleaseDate: Date?, _ fbb: inout FlatBufferBuilder) { guard let endReleaseDate = endReleaseDate else { return }; fbb.create(struct: endReleaseDate, position: VT.endReleaseDate) } public static func addVectorOf(tags: Offset, _ fbb: inout FlatBufferBuilder) { fbb.add(offset: tags, at: VT.tags) } @@ -494,13 +494,13 @@ public struct Metadata: FlatBufferTable, FlatbuffersVectorInitializable, Verifia public static func createMetadata( _ fbb: inout FlatBufferBuilder, titleOffset title: Offset, - format: Format = .manga, + format: Format? = nil, originalLanguageOffset originalLanguage: Offset = Offset(), countryOfOriginOffset countryOfOrigin: Offset = Offset(), - publicationDemographic: PublicationDemographic = .shounen, - status: Status = .finished, - contentRating: ContentRating = .safe, - source: Source = .original, + publicationDemographic: PublicationDemographic? = nil, + status: Status? = nil, + contentRating: ContentRating? = nil, + source: Source? = nil, startReleaseDate: Date? = nil, endReleaseDate: Date? = nil, tagsVectorOffset tags: Offset = Offset(), -- 2.54.0 From 70b80893c0cdf7e9e51591840f8fe53b4a501057 Mon Sep 17 00:00:00 2001 From: Vegard Bieker Matthey Date: Tue, 4 Aug 2026 03:18:44 +0200 Subject: [PATCH 6/6] change casing for text values --- ImageViewer/ViewController.swift | 109 +++++++++++++++++++++++++++---- 1 file changed, 96 insertions(+), 13 deletions(-) diff --git a/ImageViewer/ViewController.swift b/ImageViewer/ViewController.swift index 55c6591..b3dcd0e 100644 --- a/ImageViewer/ViewController.swift +++ b/ImageViewer/ViewController.swift @@ -2184,14 +2184,6 @@ class ComicOptionViewController: UITableViewController { } } -func convertPageTurnModeToString(_ pageTurnMode: PageTurnMode) -> String { - switch pageTurnMode { - case .leftToRight: return "Left to Right" - case .rightToLeft: return "Right to Left" - case .scroll: return "Scroll" - } -} - enum AniListAction: String, CaseIterable { case track = "Track" case updateStatus = "Update Status" @@ -2241,7 +2233,7 @@ extension ViewController { let items = PageTurnMode.allCases return items.map { pageTurnMode in SettingsMenuItem( - title: convertPageTurnModeToString(pageTurnMode), + title: "\(pageTurnMode)", children: nil, action: { [self] in pageTurnHandler(pageTurnMode) @@ -2259,9 +2251,9 @@ extension ViewController { return items.map { pageTurnMode in var title = "" if let mode = pageTurnMode { - title = convertPageTurnModeToString(mode) + title = "\(mode)" } else { - title = "Default (\(convertPageTurnModeToString(settings.defaultPageTurnMode)))" + title = "Default (\(settings.defaultPageTurnMode))" } return SettingsMenuItem( title: title, @@ -2445,7 +2437,7 @@ extension ViewController { guard let mediaId = comics[indexPath.item].metadata.anilistId else { return [] } return items.map { status in ComicOptionItem( - title: status.rawValue, children: nil, + title: "\(status)", children: nil, destructive: false, available: { true }, action: { [weak self] in self?.dismiss(animated: false) @@ -2569,7 +2561,7 @@ extension ViewController { descriptionOffset: description, anilistId: pM.anilistId, volumesVectorOffset: volumeOffsets, - imageCount: pM.imageCount, + imageCount: pM.imageCount ) builder.finish(offset: metadata) @@ -3078,6 +3070,7 @@ extension Source { guard let source = Source(from: string) else { return nil } self = source } + init?(from string: String) { switch string.lowercased() { case "ORIGINAL": self = .original @@ -3106,6 +3099,7 @@ extension Format { guard let format = Format(from: string) else { return nil } self = format } + init?(from string: String) { switch string { case "MANGA": self = .manga @@ -3122,6 +3116,7 @@ extension Status { guard let status = Status(from: string) else { return nil } self = status } + init?(from string: String) { switch string { case "FINISHED": self = .finished @@ -3133,3 +3128,91 @@ extension Status { } } } + +extension Status: CustomStringConvertible { + public var description: String { + switch self { + case .finished: return "Finished" + case .releasing: return "Releasing" + case .hiatus: return "Hiatus" + case .notyetreleased: return "Not Yet Released" + case .cancelled: return "Cancelled" + } + } +} + +extension Source: CustomStringConvertible { + public var description: String { + switch self { + case .original: return "Original" + case .manga: return "Manga" + case .lightnovel: return "Light Novel" + case .visualnovel: return "Visual Novel" + case .videogame: return "Video Game" + case .other: return "Other" + case .novel: return "Novel" + case .doujinshi: return "Doujinshi" + case .anime: return "Anime" + case .webnovel: return "Web Novel" + case .liveaction: return "Live Action" + case .game: return "Game" + case .comic: return "Comic" + case .multimediaproject: return "Multimedia Project" + case .picturebook: return "Picture Book" + } + } +} + +extension Format: CustomStringConvertible { + public var description: String { + switch self { + case .manga: return "Manga" + case .novel: return "Novel" + case .oneshot: return "One Shot" + } + } +} + +extension PublicationDemographic: CustomStringConvertible { + public var description: String { + switch self { + case .shounen: return "Shounen" + case .shoujou: return "Shoujou" + case .seinen: return "Seinen" + case .josei: return "Josei" + } + } +} + +extension ContentRating: CustomStringConvertible { + public var description: String { + switch self { + case .safe: return "Safe" + case .suggestive: return "Suggestive" + case .erotica: return "Erotica" + case .pornographic: return "Pornographic" + } + } +} + +extension MediaListStatus: CustomStringConvertible { + var description: String { + switch self { + case .current: return "Current" + case .planning: return "Planning" + case .completed: return "Completed" + case .dropped: return "Dropped" + case .paused: return "Paused" + } + } +} + +extension PageTurnMode: CustomStringConvertible { + var description: String { + switch self { + case .leftToRight: return "Left to Right" + case .rightToLeft: return "Right to Left" + case .scroll: return "Scroll" + } + } +} -- 2.54.0