From 57766c990ecff9843c103d3e2554586996338311 Mon Sep 17 00:00:00 2001 From: Michael Housh Date: Thu, 5 Feb 2026 16:39:40 -0500 Subject: [PATCH] feat: Initial csv parsing for uploading rooms for a project. Need to style the upload form. --- .gitignore | 1 + Package.swift | 16 +++ Sources/CSVParser/Interface.swift | 43 +++++++ Sources/CSVParser/Internal/Room+parsing.swift | 51 +++++++++ Sources/DatabaseClient/Interface.swift | 4 +- Sources/DatabaseClient/Internal/Rooms.swift | 10 +- Sources/ManualDCore/Room.swift | 12 +- Sources/ManualDCore/Routes/ViewRoute.swift | 20 +++- Sources/ViewController/Live.swift | 11 +- .../ViewController/Views/Rooms/RoomForm.swift | 2 - .../Views/Rooms/RoomsView.swift | 17 +++ Tests/CSVParsingTests/CSVParsingTests.swift | 22 ++++ Tests/DatabaseClientTests/ProjectTests.swift | 6 +- Tests/DatabaseClientTests/RoomTests.swift | 107 +++--------------- .../DatabaseClientTests/TrunkSizeTests.swift | 6 +- .../ViewControllerTests/projectDetail.2.html | 15 ++- 16 files changed, 226 insertions(+), 117 deletions(-) create mode 100644 Sources/CSVParser/Interface.swift create mode 100644 Sources/CSVParser/Internal/Room+parsing.swift create mode 100644 Tests/CSVParsingTests/CSVParsingTests.swift diff --git a/.gitignore b/.gitignore index 6ae492c..dbffbf4 100644 --- a/.gitignore +++ b/.gitignore @@ -14,3 +14,4 @@ tailwindcss .env .env* default.profraw +rooms.csv diff --git a/Package.swift b/Package.swift index 234d501..903f1d4 100644 --- a/Package.swift +++ b/Package.swift @@ -7,6 +7,7 @@ let package = Package( products: [ .executable(name: "App", targets: ["App"]), .library(name: "AuthClient", targets: ["AuthClient"]), + .library(name: "CSVParser", targets: ["CSVParser"]), .library(name: "DatabaseClient", targets: ["DatabaseClient"]), .library(name: "EnvVars", targets: ["EnvVars"]), .library(name: "FileClient", targets: ["FileClient"]), @@ -62,6 +63,20 @@ let package = Package( .product(name: "DependenciesMacros", package: "swift-dependencies"), ] ), + .target( + name: "CSVParser", + dependencies: [ + .target(name: "ManualDCore"), + .product(name: "Dependencies", package: "swift-dependencies"), + .product(name: "DependenciesMacros", package: "swift-dependencies"), + ] + ), + .testTarget( + name: "CSVParsingTests", + dependencies: [ + .target(name: "CSVParser") + ] + ), .target( name: "DatabaseClient", dependencies: [ @@ -172,6 +187,7 @@ let package = Package( name: "ViewController", dependencies: [ .target(name: "AuthClient"), + .target(name: "CSVParser"), .target(name: "DatabaseClient"), .target(name: "PdfClient"), .target(name: "ProjectClient"), diff --git a/Sources/CSVParser/Interface.swift b/Sources/CSVParser/Interface.swift new file mode 100644 index 0000000..7a5b2b4 --- /dev/null +++ b/Sources/CSVParser/Interface.swift @@ -0,0 +1,43 @@ +import Dependencies +import DependenciesMacros +import ManualDCore +import Parsing + +extension DependencyValues { + public var csvParser: CSVParser { + get { self[CSVParser.self] } + set { self[CSVParser.self] = newValue } + } +} + +@DependencyClient +public struct CSVParser: Sendable { + public var parseRooms: @Sendable (Room.CSV) async throws -> [Room.Create] +} + +extension CSVParser: DependencyKey { + public static let testValue = Self() + + public static let liveValue = Self( + parseRooms: { csv in + guard let string = String(data: csv.file, encoding: .utf8) else { + throw CSVParsingError("Unreadable file data") + } + let rows = try RoomCSVParser().parse(string[...].utf8) + let rooms = rows.reduce(into: [Room.Create]()) { + if case .room(let room) = $1 { + $0.append(room) + } + } + return rooms + } + ) +} + +public struct CSVParsingError: Error { + let reason: String + + public init(_ reason: String) { + self.reason = reason + } +} diff --git a/Sources/CSVParser/Internal/Room+parsing.swift b/Sources/CSVParser/Internal/Room+parsing.swift new file mode 100644 index 0000000..6a52db9 --- /dev/null +++ b/Sources/CSVParser/Internal/Room+parsing.swift @@ -0,0 +1,51 @@ +import ManualDCore +import Parsing + +struct RoomCSVParser: Parser { + var body: some Parser { + Many { + RoomRowParser() + } separator: { + "\n".utf8 + } + } +} + +struct RoomRowParser: Parser { + + var body: some Parser { + OneOf { + RoomCreateParser().map { RoomRowType.room($0) } + Prefix { $0 != UInt8(ascii: "\n") } + .map(.string) + .map { RoomRowType.header($0) } + } + } +} + +enum RoomRowType { + case header(String) + case room(Room.Create) +} + +struct RoomCreateParser: ParserPrinter { + + var body: some ParserPrinter { + ParsePrint { + Prefix { $0 != UInt8(ascii: ",") }.map(.string) + ",".utf8 + Double.parser() + ",".utf8 + Optionally { + Double.parser() + } + ",".utf8 + Optionally { + Double.parser() + } + ",".utf8 + Int.parser() + } + .map(.memberwise(Room.Create.init)) + } +} diff --git a/Sources/DatabaseClient/Interface.swift b/Sources/DatabaseClient/Interface.swift index 35a657c..b71475d 100644 --- a/Sources/DatabaseClient/Interface.swift +++ b/Sources/DatabaseClient/Interface.swift @@ -89,8 +89,8 @@ public struct DatabaseClient: Sendable { @DependencyClient public struct Rooms: Sendable { - public var create: @Sendable (Room.Create) async throws -> Room - public var createMany: @Sendable ([Room.Create]) async throws -> [Room] + public var create: @Sendable (Project.ID, Room.Create) async throws -> Room + public var createMany: @Sendable (Project.ID, [Room.Create]) async throws -> [Room] public var delete: @Sendable (Room.ID) async throws -> Void public var deleteRectangularSize: @Sendable (Room.ID, Room.RectangularSize.ID) async throws -> Room diff --git a/Sources/DatabaseClient/Internal/Rooms.swift b/Sources/DatabaseClient/Internal/Rooms.swift index 2002ea2..d7d97e2 100644 --- a/Sources/DatabaseClient/Internal/Rooms.swift +++ b/Sources/DatabaseClient/Internal/Rooms.swift @@ -10,14 +10,14 @@ extension DatabaseClient.Rooms: TestDependencyKey { public static func live(database: any Database) -> Self { .init( - create: { request in - let model = try request.toModel() + create: { projectID, request in + let model = try request.toModel(projectID: projectID) try await model.validateAndSave(on: database) return try model.toDTO() }, - createMany: { rooms in + createMany: { projectID, rooms in try await rooms.asyncMap { request in - let model = try request.toModel() + let model = try request.toModel(projectID: projectID) try await model.validateAndSave(on: database) return try model.toDTO() } @@ -83,7 +83,7 @@ extension DatabaseClient.Rooms: TestDependencyKey { extension Room.Create { - func toModel() throws -> RoomModel { + func toModel(projectID: Project.ID) throws -> RoomModel { return .init( name: name, heatingLoad: heatingLoad, diff --git a/Sources/ManualDCore/Room.swift b/Sources/ManualDCore/Room.swift index 09ab414..4567af7 100644 --- a/Sources/ManualDCore/Room.swift +++ b/Sources/ManualDCore/Room.swift @@ -96,8 +96,6 @@ public struct Room: Codable, Equatable, Identifiable, Sendable { extension Room { /// Represents the data required to create a new room for a project. public struct Create: Codable, Equatable, Sendable { - /// The project this room is associated with. - public let projectID: Project.ID /// A unique name for the room in the project. public let name: String /// The heating load required for the room (from Manual-J). @@ -114,14 +112,12 @@ extension Room { } public init( - projectID: Project.ID, name: String, heatingLoad: Double, coolingTotal: Double? = nil, coolingSensible: Double? = nil, registerCount: Int = 1 ) { - self.projectID = projectID self.name = name self.heatingLoad = heatingLoad self.coolingTotal = coolingTotal @@ -130,6 +126,14 @@ extension Room { } } + public struct CSV: Equatable, Sendable { + public let file: Data + + public init(file: Data) { + self.file = file + } + } + /// Represents a rectangular size calculation that is stored in the /// database for a given room. /// diff --git a/Sources/ManualDCore/Routes/ViewRoute.swift b/Sources/ManualDCore/Routes/ViewRoute.swift index e88dc3f..11eb2d6 100644 --- a/Sources/ManualDCore/Routes/ViewRoute.swift +++ b/Sources/ManualDCore/Routes/ViewRoute.swift @@ -188,6 +188,7 @@ extension SiteRoute.View.ProjectRoute { } public enum RoomRoute: Equatable, Sendable { + case csv(Room.CSV) case delete(id: Room.ID) case index case submit(Room.Create) @@ -197,6 +198,23 @@ extension SiteRoute.View.ProjectRoute { static let rootPath = "rooms" public static let router = OneOf { + Route(.case(Self.csv)) { + Path { + rootPath + "csv" + } + Headers { + Field("Content-Type") { "multipart/form-data" } + } + Method.post + Body().map(.memberwise(Room.CSV.init)) + // Body { + // FormData { + // + // } + // .map(.memberwise(Room.CSV.init)) + // } + } Route(.case(Self.delete)) { Path { rootPath @@ -215,7 +233,7 @@ extension SiteRoute.View.ProjectRoute { Method.post Body { FormData { - Field("projectID") { Project.ID.parser() } + // Field("projectID") { Project.ID.parser() } Field("name", .string) Field("heatingLoad") { Double.parser() } Optionally { diff --git a/Sources/ViewController/Live.swift b/Sources/ViewController/Live.swift index 6890c32..df63bc3 100644 --- a/Sources/ViewController/Live.swift +++ b/Sources/ViewController/Live.swift @@ -1,3 +1,4 @@ +import CSVParser import DatabaseClient import Dependencies import Elementary @@ -284,10 +285,18 @@ extension SiteRoute.View.ProjectRoute.RoomRoute { on request: ViewController.Request, projectID: Project.ID ) async -> AnySendableHTML { + @Dependency(\.csvParser) var csvParser @Dependency(\.database) var database switch self { + case .csv(let csv): + return await roomsView(on: request, projectID: projectID) { + let rooms = try await csvParser.parseRooms(csv) + _ = try await database.rooms.createMany(projectID, rooms) + } + // return EmptyHTML() + case .delete(let id): return await ResultView { try await database.rooms.delete(id) @@ -298,7 +307,7 @@ extension SiteRoute.View.ProjectRoute.RoomRoute { case .submit(let form): return await roomsView(on: request, projectID: projectID) { - _ = try await database.rooms.create(form) + _ = try await database.rooms.create(projectID, form) } case .update(let id, let form): diff --git a/Sources/ViewController/Views/Rooms/RoomForm.swift b/Sources/ViewController/Views/Rooms/RoomForm.swift index ef6cfa6..3a6496f 100644 --- a/Sources/ViewController/Views/Rooms/RoomForm.swift +++ b/Sources/ViewController/Views/Rooms/RoomForm.swift @@ -47,8 +47,6 @@ struct RoomForm: HTML, Sendable { .hx.swap(.outerHTML) ) { - input(.class("hidden"), .name("projectID"), .value("\(projectID)")) - if let id = room?.id { input(.class("hidden"), .name("id"), .value("\(id)")) } diff --git a/Sources/ViewController/Views/Rooms/RoomsView.swift b/Sources/ViewController/Views/Rooms/RoomsView.swift index c7021fb..91fd4c8 100644 --- a/Sources/ViewController/Views/Rooms/RoomsView.swift +++ b/Sources/ViewController/Views/Rooms/RoomsView.swift @@ -11,6 +11,11 @@ struct RoomsView: HTML, Sendable { let rooms: [Room] let sensibleHeatRatio: Double? + private var csvRoute: String { + SiteRoute.router.path(for: .view(.project(.detail(projectID, .rooms(.index))))) + .appendingPath("csv") + } + var body: some HTML { div(.class("flex w-full flex-col")) { PageTitleRow { @@ -44,6 +49,18 @@ struct RoomsView: HTML, Sendable { .attributes(.class("border border-error"), when: sensibleHeatRatio == nil) } .attributes(.class("tooltip-open"), when: sensibleHeatRatio == nil) + + Tooltip("Upload csv file", position: .left) { + form( + .hx.post(csvRoute), + .hx.target("body"), + .hx.swap(.outerHTML), + .custom(name: "enctype", value: "multipart/form-data") + ) { + input(.type(.file), .name("file"), .accept(".csv")) + SubmitButton() + } + } } div(.class("flex items-end space-x-4 font-bold")) { diff --git a/Tests/CSVParsingTests/CSVParsingTests.swift b/Tests/CSVParsingTests/CSVParsingTests.swift new file mode 100644 index 0000000..66db46f --- /dev/null +++ b/Tests/CSVParsingTests/CSVParsingTests.swift @@ -0,0 +1,22 @@ +import CSVParser +import Foundation +import Testing + +@Suite +struct CSVParsingTests { + + @Test + func roomParsing() async throws { + + let parser = CSVParser.liveValue + + let input = """ + Name,Heating Load,Cooling Total,Cooling Sensible,Register Count + Bed-1,12345,12345,,2 + Bed-2,1223,,1123,1 + """ + let rooms = try await parser.parseRooms(.init(file: Data(input.utf8))) + + #expect(rooms.count == 2) + } +} diff --git a/Tests/DatabaseClientTests/ProjectTests.swift b/Tests/DatabaseClientTests/ProjectTests.swift index a377c41..0e48073 100644 --- a/Tests/DatabaseClientTests/ProjectTests.swift +++ b/Tests/DatabaseClientTests/ProjectTests.swift @@ -85,7 +85,8 @@ struct ProjectTests { #expect(completed.frictionRate == true) _ = try await database.rooms.create( - .init(projectID: project.id, name: "Test", heatingLoad: 12345, coolingTotal: 12345) + project.id, + .init(name: "Test", heatingLoad: 12345, coolingTotal: 12345) ) completed = try await database.projects.getCompletedSteps(project.id) #expect(completed.rooms == true) @@ -130,7 +131,8 @@ struct ProjectTests { .init(projectID: project.id, name: "Test", value: 0.2) ) let room = try await database.rooms.create( - .init(projectID: project.id, name: "Test", heatingLoad: 12345, coolingTotal: 12345) + project.id, + .init(name: "Test", heatingLoad: 12345, coolingTotal: 12345) ) let supplyLength = try await database.equivalentLengths.create( .init( diff --git a/Tests/DatabaseClientTests/RoomTests.swift b/Tests/DatabaseClientTests/RoomTests.swift index dada7c9..8107e7e 100644 --- a/Tests/DatabaseClientTests/RoomTests.swift +++ b/Tests/DatabaseClientTests/RoomTests.swift @@ -15,7 +15,8 @@ struct RoomTests { @Dependency(\.database.rooms) var rooms let room = try await rooms.create( - .init(projectID: project.id, name: "Test", heatingLoad: 1234, coolingTotal: 1234) + project.id, + .init(name: "Test", heatingLoad: 1234, coolingTotal: 1234) ) let fetched = try await rooms.fetch(project.id) @@ -48,10 +49,13 @@ struct RoomTests { try await withTestUserAndProject { _, project in @Dependency(\.database.rooms) var rooms - let created = try await rooms.createMany([ - .init(projectID: project.id, name: "Test 1", heatingLoad: 1234, coolingTotal: 1234), - .init(projectID: project.id, name: "Test 2", heatingLoad: 1234, coolingTotal: 1234), - ]) + let created = try await rooms.createMany( + project.id, + [ + .init(name: "Test 1", heatingLoad: 1234, coolingTotal: 1234), + .init(name: "Test 2", heatingLoad: 1234, coolingTotal: 1234), + ] + ) #expect(created.count == 2) #expect(created[0].name == "Test 1") @@ -85,7 +89,7 @@ struct RoomTests { @Test( arguments: [ Room.Create( - projectID: UUID(0), + // projectID: UUID(0), name: "", heatingLoad: 12345, coolingTotal: 12344, @@ -93,7 +97,7 @@ struct RoomTests { registerCount: 1 ), Room.Create( - projectID: UUID(0), + // projectID: UUID(0), name: "Test", heatingLoad: -12345, coolingTotal: 12344, @@ -101,7 +105,7 @@ struct RoomTests { registerCount: 1 ), Room.Create( - projectID: UUID(0), + // projectID: UUID(0), name: "Test", heatingLoad: 12345, coolingTotal: -12344, @@ -109,7 +113,7 @@ struct RoomTests { registerCount: 1 ), Room.Create( - projectID: UUID(0), + // projectID: UUID(0), name: "Test", heatingLoad: 12345, coolingTotal: 12344, @@ -117,7 +121,7 @@ struct RoomTests { registerCount: 1 ), Room.Create( - projectID: UUID(0), + // projectID: UUID(0), name: "Test", heatingLoad: 12345, coolingTotal: 12344, @@ -125,7 +129,7 @@ struct RoomTests { registerCount: -1 ), Room.Create( - projectID: UUID(0), + // projectID: UUID(0), name: "", heatingLoad: -12345, coolingTotal: -12344, @@ -133,7 +137,7 @@ struct RoomTests { registerCount: -1 ), Room.Create( - projectID: UUID(0), + // projectID: UUID(0), name: "Test", heatingLoad: 12345, coolingTotal: nil, @@ -145,89 +149,12 @@ struct RoomTests { func validations(room: Room.Create) throws { #expect(throws: (any Error).self) { // do { - try room.toModel().validate() + try room.toModel(projectID: UUID(0)).validate() // } catch { // print("\(error)") // throw error // } } } - - @Test - func csvParsing() throws { - let input = """ - Name,Heating Load,Cooling Total,Cooling Sensible,Register Count - Bed-1,12345,12345,,2 - Bed-2,1223,,1123,1 - """[...].utf8 - - let commaSeparator = ParsePrint { - OneOf { - ",".utf8 - ", ".utf8 - } - } - - let rowParser = ParsePrint { - Prefix { $0 != UInt8(ascii: ",") }.map(.string) - ",".utf8 - Double.parser() - Skip { commaSeparator } - // ",".utf8 - Optionally { - Double.parser() - } - Skip { commaSeparator } - // ",".utf8 - Optionally { - Double.parser() - } - Skip { commaSeparator } - // ",".utf8 - Int.parser() - } - .map(.memberwise(Row.init)) - - let csvRowParser = OneOf { - rowParser.map { CSVRowType.row($0) } - Prefix { $0 != UInt8(ascii: "\n") }.map(.string).map { CSVRowType.header($0) } - } - - let rowsParser = Many { - csvRowParser - } separator: { - "\n".utf8 - } - - let rows = try rowsParser.parse(input) - print("rows: \(rows)") - #expect(rows.count == 3) - #expect(rows.rows.count == 2) - - print(String(try rowParser.print(rows.rows.first!))!) - } } -enum CSVRowType { - case header(String) - case row(Row) -} - -struct Row { - let name: String - let heatingLoad: Double - let coolingTotal: Double? - let coolingSensible: Double? - let registerCount: Int -} - -extension Array where Element == CSVRowType { - var rows: [Row] { - reduce(into: [Row]()) { - if case .row(let row) = $1 { - $0.append(row) - } - } - - } -} diff --git a/Tests/DatabaseClientTests/TrunkSizeTests.swift b/Tests/DatabaseClientTests/TrunkSizeTests.swift index c80c017..67afde9 100644 --- a/Tests/DatabaseClientTests/TrunkSizeTests.swift +++ b/Tests/DatabaseClientTests/TrunkSizeTests.swift @@ -14,9 +14,11 @@ struct TrunkSizeTests { @Dependency(\.database) var database let room = try await database.rooms.create( + project.id, .init( - projectID: project.id, name: "Test", heatingLoad: 12345, coolingTotal: 12345, - coolingSensible: nil, registerCount: 5) + name: "Test", heatingLoad: 12345, coolingTotal: 12345, + coolingSensible: nil, registerCount: 5 + ) ) let trunk = try await database.trunkSizes.create( diff --git a/Tests/ViewControllerTests/__Snapshots__/ViewControllerTests/projectDetail.2.html b/Tests/ViewControllerTests/__Snapshots__/ViewControllerTests/projectDetail.2.html index b3257ad..37b1222 100644 --- a/Tests/ViewControllerTests/__Snapshots__/ViewControllerTests/projectDetail.2.html +++ b/Tests/ViewControllerTests/__Snapshots__/ViewControllerTests/projectDetail.2.html @@ -63,6 +63,12 @@ p-6 w-full"> +
+
+ + +
+
Heating Total @@ -151,7 +157,6 @@ p-6 w-full">

Room

- Name