diff options
Diffstat (limited to '')
| -rw-r--r-- | src/static/main.js | 273 |
1 files changed, 226 insertions, 47 deletions
diff --git a/src/static/main.js b/src/static/main.js index 180bb91..ad98fa0 100644 --- a/src/static/main.js +++ b/src/static/main.js @@ -3,7 +3,7 @@ const uploadButton = document.getElementById("upload-button"); const markdownInput = document.getElementById("markdown"); const imageInput = document.getElementById("image"); const convertForm = document.getElementById("convert-form"); -const loadingIndicator = document.getElementById("loading"); +const convertStatusMessage = document.getElementById("convert-status"); const resultContainer = document.getElementById("result"); const uploadResultContainer = document.getElementById("upload-result"); @@ -11,12 +11,15 @@ 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 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 MAX_IMAGE_BYTES = 25 * 1024 * 1024; const MAX_STORAGE_BYTES = 25 * 1024 * 1024 * 1024; const MAX_CONVERT_REQUEST_BYTES = 512 * 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; +let snippetDetailsIsOpen = readPersistedBoolean(SNIPPET_DETAILS_OPEN_KEY, false); function readPersistedState() { try { @@ -35,6 +38,31 @@ function writePersistedState(value) { } } +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; @@ -134,8 +162,8 @@ function setConvertLoadingState(isLoading) { convertButton.textContent = isLoading ? "generating..." : "generate pdf"; } - if (loadingIndicator instanceof HTMLElement) { - loadingIndicator.hidden = !isLoading; + if (convertStatusMessage instanceof HTMLElement) { + convertStatusMessage.hidden = !isLoading; } } @@ -160,6 +188,18 @@ function buildLocalImageSnippet(name, id) { return ``; } +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; @@ -176,6 +216,24 @@ function makeImageId() { return `img-${Date.now()}-${Math.random().toString(16).slice(2, 10)}`; } +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 requestToPromise(request) { return new Promise((resolve, reject) => { request.onsuccess = () => resolve(request.result); @@ -323,42 +381,114 @@ async function resolveLocalImageTokens(markdown) { return { resolvedMarkdown, missingIds }; } -let activePreviewUrl = ""; +async function getImageSnippetHistory() { + const db = await openImageDb(); -function showUploadError(message) { - if (!(uploadResultContainer instanceof HTMLElement)) { - return; - } + try { + const transaction = db.transaction(IMAGE_STORE_NAME, "readonly"); + const store = transaction.objectStore(IMAGE_STORE_NAME); + const snippets = []; - uploadResultContainer.innerHTML = ` - <article> - <h4>image upload failed</h4> - <pre>${escapeHtml(message)}</pre> - </article> - `; + 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(); + }; + + request.onerror = () => + reject(request.error || new Error("failed to read image snippet history")); + }); + + await transactionDone(transaction); + snippets.sort((a, b) => b.createdAt - a.createdAt); + return snippets.map((entry) => entry.snippet); + } finally { + db.close(); + } } -function showUploadResult(record) { +function renderSnippetHistory(snippets, heading = "", message = "") { if (!(uploadResultContainer instanceof HTMLElement)) { return; } - if (activePreviewUrl) { - URL.revokeObjectURL(activePreviewUrl); - activePreviewUrl = ""; + if (snippets.length === 0 && !heading && !message) { + uploadResultContainer.innerHTML = ""; + return; } - const snippet = buildLocalImageSnippet(record.name, record.id); - activePreviewUrl = URL.createObjectURL(record.blob); + const headingHtml = heading ? `<h4>${escapeHtml(heading)}</h4>` : ""; + const messageHtml = message ? `<p>${escapeHtml(message)}</p>` : ""; + + let detailsHtml = ""; + if (snippets.length > 0) { + const openAttr = snippetDetailsIsOpen ? " open" : ""; + const snippetItems = snippets + .map( + (snippet) => + `<li style="all: revert;"><code style="all: revert;">${escapeHtml(snippet)}</code></li>` + ) + .join(""); + detailsHtml = ` + <br> + <details style="all: revert;" data-snippet-history="true"${openAttr}> + <summary style="all: revert; cursor: pointer;">images (${snippets.length})</summary> + <ol style="all: revert;">${snippetItems}</ol> + </details><br> + `; + } uploadResultContainer.innerHTML = ` <article> - <h4>image ready for insert</h4> - <p><a href="${escapeHtml(activePreviewUrl)}" target="_blank" rel="noreferrer">${escapeHtml(record.name)}</a></p> - <p><code id="uploaded-markdown">${escapeHtml(snippet)}</code></p> - <button type="button" data-insert-markdown="${escapeHtml(snippet)}" class="insert-markdown">insert into markdown</button> + ${headingHtml} + ${messageHtml} + ${detailsHtml} </article> `; + + const renderedDetails = uploadResultContainer.querySelector( + 'details[data-snippet-history="true"]' + ); + if (renderedDetails instanceof HTMLDetailsElement) { + renderedDetails.addEventListener("toggle", () => { + setSnippetDetailsOpen(renderedDetails.open); + }); + } +} + +async function refreshSnippetHistory(heading = "", message = "") { + try { + const snippets = await getImageSnippetHistory(); + renderSnippetHistory(snippets, heading, message); + } catch { + renderSnippetHistory([], "image upload failed", "unable to load image snippet history."); + } +} + +function showUploadError(message) { + void refreshSnippetHistory("image upload failed", message); +} + +async function showUploadResult(record) { + const snippet = buildLocalImageSnippet(record.name, record.id); + insertSnippetIntoMarkdown(snippet); + await refreshSnippetHistory("image inserted", record.name || "image"); } async function handleInsertImage() { @@ -401,7 +531,7 @@ async function handleInsertImage() { }; await saveImageRecord(record); - showUploadResult(record); + await showUploadResult(record); imageInput.value = ""; } catch (error) { const message = @@ -428,6 +558,68 @@ function showConvertError(message) { resultContainer.scrollIntoView({ behavior: "smooth", block: "start" }); } +let activePdfUrl = ""; + +function clearActivePdfUrl() { + if (activePdfUrl) { + URL.revokeObjectURL(activePdfUrl); + activePdfUrl = ""; + } +} + +function sanitizeDownloadFilename(filename) { + const epoch = Math.floor(Date.now() / 1000); + const fallback = `likha-pdf_${epoch}_${randomHex(40)}.pdf`; + if (!filename) { + return fallback; + } + + const sanitized = filename.replaceAll(/[^a-zA-Z0-9._()\- ]/g, "_").trim(); + return sanitized || fallback; +} + +function getDownloadFilenameFromResponse(response) { + const disposition = response.headers.get("content-disposition") || ""; + const utf8Match = disposition.match(/filename\*=UTF-8''([^;]+)/i); + if (utf8Match && utf8Match[1]) { + try { + return sanitizeDownloadFilename(decodeURIComponent(utf8Match[1])); + } catch { + return sanitizeDownloadFilename(utf8Match[1]); + } + } + + const plainMatch = disposition.match(/filename="?([^";]+)"?/i); + if (plainMatch && plainMatch[1]) { + return sanitizeDownloadFilename(plainMatch[1]); + } + + return sanitizeDownloadFilename(""); +} + +function showPdfReady(pdfBlob, downloadFilename) { + if (!(resultContainer instanceof HTMLElement)) { + return; + } + + try { + localStorage.setItem(PDF_FILENAME_KEY, downloadFilename); + } catch { + // ignore storage write failures + } + + clearActivePdfUrl(); + activePdfUrl = URL.createObjectURL(pdfBlob); + + resultContainer.innerHTML = ` + <article> + <h3>pdf ready</h3> + <a href="${escapeHtml(activePdfUrl)}" download="${escapeHtml(downloadFilename)}">download pdf</a> + </article> + `; + resultContainer.scrollIntoView({ behavior: "smooth", block: "start" }); +} + async function handleConvertSubmit(event) { event.preventDefault(); @@ -468,6 +660,14 @@ async function handleConvertSubmit(event) { body: formData, }); + const contentType = (response.headers.get("content-type") || "").toLowerCase(); + if (response.ok && contentType.includes("application/pdf")) { + const pdfBlob = await response.blob(); + const downloadFilename = getDownloadFilenameFromResponse(response); + showPdfReady(pdfBlob, downloadFilename); + return; + } + const responseHtml = await response.text(); if (resultContainer instanceof HTMLElement) { resultContainer.innerHTML = responseHtml; @@ -500,26 +700,5 @@ if (uploadButton instanceof HTMLButtonElement) { }); } -document.body.addEventListener("click", (event) => { - const target = event.target; - if (!(target instanceof HTMLElement)) { - return; - } - - const button = target.closest("[data-insert-markdown]"); - if (!(button instanceof HTMLElement) || !markdownInput) { - return; - } - - const snippet = button.dataset.insertMarkdown; - if (!snippet) { - return; - } - - const needsLeadingNewline = markdownInput.value && !markdownInput.value.endsWith("\n"); - const prefix = needsLeadingNewline ? "\n" : ""; - markdownInput.value += `${prefix}${snippet}\n`; - markdownInput.focus(); - collectFormState(); -}); +void refreshSnippetHistory(); |
