diff --git a/spec/whistlegraph-admin-spec.mjs b/spec/whistlegraph-admin-spec.mjs --- a/spec/whistlegraph-admin-spec.mjs +++ b/spec/whistlegraph-admin-spec.mjs @@ -64,7 +64,8 @@ it("offers chronological post ordering in both directions", () => { const page = readFileSync(new URL("../system/public/whistlegraph.org/admin.html", import.meta.url), "utf8"); expect(page).toContain('data-sort="newest"'); expect(page).toContain('data-sort="oldest"'); - expect(page).toContain('sort==="oldest"?String(a.date||"").localeCompare(String(b.date||""))'); + expect(page).toContain('params.set("sort",sort)'); + expect(page).toContain('QUERY_API="/api/whistlegraph-query"'); }); it("recovers an existing Auth0 session before returning to the login splash", () => { diff --git a/spec/whistlegraph-query-spec.mjs b/spec/whistlegraph-query-spec.mjs new file mode 100644 --- /dev/null +++ b/spec/whistlegraph-query-spec.mjs @@ -0,0 +1,42 @@ +import { listCollection } from "../system/netlify/functions/whistlegraph-query.mjs"; + +function collectionWith(rows) { + const calls = { count: null, find: null, sort: null, skip: null, limit: null }; + const cursor = { + sort(value) { calls.sort = value; return this; }, + skip(value) { calls.skip = value; return this; }, + limit(value) { calls.limit = value; return this; }, + async toArray() { return rows.slice(0, calls.limit); }, + }; + return { + calls, + async countDocuments(query) { calls.count = query; return 123; }, + find(query) { calls.find = query; return cursor; }, + }; +} + +describe("Whistlegraph Mongo query pages", () => { + it("uses the indexed workCount field for unfiled posts", async () => { + const collection = collectionWith([{ _id: "1", id: "1", date: "2026-01-01" }]); + const page = await listCollection(collection, { kind: "unfiled", sort: "newest", limit: "60" }, "posts"); + expect(collection.calls.find).toEqual({ workCount: 0 }); + expect(collection.calls.sort).toEqual({ date: -1, _id: 1 }); + expect(page.total).toBe(123); + }); + + it("caps pages and returns an opaque continuation cursor", async () => { + const rows = Array.from({ length: 101 }, (_, i) => ({ _id: String(i), id: String(i), views: 200 - i })); + const collection = collectionWith(rows); + const page = await listCollection(collection, { limit: "500" }, "posts"); + expect(page.items.length).toBe(100); + expect(page.nextCursor).toEqual(jasmine.any(String)); + expect(collection.calls.limit).toBe(101); + }); + + it("routes taxonomy text through Mongo text search", async () => { + const collection = collectionWith([]); + await listCollection(collection, { q: "silo fuzz", limit: "20" }, "posts"); + expect(collection.calls.find).toEqual({ $text: { $search: "silo fuzz" } }); + expect(collection.calls.sort).toEqual({ score: { $meta: "textScore" }, _id: 1 }); + }); +}); diff --git a/system/netlify/functions/whistlegraph-query.mjs b/system/netlify/functions/whistlegraph-query.mjs new file mode 100644 --- /dev/null +++ b/system/netlify/functions/whistlegraph-query.mjs @@ -0,0 +1,225 @@ +// Fast, public Whistlegraph reads backed by a Mongo-materialized projection. +// Generated JSON remains the reproducible source; the projection folds the +// live Desk curation overlay into queryable work/post documents. + +import { existsSync, readFileSync, statSync } from "node:fs"; +import { join } from "node:path"; +import { connect } from "../../backend/database.mjs"; +import { respond } from "../../backend/http.mjs"; +import { curationPayload, deriveVisualTags, visualSearchText } from "./whistlegraph-admin-lib.mjs"; + +const DIR = join(process.cwd(), "public", "whistlegraph.org"); +const POSTS_PATH = join(DIR, "posts.json"); +const GRAPHS_PATH = join(DIR, "graphs.json"); +const VISUALS_PATH = join(process.cwd(), "..", "toolchain", "whistlegraph", "downloads", "VISUALS.json"); +const CURATION = "whistlegraph-curation"; +const POSTS = "whistlegraph-public-posts"; +const WORKS = "whistlegraph-public-works"; +const META = "whistlegraph-public-meta"; +const HEADERS = { "Cache-Control": "public, max-age=15, stale-while-revalidate=60" }; +const MAX_LIMIT = 100; +let syncPromise = null; +let indexesPromise = null; + +const clean = (value, max = 120) => String(value || "").trim().slice(0, max); +const pageLimit = (value) => Math.max(1, Math.min(MAX_LIMIT, Number.parseInt(value || "40", 10) || 40)); +const encodeCursor = (value) => Buffer.from(JSON.stringify(value)).toString("base64url"); +const decodeCursor = (value) => { + if (!value) return null; + try { return JSON.parse(Buffer.from(String(value), "base64url").toString("utf8")); } + catch { return null; } +}; +const thumbFor = (post) => post?.thumb || (post?.media !== "audio" && post?.id + ? `https://assets.aesthetic.computer/whistlegraph/index/posts/${post.id}.jpg` + : null); +const searchText = (parts) => parts.filter(Boolean).join(" ").toLowerCase(); + +async function ensureIndexes(db) { + if (indexesPromise) return indexesPromise; + indexesPromise = Promise.all([ + db.collection(POSTS).createIndex({ works: 1, views: -1, _id: 1 }, { background: true }), + db.collection(POSTS).createIndex({ kind: 1, views: -1, _id: 1 }, { background: true }), + db.collection(POSTS).createIndex({ workCount: 1, date: -1, _id: 1 }, { background: true }), + db.collection(POSTS).createIndex({ kind: 1, date: -1, _id: 1 }, { background: true }), + db.collection(POSTS).createIndex({ plots: 1, views: -1, _id: 1 }, { background: true }), + db.collection(POSTS).createIndex({ tags: 1, views: -1, _id: 1 }, { background: true }), + db.collection(POSTS).createIndex({ date: -1, _id: 1 }, { background: true }), + db.collection(POSTS).createIndex({ views: -1, _id: 1 }, { background: true }), + db.collection(POSTS).createIndex({ searchText: "text" }, { background: true, name: "post_taxonomy_text" }), + db.collection(WORKS).createIndex({ views: -1, _id: 1 }, { background: true }), + db.collection(WORKS).createIndex({ year: 1, _id: 1 }, { background: true }), + db.collection(WORKS).createIndex({ titleLower: 1, _id: 1 }, { background: true }), + db.collection(WORKS).createIndex({ byLower: 1, _id: 1 }, { background: true }), + db.collection(WORKS).createIndex({ searchText: "text" }, { background: true, name: "work_taxonomy_text" }), + db.collection(CURATION).createIndex({ updatedAt: -1 }, { background: true }), + ]).catch((error) => { indexesPromise = null; throw error; }); + return indexesPromise; +} + +async function projectionRevision(db) { + const latest = await db.collection(CURATION).find({}).sort({ updatedAt: -1 }).limit(1).toArray(); + const visualsStamp = existsSync(VISUALS_PATH) ? statSync(VISUALS_PATH).mtimeMs : "none"; + return `${statSync(GRAPHS_PATH).mtimeMs}:${statSync(POSTS_PATH).mtimeMs}:${visualsStamp}:${latest[0]?.updatedAt?.toISOString?.() || "none"}`; +} + +async function rebuildProjection(db, revision) { + const [graphData, postData, documents] = await Promise.all([ + Promise.resolve(JSON.parse(readFileSync(GRAPHS_PATH, "utf8"))), + Promise.resolve(JSON.parse(readFileSync(POSTS_PATH, "utf8"))), + db.collection(CURATION).find({}).toArray(), + ]); + const visualData = existsSync(VISUALS_PATH) ? JSON.parse(readFileSync(VISUALS_PATH, "utf8")) : { visuals: [] }; + const visuals = new Map((visualData.visuals || []).map((record) => [String(record.postId), { + ...(record.visual || {}), + autoTags: deriveVisualTags(record), + sampledSeconds: record.source?.sampledSeconds || [], + model: record.provenance?.model || null, + promptVersion: record.provenance?.promptVersion || null, + generated: record.provenance?.createdAt || null, + searchText: visualSearchText(record), + }])); + const overlay = curationPayload(documents); + const deleted = new Set(overlay.deletedWorks || []); + const workMap = new Map(); + for (const work of graphData.works || []) { + if (!deleted.has(work.code)) workMap.set(work.code, { ...work, ...(overlay.works[work.code] || {}) }); + } + for (const [code, work] of Object.entries(overlay.createdWorks || {})) { + if (!deleted.has(code)) workMap.set(code, { code, ...work, kind: "graph", status: "confirmed" }); + } + const aggregates = new Map(); + const posts = (postData.posts || []).map((base) => { + const post = { ...base, ...(overlay.posts[base.id] || {}) }; + post.works = (post.works || []).filter((code) => workMap.has(code)); + post.workCount = post.works.length; + post.relationships = post.works.map((work) => ({ work, role: "contributes" })); + post.plots = post.plots || []; + post.tags = post.tags || []; + const visual = visuals.get(String(post.id)); + if (visual) post.visual = visual; + post.searchText = searchText([post.id, post.desc, post.platform, post.kind, ...post.works, ...post.plots, ...post.tags, visual?.searchText, ...(visual?.autoTags || [])]); + post._id = String(post.id); + post.projectionRevision = revision; + for (const code of post.works) { + const row = aggregates.get(code) || { perf: 0, views: 0, posts: [] }; + row.perf += 1; row.views += Number(post.views) || 0; row.posts.push(post); aggregates.set(code, row); + } + return post; + }); + const works = [...workMap.values()].flatMap((work) => { + const row = aggregates.get(work.code); if (!row) return []; + const selected = row.posts.find((post) => String(post.id) === String(work.featuredPost || "")); + const hero = selected && thumbFor(selected) + ? selected + : [...row.posts].sort((a, b) => (Number(b.views) || 0) - (Number(a.views) || 0)).find(thumbFor); + return [{ + ...work, + _id: work.code, + perf: row.perf, + views: row.views, + ...(thumbFor(hero) ? { thumb: thumbFor(hero) } : {}), + titleLower: String(work.title || "").toLowerCase(), + byLower: String(work.by || "").toLowerCase(), + searchText: searchText([work.code, work.title, work.by, work.year]), + projectionRevision: revision, + }]; + }); + if (posts.length) await db.collection(POSTS).bulkWrite(posts.map((post) => ({ + replaceOne: { filter: { _id: post._id }, replacement: post, upsert: true }, + })), { ordered: false }); + if (works.length) await db.collection(WORKS).bulkWrite(works.map((work) => ({ + replaceOne: { filter: { _id: work._id }, replacement: work, upsert: true }, + })), { ordered: false }); + await Promise.all([ + db.collection(POSTS).deleteMany({ projectionRevision: { $ne: revision } }), + db.collection(WORKS).deleteMany({ projectionRevision: { $ne: revision } }), + db.collection(META).updateOne({ _id: "projection" }, { $set: { revision, posts: posts.length, works: works.length, updatedAt: new Date() } }, { upsert: true }), + ]); +} + +async function ensureProjection(db) { + if (syncPromise) return syncPromise; + syncPromise = (async () => { + await ensureIndexes(db); + const revision = await projectionRevision(db); + const current = await db.collection(META).findOne({ _id: "projection" }); + if (current?.revision !== revision) await rebuildProjection(db, revision); + return revision; + })().finally(() => { syncPromise = null; }); + return syncPromise; +} + +function cursorQuery(field, direction, cursor) { + if (!cursor || cursor.v === undefined || !cursor.id) return null; + const op = direction < 0 ? "$lt" : "$gt"; + return { $or: [{ [field]: { [op]: cursor.v } }, { [field]: cursor.v, _id: { $gt: cursor.id } }] }; +} + +export async function listCollection(collection, params, kind) { + const limit = pageLimit(params.limit); + const q = clean(params.q, 200); + const cursor = decodeCursor(params.cursor); + const filter = {}; + let sortField, direction; + if (kind === "posts") { + if (params.work) filter.works = clean(params.work, 24).toLowerCase(); + if (params.kind === "unfiled") filter.workCount = 0; + else if (params.kind && params.kind !== "all") filter.kind = clean(params.kind, 24); + if (params.plot) filter.plots = clean(params.plot, 100); + if (params.tag) filter.tags = clean(params.tag, 80); + sortField = params.sort === "oldest" || params.sort === "newest" ? "date" : "views"; + direction = params.sort === "oldest" ? 1 : -1; + } else { + sortField = params.sort === "year" ? "year" : params.sort === "title" ? "titleLower" : params.sort === "author" ? "byLower" : "views"; + direction = sortField === "views" ? -1 : 1; + } + if (q) filter.$text = { $search: q }; + const ranged = !q ? cursorQuery(sortField, direction, cursor) : null; + const query = ranged ? { $and: [filter, ranged] } : filter; + const total = await collection.countDocuments(filter); + let find = collection.find(query, { projection: { projectionRevision: 0, searchText: 0, titleLower: 0, byLower: 0, visual: 0 } }); + if (q) find = find.sort({ score: { $meta: "textScore" }, _id: 1 }).skip(Number(cursor?.offset) || 0); + else find = find.sort({ [sortField]: direction, _id: 1 }); + const rows = await find.limit(limit + 1).toArray(); + const hasMore = rows.length > limit; + const items = rows.slice(0, limit); + const last = items.at(-1); + const nextCursor = hasMore && last + ? encodeCursor(q ? { offset: (Number(cursor?.offset) || 0) + limit } : { v: last[sortField] ?? null, id: last._id }) + : null; + return { items, nextCursor, limit, total }; +} + +export function createHandler({ connectFn = connect } = {}) { + return async (event) => { + if (event.httpMethod !== "GET") return respond(405, { message: "Method Not Allowed." }, HEADERS); + let database; + try { + database = await connectFn(); + await ensureProjection(database.db); + const p = event.queryStringParameters || {}; + const action = p.action || "posts"; + if (action === "post") { + const item = await database.db.collection(POSTS).findOne({ _id: clean(p.id, 32) }, { projection: { projectionRevision: 0, searchText: 0 } }); + return item ? respond(200, { item }, HEADERS) : respond(404, { message: "Post not found." }, HEADERS); + } + if (action === "work") { + const code = clean(p.code, 24).toLowerCase(); + const item = await database.db.collection(WORKS).findOne({ _id: code }, { projection: { projectionRevision: 0, searchText: 0, titleLower: 0, byLower: 0 } }); + if (!item) return respond(404, { message: "Whistlegraph not found." }, HEADERS); + const page = await listCollection(database.db.collection(POSTS), { ...p, work: code }, "posts"); + return respond(200, { item, posts: page.items, nextCursor: page.nextCursor }, HEADERS); + } + const kind = action === "works" ? "works" : "posts"; + const page = await listCollection(database.db.collection(kind === "works" ? WORKS : POSTS), p, kind); + return respond(200, { ...page, action: kind }, HEADERS); + } catch (error) { + console.error("Whistlegraph query failed:", error?.message || error); + return respond(500, { message: "Whistlegraph query unavailable." }, { ...HEADERS, "Cache-Control": "no-store" }); + } finally { + try { await database?.disconnect?.(); } catch {} + } + }; +} + +export const handler = createHandler(); diff --git a/system/public/whistlegraph.org/admin.html b/system/public/whistlegraph.org/admin.html --- a/system/public/whistlegraph.org/admin.html +++ b/system/public/whistlegraph.org/admin.html @@ -98,14 +98,14 @@ const AUTH0_CLIENT="LVdZaMbyXctkGfZDnpzDATB5nR0ZhmMt"; const AUTH0_RETURN=location.origin; const DESK_PATH="/desk"; const API="/api/whistlegraph-admin"; + const QUERY_API="/api/whistlegraph-query"; const LOCAL=/^(localhost|127\.0\.0\.1)$/.test(location.hostname); const PREVIEW=LOCAL&&new URLSearchParams(location.search).get("preview")==="1"; - const STATIC_ROOT=LOCAL?"/whistlegraph.org":""; const THUMBS="https://assets.aesthetic.computer/whistlegraph/index/posts"; - const PAGE_SIZE=180; + const PAGE_SIZE=60; const esc=value=>String(value??"").replace(/[&<>\"]/g,c=>({"&":"&","<":"<",">":">",'\"':"""}[c])); const fmt=value=>value>=1e6?`${(value/1e6).toFixed(1)}M`:value>=1e3?`${Math.round(value/1e3)}K`:String(value||0); - let client=null,token=null,mode="posts",kind="all",sort="newest",query="",shown=0,view=[]; + let client=null,token=null,mode="posts",kind="all",sort="newest",query="",shown=0,view=[],nextCursor=null,total=0,loadingPage=false,listingVersion=0; let basePosts=[],baseWorks=[],posts=[],works=[],postById={},workByCode={},curation={works:{},posts:{},createdWorks:{},deletedWorks:[],trashedWorks:{}},deployments=[],recentChanges=[],selected=null,visualCoverage={count:0}; let machineMatchIds=new Set(),machineQuery="",machineSearchTimer=null,machineSearchVersion=0; const gate=document.getElementById("gate"),desk=document.getElementById("desk"),grid=document.getElementById("grid"),drawer=document.getElementById("drawer"); @@ -113,20 +113,15 @@ function toast(message){const el=document.getElementById("toast");el.textContent=message;el.classList.remove("hidden");clearTimeout(toast.timer);toast.timer=setTimeout(()=>el.classList.add("hidden"),2400)} function fail(message){document.getElementById("gateError").textContent=message;document.getElementById("gateStatus").textContent=""} function thumb(id){return `${THUMBS}/${encodeURIComponent(id)}.jpg`} + function imageFor(item,isWork=false){return isWork?item?.thumb||"":item?.thumb||thumb(item?.id)} function splitList(value){return [...new Set(String(value||"").split(",").map(x=>x.trim()).filter(Boolean))]} function applyCuration(){ const loadedVisuals=new Map(posts.filter(post=>post.visual).map(post=>[post.id,post.visual])); const deleted=new Set(curation.deletedWorks||[]); - const allWorks=[ - ...baseWorks.filter(w=>!deleted.has(w.code)).map(w=>({...w,...(curation.works?.[w.code]||{})})), - ...Object.entries(curation.createdWorks||{}).map(([code,w])=>({code,...w,kind:"graph",created:true})), - ]; - const valid=new Set(allWorks.map(w=>w.code)); - posts=basePosts.map(p=>{const patch=curation.posts?.[p.id]||{};const next={...p,...patch};if(!next.visual&&loadedVisuals.has(p.id))next.visual=loadedVisuals.get(p.id);next.works=(next.works||[]).filter(code=>valid.has(code));next.relationships=next.works.map(work=>({work,role:"contributes"}));return next}); - const relatedByWork=new Map(); - for(const post of posts){for(const code of post.works||[]){let summary=relatedByWork.get(code);if(!summary){summary={perf:0,views:0};relatedByWork.set(code,summary)}summary.perf+=1;summary.views+=post.views||0}} - works=allWorks.filter(work=>relatedByWork.has(work.code)||work.created); - for(const work of works){const summary=relatedByWork.get(work.code)||{perf:0,views:0};work.perf=summary.perf;work.views=summary.views} + const allWorks=baseWorks.filter(w=>!deleted.has(w.code)).map(w=>({...w,...(curation.works?.[w.code]||{})})); + for(const [code,w] of Object.entries(curation.createdWorks||{})){if(!allWorks.some(work=>work.code===code))allWorks.push({code,...w,kind:"graph",created:true})} + posts=basePosts.map(p=>{const patch=curation.posts?.[p.id]||{};const next={...p,...patch};if(!next.visual&&loadedVisuals.has(p.id))next.visual=loadedVisuals.get(p.id);next.works=next.works||[];next.relationships=next.works.map(work=>({work,role:"contributes"}));return next}); + works=allWorks; postById=Object.fromEntries(posts.map(p=>[p.id,p]));workByCode=Object.fromEntries(works.map(w=>[w.code,w])); document.getElementById("workCodes").innerHTML=works.map(w=>`${esc(w.title)}`).join(""); } @@ -142,16 +137,18 @@ } async function loadData(){ grid.setAttribute("aria-busy","true");document.getElementById("count").textContent="Loading archive…"; const curationRequest=PREVIEW?Promise.resolve({data:{works:{},posts:{}}}):getJSON(`${API}?action=data`,{headers:{Authorization:`Bearer ${token}`},cache:"no-store"}).then(data=>({data})).catch(error=>({error})); - const [pd,gd,curationResult]=await Promise.all([ - getJSON(`${STATIC_ROOT}/posts.json?t=${Date.now()}`,{cache:"no-cache"}), - getJSON(`${STATIC_ROOT}/graphs.json?t=${Date.now()}`,{cache:"no-cache"}), - curationRequest - ]); - basePosts=pd.posts||[];baseWorks=gd.works||[]; + const curationResult=await curationRequest; const {data:cd,error}=curationResult; if(cd){curation={works:cd.works||{},posts:cd.posts||{},createdWorks:cd.createdWorks||{},deletedWorks:cd.deletedWorks||[],trashedWorks:cd.trashedWorks||{}};deployments=cd.deployments||[];recentChanges=cd.recent||[];visualCoverage=cd.visualCoverage||{count:0}} - applyCuration();renderReset();grid.removeAttribute("aria-busy"); + await loadListing(true);grid.removeAttribute("aria-busy"); if(error)toast(`Live edits unavailable: ${error.message}`); + } + function listingURL(cursor=null){const params=new URLSearchParams({action:mode==="works"?"works":"posts",limit:String(PAGE_SIZE),q:query.trim()});if(mode==="posts"){params.set("kind",kind);params.set("sort",sort)}if(cursor)params.set("cursor",cursor);return `${QUERY_API}?${params}`} + async function loadListing(reset=false){ + if(mode==="trash"||loadingPage&&!reset)return;const version=reset?++listingVersion:listingVersion,requestedMode=mode,url=listingURL(reset?null:nextCursor);if(reset){nextCursor=null;basePosts=[];baseWorks=[];grid.innerHTML="";shown=0} + loadingPage=true;grid.setAttribute("aria-busy","true"); + try{const page=await getJSON(url,{cache:"no-store"});if(version!==listingVersion||requestedMode!==mode)return;if(requestedMode==="works")baseWorks=reset?page.items:[...baseWorks,...page.items];else basePosts=reset?page.items:[...basePosts,...page.items];nextCursor=page.nextCursor||null;total=page.total||0;applyCuration();if(reset)renderReset();else{buildView();renderMore()}} + catch(error){if(version===listingVersion)toast(error.message)}finally{if(version===listingVersion){loadingPage=false;grid.removeAttribute("aria-busy")}} } function enterDesk(name){gate.classList.add("hidden");desk.classList.remove("hidden");document.getElementById("who").textContent=name;if(PREVIEW){document.getElementById("previewBadge").classList.remove("hidden");document.getElementById("logout").classList.add("hidden")}} function canonicalizeDesk(){if(!LOCAL&&location.pathname!==DESK_PATH)history.replaceState({},"",DESK_PATH)} @@ -202,53 +199,36 @@ const q=query.trim().toLowerCase(); if(mode==="trash"){ view=Object.entries(curation.trashedWorks||{}).map(([code,w])=>({code,...w,trashed:true})).filter(w=>!q||String(w.title||"").toLowerCase().includes(q)||w.code.includes(q)||String(w.by||"").toLowerCase().includes(q));return; } - if(mode==="works"){ - view=works.filter(w=>!q||w.title.toLowerCase().includes(q)||w.code.toLowerCase().includes(q)||(w.by||"").toLowerCase().includes(q)).sort((a,b)=>(b.views||0)-(a.views||0));return; - } - view=posts.filter(p=>{ - if(kind==="unfiled"&&(p.works||[]).length)return false; - if(kind!=="all"&&kind!=="unfiled"&&p.kind!==kind)return false; - const machineHit=machineQuery===q&&machineMatchIds.has(String(p.id)); - return !q||machineHit||postTitle(p).toLowerCase().includes(q)||String(p.id).includes(q)||(p.works||[]).some(code=>code.toLowerCase().includes(q)||(workByCode[code]?.title||"").toLowerCase().includes(q)); - }).sort((a,b)=>sort==="oldest"?String(a.date||"").localeCompare(String(b.date||"")):String(b.date||"").localeCompare(String(a.date||""))); + view=mode==="works"?works:posts; } function tileHTML(item){ const isWork=mode!=="posts",key=isWork?item.code:item.id,post=isWork?heroForWork(item.code):item; const title=isWork?item.title:postTitle(item),label=isWork?`[${item.code}]`:(item.kind||"post"),meta=isWork?`${item.by||""} · ${item.perf||0} posts`:`${item.date||""} · ${fmt(item.views)} views`; const changed=Boolean((isWork?curation.works:curation.posts)?.[key]); - return `${esc(label)}${changed?'':""}${esc(title)}${esc(meta)}`; + return `${esc(label)}${changed?'':""}${esc(title)}${esc(meta)}`; } - function updateCount(){const coverage=mode==="posts"&&visualCoverage.count?` · ${visualCoverage.count.toLocaleString()} described`:"";document.getElementById("count").textContent=`${view.length.toLocaleString()} ${mode}${coverage}`} + function updateCount(){const coverage=mode==="posts"&&visualCoverage.count?` · ${visualCoverage.count.toLocaleString()} described`:"";const count=mode==="trash"?view.length:total;document.getElementById("count").textContent=`${count.toLocaleString()} ${mode}${coverage}`} function renderMore(){const end=Math.min(shown+PAGE_SIZE,view.length);grid.insertAdjacentHTML("beforeend",view.slice(shown,end).map(tileHTML).join(""));shown=end;updateCount()} function renderReset(preservePosition=false){ const scrollY=window.scrollY,previouslyShown=shown; buildView();shown=Math.min(preservePosition?Math.max(previouslyShown,PAGE_SIZE):PAGE_SIZE,view.length);grid.innerHTML=view.slice(0,shown).map(tileHTML).join("");document.getElementById("empty").classList.toggle("hidden",view.length>0);updateCount(); if(preservePosition)requestAnimationFrame(()=>scrollTo(0,scrollY)) } - new IntersectionObserver(entries=>{if(entries.some(e=>e.isIntersecting)&&shown{if(!entries.some(e=>e.isIntersecting))return;if(shown{const tile=event.target.closest("button[data-key]");if(tile)openEditor(tile.dataset.key)}); - async function searchMachineVisuals(value,version){ - const q=value.trim().toLowerCase(); - if(!q||mode!=="posts"){machineQuery=q;machineMatchIds=new Set();renderReset();return} - try{ - const result=await getJSON(`${API}?action=visual-search&q=${encodeURIComponent(q)}`,{headers:{Authorization:`Bearer ${token}`},cache:"no-store"}); - if(version!==machineSearchVersion||query.trim().toLowerCase()!==q)return; - machineQuery=q;machineMatchIds=new Set(result.ids||[]);renderReset(); - }catch(error){if(version===machineSearchVersion)toast(error.message)} - } - function queueMachineSearch(){clearTimeout(machineSearchTimer);const version=++machineSearchVersion;machineSearchTimer=setTimeout(()=>searchMachineVisuals(query,version),180)} - document.getElementById("search").addEventListener("input",event=>{query=event.target.value;renderReset();queueMachineSearch()}); - document.getElementById("sort").addEventListener("click",event=>{const button=event.target.closest("button[data-sort]");if(!button)return;sort=button.dataset.sort;document.querySelectorAll("#sort button").forEach(x=>x.classList.toggle("on",x===button));renderReset()}); - document.getElementById("mode").addEventListener("click",event=>{const button=event.target.closest("button[data-mode]");if(!button)return;mode=button.dataset.mode;document.querySelectorAll("#mode button").forEach(x=>x.classList.toggle("on",x===button));document.getElementById("filter").classList.toggle("hidden",mode!=="posts");document.getElementById("sort").classList.toggle("hidden",mode!=="posts");document.getElementById("search").placeholder=mode==="posts"?"Search captions, IDs, codes, or machine visuals…":"Search titles, codes, or authors…";renderReset();queueMachineSearch()}); - document.getElementById("filter").addEventListener("click",event=>{const button=event.target.closest("button[data-kind]");if(!button)return;kind=button.dataset.kind;document.querySelectorAll("#filter button").forEach(x=>x.classList.toggle("on",x===button));renderReset()}); + function queueListing(){clearTimeout(machineSearchTimer);const version=++machineSearchVersion;machineSearchTimer=setTimeout(()=>{if(version===machineSearchVersion)loadListing(true)},180)} + document.getElementById("search").addEventListener("input",event=>{query=event.target.value;if(mode==="trash")renderReset();else queueListing()}); + document.getElementById("sort").addEventListener("click",event=>{const button=event.target.closest("button[data-sort]");if(!button)return;sort=button.dataset.sort;document.querySelectorAll("#sort button").forEach(x=>x.classList.toggle("on",x===button));loadListing(true)}); + document.getElementById("mode").addEventListener("click",event=>{const button=event.target.closest("button[data-mode]");if(!button)return;mode=button.dataset.mode;document.querySelectorAll("#mode button").forEach(x=>x.classList.toggle("on",x===button));document.getElementById("filter").classList.toggle("hidden",mode!=="posts");document.getElementById("sort").classList.toggle("hidden",mode!=="posts");document.getElementById("search").placeholder=mode==="posts"?"Search captions, IDs, codes, or machine visuals…":"Search titles, codes, or authors…";if(mode==="trash"){nextCursor=null;total=0;renderReset()}else loadListing(true)}); + document.getElementById("filter").addEventListener("click",event=>{const button=event.target.closest("button[data-kind]");if(!button)return;kind=button.dataset.kind;document.querySelectorAll("#filter button").forEach(x=>x.classList.toggle("on",x===button));loadListing(true)}); document.getElementById("bulkAssign").addEventListener("click",async()=>{ - if(PREVIEW||mode!=="posts"||!view.length)return toast("Search or filter Posts first"); - if(view.length>500)return toast("Narrow results to 500 posts or fewer"); - const answer=prompt(`Enter a code to add it to all ${view.length} results. Prefix it with - to remove it (example: -long).`)?.trim().toLowerCase();if(!answer)return; + if(PREVIEW||mode!=="posts"||!total)return toast("Search or filter Posts first"); + if(total>500)return toast("Narrow results to 500 posts or fewer"); + const answer=prompt(`Enter a code to add it to all ${total} results. Prefix it with - to remove it (example: -long).`)?.trim().toLowerCase();if(!answer)return; const operation=answer.startsWith("-")?"remove":"add",code=answer.replace(/^-/,"");if(!code)return; const verb=operation==="add"?"Add":"Remove",tail=operation==="add"?" Existing relationships stay intact.":" Other relationships stay intact."; - if(!confirm(`${verb} [${code}] ${operation==="add"?"to":"from"} all ${view.length} current results?${tail}`))return; - try{const result=await getJSON(`${API}?action=bulk-assign`,{method:"POST",headers:{"Content-Type":"application/json",Authorization:`Bearer ${token}`},body:JSON.stringify({code,postIds:view.map(post=>post.id),operation})});await loadData();toast(`${operation==="add"?"Assigned":"Removed"} [${result.code}] ${operation==="add"?"to":"from"} ${result.count} posts`)}catch(error){toast(error.message)} + if(!confirm(`${verb} [${code}] ${operation==="add"?"to":"from"} all ${total} current results?${tail}`))return; + try{const postIds=[];let cursor=null;do{const page=await getJSON(listingURL(cursor),{cache:"no-store"});postIds.push(...page.items.map(post=>post.id));cursor=page.nextCursor}while(cursor&&postIds.length<=500);const result=await getJSON(`${API}?action=bulk-assign`,{method:"POST",headers:{"Content-Type":"application/json",Authorization:`Bearer ${token}`},body:JSON.stringify({code,postIds,operation})});await loadData();toast(`${operation==="add"?"Assigned":"Removed"} [${result.code}] ${operation==="add"?"to":"from"} ${result.count} posts`)}catch(error){toast(error.message)} }); document.getElementById("newWork").addEventListener("click",()=>{ @@ -270,7 +250,7 @@ function renderEditor(selection,item){ if(!item||selected?.type!==selection.type||selected?.key!==selection.key)return; const {type,key}=selection,post=type==="work"?heroForWork(key):item,title=type==="work"?item.title:postTitle(item),changed=Boolean((type==="work"?curation.works:curation.posts)?.[key]); drawer.innerHTML=`${esc(title)}${type==="work"?`Whistlegraph [${esc(key)}]`:`Archive post ${esc(key)}`}× - ${type==="post"?`${esc(item.date||"date unknown")}${fmt(item.views)} views · ${fmt(item.likes)} likesOpen original ↗Open archive page ↗`:`${esc(item.by||"")}${item.year||""} · ${fmt(item.views)} viewsOpen Whistlegraph ↗`} + ${type==="post"?`${esc(item.date||"date unknown")}${fmt(item.views)} views · ${fmt(item.likes)} likesOpen original ↗Open archive page ↗`:`${esc(item.by||"")}${item.year||""} · ${fmt(item.views)} viewsOpen Whistlegraph ↗`} ${type==="post"?postFields(item,!PREVIEW&&!item.visual):workFields(item,true)} Move to trashRestore generated dataSave live`; drawer.querySelector(".close").addEventListener("click",closeEditor);drawer.querySelector("#save").addEventListener("click",saveEditor);drawer.querySelector("#reset").addEventListener("click",resetEditor);drawer.querySelector("#deleteWork").addEventListener("click",deleteWork);
${type==="work"?`Whistlegraph [${esc(key)}]`:`Archive post ${esc(key)}`}