diff --git a/Package.swift b/Package.swift index 1f083db..625543d 100644 --- a/Package.swift +++ b/Package.swift @@ -105,6 +105,8 @@ let package = Package( .target( name: "PdfClient", dependencies: [ + .target(name: "EnvClient"), + .target(name: "FileClient"), .target(name: "ManualDCore"), .product(name: "Dependencies", package: "swift-dependencies"), .product(name: "DependenciesMacros", package: "swift-dependencies"), @@ -117,6 +119,7 @@ let package = Package( .target(name: "DatabaseClient"), .target(name: "ManualDClient"), .target(name: "PdfClient"), + .product(name: "Vapor", package: "vapor"), ] ), .target( diff --git a/Public/css/htmx.css b/Public/css/htmx.css new file mode 100644 index 0000000..108aed9 --- /dev/null +++ b/Public/css/htmx.css @@ -0,0 +1,12 @@ +.htmx-indicator { + display: none; +} + +.htmx-request .htmx-indicator { + display: inline; +} + +.htmx-request.htmx-indicator { + display: inline; +} + diff --git a/Public/css/main.css b/Public/css/main.css index a86c959..f5a9b4c 100644 --- a/Public/css/main.css +++ b/Public/css/main.css @@ -2,4 +2,3 @@ @plugin "daisyui" { themes: all; } - diff --git a/Public/css/output.css b/Public/css/output.css index c1e23d1..6ec6839 100644 --- a/Public/css/output.css +++ b/Public/css/output.css @@ -8548,10 +8548,6 @@ .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 { --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,); diff --git a/Public/js/htmx-download.js b/Public/js/htmx-download.js new file mode 100644 index 0000000..1da50d7 --- /dev/null +++ b/Public/js/htmx-download.js @@ -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; + } + }, +}); diff --git a/Sources/App/configure.swift b/Sources/App/configure.swift index 9498442..941a74c 100644 --- a/Sources/App/configure.swift +++ b/Sources/App/configure.swift @@ -117,37 +117,38 @@ extension DuctSizes: Content {} // FIX: Move func handlePdf(_ projectID: Project.ID, on request: Request) async throws -> Response { @Dependency(\.projectClient) var projectClient + return try await projectClient.generatePdf(projectID, request.fileio) - let html = try await projectClient.toHTML(projectID) - let url = "/tmp/\(projectID)" - try await request.fileio.writeFile(.init(string: html.render()), at: "\(url).html") - - let process = Process() - let standardInput = Pipe() - let standardOutput = Pipe() - process.standardInput = standardInput - process.standardOutput = standardOutput - process.executableURL = URL(fileURLWithPath: "/bin/pandoc") - process.arguments = [ - "\(url).html", - "--pdf-engine=weasyprint", - "--from=html", - "--css=Public/css/pdf.css", - "-o", "\(url).pdf", - ] - try process.run() - process.waitUntilExit() - - let response = try await request.fileio.asyncStreamFile(at: "\(url).pdf", mediaType: .pdf) { _ in - // Remove files here. - try FileManager.default.removeItem(atPath: "\(url).pdf") - try FileManager.default.removeItem(atPath: "\(url).html") - } - response.headers.replaceOrAdd(name: .contentType, value: "application/octet-stream") - response.headers.replaceOrAdd( - name: .contentDisposition, value: "attachment; filename=Duct-Calc.pdf" - ) - return response + // let html = try await projectClient.toHTML(projectID) + // let url = "/tmp/\(projectID)" + // try await request.fileio.writeFile(.init(string: html.render()), at: "\(url).html") + // + // let process = Process() + // let standardInput = Pipe() + // let standardOutput = Pipe() + // process.standardInput = standardInput + // process.standardOutput = standardOutput + // process.executableURL = URL(fileURLWithPath: "/bin/pandoc") + // process.arguments = [ + // "\(url).html", + // "--pdf-engine=weasyprint", + // "--from=html", + // "--css=Public/css/pdf.css", + // "-o", "\(url).pdf", + // ] + // try process.run() + // process.waitUntilExit() + // + // let response = try await request.fileio.asyncStreamFile(at: "\(url).pdf", mediaType: .pdf) { _ in + // // Remove files here. + // try FileManager.default.removeItem(atPath: "\(url).pdf") + // try FileManager.default.removeItem(atPath: "\(url).html") + // } + // response.headers.replaceOrAdd(name: .contentType, value: "application/octet-stream") + // response.headers.replaceOrAdd( + // name: .contentDisposition, value: "attachment; filename=Duct-Calc.pdf" + // ) + // return response } @Sendable diff --git a/Sources/EnvClient/Interface.swift b/Sources/EnvClient/Interface.swift index 699e090..81d6a7e 100644 --- a/Sources/EnvClient/Interface.swift +++ b/Sources/EnvClient/Interface.swift @@ -16,13 +16,19 @@ extension DependencyValues { @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( @@ -42,6 +48,7 @@ public struct EnvVars: Codable, Equatable, Sendable { extension EnvClient: DependencyKey { static let testValue = Self() + static let liveValue = Self(env: { // Convert default values into a dictionary. let defaults = diff --git a/Sources/PdfClient/Interface.swift b/Sources/PdfClient/Interface.swift index 2e95dc6..008e881 100644 --- a/Sources/PdfClient/Interface.swift +++ b/Sources/PdfClient/Interface.swift @@ -1,6 +1,9 @@ import Dependencies import DependenciesMacros import Elementary +import EnvClient +import FileClient +import Foundation import ManualDCore extension DependencyValues { @@ -14,6 +17,13 @@ extension DependencyValues { @DependencyClient public struct PdfClient: 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 { @@ -22,6 +32,33 @@ extension PdfClient: DependencyKey { public static let liveValue = Self( html: { request in 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 projectSHR: Double + var totalEquivalentLength: Double { + maxReturnTEL.totalEquivalentLength + maxSupplyTEL.totalEquivalentLength + } + public init( project: Project, rooms: [Room], @@ -62,6 +103,17 @@ extension PdfClient { 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 diff --git a/Sources/PdfClient/Request+html.swift b/Sources/PdfClient/Request+html.swift index 357efff..7dec256 100644 --- a/Sources/PdfClient/Request+html.swift +++ b/Sources/PdfClient/Request+html.swift @@ -63,7 +63,6 @@ struct PdfDocument: HTMLDocument { } } } - div(.class("section")) { h2 { "Duct Sizes" } DuctSizesTable(rooms: request.ductSizes.rooms) diff --git a/Sources/ProjectClient/Interface.swift b/Sources/ProjectClient/Interface.swift index 9fe0ea8..5ba9cb9 100644 --- a/Sources/ProjectClient/Interface.swift +++ b/Sources/ProjectClient/Interface.swift @@ -3,6 +3,7 @@ import DependenciesMacros import Elementary import ManualDClient import ManualDCore +import Vapor extension DependencyValues { public var projectClient: ProjectClient { @@ -29,8 +30,10 @@ public struct ProjectClient: Sendable { public var frictionRate: @Sendable (Project.ID) async throws -> FrictionRateResponse // FIX: Name to something to do with generating a pdf, just experimenting now. - public var toMarkdown: @Sendable (Project.ID) async throws -> String - public var toHTML: @Sendable (Project.ID) async throws -> (any HTML & Sendable) + // public var toMarkdown: @Sendable (Project.ID) async throws -> String + // public var toHTML: @Sendable (Project.ID) async throws -> (any HTML & Sendable) + + public var generatePdf: @Sendable (Project.ID, FileIO) async throws -> Response } extension ProjectClient: TestDependencyKey { diff --git a/Sources/ProjectClient/Live.swift b/Sources/ProjectClient/Live.swift index cf160b4..fbb0e58 100644 --- a/Sources/ProjectClient/Live.swift +++ b/Sources/ProjectClient/Live.swift @@ -1,5 +1,6 @@ import DatabaseClient import Dependencies +import FileClient import Logging import ManualDClient import ManualDCore @@ -11,6 +12,7 @@ extension ProjectClient: DependencyKey { @Dependency(\.database) var database @Dependency(\.manualD) var manualD @Dependency(\.pdfClient) var pdfClient + @Dependency(\.fileClient) var fileClient return .init( calculateDuctSizes: { projectID in @@ -35,11 +37,21 @@ extension ProjectClient: DependencyKey { frictionRate: { projectID in try await manualD.frictionRate(projectID: projectID) }, - toMarkdown: { projectID in - try await pdfClient.markdown(database.makePdfRequest(projectID)) - }, - toHTML: { projectID in - try await pdfClient.html(database.makePdfRequest(projectID)) + generatePdf: { projectID, fileIO in + let pdfResponse = try await pdfClient.generatePdf( + request: 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 { - // 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 { @Dependency(\.manualD) var manualD diff --git a/Sources/Styleguide/HTMXExtensions.swift b/Sources/Styleguide/HTMXExtensions.swift index a2496e0..2ba2e84 100644 --- a/Sources/Styleguide/HTMXExtensions.swift +++ b/Sources/Styleguide/HTMXExtensions.swift @@ -32,6 +32,6 @@ extension HTMLAttribute.hx { extension HTMLAttribute.hx { @Sendable public static func indicator() -> HTMLAttribute { - indicator(".hx-indicator") + indicator(".htmx-indicator") } } diff --git a/Sources/ViewController/Live.swift b/Sources/ViewController/Live.swift index 8b6c1f2..767d15d 100644 --- a/Sources/ViewController/Live.swift +++ b/Sources/ViewController/Live.swift @@ -195,7 +195,9 @@ extension SiteRoute.View.ProjectRoute { case .pdf: // FIX: This should return a pdf to download or be wrapped in a // 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): return await route.renderView(on: request, projectID: projectID) } diff --git a/Sources/ViewController/Views/DuctSizing/DuctSizingView.swift b/Sources/ViewController/Views/DuctSizing/DuctSizingView.swift index f499e50..dc6b09a 100644 --- a/Sources/ViewController/Views/DuctSizing/DuctSizingView.swift +++ b/Sources/ViewController/Views/DuctSizing/DuctSizingView.swift @@ -24,11 +24,20 @@ struct DuctSizingView: HTML, Sendable { .attributes(.class("text-error font-bold italic mt-4")) } - a( - .class("btn btn-primary"), - .href(route: .project(.detail(projectID, .pdf))) - ) { - "PDF" + div { + button( + .class("btn btn-primary"), + .hx.get(route: .project(.detail(projectID, .pdf))), + .hx.ext("htmx-download"), + .hx.swap(.none), + .hx.indicator() + ) { + span { "PDF" } + Indicator() + } + // div { + // Indicator() + // } } } diff --git a/Sources/ViewController/Views/MainPage.swift b/Sources/ViewController/Views/MainPage.swift index a808f94..933a1ff 100644 --- a/Sources/ViewController/Views/MainPage.swift +++ b/Sources/ViewController/Views/MainPage.swift @@ -50,8 +50,10 @@ public struct MainPage: SendableHTMLDocument where Inner: Sendable meta(.content("1024"), .name("og:image:height")) meta(.content(keywords), .name(.keywords)) script(.src("https://unpkg.com/htmx.org@2.0.8")) {} + script(.src("/js/htmx-download.js")) {} script(.src("/js/main.js")) {} link(.rel(.stylesheet), .href("/css/output.css")) + link(.rel(.stylesheet), .href("/css/htmx.css")) link( .rel(.icon), .href("/images/favicon.ico"),