diff --git a/Sources/MusicAssistantCore/Persistence/ProjectStore.swift b/Sources/MusicAssistantCore/Persistence/ProjectStore.swift index 9434341..be8efc9 100644 --- a/Sources/MusicAssistantCore/Persistence/ProjectStore.swift +++ b/Sources/MusicAssistantCore/Persistence/ProjectStore.swift @@ -5,6 +5,33 @@ public protocol ProjectStore: Sendable { func save(_ project: SongProject) async throws func open(id: String) async throws -> SongProject func loadProjectIDs() async throws -> [String] + func loadProjectList() async throws -> [SongProjectListItem] +} + +public struct SongProjectListItem: Codable, Equatable, Identifiable, Sendable { + public let id: String + public var title: String + public var idea: String + public var createdAt: Date + public var updatedAt: Date + + public init(id: String, title: String, idea: String, createdAt: Date, updatedAt: Date) { + self.id = id + self.title = title + self.idea = idea + self.createdAt = createdAt + self.updatedAt = updatedAt + } + + public init(project: SongProject) { + self.init( + id: project.id, + title: project.title, + idea: project.idea, + createdAt: project.createdAt, + updatedAt: project.updatedAt + ) + } } public actor LocalSongProjectStore: ProjectStore { @@ -43,15 +70,23 @@ public actor LocalSongProjectStore: ProjectStore { } public func loadProjectIDs() async throws -> [String] { + try await loadProjectList().map(\.id) + } + + public func loadProjectList() async throws -> [SongProjectListItem] { try ensureDirectoryExists() - let fileURLs = try FileManager.default.contentsOfDirectory( - at: directoryURL, - includingPropertiesForKeys: nil - ) - return fileURLs - .filter { $0.pathExtension == Self.fileExtension } - .map { $0.deletingPathExtension().lastPathComponent } - .sorted() + return try projectFileURLs() + .map { url in + let data = try Data(contentsOf: url) + let project = try decoder.decode(SongProject.self, from: data) + return SongProjectListItem(project: project) + } + .sorted { left, right in + if left.updatedAt == right.updatedAt { + return left.title.localizedStandardCompare(right.title) == .orderedAscending + } + return left.updatedAt > right.updatedAt + } } private func ensureDirectoryExists() throws { @@ -66,6 +101,14 @@ public actor LocalSongProjectStore: ProjectStore { try data.write(to: url, options: [.atomic]) } + private func projectFileURLs() throws -> [URL] { + try FileManager.default.contentsOfDirectory( + at: directoryURL, + includingPropertiesForKeys: nil + ) + .filter { $0.pathExtension == Self.fileExtension } + } + private func projectURL(for id: String) -> URL { directoryURL .appendingPathComponent(id) diff --git a/Tests/MusicAssistantCoreTests/LocalSongProjectStoreTests.swift b/Tests/MusicAssistantCoreTests/LocalSongProjectStoreTests.swift index f31cf27..1819403 100644 --- a/Tests/MusicAssistantCoreTests/LocalSongProjectStoreTests.swift +++ b/Tests/MusicAssistantCoreTests/LocalSongProjectStoreTests.swift @@ -20,11 +20,15 @@ final class LocalSongProjectStoreTests: XCTestCase { func testCreatesOpensSavesAndListsSongProjectsLocally() async throws { let store = LocalSongProjectStore(directoryURL: temporaryDirectoryURL) + let createdAt = Date(timeIntervalSince1970: 100) + let updatedAt = Date(timeIntervalSince1970: 200) var project = SongProject( id: "local-project", title: "Local Project", idea: "Persist this song locally", - genres: [GenreStyle(id: "pop", name: "Pop")] + genres: [GenreStyle(id: "pop", name: "Pop")], + createdAt: createdAt, + updatedAt: updatedAt ) try await store.create(project) @@ -44,6 +48,39 @@ final class LocalSongProjectStoreTests: XCTestCase { XCTAssertEqual(updatedProject, project) } + func testLoadsProjectListSortedByMostRecentUpdate() async throws { + let store = LocalSongProjectStore(directoryURL: temporaryDirectoryURL) + let older = SongProject( + id: "older-project", + title: "Older", + idea: "Created first", + createdAt: Date(timeIntervalSince1970: 100), + updatedAt: Date(timeIntervalSince1970: 150) + ) + let newer = SongProject( + id: "newer-project", + title: "Newer", + idea: "Updated later", + createdAt: Date(timeIntervalSince1970: 200), + updatedAt: Date(timeIntervalSince1970: 300) + ) + + try await store.create(older) + try await store.create(newer) + + let projectList = try await store.loadProjectList() + let projectIDs = try await store.loadProjectIDs() + + XCTAssertEqual( + projectList, + [ + SongProjectListItem(project: newer), + SongProjectListItem(project: older) + ] + ) + XCTAssertEqual(projectIDs, ["newer-project", "older-project"]) + } + func testCreateDoesNotOverwriteExistingProject() async throws { let store = LocalSongProjectStore(directoryURL: temporaryDirectoryURL) let project = SongProject( diff --git a/docs/TASKS.md b/docs/TASKS.md index 903cf01..2e2a60d 100644 --- a/docs/TASKS.md +++ b/docs/TASKS.md @@ -33,7 +33,7 @@ requirement is missing and blocks implementation, record it in ## Phase 2 --- Project Persistence & Versions - [x] Create new/open/save song projects locally. -- [ ] Implement project list. +- [x] Implement project list. - [ ] Implement immutable or snapshot-based version history. - [ ] Restore a previous version without destroying later versions. - [ ] Add autosave strategy that does not create excessive versions.