import { Injectable, inject } from "@angular/core";
import { ExportColumnConfig } from "../data-transformation-service/export-column-config";
import { Logging } from "../../logging/logging.service";
import { DataTransformationService } from "../data-transformation-service/data-transformation.service";
import { Papa } from "ngx-papaparse";
import { EntityConstructor } from "app/core/entity/model/entity";
import { ExportColumnMapping } from "app/core/entity/default-datatype/default.datatype";
import { EntitySchemaField } from "app/core/entity/schema/entity-schema-field";
import { EntitySchemaService } from "app/core/entity/schema/entity-schema.service";
import { Workbook } from "exceljs";
import moment from "moment";
export interface ExportColumnResolver {
sourceFieldId: string;
schemaField: EntitySchemaField;
column: ExportColumnMapping;
}
/**
* Build export column resolvers for all fields in a schema.
*
* Iterates the schema, looks up each field's datatype, and collects
* the columns the datatype contributes via `getExportColumns`.
*
* @param useFieldIdAsFallbackLabel When true, fields without an explicit label
* use the field id as label (useful for embedded schemas like attendance items).
*/
export function buildExportColumnResolvers(
schema: Map<string, EntitySchemaField>,
entitySchemaService: EntitySchemaService,
useFieldIdAsFallbackLabel = false,
): ExportColumnResolver[] {
const resolvers: ExportColumnResolver[] = [];
for (const [fieldId, field] of schema.entries()) {
if (field.isInternalField) continue;
const schemaField: EntitySchemaField = {
...field,
id: field.id ?? fieldId,
label: field.label || (useFieldIdAsFallbackLabel ? fieldId : undefined),
};
const datatype =
entitySchemaService.getDatatypeOrDefault(schemaField.dataType, true) ??
entitySchemaService.getDatatypeOrDefault(undefined);
for (const column of datatype.getExportColumns(schemaField)) {
resolvers.push({ sourceFieldId: fieldId, schemaField, column });
}
}
return resolvers;
}
export type FileDownloadFormat = "csv" | "json" | "pdf" | "xlsx" | "zip";
/**
* This service allows to start a download process from the browser.
* Depending on the browser and the setting this might open a popup or directly download the file.
*/
@Injectable({ providedIn: "root" })
export class DownloadService {
private readonly dataTransformationService = inject(
DataTransformationService,
);
private readonly papa = inject(Papa);
private readonly entitySchemaService = inject(EntitySchemaService);
/** CSV row separator */
static readonly SEPARATOR_ROW = "\n";
/** CSV column/field separator */
static readonly SEPARATOR_COL = ",";
/**
* Starts the download process with the provided data
* @param data content of the file that will be downloaded
* @param format extension of the file that will be downloaded, support is 'csv' and 'json'
* @param filename of the file that will be downloaded
* @param exportConfig special configuration that will be applied to the 'data' before triggering the download
*/
async triggerDownload(
data: any,
format: FileDownloadFormat,
filename: string,
exportConfig?: ExportColumnConfig[],
) {
const blobData = await this.getFormattedBlobData(
data,
format,
exportConfig,
);
const filenameWithExtension = filename.endsWith("." + format)
? filename
: filename + "." + format;
const objectUrl = globalThis.URL.createObjectURL(blobData);
const link = document.createElement("a");
link.setAttribute("style", "display:none;");
link.href = objectUrl;
link.download = filenameWithExtension;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
// defer revocation so the browser has time to initiate the download
setTimeout(() => globalThis.URL.revokeObjectURL(objectUrl), 1000);
}
private async getFormattedBlobData(
data: any,
format: FileDownloadFormat,
exportConfig?: ExportColumnConfig[],
): Promise<Blob> {
let result = "";
if (exportConfig) {
data = await this.dataTransformationService.transformData(
data,
exportConfig,
);
}
switch (format.toLowerCase()) {
case "json":
result = typeof data === "string" ? data : JSON.stringify(data); // TODO: support exportConfig for json format
return new Blob([result], { type: "application/json" });
case "csv":
if (Array.isArray(data)) {
result = await this.createCsv(data);
} else {
// assume raw csv data
result = data;
}
return new Blob([result], { type: "text/csv" });
case "xlsx":
if (!Array.isArray(data)) {
Logging.warn("XLSX export requires an array of records.");
return new Blob([""]);
}
return this.createXlsx(data);
case "pdf":
return new Blob([data], { type: "application/pdf" });
case "zip":
return new Blob([data], { type: "application/zip" });
default:
Logging.warn(`Not supported format: ${format}`);
return new Blob([""]);
}
}
/**
* Creates a CSV string of the input data using the shared export data preparation.
*
* @param data an array of elements
* @returns string a valid CSV string of the input data
*/
async createCsv(data: any[]): Promise<string> {
const [headers, ...rows] = await this.prepareExportData(data);
return this.papa.unparse(
{ fields: headers, data: rows },
{
quotes: true,
newline: DownloadService.SEPARATOR_ROW,
},
);
}
/**
* Creates an XLSX Blob from the input data using the shared export data preparation.
*/
async createXlsx(data: any[]): Promise<Blob> {
const rows = await this.prepareExportData(data);
const wb = new Workbook();
const ws = wb.addWorksheet("Export");
for (const row of rows) {
ws.addRow(row);
}
const headerRow = ws.getRow(1);
headerRow.font = { bold: true };
headerRow.fill = {
type: "pattern",
pattern: "solid",
fgColor: { argb: "FFD3D3D3" },
};
headerRow.commit();
const buffer = await wb.xlsx.writeBuffer();
return new Blob([buffer], {
type: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
});
}
/**
* Prepares export data as row arrays (header row + data rows) for use with any export format.
*
* For entity data: uses schema column resolvers to produce human-readable headers and values.
* For plain objects: the first row contains the object keys as headers.
*/
async prepareExportData(data: any[]): Promise<any[][]> {
let entityConstructor: EntityConstructor | undefined;
if (data.length > 0 && typeof data[0]?.getConstructor === "function") {
entityConstructor = data[0].getConstructor();
}
if (!entityConstructor) {
const mapped = data.map((row) =>
Object.fromEntries(
Object.entries(row).map(([key, value]) => [
key,
this.ensureCsvFriendlyValue(value),
]),
),
);
const keys =
mapped.length > 0
? Array.from(new Set(mapped.flatMap((r) => Object.keys(r))))
: [];
return [keys, ...mapped.map((r) => keys.map((k) => r[k]))];
}
const entitySchema = entityConstructor.schema;
const columnLabels = new Map<string, string>();
const columnResolvers = new Map<string, ExportColumnResolver>();
for (const resolver of buildExportColumnResolvers(
entitySchema,
this.entitySchemaService,
)) {
const columnId = resolver.sourceFieldId + resolver.column.keySuffix;
columnLabels.set(columnId, resolver.column.label);
columnResolvers.set(columnId, resolver);
}
const exportEntities = await Promise.all(
data.map((item) => this.mapEntityToExportRow(item, columnResolvers)),
);
const columnKeys = Array.from(columnLabels.keys());
const headers = Array.from(columnLabels.values());
return [
headers,
...exportEntities.map((item) => columnKeys.map((key) => item[key])),
];
}
private async mapEntityToExportRow(
item: any,
columnResolvers: Map<string, ExportColumnResolver>,
): Promise<Object> {
const newItem = {};
for (const [columnId, resolver] of columnResolvers.entries()) {
const formattedValue = await resolver.column.resolveValue(
item[resolver.sourceFieldId],
resolver.schemaField,
);
newItem[columnId] = this.ensureCsvFriendlyValue(formattedValue);
}
return newItem;
}
/**
* Convert a value to a CSV/XLSX-friendly primitive, applying the same readable
* transformations as the UI (dates as YYYY-MM-DD, enum labels, location strings).
*/
private ensureCsvFriendlyValue(value: any): any {
if (value === null || value === undefined) {
return value;
}
if (value instanceof Date) {
return moment(value).format("YYYY-MM-DD");
}
if (Array.isArray(value)) {
return value.map((entry) => this.ensureCsvFriendlyValue(entry)).join(",");
}
if (typeof value === "object") {
if ("label" in value) {
return value.label;
}
if ("locationString" in value) {
return value.locationString;
}
return JSON.stringify(value);
}
return value;
}
}