Implement song project list
This commit is contained in:
@@ -5,6 +5,33 @@ public protocol ProjectStore: Sendable {
|
|||||||
func save(_ project: SongProject) async throws
|
func save(_ project: SongProject) async throws
|
||||||
func open(id: String) async throws -> SongProject
|
func open(id: String) async throws -> SongProject
|
||||||
func loadProjectIDs() async throws -> [String]
|
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 {
|
public actor LocalSongProjectStore: ProjectStore {
|
||||||
@@ -43,15 +70,23 @@ public actor LocalSongProjectStore: ProjectStore {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public func loadProjectIDs() async throws -> [String] {
|
public func loadProjectIDs() async throws -> [String] {
|
||||||
|
try await loadProjectList().map(\.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
public func loadProjectList() async throws -> [SongProjectListItem] {
|
||||||
try ensureDirectoryExists()
|
try ensureDirectoryExists()
|
||||||
let fileURLs = try FileManager.default.contentsOfDirectory(
|
return try projectFileURLs()
|
||||||
at: directoryURL,
|
.map { url in
|
||||||
includingPropertiesForKeys: nil
|
let data = try Data(contentsOf: url)
|
||||||
)
|
let project = try decoder.decode(SongProject.self, from: data)
|
||||||
return fileURLs
|
return SongProjectListItem(project: project)
|
||||||
.filter { $0.pathExtension == Self.fileExtension }
|
}
|
||||||
.map { $0.deletingPathExtension().lastPathComponent }
|
.sorted { left, right in
|
||||||
.sorted()
|
if left.updatedAt == right.updatedAt {
|
||||||
|
return left.title.localizedStandardCompare(right.title) == .orderedAscending
|
||||||
|
}
|
||||||
|
return left.updatedAt > right.updatedAt
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func ensureDirectoryExists() throws {
|
private func ensureDirectoryExists() throws {
|
||||||
@@ -66,6 +101,14 @@ public actor LocalSongProjectStore: ProjectStore {
|
|||||||
try data.write(to: url, options: [.atomic])
|
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 {
|
private func projectURL(for id: String) -> URL {
|
||||||
directoryURL
|
directoryURL
|
||||||
.appendingPathComponent(id)
|
.appendingPathComponent(id)
|
||||||
|
|||||||
@@ -20,11 +20,15 @@ final class LocalSongProjectStoreTests: XCTestCase {
|
|||||||
|
|
||||||
func testCreatesOpensSavesAndListsSongProjectsLocally() async throws {
|
func testCreatesOpensSavesAndListsSongProjectsLocally() async throws {
|
||||||
let store = LocalSongProjectStore(directoryURL: temporaryDirectoryURL)
|
let store = LocalSongProjectStore(directoryURL: temporaryDirectoryURL)
|
||||||
|
let createdAt = Date(timeIntervalSince1970: 100)
|
||||||
|
let updatedAt = Date(timeIntervalSince1970: 200)
|
||||||
var project = SongProject(
|
var project = SongProject(
|
||||||
id: "local-project",
|
id: "local-project",
|
||||||
title: "Local Project",
|
title: "Local Project",
|
||||||
idea: "Persist this song locally",
|
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)
|
try await store.create(project)
|
||||||
@@ -44,6 +48,39 @@ final class LocalSongProjectStoreTests: XCTestCase {
|
|||||||
XCTAssertEqual(updatedProject, project)
|
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 {
|
func testCreateDoesNotOverwriteExistingProject() async throws {
|
||||||
let store = LocalSongProjectStore(directoryURL: temporaryDirectoryURL)
|
let store = LocalSongProjectStore(directoryURL: temporaryDirectoryURL)
|
||||||
let project = SongProject(
|
let project = SongProject(
|
||||||
|
|||||||
+1
-1
@@ -33,7 +33,7 @@ requirement is missing and blocks implementation, record it in
|
|||||||
## Phase 2 --- Project Persistence & Versions
|
## Phase 2 --- Project Persistence & Versions
|
||||||
|
|
||||||
- [x] Create new/open/save song projects locally.
|
- [x] Create new/open/save song projects locally.
|
||||||
- [ ] Implement project list.
|
- [x] Implement project list.
|
||||||
- [ ] Implement immutable or snapshot-based version history.
|
- [ ] Implement immutable or snapshot-based version history.
|
||||||
- [ ] Restore a previous version without destroying later versions.
|
- [ ] Restore a previous version without destroying later versions.
|
||||||
- [ ] Add autosave strategy that does not create excessive versions.
|
- [ ] Add autosave strategy that does not create excessive versions.
|
||||||
|
|||||||
Reference in New Issue
Block a user