feat(export): download project photos as a grouped zip
Add POST /api/export {projectIds}: one folder per project (named after it),
photos named <lastname-firstname>.<ext>, 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.
This commit is contained in:
@@ -10,6 +10,7 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@hono/node-server": "^2.0.6",
|
"@hono/node-server": "^2.0.6",
|
||||||
|
"fflate": "^0.8.3",
|
||||||
"hono": "^4.12.27"
|
"hono": "^4.12.27"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|||||||
8
backend/pnpm-lock.yaml
generated
8
backend/pnpm-lock.yaml
generated
@@ -11,6 +11,9 @@ importers:
|
|||||||
'@hono/node-server':
|
'@hono/node-server':
|
||||||
specifier: ^2.0.6
|
specifier: ^2.0.6
|
||||||
version: 2.0.6(hono@4.12.27)
|
version: 2.0.6(hono@4.12.27)
|
||||||
|
fflate:
|
||||||
|
specifier: ^0.8.3
|
||||||
|
version: 0.8.3
|
||||||
hono:
|
hono:
|
||||||
specifier: ^4.12.27
|
specifier: ^4.12.27
|
||||||
version: 4.12.27
|
version: 4.12.27
|
||||||
@@ -33,6 +36,9 @@ packages:
|
|||||||
'@types/node@26.0.1':
|
'@types/node@26.0.1':
|
||||||
resolution: {integrity: sha512-fc3KiUoBt6kie0N9bIW3E47vZsuaMf0PM2AaUpLCLT0s/LvX1nxAim6Fc049cNxODPpGm6qRAuUOB86SkRuPQw==}
|
resolution: {integrity: sha512-fc3KiUoBt6kie0N9bIW3E47vZsuaMf0PM2AaUpLCLT0s/LvX1nxAim6Fc049cNxODPpGm6qRAuUOB86SkRuPQw==}
|
||||||
|
|
||||||
|
fflate@0.8.3:
|
||||||
|
resolution: {integrity: sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA==}
|
||||||
|
|
||||||
hono@4.12.27:
|
hono@4.12.27:
|
||||||
resolution: {integrity: sha512-1yrb/+w6HWQJrUCLkJ2IF5jNIPvvFkblV5RNOYl6bV+OA6p9GLcMpHFFGTosSvHvcAUibuUukRqhlYI4z32C7Q==}
|
resolution: {integrity: sha512-1yrb/+w6HWQJrUCLkJ2IF5jNIPvvFkblV5RNOYl6bV+OA6p9GLcMpHFFGTosSvHvcAUibuUukRqhlYI4z32C7Q==}
|
||||||
engines: {node: '>=16.9.0'}
|
engines: {node: '>=16.9.0'}
|
||||||
@@ -55,6 +61,8 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
undici-types: 8.3.0
|
undici-types: 8.3.0
|
||||||
|
|
||||||
|
fflate@0.8.3: {}
|
||||||
|
|
||||||
hono@4.12.27: {}
|
hono@4.12.27: {}
|
||||||
|
|
||||||
typescript@6.0.3: {}
|
typescript@6.0.3: {}
|
||||||
|
|||||||
30
backend/src/avatar.ts
Normal file
30
backend/src/avatar.ts
Normal file
@@ -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<string, string> = {
|
||||||
|
"<": "<",
|
||||||
|
">": ">",
|
||||||
|
"&": "&",
|
||||||
|
"'": "'",
|
||||||
|
'"': """,
|
||||||
|
}
|
||||||
|
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 `<svg xmlns="http://www.w3.org/2000/svg" width="512" height="512" viewBox="0 0 512 512">
|
||||||
|
<rect width="512" height="512" fill="${color}"/>
|
||||||
|
<text x="256" y="256" dy="0.08em" fill="#ffffff" font-family="Inter, system-ui, sans-serif" font-size="220" font-weight="600" text-anchor="middle" dominant-baseline="central">${escapeXml(initials)}</text>
|
||||||
|
</svg>`
|
||||||
|
}
|
||||||
23
backend/src/data.ts
Normal file
23
backend/src/data.ts
Normal file
@@ -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 }
|
||||||
|
}
|
||||||
98
backend/src/export.ts
Normal file
98
backend/src/export.ts
Normal file
@@ -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<Uint8Array> {
|
||||||
|
const res = await fetch(url)
|
||||||
|
if (!res.ok) throw new Error(`image ${res.status}`)
|
||||||
|
return new Uint8Array(await res.arrayBuffer())
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runPool<T>(
|
||||||
|
items: T[],
|
||||||
|
limit: number,
|
||||||
|
fn: (item: T) => Promise<void>,
|
||||||
|
): Promise<void> {
|
||||||
|
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 `<lastname-firstname>.<ext>`. 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<Uint8Array> {
|
||||||
|
const { projects } = await getProjects()
|
||||||
|
const nameById = new Map(projects.map((p) => [p.id, p.name]))
|
||||||
|
const files: Record<string, Uint8Array> = {}
|
||||||
|
const folderCounts = new Map<string, number>()
|
||||||
|
const imageCache = new Map<string, Uint8Array>()
|
||||||
|
|
||||||
|
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<string>()
|
||||||
|
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 })
|
||||||
|
}
|
||||||
@@ -2,9 +2,9 @@ import { serve } from "@hono/node-server"
|
|||||||
import { Hono } from "hono"
|
import { Hono } from "hono"
|
||||||
import { logger } from "hono/logger"
|
import { logger } from "hono/logger"
|
||||||
import { config, hasNaptaCredentials, hasSlackCredentials } from "./config.ts"
|
import { config, hasNaptaCredentials, hasSlackCredentials } from "./config.ts"
|
||||||
import { fixtureMembers, fixtureProjects } from "./fixtures.ts"
|
import { getProjectMembers, getProjects } from "./data.ts"
|
||||||
import { fetchNaptaProjectMembers, fetchNaptaProjects } from "./napta.ts"
|
import { refreshDirectory } from "./slack.ts"
|
||||||
import { enrichWithSlackAvatars, refreshDirectory } from "./slack.ts"
|
import { buildExportZip } from "./export.ts"
|
||||||
import "./db.ts" // initialise the SQLite schema at boot
|
import "./db.ts" // initialise the SQLite schema at boot
|
||||||
|
|
||||||
function errorMessage(err: unknown): string {
|
function errorMessage(err: unknown): string {
|
||||||
@@ -17,28 +17,19 @@ app.use("*", logger())
|
|||||||
app.get("/api/health", (c) => c.json({ status: "ok" }))
|
app.get("/api/health", (c) => c.json({ status: "ok" }))
|
||||||
|
|
||||||
app.get("/api/projects", async (c) => {
|
app.get("/api/projects", async (c) => {
|
||||||
if (!hasNaptaCredentials) {
|
|
||||||
return c.json({ source: "fixture", projects: fixtureProjects })
|
|
||||||
}
|
|
||||||
try {
|
try {
|
||||||
const projects = await fetchNaptaProjects()
|
return c.json(await getProjects())
|
||||||
return c.json({ source: "napta", projects })
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("fetchNaptaProjects failed:", err)
|
console.error("getProjects failed:", err)
|
||||||
return c.json({ error: errorMessage(err) }, 502)
|
return c.json({ error: errorMessage(err) }, 502)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
app.get("/api/projects/:id/members", async (c) => {
|
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 {
|
try {
|
||||||
const { members, slack } = await enrichWithSlackAvatars(await fetchNaptaProjectMembers(id))
|
return c.json(await getProjectMembers(c.req.param("id")))
|
||||||
return c.json({ source: "napta", slack, members })
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("fetchNaptaProjectMembers failed:", err)
|
console.error("getProjectMembers failed:", err)
|
||||||
return c.json({ error: errorMessage(err) }, 502)
|
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) => {
|
app.post("/api/slack/refresh", async (c) => {
|
||||||
if (!hasSlackCredentials) return c.json({ error: "Slack is not configured" }, 400)
|
if (!hasSlackCredentials) return c.json({ error: "Slack is not configured" }, 400)
|
||||||
try {
|
try {
|
||||||
const refreshed = await refreshDirectory()
|
return c.json({ refreshed: await refreshDirectory() })
|
||||||
return c.json({ refreshed })
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("slack refresh failed:", err)
|
console.error("slack refresh failed:", err)
|
||||||
return c.json({ error: errorMessage(err) }, 502)
|
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 })
|
serve({ fetch: app.fetch, port: config.port })
|
||||||
|
|||||||
37
src/App.vue
37
src/App.vue
@@ -1,7 +1,7 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, onMounted, ref, watch } from "vue"
|
import { computed, onMounted, ref, watch } from "vue"
|
||||||
import type { DataSource, Member, Project, SlackStatus } from "@/types"
|
import type { DataSource, Member, Project, SlackStatus } from "@/types"
|
||||||
import { fetchMembers, fetchProjects, refreshSlackDirectory } from "@/api"
|
import { exportPhotos, fetchMembers, fetchProjects, refreshSlackDirectory } from "@/api"
|
||||||
import ProjectPicker from "@/components/ProjectPicker.vue"
|
import ProjectPicker from "@/components/ProjectPicker.vue"
|
||||||
import ProjectGroup from "@/components/ProjectGroup.vue"
|
import ProjectGroup from "@/components/ProjectGroup.vue"
|
||||||
|
|
||||||
@@ -19,6 +19,8 @@ const source = ref<DataSource>("napta")
|
|||||||
const slackStatus = ref<SlackStatus | null>(null)
|
const slackStatus = ref<SlackStatus | null>(null)
|
||||||
const projectsError = ref<string | null>(null)
|
const projectsError = ref<string | null>(null)
|
||||||
const refreshing = ref(false)
|
const refreshing = ref(false)
|
||||||
|
const exporting = ref(false)
|
||||||
|
const exportError = ref<string | null>(null)
|
||||||
|
|
||||||
function errMsg(err: unknown): string {
|
function errMsg(err: unknown): string {
|
||||||
return err instanceof Error ? err.message : String(err)
|
return err instanceof Error ? err.message : String(err)
|
||||||
@@ -83,6 +85,24 @@ async function refreshAvatars() {
|
|||||||
refreshing.value = false
|
refreshing.value = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function downloadExport() {
|
||||||
|
exporting.value = true
|
||||||
|
exportError.value = null
|
||||||
|
try {
|
||||||
|
const blob = await exportPhotos(selectedIds.value)
|
||||||
|
const url = URL.createObjectURL(blob)
|
||||||
|
const a = document.createElement("a")
|
||||||
|
a.href = url
|
||||||
|
a.download = `photofetch-${selectedIds.value.length}-projects.zip`
|
||||||
|
a.click()
|
||||||
|
URL.revokeObjectURL(url)
|
||||||
|
} catch (err) {
|
||||||
|
exportError.value = errMsg(err)
|
||||||
|
} finally {
|
||||||
|
exporting.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -104,6 +124,17 @@ async function refreshAvatars() {
|
|||||||
<span v-if="refreshing" class="loading loading-spinner loading-xs"></span>
|
<span v-if="refreshing" class="loading loading-spinner loading-xs"></span>
|
||||||
Refresh avatars
|
Refresh avatars
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
v-if="groups.length > 0"
|
||||||
|
type="button"
|
||||||
|
class="btn btn-primary btn-sm"
|
||||||
|
:disabled="exporting"
|
||||||
|
title="Download a zip of photos, one folder per project"
|
||||||
|
@click="downloadExport"
|
||||||
|
>
|
||||||
|
<span v-if="exporting" class="loading loading-spinner loading-xs"></span>
|
||||||
|
Export photos
|
||||||
|
</button>
|
||||||
<ProjectPicker
|
<ProjectPicker
|
||||||
v-model="selectedIds"
|
v-model="selectedIds"
|
||||||
:projects="projects"
|
:projects="projects"
|
||||||
@@ -135,6 +166,10 @@ async function refreshAvatars() {
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div v-if="exportError" role="alert" class="alert alert-error">
|
||||||
|
<span>Export failed: {{ exportError }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div v-if="projectsError" role="alert" class="alert alert-error">
|
<div v-if="projectsError" role="alert" class="alert alert-error">
|
||||||
<span>Could not load projects: {{ projectsError }}</span>
|
<span>Could not load projects: {{ projectsError }}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
13
src/api.ts
13
src/api.ts
@@ -25,3 +25,16 @@ export async function refreshSlackDirectory(): Promise<{ refreshed: number }> {
|
|||||||
}
|
}
|
||||||
return (await res.json()) as { refreshed: number }
|
return (await res.json()) as { refreshed: number }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function exportPhotos(projectIds: string[]): Promise<Blob> {
|
||||||
|
const res = await fetch("/api/export", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ projectIds }),
|
||||||
|
})
|
||||||
|
if (!res.ok) {
|
||||||
|
const body = await res.text()
|
||||||
|
throw new Error(`${res.status} ${res.statusText}${body ? `: ${body}` : ""}`)
|
||||||
|
}
|
||||||
|
return res.blob()
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user