add SwiftData storage with BookStore for library management, position tracking

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-13 21:55:26 +01:00
parent e7c9c018f1
commit c76ef0aec6
4 changed files with 161 additions and 0 deletions

View File

@@ -10,6 +10,7 @@ let package = Package(
products: [
.library(name: "VorleserKit", targets: ["VorleserKit"]),
.library(name: "BookParser", targets: ["BookParser"]),
.library(name: "Storage", targets: ["Storage"]),
],
dependencies: [
.package(url: "https://github.com/weichsel/ZIPFoundation.git", from: "0.9.0"),
@@ -18,12 +19,20 @@ let package = Package(
targets: [
.target(
name: "VorleserKit",
dependencies: ["Storage"]
),
.target(
name: "Storage",
dependencies: []
),
.target(
name: "BookParser",
dependencies: ["VorleserKit", "ZIPFoundation", "SwiftSoup"]
),
.testTarget(
name: "StorageTests",
dependencies: ["Storage"]
),
.testTarget(
name: "BookParserTests",
dependencies: ["BookParser"],

View File

@@ -0,0 +1,57 @@
import Foundation
import SwiftData
public struct BookStore {
private let modelContainer: ModelContainer
private let documentsDirectory: URL
public init(modelContainer: ModelContainer, documentsDirectory: URL) {
self.modelContainer = modelContainer
self.documentsDirectory = documentsDirectory
}
@MainActor
public func importBook(from sourceURL: URL, title: String, author: String?) throws -> StoredBook {
let fileName = "\(UUID().uuidString)_\(sourceURL.lastPathComponent)"
let destination = documentsDirectory.appendingPathComponent(fileName)
try FileManager.default.copyItem(at: sourceURL, to: destination)
let stored = StoredBook(title: title, author: author, sourceFileName: fileName)
let context = modelContainer.mainContext
context.insert(stored)
try context.save()
return stored
}
@MainActor
public func allBooks() throws -> [StoredBook] {
let context = modelContainer.mainContext
let descriptor = FetchDescriptor<StoredBook>(
sortBy: [SortDescriptor(\.lastRead, order: .reverse), SortDescriptor(\.dateAdded, order: .reverse)]
)
return try context.fetch(descriptor)
}
@MainActor
public func updatePosition(_ book: StoredBook, position: Int) throws {
book.lastPosition = position
book.lastRead = .now
try modelContainer.mainContext.save()
}
@MainActor
public func deleteBook(_ book: StoredBook) throws {
let filePath = documentsDirectory.appendingPathComponent(book.sourceFileName)
try? FileManager.default.removeItem(at: filePath)
modelContainer.mainContext.delete(book)
try modelContainer.mainContext.save()
}
public func fileURL(for book: StoredBook) -> URL {
documentsDirectory.appendingPathComponent(book.sourceFileName)
}
public func fileExists(for book: StoredBook) -> Bool {
FileManager.default.fileExists(atPath: fileURL(for: book).path)
}
}

View File

@@ -0,0 +1,34 @@
import Foundation
import SwiftData
@Model
public class StoredBook {
public var bookID: UUID
public var title: String
public var author: String?
public var sourceFileName: String
public var dateAdded: Date
public var lastPosition: Int
public var lastRead: Date?
public var voiceName: String?
public init(
bookID: UUID = UUID(),
title: String,
author: String? = nil,
sourceFileName: String,
dateAdded: Date = .now,
lastPosition: Int = 0,
lastRead: Date? = nil,
voiceName: String? = nil
) {
self.bookID = bookID
self.title = title
self.author = author
self.sourceFileName = sourceFileName
self.dateAdded = dateAdded
self.lastPosition = lastPosition
self.lastRead = lastRead
self.voiceName = voiceName
}
}

View File

@@ -0,0 +1,61 @@
import Testing
import Foundation
import SwiftData
@testable import Storage
@Suite("BookStore")
struct BookStoreTests {
@MainActor
func makeStore() throws -> (BookStore, URL) {
let config = ModelConfiguration(isStoredInMemoryOnly: true)
let container = try ModelContainer(for: StoredBook.self, configurations: config)
let tmpDir = URL(fileURLWithPath: NSTemporaryDirectory())
.appendingPathComponent(UUID().uuidString)
try FileManager.default.createDirectory(at: tmpDir, withIntermediateDirectories: true)
return (BookStore(modelContainer: container, documentsDirectory: tmpDir), tmpDir)
}
@Test @MainActor func importAndList() throws {
let (store, tmpDir) = try makeStore()
defer { try? FileManager.default.removeItem(at: tmpDir) }
let sourceFile = tmpDir.appendingPathComponent("source.epub")
try "fake epub".write(to: sourceFile, atomically: true, encoding: .utf8)
let stored = try store.importBook(from: sourceFile, title: "Test Book", author: "Author")
#expect(stored.title == "Test Book")
#expect(stored.lastPosition == 0)
let books = try store.allBooks()
#expect(books.count == 1)
#expect(store.fileExists(for: books[0]))
}
@Test @MainActor func updatePosition() throws {
let (store, tmpDir) = try makeStore()
defer { try? FileManager.default.removeItem(at: tmpDir) }
let sourceFile = tmpDir.appendingPathComponent("source.txt")
try "text".write(to: sourceFile, atomically: true, encoding: .utf8)
let stored = try store.importBook(from: sourceFile, title: "Book", author: nil)
try store.updatePosition(stored, position: 500)
#expect(stored.lastPosition == 500)
#expect(stored.lastRead != nil)
}
@Test @MainActor func deleteBook() throws {
let (store, tmpDir) = try makeStore()
defer { try? FileManager.default.removeItem(at: tmpDir) }
let sourceFile = tmpDir.appendingPathComponent("source.txt")
try "text".write(to: sourceFile, atomically: true, encoding: .utf8)
let stored = try store.importBook(from: sourceFile, title: "Book", author: nil)
#expect(store.fileExists(for: stored))
try store.deleteBook(stored)
let books = try store.allBooks()
#expect(books.isEmpty)
}
}