// "Open your own": read a scroll from files, a zip or a URL, entirely in this browser. // Nothing is uploaded: files are read with the File API, checked, and kept in IndexedDB on this device. import { validateManifest, LOCAL_LIMITS, ext, MESH_EXT, IMAGE_EXT } from './manifest.js'; import { readZip } from './formats.js'; import { findManifest, fileSource, buildMeshScroll, saveLocal, deleteLocal, listLocal } from './scrolls.js'; import { exampleZip } from './example.js'; const esc = (s) => String(s).replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c])); const MB = (n) => `${(n / 1048576).toFixed(n < 10485760 ? 1 : 0)} MB`; const OK_EXT = ['json', 'zip', 'txt', 'md', ...MESH_EXT, ...IMAGE_EXT]; // files from a drop, a file picker or a zip: [{ name, blob }] export async function gatherFiles(list, lim = LOCAL_LIMITS) { const out = []; for (const f of list) { const name = f.relPath || f.webkitRelativePath || f.name; const e = ext(name); if (e === 'zip') { if (f.size > lim.maxZipBytes) throw new Error(`${f.name} is ${MB(f.size)}; the limit for a zip is ${MB(lim.maxZipBytes)}.`); out.push(...(await readZip(await f.arrayBuffer(), lim))); } else if (OK_EXT.includes(e)) { if (f.size > lim.maxFileBytes) throw new Error(`${name} is ${MB(f.size)}; the limit per file is ${MB(lim.maxFileBytes)}.`); out.push({ name, blob: f }); } } if (!out.length) throw new Error('None of these files can be used. Open a manifest.json with its OBJ or PLY meshes and PNG, JPG or TIFF images, or a zip of them.'); const total = out.reduce((a, f) => a + f.blob.size, 0); if (total > lim.maxTotalBytes) throw new Error(`These files add up to ${MB(total)}; the limit is ${MB(lim.maxTotalBytes)}.`); return out.filter((f) => !/(^|\/)(__MACOSX|\.)/.test(f.name)); } // a manifest or zip at a URL; the manifest's files are fetched from beside it. Needs CORS on that server. export async function filesFromURL(url, lim = LOCAL_LIMITS, onStep = () => {}) { let u; try { u = new URL(url, location.href); } catch (e) { throw new Error('That is not a valid URL.'); } if (!/^https?:$/.test(u.protocol)) throw new Error('Only http and https URLs can be opened.'); const get = async (href, max) => { let r; try { r = await fetch(href, { mode: 'cors', credentials: 'omit' }); } catch (e) { throw new Error(`Could not fetch ${href}. The server may not allow cross-origin requests (CORS), or it is unreachable. Download the files and drop them here instead.`); } if (!r.ok) throw new Error(`${href}: the server answered ${r.status}.`); const len = +r.headers.get('content-length'); if (len && len > max) throw new Error(`${href} is ${MB(len)}; the limit is ${MB(max)}.`); const b = await r.blob(); if (b.size > max) throw new Error(`${href} is ${MB(b.size)}; the limit is ${MB(max)}.`); return b; }; onStep(`Fetching ${u.href}`); const first = await get(u.href, lim.maxZipBytes); const head = new Uint8Array(await first.slice(0, 4).arrayBuffer()); if (ext(u.pathname) === 'zip' || (head[0] === 0x50 && head[1] === 0x4b)) return readZip(await first.arrayBuffer(), lim); let m; try { m = JSON.parse(await first.text()); } catch (e) { throw new Error('The URL is neither a zip nor a JSON manifest.'); } const v = validateManifest(m, { limits: lim }); if (!v.ok) throw Object.assign(new Error('The manifest has problems.'), { list: v.errors }); const files = [{ name: 'manifest.json', blob: first }]; let total = first.size; for (const p of v.files) { onStep(`Fetching ${p}`); const b = await get(new URL(p, u).href, lim.maxFileBytes); total += b.size; if (total > lim.maxTotalBytes) throw new Error(`The files add up to more than ${MB(lim.maxTotalBytes)}.`); files.push({ name: p, blob: b }); } return files; } // check everything a scroll needs, parse it all once, and keep it in this browser export async function checkAndStore(files, onStep = () => {}) { const { path, manifest } = await findManifest(files); const src = fileSource(files, path); const v = validateManifest(manifest, { limits: LOCAL_LIMITS, fileSize: (p) => src.size(p) }); if (!v.ok) throw Object.assign(new Error(`${path} has ${v.errors.length} problem${v.errors.length > 1 ? 's' : ''}.`), { list: v.errors, warnings: v.warnings }); onStep('Reading meshes and images'); await buildMeshScroll(manifest, src, { limits: LOCAL_LIMITS, atlasSide: 1024 }); // a full trial build, small atlas const keep = v.files.concat(path).map((p) => src.find(p) || { name: p }).filter((f) => f.blob); const key = `local-${manifest.id}-${Date.now().toString(36)}`; const size = keep.reduce((a, f) => a + f.blob.size, 0); // opening the same scroll again replaces the copy kept before for (const old of await listLocal()) if (old.id === manifest.id) await deleteLocal(old.key).catch(() => {}); try { await saveLocal({ key, id: manifest.id, name: manifest.name, added: Date.now(), size, files: keep.map((f) => ({ name: f.name, blob: f.blob })) }); } catch (e) { throw new Error(`This browser would not store the scroll (${e.message || e}). Private browsing or a full disk can cause this.`); } return { key, warnings: v.warnings }; } export function setupOpenOwn({ $, onOpen, currentKey }) { const sheet = $('openSheet'), drop = $('oDrop'), status = $('oStatus'); const show = (html, cls = '') => { status.className = `o-status mono ${cls}`; status.innerHTML = html; status.hidden = !html; }; const fail = (e) => { const list = e.list ? `