diff --git a/VorleserKit/Package.swift b/VorleserKit/Package.swift index 2c9b798..b18ae9f 100644 --- a/VorleserKit/Package.swift +++ b/VorleserKit/Package.swift @@ -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"], diff --git a/VorleserKit/Sources/Storage/BookStore.swift b/VorleserKit/Sources/Storage/BookStore.swift new file mode 100644 index 0000000..ba0fcea --- /dev/null +++ b/VorleserKit/Sources/Storage/BookStore.swift @@ -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( + 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) + } +} diff --git a/VorleserKit/Sources/Storage/StoredBook.swift b/VorleserKit/Sources/Storage/StoredBook.swift new file mode 100644 index 0000000..a59e632 --- /dev/null +++ b/VorleserKit/Sources/Storage/StoredBook.swift @@ -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 + } +} diff --git a/VorleserKit/Tests/StorageTests/BookStoreTests.swift b/VorleserKit/Tests/StorageTests/BookStoreTests.swift new file mode 100644 index 0000000..f1b0472 --- /dev/null +++ b/VorleserKit/Tests/StorageTests/BookStoreTests.swift @@ -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) + } +}