From 2b4d056ab8b2147e0510a60a26503fe731064af6 Mon Sep 17 00:00:00 2001 From: Julien Calixte Date: Fri, 26 Jun 2026 16:29:05 +0100 Subject: [PATCH] feat(export): download project photos as a grouped zip Add POST /api/export {projectIds}: one folder per project (named after it), photos named ., with a generated initials SVG for Members without a Slack photo. Dedupe image fetches, bounded concurrency, fflate zip. Factor data.ts so routes + export share one fixtures/live gating path. Frontend: Export-photos button downloads the zip. --- backend/package.json | 1 + backend/pnpm-lock.yaml | 8 ++++ backend/src/avatar.ts | 30 +++++++++++++ backend/src/data.ts | 23 ++++++++++ backend/src/export.ts | 98 ++++++++++++++++++++++++++++++++++++++++++ backend/src/index.ts | 51 ++++++++++++++-------- src/App.vue | 37 +++++++++++++++- src/api.ts | 13 ++++++ 8 files changed, 241 insertions(+), 20 deletions(-) create mode 100644 backend/src/avatar.ts create mode 100644 backend/src/data.ts create mode 100644 backend/src/export.ts diff --git a/backend/package.json b/backend/package.json index 551c179..78c9ccc 100644 --- a/backend/package.json +++ b/backend/package.json @@ -10,6 +10,7 @@ }, "dependencies": { "@hono/node-server": "^2.0.6", + "fflate": "^0.8.3", "hono": "^4.12.27" }, "devDependencies": { diff --git a/backend/pnpm-lock.yaml b/backend/pnpm-lock.yaml index e5259cc..c3fec58 100644 --- a/backend/pnpm-lock.yaml +++ b/backend/pnpm-lock.yaml @@ -11,6 +11,9 @@ importers: '@hono/node-server': specifier: ^2.0.6 version: 2.0.6(hono@4.12.27) + fflate: + specifier: ^0.8.3 + version: 0.8.3 hono: specifier: ^4.12.27 version: 4.12.27 @@ -33,6 +36,9 @@ packages: '@types/node@26.0.1': resolution: {integrity: sha512-fc3KiUoBt6kie0N9bIW3E47vZsuaMf0PM2AaUpLCLT0s/LvX1nxAim6Fc049cNxODPpGm6qRAuUOB86SkRuPQw==} + fflate@0.8.3: + resolution: {integrity: sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA==} + hono@4.12.27: resolution: {integrity: sha512-1yrb/+w6HWQJrUCLkJ2IF5jNIPvvFkblV5RNOYl6bV+OA6p9GLcMpHFFGTosSvHvcAUibuUukRqhlYI4z32C7Q==} engines: {node: '>=16.9.0'} @@ -55,6 +61,8 @@ snapshots: dependencies: undici-types: 8.3.0 + fflate@0.8.3: {} + hono@4.12.27: {} typescript@6.0.3: {} diff --git a/backend/src/avatar.ts b/backend/src/avatar.ts new file mode 100644 index 0000000..5530f36 --- /dev/null +++ b/backend/src/avatar.ts @@ -0,0 +1,30 @@ +// Deterministic initials Avatar as an SVG string — used in the Export for +// Members with no Slack photo, mirroring the in-app initials fallback. + +const COLORS = ["#4A154B", "#1264A3", "#2EB67D", "#E01E5A", "#ECB22E", "#611F69", "#36C5F0"] + +function hashCode(s: string): number { + let h = 0 + for (let i = 0; i < s.length; i++) h = (h * 31 + s.charCodeAt(i)) | 0 + return Math.abs(h) +} + +function escapeXml(s: string): string { + const map: Record = { + "<": "<", + ">": ">", + "&": "&", + "'": "'", + '"': """, + } + return s.replace(/[<>&'"]/g, (c) => map[c] ?? c) +} + +export function initialsAvatarSvg(firstName: string, lastName: string): string { + const initials = `${firstName.charAt(0)}${lastName.charAt(0)}`.toUpperCase() || "?" + const color = COLORS[hashCode(`${firstName} ${lastName}`) % COLORS.length] ?? COLORS[0] + return ` + + ${escapeXml(initials)} +` +} diff --git a/backend/src/data.ts b/backend/src/data.ts new file mode 100644 index 0000000..679eb63 --- /dev/null +++ b/backend/src/data.ts @@ -0,0 +1,23 @@ +import { hasNaptaCredentials } from "./config.ts" +import { fixtureMembers, fixtureProjects } from "./fixtures.ts" +import { fetchNaptaProjectMembers, fetchNaptaProjects } from "./napta.ts" +import { type EnrichResult, enrichWithSlackAvatars } from "./slack.ts" +import type { DataSource, Project } from "./types.ts" + +// Single source of truth for "fixtures vs live", shared by the API routes and +// the Export so the gating logic isn't duplicated. + +export async function getProjects(): Promise<{ source: DataSource; projects: Project[] }> { + if (!hasNaptaCredentials) return { source: "fixture", projects: fixtureProjects } + return { source: "napta", projects: await fetchNaptaProjects() } +} + +export async function getProjectMembers( + projectId: string, +): Promise<{ source: DataSource } & EnrichResult> { + if (!hasNaptaCredentials) { + return { source: "fixture", members: fixtureMembers(projectId), slack: "unconfigured" } + } + const enriched = await enrichWithSlackAvatars(await fetchNaptaProjectMembers(projectId)) + return { source: "napta", ...enriched } +} diff --git a/backend/src/export.ts b/backend/src/export.ts new file mode 100644 index 0000000..358424f --- /dev/null +++ b/backend/src/export.ts @@ -0,0 +1,98 @@ +import { strToU8, zipSync } from "fflate" +import { getProjectMembers, getProjects } from "./data.ts" +import { initialsAvatarSvg } from "./avatar.ts" +import type { Member } from "./types.ts" + +const CONCURRENCY = 8 + +// Keep folder/file names filesystem-safe; collapse whitespace. +function sanitize(s: string): string { + const cleaned = s + .replace(/[^\p{L}\p{N} ._-]/gu, "") + .replace(/\s+/g, " ") + .trim() + return cleaned || "untitled" +} + +function fileBase(m: Member): string { + const base = sanitize(`${m.lastName} ${m.firstName}`).toLowerCase().replace(/\s+/g, "-") + return base || `member-${m.id}` +} + +function extFromUrl(url: string): string { + const path = url.split("?")[0] ?? "" + const m = path.match(/\.(jpe?g|png|gif|webp)$/i) + return m ? m[1]!.toLowerCase().replace("jpeg", "jpg") : "jpg" +} + +async function fetchImage(url: string): Promise { + const res = await fetch(url) + if (!res.ok) throw new Error(`image ${res.status}`) + return new Uint8Array(await res.arrayBuffer()) +} + +async function runPool( + items: T[], + limit: number, + fn: (item: T) => Promise, +): Promise { + let i = 0 + const workers = Array.from({ length: Math.min(limit, items.length) }, async () => { + while (i < items.length) { + const item = items[i++]! + await fn(item) + } + }) + await Promise.all(workers) +} + +// Build a zip: one folder per Project (folder = project name), each holding its +// Members' photos named `.`. Members with no Slack photo +// get a generated initials SVG. Image fetches are deduped across the whole export. +export async function buildExportZip(projectIds: string[]): Promise { + const { projects } = await getProjects() + const nameById = new Map(projects.map((p) => [p.id, p.name])) + const files: Record = {} + const folderCounts = new Map() + const imageCache = new Map() + + for (const pid of projectIds) { + const baseFolder = sanitize(nameById.get(pid) ?? pid) + const seen = folderCounts.get(baseFolder) ?? 0 + folderCounts.set(baseFolder, seen + 1) + const folder = seen === 0 ? baseFolder : `${baseFolder} (${seen + 1})` + + const { members } = await getProjectMembers(pid) + + // Assign unique file bases within this folder before fetching. + const usedNames = new Set() + const planned = members.map((m) => { + let candidate = fileBase(m) + let n = 1 + while (usedNames.has(candidate)) candidate = `${fileBase(m)}-${++n}` + usedNames.add(candidate) + return { member: m, base: candidate } + }) + + await runPool(planned, CONCURRENCY, async ({ member, base }) => { + if (member.imageUrl) { + let bytes = imageCache.get(member.imageUrl) + if (!bytes) { + try { + bytes = await fetchImage(member.imageUrl) + imageCache.set(member.imageUrl, bytes) + } catch { + bytes = undefined + } + } + if (bytes) { + files[`${folder}/${base}.${extFromUrl(member.imageUrl)}`] = bytes + return + } + } + files[`${folder}/${base}.svg`] = strToU8(initialsAvatarSvg(member.firstName, member.lastName)) + }) + } + + return zipSync(files, { level: 6 }) +} diff --git a/backend/src/index.ts b/backend/src/index.ts index 396a9cc..abf48a4 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -2,9 +2,9 @@ import { serve } from "@hono/node-server" import { Hono } from "hono" import { logger } from "hono/logger" import { config, hasNaptaCredentials, hasSlackCredentials } from "./config.ts" -import { fixtureMembers, fixtureProjects } from "./fixtures.ts" -import { fetchNaptaProjectMembers, fetchNaptaProjects } from "./napta.ts" -import { enrichWithSlackAvatars, refreshDirectory } from "./slack.ts" +import { getProjectMembers, getProjects } from "./data.ts" +import { refreshDirectory } from "./slack.ts" +import { buildExportZip } from "./export.ts" import "./db.ts" // initialise the SQLite schema at boot function errorMessage(err: unknown): string { @@ -17,28 +17,19 @@ app.use("*", logger()) app.get("/api/health", (c) => c.json({ status: "ok" })) app.get("/api/projects", async (c) => { - if (!hasNaptaCredentials) { - return c.json({ source: "fixture", projects: fixtureProjects }) - } try { - const projects = await fetchNaptaProjects() - return c.json({ source: "napta", projects }) + return c.json(await getProjects()) } catch (err) { - console.error("fetchNaptaProjects failed:", err) + console.error("getProjects failed:", err) return c.json({ error: errorMessage(err) }, 502) } }) app.get("/api/projects/:id/members", async (c) => { - const id = c.req.param("id") - if (!hasNaptaCredentials) { - return c.json({ source: "fixture", slack: "unconfigured", members: fixtureMembers(id) }) - } try { - const { members, slack } = await enrichWithSlackAvatars(await fetchNaptaProjectMembers(id)) - return c.json({ source: "napta", slack, members }) + return c.json(await getProjectMembers(c.req.param("id"))) } catch (err) { - console.error("fetchNaptaProjectMembers failed:", err) + console.error("getProjectMembers failed:", err) return c.json({ error: errorMessage(err) }, 502) } }) @@ -47,13 +38,35 @@ app.get("/api/projects/:id/members", async (c) => { app.post("/api/slack/refresh", async (c) => { if (!hasSlackCredentials) return c.json({ error: "Slack is not configured" }, 400) try { - const refreshed = await refreshDirectory() - return c.json({ refreshed }) + return c.json({ refreshed: await refreshDirectory() }) } catch (err) { console.error("slack refresh failed:", err) return c.json({ error: errorMessage(err) }, 502) } }) -console.log(`photofetch backend listening on :${config.port} (napta: ${hasNaptaCredentials})`) +// Export the selected Projects' photos as a zip (one folder per Project). +app.post("/api/export", async (c) => { + const body = (await c.req.json().catch(() => ({}))) as { projectIds?: unknown } + const ids = Array.isArray(body.projectIds) + ? body.projectIds.filter((x): x is string => typeof x === "string") + : [] + if (ids.length === 0) return c.json({ error: "projectIds required" }, 400) + try { + const zip = await buildExportZip(ids) + return new Response(zip, { + headers: { + "Content-Type": "application/zip", + "Content-Disposition": `attachment; filename="photofetch-${ids.length}-projects.zip"`, + }, + }) + } catch (err) { + console.error("export failed:", err) + return c.json({ error: errorMessage(err) }, 502) + } +}) + +console.log( + `photofetch backend listening on :${config.port} (napta: ${hasNaptaCredentials}, slack: ${hasSlackCredentials})`, +) serve({ fetch: app.fetch, port: config.port }) diff --git a/src/App.vue b/src/App.vue index 0afcdd9..da8b14b 100644 --- a/src/App.vue +++ b/src/App.vue @@ -1,7 +1,7 @@