|
@@ -0,0 +1,98 @@
|
|
|
|
|
+/**
|
|
|
|
|
+ * PDF URL helpers for the Explorer (RHL-023).
|
|
|
|
|
+ *
|
|
|
|
|
+ * Why this file exists:
|
|
|
|
|
+ * - The PDF endpoint is a binary stream and must NOT be called via apiClient (JSON-centric).
|
|
|
|
|
+ * - We want centralized, testable URL construction for:
|
|
|
|
|
+ * GET /api/files/:branch/:year/:month/:day/:filename
|
|
|
|
|
+ *
|
|
|
|
|
+ * Design goals:
|
|
|
|
|
+ * - Pure functions (no React, no window, no Next runtime).
|
|
|
|
|
+ * - Defensive encoding for filename (spaces, #, unicode, etc.).
|
|
|
|
|
+ * - Minimal, predictable behavior.
|
|
|
|
|
+ */
|
|
|
|
|
+
|
|
|
|
|
+/**
|
|
|
|
|
+ * Ensure a required string input is present and not empty/whitespace.
|
|
|
|
|
+ *
|
|
|
|
|
+ * Note:
|
|
|
|
|
+ * - We validate emptiness using trim(), but we may return the original value
|
|
|
|
|
+ * to avoid mutating semantics (important for filenames).
|
|
|
|
|
+ *
|
|
|
|
|
+ * @param {string} name
|
|
|
|
|
+ * @param {unknown} value
|
|
|
|
|
+ * @returns {string}
|
|
|
|
|
+ */
|
|
|
|
|
+function requireNonEmptyString(name, value) {
|
|
|
|
|
+ if (typeof value !== "string") {
|
|
|
|
|
+ throw new Error(`Route segment "${name}" must be a string`);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ if (!value.trim()) {
|
|
|
|
|
+ throw new Error(`Route segment "${name}" must not be empty`);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ return value;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+/**
|
|
|
|
|
+ * Encode a standard route segment (branch/year/month/day).
|
|
|
|
|
+ * - We normalize by trimming.
|
|
|
|
|
+ *
|
|
|
|
|
+ * @param {string} name
|
|
|
|
|
+ * @param {unknown} value
|
|
|
|
|
+ * @returns {string}
|
|
|
|
|
+ */
|
|
|
|
|
+function encodeSegment(name, value) {
|
|
|
|
|
+ return encodeURIComponent(requireNonEmptyString(name, value).trim());
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+/**
|
|
|
|
|
+ * Encode a filename segment.
|
|
|
|
|
+ * - We do NOT trim the filename (filenames are filesystem-exact).
|
|
|
|
|
+ * - We still validate that it isn't empty/whitespace.
|
|
|
|
|
+ *
|
|
|
|
|
+ * @param {unknown} value
|
|
|
|
|
+ * @returns {string}
|
|
|
|
|
+ */
|
|
|
|
|
+function encodeFilename(value) {
|
|
|
|
|
+ return encodeURIComponent(requireNonEmptyString("filename", value));
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+/**
|
|
|
|
|
+ * Build the PDF stream URL (inline by default).
|
|
|
|
|
+ *
|
|
|
|
|
+ * @param {{
|
|
|
|
|
+ * branch: string,
|
|
|
|
|
+ * year: string,
|
|
|
|
|
+ * month: string,
|
|
|
|
|
+ * day: string,
|
|
|
|
|
+ * filename: string
|
|
|
|
|
+ * }} input
|
|
|
|
|
+ * @returns {string}
|
|
|
|
|
+ */
|
|
|
|
|
+export function buildPdfUrl({ branch, year, month, day, filename }) {
|
|
|
|
|
+ const b = encodeSegment("branch", branch);
|
|
|
|
|
+ const y = encodeSegment("year", year);
|
|
|
|
|
+ const m = encodeSegment("month", month);
|
|
|
|
|
+ const d = encodeSegment("day", day);
|
|
|
|
|
+ const f = encodeFilename(filename);
|
|
|
|
|
+
|
|
|
|
|
+ return `/api/files/${b}/${y}/${m}/${d}/${f}`;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+/**
|
|
|
|
|
+ * Build the PDF download URL (forces Content-Disposition: attachment).
|
|
|
|
|
+ *
|
|
|
|
|
+ * @param {{
|
|
|
|
|
+ * branch: string,
|
|
|
|
|
+ * year: string,
|
|
|
|
|
+ * month: string,
|
|
|
|
|
+ * day: string,
|
|
|
|
|
+ * filename: string
|
|
|
|
|
+ * }} input
|
|
|
|
|
+ * @returns {string}
|
|
|
|
|
+ */
|
|
|
|
|
+export function buildPdfDownloadUrl(input) {
|
|
|
|
|
+ return `${buildPdfUrl(input)}?download=1`;
|
|
|
|
|
+}
|