import { inject, Injectable } from "@angular/core";
import { FileService } from "../../file/file.service";
import { SafeUrl } from "@angular/platform-browser";
import { Entity } from "app/core/entity/model/entity";
import { from, Observable, of, throwError } from "rxjs";
import { HttpResponse } from "@angular/common/http";
import { NotAvailableOfflineError } from "../../../core/session/not-available-offline.error";
import { NAVIGATOR_TOKEN } from "../../../utils/di-tokens";
import { switchMap } from "rxjs/operators";
import { TemplateExport } from "../template-export.entity";
import { resolveLocaleText } from "../../../core/language/active-locale";
import { Logging } from "../../../core/logging/logging.service";
import { environment } from "../../../../environments/environment";
import {
TemplateExportComplement,
TemplateExportContextService,
} from "../template-export-context/template-export-context.service";
import { EntityRelationResolverService } from "../../../core/entity/entity-relation-resolver/entity-relation-resolver.service";
/**
* Format of API response body upon uploading a new template file.
*/
interface TemplateUploadResponseDto {
templateId: string;
}
/**
* Format of API request body to render one or more files from a template.
* TemplateId is provided via URL path.
*
* `data` is a single record for `/render`, or an array of records for `/render-batch`
* (the backend renders each one and returns either a ZIP or a combined PDF).
*/
interface TemplateRenderRequestDto {
/**
* target file type (e.g. "pdf")
*/
convertTo: string;
/**
* The data used to fill placeholders in the template.
*/
data: Object | Object[];
/**
* Additional context data available in the template under the `{c.…}` prefix.
*/
complement?: TemplateExportComplement;
}
export interface TemplateExportResult {
filename: string;
file: ArrayBuffer;
}
/**
* Interact with the PDF Template Generation API that uses File Templates to generate custom pdf documents.
*/
@Injectable({
providedIn: "root",
})
export class TemplateExportApiService extends FileService {
private navigator = inject<Navigator>(NAVIGATOR_TOKEN);
private readonly exportContext = inject(TemplateExportContextService);
private readonly relationResolver = inject(EntityRelationResolverService);
readonly API_URL = environment.API_PROXY_PREFIX + "/v1/export";
/*
--- FileService methods ---
*/
/**
* Upload a new template file to the API.
* @param file to be uploaded to the API as a template
* @param entity
* @param property
* @return The template ID generated by the API
*/
uploadFile(
file: File,
entity: TemplateExport,
property: string,
): Observable<string> {
if (!this.navigator.onLine) {
return throwError(() => new NotAvailableOfflineError("File Attachments"));
}
const formData = new FormData();
formData.append("template", file, file.name);
return this.httpClient.post(this.API_URL + "/template", formData).pipe(
switchMap(async (res: TemplateUploadResponseDto) => {
entity.templateId = res.templateId;
await this.entityMapper.save(entity);
return res.templateId;
}),
);
}
protected override getShowFileUrl(
entity: TemplateExport,
property: string,
): string {
return this.API_URL + "/template/" + entity.getId();
}
loadFile(entity: Entity, property: string): Observable<SafeUrl> {
// should not be required for our use cases of the Template Export API
throw new Error("Method not implemented.");
}
removeFile(entity: Entity, property: string): Observable<any> {
// we do not do explicit file removal due to the design of the API
Logging.debug("skipping file removal for Template Export API");
return of(true);
}
removeAllFiles(entity: Entity): Observable<any> {
Logging.debug("skipping file removal for Template Export API");
return of(true);
}
/*
--- PDF Generation API methods ---
*/
/**
* POST a render request to the given endpoint and resolve the response's Content-Disposition
* filename, falling back to `fallbackFilename` if the header is missing or cannot be parsed.
*
* Shared by the single-record and batch render methods below, which only differ in the
* endpoint URL, the shape of `data`, and the fallback filename.
*/
private renderTemplate(
url: string,
data: Object | Object[],
fallbackFilename: string,
): Observable<TemplateExportResult> {
const complement = this.exportContext.getComplement();
// give the template access to details of linked records (e.g. a Note's related
// Child) instead of just their id - resolved one level deep, see the service.
return from(this.relationResolver.resolveRelations(data)).pipe(
switchMap((resolvedData) =>
this.httpClient.post(
url,
{
convertTo: "pdf",
data: resolvedData,
...(complement ? { complement } : {}),
} as TemplateRenderRequestDto,
{ observe: "response", responseType: "arraybuffer" },
),
),
switchMap(async (res: HttpResponse<ArrayBuffer>) => {
// the API returns the filename in the Content-Disposition header as a URL-encoded string with special delimiters
const disposition = res.headers.get("Content-Disposition");
let filenameMatch: RegExpMatchArray | null = null;
if (disposition) {
try {
filenameMatch = decodeURIComponent(disposition).match(
/filename="?([^";]+)"?/,
);
} catch (err) {
// malformed percent-encoding must not fail an otherwise successful
// download - fall back to fallbackFilename below
Logging.warn(
"TemplateExportApiService: could not decode Content-Disposition filename",
err,
);
}
}
return {
filename: filenameMatch?.[1] ?? fallbackFilename,
file: res.body,
};
}),
);
}
/**
* Generate a PDF applying actual data to an existing template.
* @param template The TemplateExport entity to render
* @param data The data object (typically an entity) to be applied to the template
* @return An array buffer of the generated PDF
*/
generatePdfFromTemplate(
template: TemplateExport,
data: Object,
): Observable<TemplateExportResult> {
return this.renderTemplate(
this.API_URL + "/render/" + template.getId(),
data,
resolveLocaleText(template.title),
);
}
/**
* Generate output from one template for many records in a single request.
*
* Both modes go through the template engine's native batch rendering — the backend
* just forwards the request:
* - `mode: "zip"` (default): N independent files packaged in a ZIP archive.
* - `mode: "combined"`: N rendered files merged into a single multi-page PDF.
*
* @param template The TemplateExport entity to render
* @param dataList The array of data objects (typically entities) to apply to the template
* @param mode How the backend should aggregate the output
* @return The generated file (ZIP or PDF) and a derived filename
*/
generateBatchFromTemplate(
template: TemplateExport,
dataList: Object[],
mode: "zip" | "combined" = "zip",
): Observable<TemplateExportResult> {
return this.renderTemplate(
this.API_URL + "/render-batch/" + template.getId() + "?mode=" + mode,
dataList,
resolveLocaleText(template.title) +
(mode === "combined" ? ".pdf" : ".zip"),
);
}
}