Files
libnovel/ios/LibNovelV2/Models/Models.swift
Admin 7413313100
All checks were successful
CI / Scraper / Lint (push) Successful in 10s
CI / Scraper / Test (push) Successful in 14s
Release / Scraper / Test (push) Successful in 18s
CI / Scraper / Lint (pull_request) Successful in 18s
Release / UI / Build (push) Successful in 23s
CI / Scraper / Test (pull_request) Successful in 15s
CI / UI / Build (pull_request) Successful in 32s
Release / Scraper / Docker (push) Successful in 55s
CI / Scraper / Docker Push (pull_request) Has been skipped
CI / UI / Docker Push (pull_request) Has been skipped
CI / Scraper / Docker Push (push) Successful in 1m5s
Release / UI / Docker (push) Successful in 1m12s
iOS CI / Build (push) Successful in 4m18s
iOS CI / Build (pull_request) Successful in 4m25s
iOS CI / Test (push) Successful in 8m11s
iOS CI / Test (pull_request) Successful in 8m21s
fix: update integration_test.go to match server.New signature (version, commit args)
2026-03-14 14:25:46 +05:00

418 lines
12 KiB
Swift

import Foundation
import SwiftUI
// MARK: - Book
struct Book: Identifiable, Codable, Hashable {
let id: String
let slug: String
let title: String
let author: String
let cover: String // proxied via /api/cover/...
let status: String
let genres: [String]
let summary: String
let totalChapters: Int
let sourceURL: String
let ranking: Int
let metaUpdated: String
enum CodingKeys: String, CodingKey {
case id, slug, title, author, cover, status, genres, summary, ranking
case totalChapters = "total_chapters"
case sourceURL = "source_url"
case metaUpdated = "meta_updated"
}
init(from decoder: Decoder) throws {
let c = try decoder.container(keyedBy: CodingKeys.self)
id = try c.decode(String.self, forKey: .id)
slug = try c.decode(String.self, forKey: .slug)
title = try c.decode(String.self, forKey: .title)
author = try c.decodeIfPresent(String.self, forKey: .author) ?? ""
cover = try c.decodeIfPresent(String.self, forKey: .cover) ?? ""
status = try c.decodeIfPresent(String.self, forKey: .status) ?? ""
totalChapters = try c.decodeIfPresent(Int.self, forKey: .totalChapters) ?? 0
sourceURL = try c.decodeIfPresent(String.self, forKey: .sourceURL) ?? ""
ranking = try c.decodeIfPresent(Int.self, forKey: .ranking) ?? 0
metaUpdated = try c.decodeIfPresent(String.self, forKey: .metaUpdated) ?? ""
summary = try c.decodeIfPresent(String.self, forKey: .summary) ?? ""
// genres can arrive as a JSON-encoded string or a real array
if let arr = try? c.decode([String].self, forKey: .genres) {
genres = arr
} else if let raw = try? c.decode(String.self, forKey: .genres),
let data = raw.data(using: .utf8),
let arr = try? JSONDecoder().decode([String].self, from: data) {
genres = arr
} else {
genres = []
}
}
}
// MARK: - Chapter index
struct ChapterIndex: Identifiable, Codable, Hashable {
let id: String
let slug: String
let number: Int
let title: String
let dateLabel: String
enum CodingKeys: String, CodingKey {
case id, slug, number, title
case dateLabel = "date_label"
}
}
struct ChapterBrief: Identifiable, Codable, Hashable {
var id: Int { number }
let number: Int
let title: String
}
// Full chapter response from /api/chapter-text/{slug}/{n}
struct ChapterResponse: Decodable {
struct BookBrief: Decodable {
let slug: String
let title: String
let cover: String
}
struct ChapterDetail: Decodable {
let number: Int
let title: String
let dateLabel: String
enum CodingKeys: String, CodingKey {
case number, title
case dateLabel = "date_label"
}
}
let book: BookBrief
let chapter: ChapterDetail
let chapters: [ChapterBrief]
let html: String
let text: String
let prev: Int?
let next: Int?
}
// MARK: - Ranking
struct RankingItem: Codable, Identifiable {
var id: String { slug }
let rank: Int
let slug: String
let title: String
let author: String
let cover: String
let status: String
let genres: [String]
let sourceURL: String
enum CodingKeys: String, CodingKey {
case rank, slug, title, author, cover, status, genres
case sourceURL = "source_url"
}
}
// MARK: - Browse listing
struct NovelListing: Codable, Identifiable {
var id: String { slug }
let slug: String
let title: String
let author: String?
let cover: String?
let status: String?
let genres: [String]?
let rank: Int?
let rating: String?
let chapters: String? // e.g. "123 chapters"
let url: String?
let sourceURL: String?
enum CodingKeys: String, CodingKey {
case slug, title, author, cover, status, genres, rank, rating, chapters, url
case sourceURL = "source_url"
}
}
// MARK: - Home
struct HomeStats: Codable {
let totalBooks: Int
let totalChapters: Int
let booksInProgress: Int
enum CodingKeys: String, CodingKey {
case totalBooks = "total_books"
case totalChapters = "total_chapters"
case booksInProgress = "books_in_progress"
}
}
struct ContinueReadingItem: Identifiable {
var id: String { book.id }
let book: Book
let chapter: Int
}
struct SubscriptionFeedItem: Identifiable, Decodable {
var id: String { book.id + readerUsername }
let book: Book
let readerUsername: String
enum CodingKeys: String, CodingKey {
case book
case readerUsername = "readerUsername"
}
}
// MARK: - User
struct AppUser: Codable, Identifiable {
let id: String
let username: String
let role: String
let created: String
let avatarURL: String?
var isAdmin: Bool { role == "admin" }
enum CodingKeys: String, CodingKey {
case id, username, role, created
case avatarURL = "avatar_url"
}
init(from decoder: Decoder) throws {
let c = try decoder.container(keyedBy: CodingKeys.self)
id = try c.decode(String.self, forKey: .id)
username = try c.decode(String.self, forKey: .username)
role = try c.decodeIfPresent(String.self, forKey: .role) ?? "user"
created = try c.decodeIfPresent(String.self, forKey: .created) ?? ""
avatarURL = try c.decodeIfPresent(String.self, forKey: .avatarURL)
}
init(id: String, username: String, role: String, created: String, avatarURL: String?) {
self.id = id
self.username = username
self.role = role
self.created = created
self.avatarURL = avatarURL
}
}
// MARK: - User settings
struct UserSettings: Codable {
var autoNext: Bool
var voice: String
var speed: Double
static let `default` = UserSettings(autoNext: false, voice: "af_bella", speed: 1.0)
}
// MARK: - Session
struct UserSession: Codable, Identifiable {
let id: String
let userAgent: String
let ip: String
let createdAt: String
let lastSeen: String
var isCurrent: Bool
enum CodingKeys: String, CodingKey {
case id, ip
case userAgent = "user_agent"
case createdAt = "created_at"
case lastSeen = "last_seen"
case isCurrent = "is_current"
}
}
// MARK: - Comments
struct BookComment: Identifiable, Codable, Hashable {
let id: String
let slug: String
let userId: String
let username: String
let body: String
var upvotes: Int
var downvotes: Int
let created: String
let parentId: String
var replies: [BookComment]?
enum CodingKeys: String, CodingKey {
case id, slug, username, body, upvotes, downvotes, created, replies
case userId = "user_id"
case parentId = "parent_id"
}
init(from decoder: Decoder) throws {
let c = try decoder.container(keyedBy: CodingKeys.self)
id = try c.decode(String.self, forKey: .id)
slug = try c.decodeIfPresent(String.self, forKey: .slug) ?? ""
userId = try c.decodeIfPresent(String.self, forKey: .userId) ?? ""
username = try c.decodeIfPresent(String.self, forKey: .username) ?? ""
body = try c.decodeIfPresent(String.self, forKey: .body) ?? ""
upvotes = try c.decodeIfPresent(Int.self, forKey: .upvotes) ?? 0
downvotes = try c.decodeIfPresent(Int.self, forKey: .downvotes) ?? 0
created = try c.decodeIfPresent(String.self, forKey: .created) ?? ""
parentId = try c.decodeIfPresent(String.self, forKey: .parentId) ?? ""
replies = try c.decodeIfPresent([BookComment].self, forKey: .replies)
}
}
struct CommentsResponse: Decodable {
let comments: [BookComment]
let myVotes: [String: String]
let avatarUrls: [String: String]
enum CodingKeys: String, CodingKey {
case comments, myVotes, avatarUrls
}
init(from decoder: Decoder) throws {
let c = try decoder.container(keyedBy: CodingKeys.self)
comments = try c.decode([BookComment].self, forKey: .comments)
myVotes = try c.decodeIfPresent([String: String].self, forKey: .myVotes) ?? [:]
avatarUrls = try c.decodeIfPresent([String: String].self, forKey: .avatarUrls) ?? [:]
}
}
// MARK: - Public user profile
struct PublicUserProfile: Decodable, Identifiable {
let id: String
let username: String
let avatarUrl: String?
let created: String
let followerCount: Int
let followingCount: Int
let isSubscribed: Bool
let isSelf: Bool
enum CodingKeys: String, CodingKey {
case id, username, created
case avatarUrl = "avatarUrl"
case followerCount = "followerCount"
case followingCount = "followingCount"
case isSubscribed = "isSubscribed"
case isSelf = "isSelf"
}
init(from decoder: Decoder) throws {
let c = try decoder.container(keyedBy: CodingKeys.self)
id = try c.decode(String.self, forKey: .id)
username = try c.decode(String.self, forKey: .username)
avatarUrl = try c.decodeIfPresent(String.self, forKey: .avatarUrl)
created = try c.decodeIfPresent(String.self, forKey: .created) ?? ""
followerCount = try c.decodeIfPresent(Int.self, forKey: .followerCount) ?? 0
followingCount = try c.decodeIfPresent(Int.self, forKey: .followingCount) ?? 0
isSubscribed = try c.decodeIfPresent(Bool.self, forKey: .isSubscribed) ?? false
isSelf = try c.decodeIfPresent(Bool.self, forKey: .isSelf) ?? false
}
}
struct PublicLibraryItem: Decodable, Identifiable {
var id: String { book.id }
let book: Book
let lastChapter: Int?
let saved: Bool
enum CodingKeys: String, CodingKey {
case book
case lastChapter = "last_chapter"
case saved
}
}
struct PublicUserLibraryResponse: Decodable {
let currentlyReading: [PublicLibraryItem]
let library: [PublicLibraryItem]
enum CodingKeys: String, CodingKey {
case currentlyReading = "currently_reading"
case library
}
}
// MARK: - Reader Settings (local UserDefaults)
enum ReaderTheme: String, CaseIterable, Codable {
case white, sepia, night
var backgroundColor: Color {
switch self {
case .white: return Color(.sRGB, white: 1.0, opacity: 1)
case .sepia: return Color(red: 0.97, green: 0.93, blue: 0.82)
case .night: return Color(red: 0.10, green: 0.10, blue: 0.12)
}
}
var textColor: Color {
switch self {
case .white: return Color(.sRGB, white: 0.10, opacity: 1)
case .sepia: return Color(red: 0.25, green: 0.18, blue: 0.08)
case .night: return Color(red: 0.85, green: 0.85, blue: 0.87)
}
}
var colorScheme: ColorScheme? {
switch self {
case .white: return nil
case .sepia: return .light
case .night: return .dark
}
}
}
enum ReaderFont: String, CaseIterable, Codable {
case system = "System"
case georgia = "Georgia"
case newYork = "New York"
var fontName: String? {
switch self {
case .system: return nil
case .georgia: return "Georgia"
case .newYork: return "NewYorkMedium-Regular"
}
}
}
struct ReaderSettings: Codable, Equatable {
var fontSize: CGFloat = 17
var lineSpacing: CGFloat = 1.7
var font: ReaderFont = .system
var theme: ReaderTheme = .white
var scrollMode: Bool = false
private static let key = "v2.readerSettings"
static func load() -> ReaderSettings {
guard let data = UserDefaults.standard.data(forKey: key),
let decoded = try? JSONDecoder().decode(ReaderSettings.self, from: data)
else { return ReaderSettings() }
return decoded
}
func save() {
if let data = try? JSONEncoder().encode(self) {
UserDefaults.standard.set(data, forKey: ReaderSettings.key)
}
}
}
// MARK: - Audio prefetch status
enum NextPrefetchStatus {
case none, prefetching, prefetched, failed
}