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:
@@ -10,6 +10,7 @@ let package = Package(
|
|||||||
products: [
|
products: [
|
||||||
.library(name: "VorleserKit", targets: ["VorleserKit"]),
|
.library(name: "VorleserKit", targets: ["VorleserKit"]),
|
||||||
.library(name: "BookParser", targets: ["BookParser"]),
|
.library(name: "BookParser", targets: ["BookParser"]),
|
||||||
|
.library(name: "Storage", targets: ["Storage"]),
|
||||||
],
|
],
|
||||||
dependencies: [
|
dependencies: [
|
||||||
.package(url: "https://github.com/weichsel/ZIPFoundation.git", from: "0.9.0"),
|
.package(url: "https://github.com/weichsel/ZIPFoundation.git", from: "0.9.0"),
|
||||||
@@ -18,12 +19,20 @@ let package = Package(
|
|||||||
targets: [
|
targets: [
|
||||||
.target(
|
.target(
|
||||||
name: "VorleserKit",
|
name: "VorleserKit",
|
||||||
|
dependencies: ["Storage"]
|
||||||
|
),
|
||||||
|
.target(
|
||||||
|
name: "Storage",
|
||||||
dependencies: []
|
dependencies: []
|
||||||
),
|
),
|
||||||
.target(
|
.target(
|
||||||
name: "BookParser",
|
name: "BookParser",
|
||||||
dependencies: ["VorleserKit", "ZIPFoundation", "SwiftSoup"]
|
dependencies: ["VorleserKit", "ZIPFoundation", "SwiftSoup"]
|
||||||
),
|
),
|
||||||
|
.testTarget(
|
||||||
|
name: "StorageTests",
|
||||||
|
dependencies: ["Storage"]
|
||||||
|
),
|
||||||
.testTarget(
|
.testTarget(
|
||||||
name: "BookParserTests",
|
name: "BookParserTests",
|
||||||
dependencies: ["BookParser"],
|
dependencies: ["BookParser"],
|
||||||
|
|||||||
57
VorleserKit/Sources/Storage/BookStore.swift
Normal file
57
VorleserKit/Sources/Storage/BookStore.swift
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
34
VorleserKit/Sources/Storage/StoredBook.swift
Normal file
34
VorleserKit/Sources/Storage/StoredBook.swift
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
61
VorleserKit/Tests/StorageTests/BookStoreTests.swift
Normal file
61
VorleserKit/Tests/StorageTests/BookStoreTests.swift
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user