Files
legado-ios/App/Features/Activity/ReadingActivityModel.swift
T

188 lines
7.0 KiB
Swift

import Foundation
@MainActor
final class ReadingActivityModel: ObservableObject {
@Published private(set) var bookmarks: [Bookmark] = []
@Published private(set) var readRecords: [ReadRecord] = []
@Published private(set) var errorMessage: String?
private let repository: any ReadingActivityRepository
private let service = ReadingActivityService()
private let deviceID: String
private var sessions: [String: Date] = [:]
init(repository: any ReadingActivityRepository, deviceID: String) {
self.repository = repository
self.deviceID = deviceID
}
static func live() -> ReadingActivityModel {
let base = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask)[0]
.appendingPathComponent("Legado", isDirectory: true)
let key = "legado.reading.device-id"
let defaults = UserDefaults.standard
let identifier = defaults.string(forKey: key) ?? UUID().uuidString
defaults.set(identifier, forKey: key)
let bookmarksURL = base.appendingPathComponent("bookmarks.json")
let recordsURL = base.appendingPathComponent("read-records.json")
let repository: any ReadingActivityRepository
if let database = try? LegadoDatabase(url: base.appendingPathComponent("legado.sqlite3")) {
repository = SQLiteReadingActivityRepository(
database: database,
legacyBookmarksURL: bookmarksURL,
legacyRecordsURL: recordsURL
)
} else {
repository = FileReadingActivityRepository(bookmarksURL: bookmarksURL, recordsURL: recordsURL)
}
return ReadingActivityModel(repository: repository, deviceID: identifier)
}
static func demo(bookmarks: [Bookmark] = [], records: [ReadRecord] = []) -> ReadingActivityModel {
ReadingActivityModel(
repository: InMemoryReadingActivityRepository(bookmarks: bookmarks, records: records),
deviceID: "ios-simulator"
)
}
var summaries: [ReadRecordSummary] { service.summaries(readRecords) }
var statistics: ReadingStatistics { service.statistics(readRecords) }
func summaries(searchKey: String, sortMode: Int) -> [ReadRecordSummary] {
service.displayedSummaries(readRecords, searchKey: searchKey, sortMode: sortMode)
}
func load() async {
do {
async let storedBookmarks = repository.loadBookmarks()
async let storedRecords = repository.loadReadRecords()
bookmarks = service.sortedBookmarks(try await storedBookmarks)
readRecords = try await storedRecords
} catch {
errorMessage = error.localizedDescription
}
}
func bookmarks(for book: Book) -> [Bookmark] {
bookmarks.filter { $0.bookName == book.name && $0.bookAuthor == book.author }
.sorted {
if $0.chapterIndex == $1.chapterIndex { return $0.chapterPosition < $1.chapterPosition }
return $0.chapterIndex < $1.chapterIndex
}
}
func addBookmark(book: Book, chapter: BookChapter, position: Int, content: String) async {
var timestamp = Int64(Date().timeIntervalSince1970 * 1_000)
while bookmarks.contains(where: { $0.time == timestamp }) { timestamp += 1 }
var bookmark = Bookmark(time: timestamp, bookName: book.name, bookAuthor: book.author)
bookmark.chapterIndex = chapter.index
bookmark.chapterPosition = max(position, 0)
bookmark.chapterName = chapter.title
bookmark.bookText = excerpt(content, around: position)
var updated = bookmarks
updated.append(bookmark)
do {
try await repository.saveBookmarks(updated)
bookmarks = service.sortedBookmarks(updated)
} catch {
errorMessage = error.localizedDescription
}
}
func removeBookmark(_ bookmark: Bookmark) async {
let updated = bookmarks.filter { $0.id != bookmark.id }
do {
try await repository.saveBookmarks(updated)
bookmarks = updated
} catch {
errorMessage = error.localizedDescription
}
}
func updateBookmark(_ bookmark: Bookmark) async {
var updated = bookmarks
if let index = updated.firstIndex(where: { $0.id == bookmark.id }) {
updated[index] = bookmark
} else {
updated.append(bookmark)
}
do {
try await repository.saveBookmarks(updated)
bookmarks = service.sortedBookmarks(updated)
} catch {
errorMessage = error.localizedDescription
}
}
func beginReading(_ book: Book, now: Date = Date()) {
sessions[book.bookUrl] = sessions[book.bookUrl] ?? now
}
func checkpoint(_ book: Book, now: Date = Date()) async {
guard let start = sessions[book.bookUrl] else {
sessions[book.bookUrl] = now
return
}
sessions[book.bookUrl] = now
await record(book: book, elapsed: now.timeIntervalSince(start), now: now)
}
func endReading(_ book: Book, now: Date = Date()) async {
guard let start = sessions.removeValue(forKey: book.bookUrl) else { return }
await record(book: book, elapsed: now.timeIntervalSince(start), now: now)
}
func cancelReading(_ book: Book) {
sessions.removeValue(forKey: book.bookUrl)
}
func removeHistory(bookName: String) async {
let updated = readRecords.filter { $0.bookName != bookName }
do {
try await repository.saveReadRecords(updated)
readRecords = updated
} catch {
errorMessage = error.localizedDescription
}
}
func clearHistory() async {
do {
try await repository.saveReadRecords([])
readRecords = []
} catch {
errorMessage = error.localizedDescription
}
}
func clearError() { errorMessage = nil }
private func record(book: Book, elapsed: TimeInterval, now: Date) async {
let milliseconds = Int64(max(elapsed, 0) * 1_000)
guard milliseconds > 0 else { return }
let updated = service.addingReadTime(
milliseconds,
deviceID: deviceID,
bookName: book.name,
now: Int64(now.timeIntervalSince1970 * 1_000),
to: readRecords
)
readRecords = updated
do {
try await repository.saveReadRecords(updated)
} catch {
errorMessage = error.localizedDescription
}
}
private func excerpt(_ content: String, around position: Int) -> String {
guard !content.isEmpty else { return "" }
let center = min(max(position, 0), content.count)
let start = max(center - 24, 0)
let end = min(center + 96, content.count)
let lower = content.index(content.startIndex, offsetBy: start)
let upper = content.index(content.startIndex, offsetBy: end)
return String(content[lower..<upper]).trimmingCharacters(in: .whitespacesAndNewlines)
}
}