const convertButton = document.getElementById("convert-button"); const uploadButton = document.getElementById("upload-button"); const markdownInput = document.getElementById("markdown"); const imageInput = document.getElementById("image"); const convertForm = document.getElementById("convert-form"); const convertStatusMessage = document.getElementById("convert-status"); const resultContainer = document.getElementById("result"); const uploadResultContainer = document.getElementById("upload-result"); const PERSISTENCE_KEY = "likha-pdf:form-state:v1"; 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 SESSION_IMAGE_SCHEME = "session-image://"; const MAX_IMAGE_BYTES = 25 * 1024 * 1024; const MAX_CONVERT_REQUEST_BYTES = 2048 * 1024 * 1024; 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 { const raw = localStorage.getItem(PERSISTENCE_KEY); return raw ? JSON.parse(raw) : null; } catch { return null; } } function writePersistedState(value) { try { localStorage.setItem(PERSISTENCE_KEY, JSON.stringify(value)); } catch { // ignore persistence failures in restricted storage contexts } } function readPersistedBoolean(key, fallback = false) { try { const raw = localStorage.getItem(key); if (raw === null) { return fallback; } return raw === "true"; } catch { return fallback; } } function writePersistedBoolean(key, value) { try { localStorage.setItem(key, value ? "true" : "false"); } catch { // ignore persistence failures in restricted storage contexts } } function setSnippetDetailsOpen(isOpen) { snippetDetailsIsOpen = Boolean(isOpen); writePersistedBoolean(SNIPPET_DETAILS_OPEN_KEY, snippetDetailsIsOpen); } function collectFormState() { if (!(convertForm instanceof HTMLFormElement)) { return; } const fields = {}; const elements = Array.from(convertForm.elements); for (const element of elements) { if (!(element instanceof HTMLElement)) { continue; } const name = element.getAttribute("name"); if (!name || name === "markdown") { continue; } if (element instanceof HTMLInputElement) { if (element.type === "radio") { if (element.checked) { fields[name] = element.value; } } else if (element.type === "checkbox") { fields[name] = element.checked; } continue; } if (element instanceof HTMLSelectElement) { fields[name] = element.value; } } writePersistedState({ markdown: markdownInput?.value ?? "", fields, }); } function restoreFormState() { if (!(convertForm instanceof HTMLFormElement)) { return; } const state = readPersistedState(); if (!state || typeof state !== "object") { return; } if (markdownInput && typeof state.markdown === "string") { markdownInput.value = state.markdown; markdownInput.readOnly = false; } if (imageInput) { imageInput.disabled = false; } if (uploadButton) { uploadButton.disabled = false; } const fields = state.fields; if (!fields || typeof fields !== "object") { return; } for (const [name, value] of Object.entries(fields)) { const element = convertForm.elements.namedItem(name); if (!element) { continue; } if (element instanceof RadioNodeList) { element.value = String(value); continue; } if (element instanceof HTMLInputElement && element.type === "checkbox") { element.checked = Boolean(value); continue; } if (element instanceof HTMLInputElement || element instanceof HTMLSelectElement) { element.value = String(value); } } } function setConvertLoadingState(isLoading) { if (convertButton instanceof HTMLButtonElement) { convertButton.disabled = isLoading; convertButton.textContent = isLoading ? "generating..." : "generate pdf"; } if (convertStatusMessage instanceof HTMLElement) { convertStatusMessage.hidden = !isLoading; } } function setUploadLoadingState(isLoading) { if (uploadButton instanceof HTMLButtonElement) { uploadButton.disabled = isLoading; uploadButton.textContent = isLoading ? "uploading..." : "insert image"; } } function escapeHtml(value) { return String(value) .replaceAll("&", "&") .replaceAll("<", "<") .replaceAll(">", ">") .replaceAll('"', """) .replaceAll("'", "'"); } function formatBytes(numBytes) { const parsed = Number(numBytes); if (!Number.isFinite(parsed) || parsed <= 0) { return "0 B"; } if (parsed < 1024) { return `${Math.round(parsed)} B`; } 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 `${value.toFixed(2)} PB`; } function buildSessionImageSnippet(name, id) { const cleanName = String(name || "image").replace(/[\]\r\n]/g, ""); return ``; } function normalizeImageRecord(raw) { if (!raw || typeof raw !== "object") { return null; } const id = typeof raw.id === "string" ? raw.id.trim() : ""; if (!id) { return null; } 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 dedupeImageRecords(records) { const deduped = []; const seen = new Set(); for (const record of records) { if (!record || seen.has(record.id)) { continue; } seen.add(record.id); deduped.push(record); } return deduped; } function sortImageRecordsByCreatedAt(records) { return [...records].sort((a, b) => b.createdAt - a.createdAt); } function toCacheImageRecord(record) { return { id: record.id, name: record.name, mimeType: record.mimeType, sizeBytes: record.sizeBytes, createdAt: record.createdAt, snippet: record.snippet, }; } function readSessionImageCache() { try { const raw = sessionStorage.getItem(SESSION_IMAGE_CACHE_KEY); if (!raw) { return []; } const parsed = JSON.parse(raw); if (!Array.isArray(parsed)) { return []; } const normalized = parsed .map(normalizeImageRecord) .filter((record) => record !== null); return dedupeImageRecords(normalized); } catch { return []; } } function writeSessionImageCache(records) { try { const payload = records.map(toCacheImageRecord); sessionStorage.setItem(SESSION_IMAGE_CACHE_KEY, JSON.stringify(payload)); } catch { // ignore storage write failures in restricted storage contexts } } function setSessionImageCache(records) { const normalized = records .map(normalizeImageRecord) .filter((record) => record !== null); sessionImageCache = dedupeImageRecords(normalized); writeSessionImageCache(sessionImageCache); } function getSessionImageCache() { return [...sessionImageCache]; } function mergeImageRecordsCachedFirst(cachedRecords, serverRecords) { const normalizedCache = dedupeImageRecords( cachedRecords.map(normalizeImageRecord).filter((record) => record !== null) ); const normalizedServer = sortImageRecordsByCreatedAt( dedupeImageRecords( serverRecords.map(normalizeImageRecord).filter((record) => record !== null) ) ); const serverById = new Map(normalizedServer.map((record) => [record.id, record])); const mergedCache = normalizedCache.map((cachedRecord) => ({ ...(serverById.get(cachedRecord.id) || {}), ...cachedRecord, })); const cacheIds = new Set(mergedCache.map((record) => record.id)); const serverOnly = normalizedServer.filter((record) => !cacheIds.has(record.id)); return [...mergedCache, ...serverOnly]; } function upsertSessionImageCache(record) { const normalized = normalizeImageRecord(record); if (!normalized) { return null; } const withoutRecord = sessionImageCache.filter((entry) => entry.id !== normalized.id); setSessionImageCache([normalized, ...withoutRecord]); return normalized; } function insertSnippetIntoMarkdown(snippet) { if (!markdownInput) { return; } const needsLeadingNewline = markdownInput.value && !markdownInput.value.endsWith("\n"); const prefix = needsLeadingNewline ? "\n" : ""; markdownInput.value += `${prefix}${snippet}\n`; markdownInput.focus(); collectFormState(); } function isAllowedImageFile(file) { if (file.type.startsWith("image/")) { return true; } return ALLOWED_IMAGE_EXT_PATTERN.test(file.name || ""); } function randomHex(length = 40) { const byteLen = Math.ceil(length / 2); 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); } let out = ""; while (out.length < length) { out += Math.random().toString(16).slice(2); } return out.slice(0, length); } function renderSnippetHistory(records, heading = "", message = "") { if (!(uploadResultContainer instanceof HTMLElement)) { return; } if (records.length === 0 && !heading && !message) { uploadResultContainer.innerHTML = ""; return; } const headingHtml = heading ? `
${escapeHtml(message)}
` : ""; let detailsHtml = ""; if (records.length > 0) { const openAttr = snippetDetailsIsOpen ? " open" : ""; const detailsItems = records .map((record) => { const sizeText = formatBytes(record.sizeBytes); return `${escapeHtml(record.snippet)}
${escapeHtml(message)}