WIP: Mostly done with pdf client, need to add tests.

This commit is contained in:
2026-01-28 15:47:56 -05:00
parent 458b3bd644
commit c82f20bb60
15 changed files with 211 additions and 76 deletions

View File

@@ -105,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"),
@@ -117,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

@@ -16,13 +16,19 @@ extension DependencyValues {
@DependencyClient @DependencyClient
struct EnvClient: Sendable { struct EnvClient: Sendable {
public var env: @Sendable () throws -> EnvVars 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 { public struct EnvVars: Codable, Equatable, Sendable {
/// The path to the pandoc executable on the system, used to generate pdf's.
public let pandocPath: String public let pandocPath: String
/// The pdf engine to use with pandoc when creating pdf's.
public let pdfEngine: String public let pdfEngine: String
public init( public init(
@@ -42,6 +48,7 @@ public struct EnvVars: Codable, Equatable, Sendable {
extension EnvClient: DependencyKey { extension EnvClient: DependencyKey {
static let testValue = Self() static let testValue = Self()
static let liveValue = Self(env: { static let liveValue = Self(env: {
// Convert default values into a dictionary. // Convert default values into a dictionary.
let defaults = let defaults =

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,6 +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 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 {
@@ -22,6 +32,33 @@ extension PdfClient: DependencyKey {
public static let liveValue = Self( public static let liveValue = Self(
html: { request in html: { request in
request.toHTML() request.toHTML()
},
generatePdf: { projectID, html in
@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")
} }
) )
} }
@@ -40,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],
@@ -62,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

@@ -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"),