Staff report: inline lightbox for image/PDF attachments
Adds a /api/staff/file proxy that re-streams Civi attachments with Content-Disposition: inline so a native <dialog> lightbox can preview images and PDFs in place. Office docs keep their plain download link and gain a "View in Google Docs" secondary link (uses the Civi-signed URL so Google can fetch without our staff key). Also threads mime through /api/staff/report (Attachment.get mime_type) so the dispatcher picks the right affordance without relying solely on filename inference.
This commit is contained in:
@@ -0,0 +1,86 @@
|
||||
// @ts-check
|
||||
/**
|
||||
* Mime helpers for staff-report attachment rendering.
|
||||
*
|
||||
* The staff report shows file attachments with three different affordances:
|
||||
* - images / PDF -> inline lightbox preview
|
||||
* - office docs -> plain download + "View in Google Docs" link
|
||||
* - anything else -> plain download
|
||||
*
|
||||
* Civi can serve a mime via `Attachment.get`, but historical uploads may
|
||||
* have a stale or missing `mime_type` column. Fall back to extension-based
|
||||
* inference so we always reach a stable category.
|
||||
*
|
||||
* Written as JS+JSDoc rather than TS so Node's built-in --test runner can
|
||||
* import this file directly without any tooling — matches the pattern set
|
||||
* by lib/staff-field-mapping.mjs.
|
||||
*
|
||||
* @typedef {"image" | "pdf" | "office" | "other"} AttachmentCategory
|
||||
*/
|
||||
|
||||
/** @type {Record<string, string>} */
|
||||
const EXT_TO_MIME = {
|
||||
pdf: "application/pdf",
|
||||
png: "image/png",
|
||||
jpg: "image/jpeg",
|
||||
jpeg: "image/jpeg",
|
||||
gif: "image/gif",
|
||||
webp: "image/webp",
|
||||
doc: "application/msword",
|
||||
docx: "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
||||
xls: "application/vnd.ms-excel",
|
||||
xlsx: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||||
};
|
||||
|
||||
const OFFICE_MIMES = new Set([
|
||||
"application/msword",
|
||||
"application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
||||
"application/vnd.ms-excel",
|
||||
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||||
]);
|
||||
|
||||
/**
|
||||
* Pull the lowercased extension from a filename, or "" if absent.
|
||||
* @param {string | undefined | null} filename
|
||||
* @returns {string}
|
||||
*/
|
||||
export function extOf(filename) {
|
||||
if (!filename) return "";
|
||||
const dot = filename.lastIndexOf(".");
|
||||
if (dot < 0 || dot === filename.length - 1) return "";
|
||||
return filename.slice(dot + 1).toLowerCase();
|
||||
}
|
||||
|
||||
/**
|
||||
* Map a filename's extension to a known mime, or null if unrecognised.
|
||||
* @param {string | undefined | null} filename
|
||||
* @returns {string | null}
|
||||
*/
|
||||
export function mimeFromFilename(filename) {
|
||||
const ext = extOf(filename);
|
||||
return EXT_TO_MIME[ext] ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a mime by trusting the explicit value first, then falling back to
|
||||
* filename inference. Returns "application/octet-stream" if nothing matches.
|
||||
* @param {string | undefined | null} explicit
|
||||
* @param {string | undefined | null} filename
|
||||
* @returns {string}
|
||||
*/
|
||||
export function resolveMime(explicit, filename) {
|
||||
if (explicit && explicit !== "application/octet-stream") return explicit;
|
||||
return mimeFromFilename(filename) ?? explicit ?? "application/octet-stream";
|
||||
}
|
||||
|
||||
/**
|
||||
* Categorise a mime for UI dispatch.
|
||||
* @param {string} mime
|
||||
* @returns {AttachmentCategory}
|
||||
*/
|
||||
export function categoryFromMime(mime) {
|
||||
if (mime.startsWith("image/")) return "image";
|
||||
if (mime === "application/pdf") return "pdf";
|
||||
if (OFFICE_MIMES.has(mime)) return "office";
|
||||
return "other";
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
// Run with: npm run test:mime
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import {
|
||||
extOf,
|
||||
mimeFromFilename,
|
||||
resolveMime,
|
||||
categoryFromMime,
|
||||
} from "./mime.mjs";
|
||||
|
||||
test("extOf returns lowercased extension", () => {
|
||||
assert.equal(extOf("Photo.JPG"), "jpg");
|
||||
assert.equal(extOf("doc.tar.gz"), "gz");
|
||||
});
|
||||
|
||||
test("extOf handles missing or trailing-dot names", () => {
|
||||
assert.equal(extOf(""), "");
|
||||
assert.equal(extOf(null), "");
|
||||
assert.equal(extOf("no-extension"), "");
|
||||
assert.equal(extOf("trailing."), "");
|
||||
});
|
||||
|
||||
test("mimeFromFilename recognises known types", () => {
|
||||
assert.equal(mimeFromFilename("vision.pdf"), "application/pdf");
|
||||
assert.equal(mimeFromFilename("budget.XLSX"),
|
||||
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet");
|
||||
assert.equal(mimeFromFilename("logo.PNG"), "image/png");
|
||||
});
|
||||
|
||||
test("mimeFromFilename returns null for unknown extensions", () => {
|
||||
assert.equal(mimeFromFilename("archive.zip"), null);
|
||||
assert.equal(mimeFromFilename("readme"), null);
|
||||
});
|
||||
|
||||
test("resolveMime trusts an explicit non-octet mime", () => {
|
||||
assert.equal(resolveMime("application/pdf", "anything.txt"), "application/pdf");
|
||||
});
|
||||
|
||||
test("resolveMime falls back to filename when explicit is missing", () => {
|
||||
assert.equal(resolveMime(undefined, "report.pdf"), "application/pdf");
|
||||
assert.equal(resolveMime(null, "image.gif"), "image/gif");
|
||||
});
|
||||
|
||||
test("resolveMime falls back to filename when explicit is octet-stream", () => {
|
||||
assert.equal(
|
||||
resolveMime("application/octet-stream", "spreadsheet.xlsx"),
|
||||
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||||
);
|
||||
});
|
||||
|
||||
test("resolveMime returns octet-stream when nothing is known", () => {
|
||||
assert.equal(resolveMime(null, null), "application/octet-stream");
|
||||
assert.equal(resolveMime(undefined, "unknownfile"), "application/octet-stream");
|
||||
});
|
||||
|
||||
test("categoryFromMime: image", () => {
|
||||
assert.equal(categoryFromMime("image/png"), "image");
|
||||
assert.equal(categoryFromMime("image/webp"), "image");
|
||||
});
|
||||
|
||||
test("categoryFromMime: pdf", () => {
|
||||
assert.equal(categoryFromMime("application/pdf"), "pdf");
|
||||
});
|
||||
|
||||
test("categoryFromMime: office variants", () => {
|
||||
assert.equal(categoryFromMime("application/msword"), "office");
|
||||
assert.equal(
|
||||
categoryFromMime("application/vnd.openxmlformats-officedocument.wordprocessingml.document"),
|
||||
"office",
|
||||
);
|
||||
assert.equal(categoryFromMime("application/vnd.ms-excel"), "office");
|
||||
assert.equal(
|
||||
categoryFromMime("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"),
|
||||
"office",
|
||||
);
|
||||
});
|
||||
|
||||
test("categoryFromMime: other", () => {
|
||||
assert.equal(categoryFromMime("application/zip"), "other");
|
||||
assert.equal(categoryFromMime("text/plain"), "other");
|
||||
assert.equal(categoryFromMime("application/octet-stream"), "other");
|
||||
});
|
||||
Reference in New Issue
Block a user