From 6fd3f732dd6430a7e5644524b27ea6d60f5e2a45 Mon Sep 17 00:00:00 2001 From: kj_sh604 Date: Mon, 1 Jun 2026 14:37:28 -0400 Subject: refactor: upload photos --- src/static/main.js | 523 ++++++++++++++++++++++++++++------------------------- 1 file changed, 279 insertions(+), 244 deletions(-) (limited to 'src/static') diff --git a/src/static/main.js b/src/static/main.js index 43715d5..8523b87 100644 --- a/src/static/main.js +++ b/src/static/main.js @@ -8,19 +8,17 @@ const resultContainer = document.getElementById("result"); const uploadResultContainer = document.getElementById("upload-result"); const PERSISTENCE_KEY = "likha-pdf:form-state:v1"; -const IMAGE_DB_NAME = "likha-pdf:image-db:v1"; -const IMAGE_DB_VERSION = 1; -const IMAGE_STORE_NAME = "images"; +const SESSION_IMAGE_CACHE_KEY = "likha-pdf:session-images:v1"; const PDF_FILENAME_KEY = "likha-pdf:last-pdf-filename"; const SNIPPET_DETAILS_OPEN_KEY = "likha-pdf:snippet-details-open:v1"; -const LOCAL_IMAGE_SCHEME = "local-image://"; +const SESSION_IMAGE_SCHEME = "session-image://"; const MAX_IMAGE_BYTES = 25 * 1024 * 1024; -const MAX_STORAGE_BYTES = 25 * 1024 * 1024 * 1024; const MAX_CONVERT_REQUEST_BYTES = 2048 * 1024 * 1024; -const LOCAL_IMAGE_TOKEN_PATTERN = /local-image:\/\/([a-zA-Z0-9-]+)/g; const ALLOWED_IMAGE_EXT_PATTERN = /\.(png|jpe?g|gif|webp|svg)$/i; const TAB_SPACES = " "; + let snippetDetailsIsOpen = readPersistedBoolean(SNIPPET_DETAILS_OPEN_KEY, false); +let sessionImageCache = readSessionImageCache(); function readPersistedState() { try { @@ -143,10 +141,7 @@ function restoreFormState() { continue; } - if ( - element instanceof HTMLInputElement && - element.type === "checkbox" - ) { + if (element instanceof HTMLInputElement && element.type === "checkbox") { element.checked = Boolean(value); continue; } @@ -171,12 +166,12 @@ function setConvertLoadingState(isLoading) { function setUploadLoadingState(isLoading) { if (uploadButton instanceof HTMLButtonElement) { uploadButton.disabled = isLoading; - uploadButton.textContent = isLoading ? "preparing..." : "insert image"; + uploadButton.textContent = isLoading ? "uploading..." : "insert image"; } } function escapeHtml(value) { - return value + return String(value) .replaceAll("&", "&") .replaceAll("<", "<") .replaceAll(">", ">") @@ -184,252 +179,222 @@ function escapeHtml(value) { .replaceAll("'", "'"); } -function buildLocalImageSnippet(name, id) { - const cleanName = String(name || "image").replace(/[\]\r\n]/g, ""); - return ``; -} - -function insertSnippetIntoMarkdown(snippet) { - if (!markdownInput) { - return; +function formatBytes(numBytes) { + const parsed = Number(numBytes); + if (!Number.isFinite(parsed) || parsed <= 0) { + return "0 B"; } - const needsLeadingNewline = markdownInput.value && !markdownInput.value.endsWith("\n"); - const prefix = needsLeadingNewline ? "\n" : ""; - markdownInput.value += `${prefix}${snippet}\n`; - markdownInput.focus(); - collectFormState(); -} + if (parsed < 1024) { + return `${Math.round(parsed)} B`; + } -function isAllowedImageFile(file) { - if (file.type.startsWith("image/")) { - return true; + const units = ["KB", "MB", "GB", "TB"]; + let value = parsed; + for (const unit of units) { + value /= 1024; + if (value < 1024) { + return `${value.toFixed(2)} ${unit}`; + } } - return ALLOWED_IMAGE_EXT_PATTERN.test(file.name || ""); + return `${value.toFixed(2)} PB`; +} + +function buildSessionImageSnippet(name, id) { + const cleanName = String(name || "image").replace(/[\]\r\n]/g, ""); + return ``; } -function makeImageId() { - if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") { - return crypto.randomUUID(); +function normalizeImageRecord(raw) { + if (!raw || typeof raw !== "object") { + return null; + } + + const id = typeof raw.id === "string" ? raw.id.trim() : ""; + if (!id) { + return null; } - return `img-${Date.now()}-${Math.random().toString(16).slice(2, 10)}`; + const nameValue = typeof raw.name === "string" ? raw.name.trim() : ""; + const name = nameValue || "image"; + + const mimeValue = typeof raw.mimeType === "string" ? raw.mimeType.trim() : ""; + const mimeType = mimeValue || "application/octet-stream"; + + const sizeValue = Number(raw.sizeBytes); + const sizeBytes = Number.isFinite(sizeValue) && sizeValue >= 0 ? sizeValue : 0; + + const createdAtValue = Number(raw.createdAt); + const createdAt = Number.isFinite(createdAtValue) && createdAtValue > 0 + ? createdAtValue + : Date.now(); + + const snippetValue = typeof raw.snippet === "string" ? raw.snippet.trim() : ""; + const snippet = snippetValue || buildSessionImageSnippet(name, id); + + return { + id, + name, + mimeType, + sizeBytes, + createdAt, + snippet, + }; } -function randomHex(length = 40) { - const byteLen = Math.ceil(length / 2); +function dedupeImageRecords(records) { + const deduped = []; + const seen = new Set(); - if (typeof crypto !== "undefined" && typeof crypto.getRandomValues === "function") { - const bytes = new Uint8Array(byteLen); - crypto.getRandomValues(bytes); - return Array.from(bytes, (value) => value.toString(16).padStart(2, "0")) - .join("") - .slice(0, length); + for (const record of records) { + if (!record || seen.has(record.id)) { + continue; + } + seen.add(record.id); + deduped.push(record); } - let out = ""; - while (out.length < length) { - out += Math.random().toString(16).slice(2); - } - return out.slice(0, length); + return deduped; } -function requestToPromise(request) { - return new Promise((resolve, reject) => { - request.onsuccess = () => resolve(request.result); - request.onerror = () => reject(request.error || new Error("indexeddb request failed")); - }); +function sortImageRecordsByCreatedAt(records) { + return [...records].sort((a, b) => b.createdAt - a.createdAt); } -function transactionDone(transaction) { - return new Promise((resolve, reject) => { - transaction.oncomplete = () => resolve(); - transaction.onerror = () => - reject(transaction.error || new Error("indexeddb transaction failed")); - transaction.onabort = () => - reject(transaction.error || new Error("indexeddb transaction aborted")); - }); +function toCacheImageRecord(record) { + return { + id: record.id, + name: record.name, + mimeType: record.mimeType, + sizeBytes: record.sizeBytes, + createdAt: record.createdAt, + snippet: record.snippet, + }; } -function openImageDb() { - return new Promise((resolve, reject) => { - const request = indexedDB.open(IMAGE_DB_NAME, IMAGE_DB_VERSION); - - request.onupgradeneeded = () => { - const db = request.result; - if (!db.objectStoreNames.contains(IMAGE_STORE_NAME)) { - db.createObjectStore(IMAGE_STORE_NAME, { keyPath: "id" }); - } - }; +function readSessionImageCache() { + try { + const raw = sessionStorage.getItem(SESSION_IMAGE_CACHE_KEY); + if (!raw) { + return []; + } - request.onsuccess = () => resolve(request.result); - request.onerror = () => reject(request.error || new Error("failed to open indexeddb")); - }); -} + const parsed = JSON.parse(raw); + if (!Array.isArray(parsed)) { + return []; + } -async function saveImageRecord(record) { - const db = await openImageDb(); + const normalized = parsed + .map(normalizeImageRecord) + .filter((record) => record !== null); - try { - const transaction = db.transaction(IMAGE_STORE_NAME, "readwrite"); - transaction.objectStore(IMAGE_STORE_NAME).put(record); - await transactionDone(transaction); - } finally { - db.close(); + return dedupeImageRecords(normalized); + } catch { + return []; } } -async function getImageRecord(imageId) { - const db = await openImageDb(); - +function writeSessionImageCache(records) { try { - const transaction = db.transaction(IMAGE_STORE_NAME, "readonly"); - const request = transaction.objectStore(IMAGE_STORE_NAME).get(imageId); - const record = await requestToPromise(request); - await transactionDone(transaction); - return record; - } finally { - db.close(); + const payload = records.map(toCacheImageRecord); + sessionStorage.setItem(SESSION_IMAGE_CACHE_KEY, JSON.stringify(payload)); + } catch { + // ignore storage write failures in restricted storage contexts } } -async function getImageUsageStats() { - const db = await openImageDb(); +function setSessionImageCache(records) { + const normalized = records + .map(normalizeImageRecord) + .filter((record) => record !== null); - try { - const transaction = db.transaction(IMAGE_STORE_NAME, "readonly"); - const store = transaction.objectStore(IMAGE_STORE_NAME); + sessionImageCache = dedupeImageRecords(normalized); + writeSessionImageCache(sessionImageCache); +} - let count = 0; - let totalBytes = 0; +function getSessionImageCache() { + return [...sessionImageCache]; +} - await new Promise((resolve, reject) => { - const request = store.openCursor(); +function mergeImageRecordsCachedFirst(cachedRecords, serverRecords) { + const normalizedCache = dedupeImageRecords( + cachedRecords.map(normalizeImageRecord).filter((record) => record !== null) + ); - request.onsuccess = () => { - const cursor = request.result; - if (!cursor) { - resolve(); - return; - } + const normalizedServer = sortImageRecordsByCreatedAt( + dedupeImageRecords( + serverRecords.map(normalizeImageRecord).filter((record) => record !== null) + ) + ); - count += 1; - let sizeBytes = Number(cursor.value?.sizeBytes); - if (!Number.isFinite(sizeBytes) || sizeBytes < 0) { - sizeBytes = Number(cursor.value?.blob?.size) || 0; - } - totalBytes += sizeBytes; - cursor.continue(); - }; + const serverById = new Map(normalizedServer.map((record) => [record.id, record])); + const mergedCache = normalizedCache.map((cachedRecord) => ({ + ...(serverById.get(cachedRecord.id) || {}), + ...cachedRecord, + })); - request.onerror = () => reject(request.error || new Error("failed to read image usage")); - }); + const cacheIds = new Set(mergedCache.map((record) => record.id)); + const serverOnly = normalizedServer.filter((record) => !cacheIds.has(record.id)); - await transactionDone(transaction); - return { count, totalBytes }; - } finally { - db.close(); - } + return [...mergedCache, ...serverOnly]; } -function getUniqueLocalImageIds(markdown) { - const ids = new Set(); - let match = null; - - while ((match = LOCAL_IMAGE_TOKEN_PATTERN.exec(markdown)) !== null) { - const imageId = match[1]; - if (imageId) { - ids.add(imageId); - } +function upsertSessionImageCache(record) { + const normalized = normalizeImageRecord(record); + if (!normalized) { + return null; } - LOCAL_IMAGE_TOKEN_PATTERN.lastIndex = 0; - return Array.from(ids); + const withoutRecord = sessionImageCache.filter((entry) => entry.id !== normalized.id); + setSessionImageCache([normalized, ...withoutRecord]); + return normalized; } -function blobToDataUrl(blob) { - return new Promise((resolve, reject) => { - const reader = new FileReader(); - reader.onload = () => resolve(String(reader.result || "")); - reader.onerror = () => reject(reader.error || new Error("failed to read image data")); - reader.readAsDataURL(blob); - }); -} - -async function resolveLocalImageTokens(markdown) { - const ids = getUniqueLocalImageIds(markdown); - if (ids.length === 0) { - return { resolvedMarkdown: markdown, missingIds: [] }; +function insertSnippetIntoMarkdown(snippet) { + if (!markdownInput) { + return; } - let resolvedMarkdown = markdown; - const missingIds = []; - - for (const imageId of ids) { - const record = await getImageRecord(imageId); - if (!record || !(record.blob instanceof Blob)) { - missingIds.push(imageId); - continue; - } + const needsLeadingNewline = markdownInput.value && !markdownInput.value.endsWith("\n"); + const prefix = needsLeadingNewline ? "\n" : ""; + markdownInput.value += `${prefix}${snippet}\n`; + markdownInput.focus(); + collectFormState(); +} - const dataUrl = await blobToDataUrl(record.blob); - resolvedMarkdown = resolvedMarkdown - .split(`${LOCAL_IMAGE_SCHEME}${imageId}`) - .join(dataUrl); +function isAllowedImageFile(file) { + if (file.type.startsWith("image/")) { + return true; } - return { resolvedMarkdown, missingIds }; + return ALLOWED_IMAGE_EXT_PATTERN.test(file.name || ""); } -async function getImageSnippetHistory() { - const db = await openImageDb(); - - try { - const transaction = db.transaction(IMAGE_STORE_NAME, "readonly"); - const store = transaction.objectStore(IMAGE_STORE_NAME); - const snippets = []; - - await new Promise((resolve, reject) => { - const request = store.openCursor(); - - request.onsuccess = () => { - const cursor = request.result; - if (!cursor) { - resolve(); - return; - } - - const value = cursor.value || {}; - const id = typeof value.id === "string" ? value.id : ""; - if (id) { - snippets.push({ - createdAt: Number(value.createdAt) || 0, - snippet: buildLocalImageSnippet(value.name, id), - }); - } - - cursor.continue(); - }; +function randomHex(length = 40) { + const byteLen = Math.ceil(length / 2); - request.onerror = () => - reject(request.error || new Error("failed to read image snippet history")); - }); + if (typeof crypto !== "undefined" && typeof crypto.getRandomValues === "function") { + const bytes = new Uint8Array(byteLen); + crypto.getRandomValues(bytes); + return Array.from(bytes, (value) => value.toString(16).padStart(2, "0")) + .join("") + .slice(0, length); + } - await transactionDone(transaction); - snippets.sort((a, b) => b.createdAt - a.createdAt); - return snippets.map((entry) => entry.snippet); - } finally { - db.close(); + let out = ""; + while (out.length < length) { + out += Math.random().toString(16).slice(2); } + return out.slice(0, length); } -function renderSnippetHistory(snippets, heading = "", message = "") { +function renderSnippetHistory(records, heading = "", message = "") { if (!(uploadResultContainer instanceof HTMLElement)) { return; } - if (snippets.length === 0 && !heading && !message) { + if (records.length === 0 && !heading && !message) { uploadResultContainer.innerHTML = ""; return; } @@ -438,20 +403,30 @@ function renderSnippetHistory(snippets, heading = "", message = "") { const messageHtml = message ? `
${escapeHtml(message)}
` : ""; let detailsHtml = ""; - if (snippets.length > 0) { + if (records.length > 0) { const openAttr = snippetDetailsIsOpen ? " open" : ""; - const snippetItems = snippets - .map( - (snippet) => - `${escapeHtml(snippet)}${escapeHtml(record.snippet)}
+