2 Commits

Author SHA1 Message Date
c82f20bb60 WIP: Mostly done with pdf client, need to add tests. 2026-01-28 15:47:56 -05:00
458b3bd644 feat: Adds EnvClient 2026-01-28 11:34:52 -05:00
16 changed files with 285 additions and 191 deletions

View File

@@ -9,6 +9,7 @@ let package = Package(
.library(name: "ApiController", targets: ["ApiController"]), .library(name: "ApiController", targets: ["ApiController"]),
.library(name: "AuthClient", targets: ["AuthClient"]), .library(name: "AuthClient", targets: ["AuthClient"]),
.library(name: "DatabaseClient", targets: ["DatabaseClient"]), .library(name: "DatabaseClient", targets: ["DatabaseClient"]),
.library(name: "EnvClient", targets: ["EnvClient"]),
.library(name: "FileClient", targets: ["FileClient"]), .library(name: "FileClient", targets: ["FileClient"]),
.library(name: "HTMLSnapshotTesting", targets: ["HTMLSnapshotTesting"]), .library(name: "HTMLSnapshotTesting", targets: ["HTMLSnapshotTesting"]),
.library(name: "PdfClient", targets: ["PdfClient"]), .library(name: "PdfClient", targets: ["PdfClient"]),
@@ -79,6 +80,14 @@ let package = Package(
.product(name: "Vapor", package: "vapor"), .product(name: "Vapor", package: "vapor"),
] ]
), ),
.target(
name: "EnvClient",
dependencies: [
.product(name: "Dependencies", package: "swift-dependencies"),
.product(name: "DependenciesMacros", package: "swift-dependencies"),
]
),
.target( .target(
name: "FileClient", name: "FileClient",
dependencies: [ dependencies: [
@@ -96,6 +105,8 @@ let package = Package(
.target( .target(
name: "PdfClient", name: "PdfClient",
dependencies: [ dependencies: [
.target(name: "EnvClient"),
.target(name: "FileClient"),
.target(name: "ManualDCore"), .target(name: "ManualDCore"),
.product(name: "Dependencies", package: "swift-dependencies"), .product(name: "Dependencies", package: "swift-dependencies"),
.product(name: "DependenciesMacros", package: "swift-dependencies"), .product(name: "DependenciesMacros", package: "swift-dependencies"),
@@ -108,6 +119,7 @@ let package = Package(
.target(name: "DatabaseClient"), .target(name: "DatabaseClient"),
.target(name: "ManualDClient"), .target(name: "ManualDClient"),
.target(name: "PdfClient"), .target(name: "PdfClient"),
.product(name: "Vapor", package: "vapor"),
] ]
), ),
.target( .target(

12
Public/css/htmx.css Normal file
View File

@@ -0,0 +1,12 @@
.htmx-indicator {
display: none;
}
.htmx-request .htmx-indicator {
display: inline;
}
.htmx-request.htmx-indicator {
display: inline;
}

View File

@@ -2,4 +2,3 @@
@plugin "daisyui" { @plugin "daisyui" {
themes: all; themes: all;
} }

View File

@@ -8548,10 +8548,6 @@
.italic { .italic {
font-style: italic; font-style: italic;
} }
.lining-nums {
--tw-numeric-figure: lining-nums;
font-variant-numeric: var(--tw-ordinal,) var(--tw-slashed-zero,) var(--tw-numeric-figure,) var(--tw-numeric-spacing,) var(--tw-numeric-fraction,);
}
.tabular-nums { .tabular-nums {
--tw-numeric-spacing: tabular-nums; --tw-numeric-spacing: tabular-nums;
font-variant-numeric: var(--tw-ordinal,) var(--tw-slashed-zero,) var(--tw-numeric-figure,) var(--tw-numeric-spacing,) var(--tw-numeric-fraction,); font-variant-numeric: var(--tw-ordinal,) var(--tw-slashed-zero,) var(--tw-numeric-figure,) var(--tw-numeric-spacing,) var(--tw-numeric-fraction,);

View File

@@ -0,0 +1,63 @@
// Copied from: https://github.com/dakixr/htmx-download/blob/main/htmx-download.js
htmx.defineExtension('htmx-download', {
onEvent: function(name, evt) {
if (name === 'htmx:beforeRequest') {
// Set the responseType to 'arraybuffer' to handle binary data
evt.detail.xhr.responseType = 'arraybuffer';
}
if (name === 'htmx:beforeSwap') {
const xhr = evt.detail.xhr;
if (xhr.status === 200) {
// Parse headers
const headers = {};
const headerStr = xhr.getAllResponseHeaders();
const headerArr = headerStr.trim().split(/[\r\n]+/);
headerArr.forEach((line) => {
const parts = line.split(": ");
const header = parts.shift().toLowerCase();
const value = parts.join(": ");
headers[header] = value;
});
// Extract filename
let filename = 'downloaded_file.xlsx';
if (headers['content-disposition']) {
const filenameMatch = headers['content-disposition'].match(/filename\*?=(?:UTF-8'')?"?([^;\n"]+)/i);
if (filenameMatch && filenameMatch[1]) {
filename = decodeURIComponent(filenameMatch[1].replace(/['"]/g, ''));
}
}
// Determine MIME type
const mimetype = headers['content-type'] || 'application/octet-stream';
// Create Blob
const blob = new Blob([xhr.response], { type: mimetype });
const url = URL.createObjectURL(blob);
// Trigger download
const link = document.createElement("a");
link.style.display = "none";
link.href = url;
link.download = filename;
document.body.appendChild(link);
link.click();
// Cleanup
setTimeout(() => {
URL.revokeObjectURL(url);
link.remove();
}, 100);
} else {
console.warn(`[htmx-download] Unexpected response status: ${xhr.status}`);
}
// Prevent htmx from swapping content
evt.detail.shouldSwap = false;
}
},
});

View File

@@ -117,37 +117,38 @@ extension DuctSizes: Content {}
// FIX: Move // FIX: Move
func handlePdf(_ projectID: Project.ID, on request: Request) async throws -> Response { func handlePdf(_ projectID: Project.ID, on request: Request) async throws -> Response {
@Dependency(\.projectClient) var projectClient @Dependency(\.projectClient) var projectClient
return try await projectClient.generatePdf(projectID, request.fileio)
let html = try await projectClient.toHTML(projectID) // let html = try await projectClient.toHTML(projectID)
let url = "/tmp/\(projectID)" // let url = "/tmp/\(projectID)"
try await request.fileio.writeFile(.init(string: html.render()), at: "\(url).html") // try await request.fileio.writeFile(.init(string: html.render()), at: "\(url).html")
//
let process = Process() // let process = Process()
let standardInput = Pipe() // let standardInput = Pipe()
let standardOutput = Pipe() // let standardOutput = Pipe()
process.standardInput = standardInput // process.standardInput = standardInput
process.standardOutput = standardOutput // process.standardOutput = standardOutput
process.executableURL = URL(fileURLWithPath: "/bin/pandoc") // process.executableURL = URL(fileURLWithPath: "/bin/pandoc")
process.arguments = [ // process.arguments = [
"\(url).html", // "\(url).html",
"--pdf-engine=weasyprint", // "--pdf-engine=weasyprint",
"--from=html", // "--from=html",
"--css=Public/css/pdf.css", // "--css=Public/css/pdf.css",
"-o", "\(url).pdf", // "-o", "\(url).pdf",
] // ]
try process.run() // try process.run()
process.waitUntilExit() // process.waitUntilExit()
//
let response = try await request.fileio.asyncStreamFile(at: "\(url).pdf", mediaType: .pdf) { _ in // let response = try await request.fileio.asyncStreamFile(at: "\(url).pdf", mediaType: .pdf) { _ in
// Remove files here. // // Remove files here.
try FileManager.default.removeItem(atPath: "\(url).pdf") // try FileManager.default.removeItem(atPath: "\(url).pdf")
try FileManager.default.removeItem(atPath: "\(url).html") // try FileManager.default.removeItem(atPath: "\(url).html")
} // }
response.headers.replaceOrAdd(name: .contentType, value: "application/octet-stream") // response.headers.replaceOrAdd(name: .contentType, value: "application/octet-stream")
response.headers.replaceOrAdd( // response.headers.replaceOrAdd(
name: .contentDisposition, value: "attachment; filename=Duct-Calc.pdf" // name: .contentDisposition, value: "attachment; filename=Duct-Calc.pdf"
) // )
return response // return response
} }
@Sendable @Sendable

View File

@@ -0,0 +1,74 @@
import Dependencies
import DependenciesMacros
import Foundation
extension DependencyValues {
/// Holds values defined in the process environment that are needed.
///
/// These are generally loaded from a `.env` file, but also have default values,
/// if not found.
public var env: @Sendable () throws -> EnvVars {
get { self[EnvClient.self].env }
set { self[EnvClient.self].env = newValue }
}
}
@DependencyClient
struct EnvClient: Sendable {
public var env: @Sendable () throws -> EnvVars
}
/// Holds values defined in the process environment that are needed.
///
/// These are generally loaded from a `.env` file, but also have default values,
/// if not found.
public struct EnvVars: Codable, Equatable, Sendable {
/// The path to the pandoc executable on the system, used to generate pdf's.
public let pandocPath: String
/// The pdf engine to use with pandoc when creating pdf's.
public let pdfEngine: String
public init(
pandocPath: String = "/bin/pandoc",
pdfEngine: String = "weasyprint"
) {
self.pandocPath = pandocPath
self.pdfEngine = pdfEngine
}
enum CodingKeys: String, CodingKey {
case pandocPath = "PANDOC_PATH"
case pdfEngine = "PDF_ENGINE"
}
}
extension EnvClient: DependencyKey {
static let testValue = Self()
static let liveValue = Self(env: {
// Convert default values into a dictionary.
let defaults =
(try? encoder.encode(EnvVars()))
.flatMap { try? decoder.decode([String: String].self, from: $0) }
?? [:]
// Merge the default values with values found in process environment.
let assigned = defaults.merging(ProcessInfo.processInfo.environment, uniquingKeysWith: { $1 })
return (try? JSONSerialization.data(withJSONObject: assigned))
.flatMap { try? decoder.decode(EnvVars.self, from: $0) }
?? .init()
})
}
private let encoder: JSONEncoder = {
JSONEncoder()
}()
private let decoder: JSONDecoder = {
JSONDecoder()
}()

View File

@@ -1,6 +1,9 @@
import Dependencies import Dependencies
import DependenciesMacros import DependenciesMacros
import Elementary import Elementary
import EnvClient
import FileClient
import Foundation
import ManualDCore import ManualDCore
extension DependencyValues { extension DependencyValues {
@@ -14,7 +17,13 @@ extension DependencyValues {
@DependencyClient @DependencyClient
public struct PdfClient: Sendable { public struct PdfClient: Sendable {
public var html: @Sendable (Request) async throws -> (any HTML & Sendable) public var html: @Sendable (Request) async throws -> (any HTML & Sendable)
public var markdown: @Sendable (Request) async throws -> String public var generatePdf: @Sendable (Project.ID, any HTML & Sendable) async throws -> Response
public func generatePdf(request: Request) async throws -> Response {
let html = try await self.html(request)
return try await self.generatePdf(request.project.id, html)
}
} }
extension PdfClient: DependencyKey { extension PdfClient: DependencyKey {
@@ -24,8 +33,32 @@ extension PdfClient: DependencyKey {
html: { request in html: { request in
request.toHTML() request.toHTML()
}, },
markdown: { request in generatePdf: { projectID, html in
request.toMarkdown() @Dependency(\.fileClient) var fileClient
@Dependency(\.env) var env
let envVars = try env()
let baseUrl = "/tmp/\(projectID)"
try await fileClient.writeFile(html.render(), "\(baseUrl).html")
let process = Process()
let standardInput = Pipe()
let standardOutput = Pipe()
process.standardInput = standardInput
process.standardOutput = standardOutput
process.executableURL = URL(fileURLWithPath: envVars.pandocPath)
process.arguments = [
"\(baseUrl).html",
"--pdf-engine=\(envVars.pdfEngine)",
"--from=html",
"--css=Public/css/pdf.css",
"--output=\(baseUrl).pdf",
]
try process.run()
process.waitUntilExit()
return .init(htmlPath: "\(baseUrl).html", pdfPath: "\(baseUrl).pdf")
} }
) )
} }
@@ -44,6 +77,10 @@ extension PdfClient {
public let frictionRate: FrictionRate public let frictionRate: FrictionRate
public let projectSHR: Double public let projectSHR: Double
var totalEquivalentLength: Double {
maxReturnTEL.totalEquivalentLength + maxSupplyTEL.totalEquivalentLength
}
public init( public init(
project: Project, project: Project,
rooms: [Room], rooms: [Room],
@@ -66,6 +103,17 @@ extension PdfClient {
self.projectSHR = projectSHR self.projectSHR = projectSHR
} }
} }
public struct Response: Equatable, Sendable {
public let htmlPath: String
public let pdfPath: String
public init(htmlPath: String, pdfPath: String) {
self.htmlPath = htmlPath
self.pdfPath = pdfPath
}
}
} }
#if DEBUG #if DEBUG

View File

@@ -63,7 +63,6 @@ struct PdfDocument: HTMLDocument {
} }
} }
} }
div(.class("section")) { div(.class("section")) {
h2 { "Duct Sizes" } h2 { "Duct Sizes" }
DuctSizesTable(rooms: request.ductSizes.rooms) DuctSizesTable(rooms: request.ductSizes.rooms)

View File

@@ -1,113 +0,0 @@
import Foundation
import ManualDCore
extension PdfClient.Request {
func toMarkdown() -> String {
var retval = """
# Duct Calc
**Name:** \(project.name)
**Address:** \(project.streetAddress)
\(project.city), \(project.state) \(project.zipCode)
## Equipment
| | Value |
|:----------------|:--------------------------------|
| Static Pressure | \(equipmentInfo.staticPressure.string()) |
| Heating CFM | \(equipmentInfo.heatingCFM.string()) |
| Cooling CFM | \(equipmentInfo.coolingCFM.string()) |
## Friction Rate
| Component Loss | Value |
|:----------------|:--------------------------------|
"""
for row in componentLosses {
retval += "\(componentLossRow(row))\n"
}
retval += """
| Results | Value |
|:-----------------|:---------------------------------|
| Available Static Pressure | \(frictionRate.availableStaticPressure.string()) |
| Total Equivalent Length | \(totalEquivalentLength.string()) |
| Friction Rate Design Value | \(frictionRate.value.string()) |
## Duct Sizes
| Register | Dsn CFM | Round Size | Velocity | Final Size | Flex Size | Height | Width |
|:---------|:--------|:----------------|:---------|:-----------|:----------|:-------|:------|
"""
for row in ductSizes.rooms {
retval += "\(registerRow(row))\n"
}
retval += """
## Trunk Sizes
### Supply Trunks
| Name | Associated Supplies | Dsn CFM | Velocity | Final Size | Flex Size | Height | Width |
|:---------|:--------------------|:--------|:---------|:-----------|:----------|:-------|:------|
"""
for row in ductSizes.trunks.filter({ $0.type == .supply }) {
retval += "\(trunkRow(row))\n"
}
retval += """
### Return Trunks / Run Outs
| Name | Associated Supplies | Dsn CFM | Velocity | Final Size | Flex Size | Height | Width |
|:---------|:--------------------|:--------|:---------|:-----------|:----------|:-------|:------|
"""
for row in ductSizes.trunks.filter({ $0.type == .return }) {
retval += "\(trunkRow(row))\n"
}
return retval
}
func registerRow(_ row: DuctSizes.RoomContainer) -> String {
return """
| \(row.roomName) | \(row.designCFM.value.string(digits: 0)) | \(row.roundSize.string()) | \(row.velocity.string()) | \(row.finalSize.string()) | \(row.flexSize.string()) | \(row.height?.string() ?? "") | \(row.width?.string() ?? "") |
"""
}
func trunkRow(_ row: DuctSizes.TrunkContainer) -> String {
return """
| \(row.name ?? "") | \(associatedSupplyString(row)) | \(row.designCFM.value.string(digits: 0)) | \(row.roundSize.string()) | \(row.velocity.string()) | \(row.finalSize.string()) | \(row.flexSize.string()) | \(row.ductSize.height?.string() ?? "") | \(row.width?.string() ?? "") |
"""
}
func componentLossRow(_ row: ComponentPressureLoss) -> String {
return """
| \(row.name) | \(row.value.string()) |
"""
}
var totalEquivalentLength: Double {
maxSupplyTEL.totalEquivalentLength + maxReturnTEL.totalEquivalentLength
}
func associatedSupplyString(_ row: DuctSizes.TrunkContainer) -> String {
row.associatedSupplyString(rooms: ductSizes.rooms)
}
}
extension DuctSizes.TrunkContainer {
func associatedSupplyString(rooms: [DuctSizes.RoomContainer]) -> String {
self.registerIDS(rooms: rooms)
.joined(separator: ", ")
}
}

View File

@@ -3,6 +3,7 @@ import DependenciesMacros
import Elementary import Elementary
import ManualDClient import ManualDClient
import ManualDCore import ManualDCore
import Vapor
extension DependencyValues { extension DependencyValues {
public var projectClient: ProjectClient { public var projectClient: ProjectClient {
@@ -29,8 +30,10 @@ public struct ProjectClient: Sendable {
public var frictionRate: @Sendable (Project.ID) async throws -> FrictionRateResponse public var frictionRate: @Sendable (Project.ID) async throws -> FrictionRateResponse
// FIX: Name to something to do with generating a pdf, just experimenting now. // FIX: Name to something to do with generating a pdf, just experimenting now.
public var toMarkdown: @Sendable (Project.ID) async throws -> String // public var toMarkdown: @Sendable (Project.ID) async throws -> String
public var toHTML: @Sendable (Project.ID) async throws -> (any HTML & Sendable) // public var toHTML: @Sendable (Project.ID) async throws -> (any HTML & Sendable)
public var generatePdf: @Sendable (Project.ID, FileIO) async throws -> Response
} }
extension ProjectClient: TestDependencyKey { extension ProjectClient: TestDependencyKey {

View File

@@ -1,5 +1,6 @@
import DatabaseClient import DatabaseClient
import Dependencies import Dependencies
import FileClient
import Logging import Logging
import ManualDClient import ManualDClient
import ManualDCore import ManualDCore
@@ -11,6 +12,7 @@ extension ProjectClient: DependencyKey {
@Dependency(\.database) var database @Dependency(\.database) var database
@Dependency(\.manualD) var manualD @Dependency(\.manualD) var manualD
@Dependency(\.pdfClient) var pdfClient @Dependency(\.pdfClient) var pdfClient
@Dependency(\.fileClient) var fileClient
return .init( return .init(
calculateDuctSizes: { projectID in calculateDuctSizes: { projectID in
@@ -35,11 +37,21 @@ extension ProjectClient: DependencyKey {
frictionRate: { projectID in frictionRate: { projectID in
try await manualD.frictionRate(projectID: projectID) try await manualD.frictionRate(projectID: projectID)
}, },
toMarkdown: { projectID in generatePdf: { projectID, fileIO in
try await pdfClient.markdown(database.makePdfRequest(projectID)) let pdfResponse = try await pdfClient.generatePdf(
}, request: database.makePdfRequest(projectID))
toHTML: { projectID in
try await pdfClient.html(database.makePdfRequest(projectID)) let response = try await fileIO.asyncStreamFile(at: pdfResponse.pdfPath) { _ in
try await fileClient.removeFile(pdfResponse.htmlPath)
try await fileClient.removeFile(pdfResponse.pdfPath)
}
response.headers.replaceOrAdd(name: .contentType, value: "application/octet-stream")
response.headers.replaceOrAdd(
name: .contentDisposition, value: "attachment; filename=Duct-Calc.pdf"
)
return response
} }
) )
} }
@@ -48,31 +60,6 @@ extension ProjectClient: DependencyKey {
extension DatabaseClient { extension DatabaseClient {
// fileprivate func makePdfRequest(_ projectID: Project.ID) async throws -> PdfClient.Request {
// @Dependency(\.manualD) var manualD
//
// guard let project = try await projects.get(projectID) else {
// throw ProjectClientError("Project not found. id: \(projectID)")
// }
// let frictionRateResponse = try await manualD.frictionRate(projectID: projectID)
// guard let frictionRate = frictionRateResponse.frictionRate else {
// throw ProjectClientError("Friction rate not found. id: \(projectID)")
// }
// let (ductSizes, sharedInfo, rooms) = try await calculateDuctSizes(projectID: projectID)
//
// return .init(
// project: project,
// rooms: rooms,
// componentLosses: frictionRateResponse.componentLosses,
// ductSizes: ductSizes,
// equipmentInfo: sharedInfo.equipmentInfo,
// maxSupplyTEL: sharedInfo.maxSupplyLength,
// maxReturnTEL: sharedInfo.maxReturnLenght,
// frictionRate: frictionRate,
// projectSHR: sharedInfo.projectSHR
// )
// }
fileprivate func makePdfRequest(_ projectID: Project.ID) async throws -> PdfClient.Request { fileprivate func makePdfRequest(_ projectID: Project.ID) async throws -> PdfClient.Request {
@Dependency(\.manualD) var manualD @Dependency(\.manualD) var manualD

View File

@@ -32,6 +32,6 @@ extension HTMLAttribute.hx {
extension HTMLAttribute.hx { extension HTMLAttribute.hx {
@Sendable @Sendable
public static func indicator() -> HTMLAttribute { public static func indicator() -> HTMLAttribute {
indicator(".hx-indicator") indicator(".htmx-indicator")
} }
} }

View File

@@ -195,7 +195,9 @@ extension SiteRoute.View.ProjectRoute {
case .pdf: case .pdf:
// FIX: This should return a pdf to download or be wrapped in a // FIX: This should return a pdf to download or be wrapped in a
// result view. // result view.
return try! await projectClient.toHTML(projectID) // return try! await projectClient.toHTML(projectID)
// This get's handled elsewhere because it returns a response, not a view.
fatalError()
case .rooms(let route): case .rooms(let route):
return await route.renderView(on: request, projectID: projectID) return await route.renderView(on: request, projectID: projectID)
} }

View File

@@ -24,11 +24,20 @@ struct DuctSizingView: HTML, Sendable {
.attributes(.class("text-error font-bold italic mt-4")) .attributes(.class("text-error font-bold italic mt-4"))
} }
a( div {
.class("btn btn-primary"), button(
.href(route: .project(.detail(projectID, .pdf))) .class("btn btn-primary"),
) { .hx.get(route: .project(.detail(projectID, .pdf))),
"PDF" .hx.ext("htmx-download"),
.hx.swap(.none),
.hx.indicator()
) {
span { "PDF" }
Indicator()
}
// div {
// Indicator()
// }
} }
} }

View File

@@ -50,8 +50,10 @@ public struct MainPage<Inner: HTML>: SendableHTMLDocument where Inner: Sendable
meta(.content("1024"), .name("og:image:height")) meta(.content("1024"), .name("og:image:height"))
meta(.content(keywords), .name(.keywords)) meta(.content(keywords), .name(.keywords))
script(.src("https://unpkg.com/htmx.org@2.0.8")) {} script(.src("https://unpkg.com/htmx.org@2.0.8")) {}
script(.src("/js/htmx-download.js")) {}
script(.src("/js/main.js")) {} script(.src("/js/main.js")) {}
link(.rel(.stylesheet), .href("/css/output.css")) link(.rel(.stylesheet), .href("/css/output.css"))
link(.rel(.stylesheet), .href("/css/htmx.css"))
link( link(
.rel(.icon), .rel(.icon),
.href("/images/favicon.ico"), .href("/images/favicon.ico"),