1import { elements, populateSelect } from "./dom.js";2import { state } from "./state.js";3import { selectVolume } from "./navigation.js";4import { readRoute, readSession } from "./session.js";56// The library is the set of installed bundles, and it can change under a7// running application: a file dropped into the bundles directory, replaced8// with a newer build, or deleted. The backend says so with one event, and9// this refetch reconciles the page with whatever the catalog now holds.1011let refreshing = null;1213export function refreshCatalog() {14 // Bursts collapse into one refetch; the directory watcher already15 // debounces, so a second signal during a refresh means "look again".16 if (!refreshing) {17 refreshing = reconcile().finally(() => { refreshing = null; });18 }19 return refreshing;20}2122async function reconcile() {23 const response = await fetch("/data/catalog.json");24 if (!response.ok) return;25 const previous = state.volume;26 state.catalog = await response.json();27 populateSelect(elements.volume, state.catalog.volumes, "title");28 syncEmptyState();2930 if (!state.catalog.volumes.length) return;31 const current = previous && state.catalog.volumes.find((volume) => volume.slug === previous.slug);32 if (!current) {33 // Nothing was open, or the open volume's bundle left: land somewhere that34 // still exists, preferring wherever the address bar or the last session35 // points.36 const route = readRoute();37 await selectVolume(38 route.volume || readSession()?.volume || state.catalog.volumes[0].slug,39 route.volume ? route.world : undefined,40 );41 return;42 }43 if (current.stamp !== previous.stamp) {44 // The open volume was replaced by a newer build. Reload the world that is on45 // screen through the new stamped base, carrying the exact arrangement --46 // camera, filters, panels -- across so the swap reads as the map47 // sharpening rather than the application starting over.48 for (const key of state.textByWorld.keys()) {49 if (key.startsWith(previous.base + "/")) state.textByWorld.delete(key);50 }51 const view = state.engine?.getView();52 state.restore = {53 volume: current.slug,54 world: state.world?.slug,55 lens: state.world ? state.world.lenses.indexOf(state.lens) : 0,56 center: view?.getCenter(),57 zoom: view?.getZoom(),58 hidden: [...state.hiddenCollections],59 collapsed: [...state.collapsedSections],60 expanded: [...state.expandedCollections],61 labels: [...state.labelOverrides],62 overviewDocked: state.overviewDocked,63 dockFolded: state.dockFolded,64 dockDismissed: state.dockDismissed,65 };66 await selectVolume(current.slug, state.world?.slug);67 return;68 }69 // Same volume, same build: only the select needed repainting, but the old70 // catalog objects are gone, so the state points at their replacements.71 state.volume = current;72 elements.volume.value = current.slug;73}7475export function syncEmptyState() {76 const empty = !state.catalog?.volumes?.length;77 elements.emptyState.hidden = !empty;78 if (state.catalog?.bundlesDir) elements.emptyPath.textContent = state.catalog.bundlesDir;79}8081// importBundles asks the backend to raise the native picker and copy the82// chosen files in. The watcher would notice on its own; refreshing here just83// spares the reader the debounce wait, and the first imported volume is opened84// so the action lands somewhere visible.85export async function importBundles() {86 for (const button of [elements.emptyOpen, elements.addBundles]) button.disabled = true;87 try {88 const response = await fetch("/data/bundles/import", { method: "POST" });89 const outcome = await response.json().catch(() => ({}));90 await refreshCatalog();91 const first = outcome.imported?.[0];92 if (first && state.catalog.volumes.some((volume) => volume.slug === first)) {93 await selectVolume(first);94 }95 } finally {96 for (const button of [elements.emptyOpen, elements.addBundles]) button.disabled = false;97 }98}