import Foundation public protocol ProjectStore: Sendable { func create(_ project: SongProject) async throws 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 { private let directoryURL: URL private let encoder: JSONEncoder private let decoder: JSONDecoder public init(directoryURL: URL) { self.directoryURL = directoryURL self.encoder = JSONEncoder() self.decoder = JSONDecoder() encoder.outputFormatting = [.prettyPrinted, .sortedKeys] } public func create(_ project: SongProject) async throws { try ensureDirectoryExists() let url = projectURL(for: project.id) guard !FileManager.default.fileExists(atPath: url.path) else { throw LocalSongProjectStoreError.projectAlreadyExists(project.id) } try saveEncoded(project, to: url) } public func save(_ project: SongProject) async throws { try ensureDirectoryExists() try saveEncoded(project, to: projectURL(for: project.id)) } public func open(id: String) async throws -> SongProject { let url = projectURL(for: id) guard FileManager.default.fileExists(atPath: url.path) else { throw LocalSongProjectStoreError.projectNotFound(id) } let data = try Data(contentsOf: url) return try decoder.decode(SongProject.self, from: data) } public func loadProjectIDs() async throws -> [String] { try await loadProjectList().map(\.id) } public func loadProjectList() async throws -> [SongProjectListItem] { try ensureDirectoryExists() 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 { try FileManager.default.createDirectory( at: directoryURL, withIntermediateDirectories: true ) } private func saveEncoded(_ project: SongProject, to url: URL) throws { let data = try encoder.encode(project) 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) .appendingPathExtension(Self.fileExtension) } private static let fileExtension = "json" } public enum LocalSongProjectStoreError: Error, Equatable, Sendable { case projectAlreadyExists(String) case projectNotFound(String) }