src/app/core/export/download-service/download.service.ts
import { Injectable, inject } from "@angular/core";
import { Logging } from "../../logging/logging.service";
import {
  ExportColumnConfig,
  normalizeQueryKey,
} from "../data-transformation-service/export-column-config";
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,
    );

    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 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 selectedColumns optional list of export column keys to restrict (and order) the exported columns
   */
  async triggerDownload(
    data: any,
    format: FileDownloadFormat,
    filename: string,
    selectedColumns?: ExportColumnConfig[],
  ) {
    const blobData = await this.getFormattedBlobData(
      data,
      format,
      selectedColumns,
    );
    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,
    selectedColumns?: ExportColumnConfig[],
  ): Promise<Blob> {
    let result = "";

    switch (format.toLowerCase()) {
      case "json":
        result = typeof data === "string" ? data : JSON.stringify(data); // TODO: support column selection for json format
        return new Blob([result], { type: "application/json" });
      case "csv":
        if (Array.isArray(data)) {
          result = await this.createCsv(data, selectedColumns);
        } else {
          // assume raw csv data
          result = data;
        }
        return new Blob([result], { type: "text/csv" });
      case "xlsx":
        if (!Array.isArray(data) && typeof data !== "string") {
          Logging.warn(
            "XLSX export requires an array of records or a CSV string.",
          );
          return new Blob([""]);
        }
        return this.createXlsx(data, selectedColumns);
      case "pdf":
        return new Blob([data], { type: "application/pdf" });
      case "zip":
        return new Blob([data], { type: "application/zip" });
      default:
        Logging.warn("Export format not supported", { 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[],
    selectedColumns?: ExportColumnConfig[],
  ): Promise<string> {
    const [headers, ...rows] = await this.prepareExportData(
      data,
      selectedColumns,
    );
    return this.papa.unparse(
      { fields: headers, data: rows },
      {
        quotes: true,
        newline: DownloadService.SEPARATOR_ROW,
      },
    );
  }

  /**
   * Creates an XLSX Blob from the input data.
   *
   * Accepts either an array of records (transformed via the shared export data
   * preparation) or a raw, pre-formatted CSV string (e.g. a hierarchical SQL
   * report). The raw-string path mirrors {@link getFormattedBlobData}'s CSV
   * handling so the same data can be exported to both CSV and XLSX.
   */
  async createXlsx(
    data: any[] | string,
    selectedColumns?: ExportColumnConfig[],
  ): Promise<Blob> {
    const rows =
      typeof data === "string"
        ? this.parseCsvToRows(data)
        : await this.prepareExportData(data, selectedColumns);
    return this.buildXlsxBlob(rows);
  }

  /** Parses a raw CSV string into an array of row arrays for XLSX output. */
  private parseCsvToRows(csv: string): any[][] {
    const parsed = this.papa.parse(csv, { skipEmptyLines: true });
    return (parsed?.data as any[][]) ?? [];
  }

  /** Builds a styled XLSX workbook Blob from an array of row arrays. */
  private async buildXlsxBlob(rows: any[][]): Promise<Blob> {
    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[],
    selectedColumns?: ExportColumnConfig[],
  ): Promise<any[][]> {
    // map of selected column key -> custom label (e.g. the list view column label)
    const selectedKeys = selectedColumns?.map((c) =>
      normalizeQueryKey(c.query),
    );
    const labelOverrides = new Map<string, string>();
    for (const c of selectedColumns ?? []) {
      if (c.label) labelOverrides.set(normalizeQueryKey(c.query), c.label);
    }

    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 allKeys =
        mapped.length > 0
          ? Array.from(new Set(mapped.flatMap((r) => Object.keys(r))))
          : [];
      const keys = selectedKeys
        ? selectedKeys.filter((k) => allKeys.includes(k))
        : allKeys;
      const headers = keys.map((k) => labelOverrides.get(k) ?? k);
      return [headers, ...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)),
    );

    // Keep selected columns that have no schema resolver (e.g. runtime-attached
    // fields like a Child's `schoolId`, or configured query-expression columns)
    // and read their value directly from the entity, so they are not silently
    // dropped from the export.
    const columnKeys = selectedKeys ?? Array.from(columnLabels.keys());
    const headers = columnKeys.map(
      (key) => labelOverrides.get(key) ?? columnLabels.get(key) ?? key,
    );
    return [
      headers,
      ...data.map((item, i) =>
        columnKeys.map((key) =>
          columnResolvers.has(key)
            ? exportEntities[i][key]
            : this.ensureCsvFriendlyValue(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;
  }
}

results matching ""

    No results matching ""