WIP: Mostly done with pdf client, need to add tests.
This commit is contained in:
@@ -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(
|
||||
|
||||
12
Public/css/htmx.css
Normal file
12
Public/css/htmx.css
Normal file
@@ -0,0 +1,12 @@
|
||||
.htmx-indicator {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.htmx-request .htmx-indicator {
|
||||
display: inline;
|
||||
}
|
||||
|
||||
.htmx-request.htmx-indicator {
|
||||
display: inline;
|
||||
}
|
||||
|
||||
@@ -2,4 +2,3 @@
|
||||
@plugin "daisyui" {
|
||||
themes: all;
|
||||
}
|
||||
|
||||
|
||||
@@ -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,);
|
||||
|
||||
63
Public/js/htmx-download.js
Normal file
63
Public/js/htmx-download.js
Normal 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;
|
||||
}
|
||||
},
|
||||
});
|
||||
@@ -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
|
||||
|
||||
@@ -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 =
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -63,7 +63,6 @@ struct PdfDocument: HTMLDocument {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
div(.class("section")) {
|
||||
h2 { "Duct Sizes" }
|
||||
DuctSizesTable(rooms: request.ductSizes.rooms)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -32,6 +32,6 @@ extension HTMLAttribute.hx {
|
||||
extension HTMLAttribute.hx {
|
||||
@Sendable
|
||||
public static func indicator() -> HTMLAttribute {
|
||||
indicator(".hx-indicator")
|
||||
indicator(".htmx-indicator")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
// }
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -50,8 +50,10 @@ public struct MainPage<Inner: HTML>: 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"),
|
||||
|
||||
Reference in New Issue
Block a user