mirror of
https://github.com/Aminark/legado-ios.git
synced 2026-10-20 19:21:22 +08:00
228 lines
8.6 KiB
Swift
228 lines
8.6 KiB
Swift
import Foundation
|
|
|
|
public protocol ReadingActivityRepository: Sendable {
|
|
func loadBookmarks() async throws -> [Bookmark]
|
|
func saveBookmarks(_ bookmarks: [Bookmark]) async throws
|
|
func loadReadRecords() async throws -> [ReadRecord]
|
|
func saveReadRecords(_ records: [ReadRecord]) async throws
|
|
}
|
|
|
|
public actor FileReadingActivityRepository: ReadingActivityRepository {
|
|
private let bookmarksURL: URL
|
|
private let recordsURL: URL
|
|
|
|
public init(bookmarksURL: URL, recordsURL: URL) {
|
|
self.bookmarksURL = bookmarksURL
|
|
self.recordsURL = recordsURL
|
|
}
|
|
|
|
public func loadBookmarks() throws -> [Bookmark] { try load([Bookmark].self, from: bookmarksURL) }
|
|
public func saveBookmarks(_ bookmarks: [Bookmark]) throws { try save(bookmarks, to: bookmarksURL) }
|
|
public func loadReadRecords() throws -> [ReadRecord] { try load([ReadRecord].self, from: recordsURL) }
|
|
public func saveReadRecords(_ records: [ReadRecord]) throws { try save(records, to: recordsURL) }
|
|
|
|
private func load<T: Decodable>(_ type: T.Type, from url: URL) throws -> T where T: ExpressibleByArrayLiteral {
|
|
guard FileManager.default.fileExists(atPath: url.path) else { return [] }
|
|
return try JSONDecoder().decode(type, from: Data(contentsOf: url))
|
|
}
|
|
|
|
private func save<T: Encodable>(_ value: T, to url: URL) throws {
|
|
try FileManager.default.createDirectory(at: url.deletingLastPathComponent(), withIntermediateDirectories: true)
|
|
let encoder = JSONEncoder()
|
|
encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
|
|
try encoder.encode(value).write(to: url, options: .atomic)
|
|
}
|
|
}
|
|
|
|
public actor InMemoryReadingActivityRepository: ReadingActivityRepository {
|
|
private var bookmarks: [Bookmark]
|
|
private var records: [ReadRecord]
|
|
|
|
public init(bookmarks: [Bookmark] = [], records: [ReadRecord] = []) {
|
|
self.bookmarks = bookmarks
|
|
self.records = records
|
|
}
|
|
|
|
public func loadBookmarks() -> [Bookmark] { bookmarks }
|
|
public func saveBookmarks(_ bookmarks: [Bookmark]) { self.bookmarks = bookmarks }
|
|
public func loadReadRecords() -> [ReadRecord] { records }
|
|
public func saveReadRecords(_ records: [ReadRecord]) { self.records = records }
|
|
}
|
|
|
|
public struct ReadingActivityService: Sendable {
|
|
public init() {}
|
|
|
|
public func sortedBookmarks(_ bookmarks: [Bookmark]) -> [Bookmark] {
|
|
bookmarks.sorted {
|
|
let name = $0.bookName.localizedStandardCompare($1.bookName)
|
|
if name != .orderedSame { return name == .orderedAscending }
|
|
let author = $0.bookAuthor.localizedStandardCompare($1.bookAuthor)
|
|
if author != .orderedSame { return author == .orderedAscending }
|
|
if $0.chapterIndex != $1.chapterIndex { return $0.chapterIndex < $1.chapterIndex }
|
|
return $0.chapterPosition < $1.chapterPosition
|
|
}
|
|
}
|
|
|
|
public func summaries(_ records: [ReadRecord]) -> [ReadRecordSummary] {
|
|
Dictionary(grouping: records, by: \.bookName)
|
|
.map { name, values in
|
|
ReadRecordSummary(
|
|
bookName: name,
|
|
readTime: values.reduce(0) { $0 + max($1.readTime, 0) },
|
|
lastRead: values.map(\.lastRead).max() ?? 0
|
|
)
|
|
}
|
|
.sorted { $0.lastRead > $1.lastRead }
|
|
}
|
|
|
|
public func displayedSummaries(
|
|
_ records: [ReadRecord],
|
|
searchKey: String,
|
|
sortMode: Int
|
|
) -> [ReadRecordSummary] {
|
|
let values = summaries(records).filter {
|
|
searchKey.isEmpty || $0.bookName.localizedCaseInsensitiveContains(searchKey)
|
|
}
|
|
return values.sorted {
|
|
switch sortMode {
|
|
case 1:
|
|
if $0.readTime != $1.readTime { return $0.readTime > $1.readTime }
|
|
case 2:
|
|
if $0.lastRead != $1.lastRead { return $0.lastRead > $1.lastRead }
|
|
default:
|
|
let order = $0.bookName.localizedStandardCompare($1.bookName)
|
|
if order != .orderedSame { return order == .orderedAscending }
|
|
}
|
|
return $0.bookName.localizedStandardCompare($1.bookName) == .orderedAscending
|
|
}
|
|
}
|
|
|
|
public func addingReadTime(
|
|
_ elapsedMilliseconds: Int64,
|
|
deviceID: String,
|
|
bookName: String,
|
|
now: Int64,
|
|
to records: [ReadRecord],
|
|
calendar: Calendar = .current
|
|
) -> [ReadRecord] {
|
|
guard elapsedMilliseconds > 0, !bookName.isEmpty else { return records }
|
|
var updated = records
|
|
let day = Self.dayKey(
|
|
for: Date(timeIntervalSince1970: Double(now) / 1_000),
|
|
calendar: calendar
|
|
)
|
|
if let index = updated.firstIndex(where: { $0.deviceId == deviceID && $0.bookName == bookName }) {
|
|
updated[index].readTime += elapsedMilliseconds
|
|
updated[index].lastRead = now
|
|
updated[index].dailyReadTimes[day, default: 0] += elapsedMilliseconds
|
|
} else {
|
|
updated.append(.init(
|
|
deviceId: deviceID,
|
|
bookName: bookName,
|
|
readTime: elapsedMilliseconds,
|
|
lastRead: now,
|
|
dailyReadTimes: [day: elapsedMilliseconds]
|
|
))
|
|
}
|
|
return updated
|
|
}
|
|
|
|
public func statistics(
|
|
_ records: [ReadRecord],
|
|
now: Date = Date(),
|
|
calendar: Calendar = .current
|
|
) -> ReadingStatistics {
|
|
let dailyTotals = records.reduce(into: [String: Int64]()) { result, record in
|
|
for (day, milliseconds) in record.dailyReadTimes where milliseconds > 0 {
|
|
result[day, default: 0] += milliseconds
|
|
}
|
|
}
|
|
let today = calendar.startOfDay(for: now)
|
|
let todayKey = Self.dayKey(for: today, calendar: calendar)
|
|
let yesterday = calendar.date(byAdding: .day, value: -1, to: today) ?? today
|
|
var cursor: Date?
|
|
if dailyTotals[todayKey, default: 0] > 0 {
|
|
cursor = today
|
|
} else if dailyTotals[Self.dayKey(for: yesterday, calendar: calendar), default: 0] > 0 {
|
|
cursor = yesterday
|
|
} else {
|
|
cursor = nil
|
|
}
|
|
var streak = 0
|
|
while let day = cursor,
|
|
dailyTotals[Self.dayKey(for: day, calendar: calendar), default: 0] > 0 {
|
|
streak += 1
|
|
cursor = calendar.date(byAdding: .day, value: -1, to: day)
|
|
}
|
|
let recentDays = (-6...0).compactMap { offset -> DailyReadingTotal? in
|
|
guard let date = calendar.date(byAdding: .day, value: offset, to: today) else { return nil }
|
|
return DailyReadingTotal(
|
|
date: date,
|
|
readTime: dailyTotals[Self.dayKey(for: date, calendar: calendar), default: 0]
|
|
)
|
|
}
|
|
return ReadingStatistics(
|
|
todayReadTime: dailyTotals[todayKey, default: 0],
|
|
currentStreak: streak,
|
|
totalReadTime: records.reduce(0) { $0 + max($1.readTime, 0) },
|
|
bookCount: Set(records.lazy.map(\.bookName).filter { !$0.isEmpty }).count,
|
|
recentDays: recentDays
|
|
)
|
|
}
|
|
|
|
private static func dayKey(for date: Date, calendar: Calendar) -> String {
|
|
let components = calendar.dateComponents([.year, .month, .day], from: date)
|
|
return String(format: "%04d-%02d-%02d", components.year ?? 0, components.month ?? 0, components.day ?? 0)
|
|
}
|
|
}
|
|
|
|
public struct DailyReadingTotal: Hashable, Sendable {
|
|
public let date: Date
|
|
public let readTime: Int64
|
|
|
|
public init(date: Date, readTime: Int64) {
|
|
self.date = date
|
|
self.readTime = readTime
|
|
}
|
|
}
|
|
|
|
public struct ReadingStatistics: Hashable, Sendable {
|
|
public let todayReadTime: Int64
|
|
public let currentStreak: Int
|
|
public let totalReadTime: Int64
|
|
public let bookCount: Int
|
|
public let recentDays: [DailyReadingTotal]
|
|
|
|
public init(
|
|
todayReadTime: Int64,
|
|
currentStreak: Int,
|
|
totalReadTime: Int64,
|
|
bookCount: Int,
|
|
recentDays: [DailyReadingTotal]
|
|
) {
|
|
self.todayReadTime = todayReadTime
|
|
self.currentStreak = currentStreak
|
|
self.totalReadTime = totalReadTime
|
|
self.bookCount = bookCount
|
|
self.recentDays = recentDays
|
|
}
|
|
}
|
|
|
|
public struct BookProgress: Codable, Hashable, Sendable {
|
|
public var name: String
|
|
public var author: String
|
|
public var durChapterIndex: Int
|
|
public var durChapterPos: Int
|
|
public var durChapterTime: Int64
|
|
public var durChapterTitle: String?
|
|
|
|
public init(book: Book) {
|
|
name = book.name
|
|
author = book.author
|
|
durChapterIndex = book.durChapterIndex
|
|
durChapterPos = book.durChapterPos
|
|
durChapterTime = book.durChapterTime
|
|
durChapterTitle = book.durChapterTitle
|
|
}
|
|
}
|