/* * Copyright (C) 2026 Mateusz Gruszczyński @linuxiarz.pl * Source-Available Code / Dual-Licensed. * * Free for non-commercial and evaluation use under terms of BSL/GPLv3. * Commercial or production use requires a valid paid license. * See LICENSE file in repository root for details. */ import { formatDateTime, formatNumber, t, tp } from "@rustpad/i18n"; import { api, uploadWithProgress } from "@rustpad/api"; import { copyText } from "@rustpad/clipboard"; import { prepareImageFile } from "@rustpad/image-upload"; import { askConfirm } from "@rustpad/modal"; import { safeAppUrl, safePublicUrl } from "@rustpad/security"; import { createUploadToast } from "@rustpad/toast"; function escapeHtml(value) { return String(value).replace(/[&<>"']/g, character => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[character])); } function formatBytes(value) { const bytes = Number(value) || 0; if (bytes < 1024) return `${formatNumber(bytes)} B`; if (bytes < 1024 * 1024) return `${formatNumber(bytes / 1024, { maximumFractionDigits: 1 })} KB`; return `${formatNumber(bytes / (1024 * 1024), { maximumFractionDigits: 1 })} MB`; } function formatDate(value) { const date = new Date(value); return Number.isNaN(date.getTime()) ? "" : formatDateTime(date); } function isVideo(mimeType) { return String(mimeType || "").startsWith("video/"); } function isLikelyVideoFile(file) { return isVideo(file?.type) || /\.(?:mp4|m4v|mov|webm|ogv)$/i.test(String(file?.name || "")); } function aliasCode(filename, label, mimeType, mode = "auto") { const safeLabel = String(label || filename).replace(/\]/g, ")").replace(/[\r\n]+/g, " ").trim() || filename; let kind = String(mimeType || "").startsWith("image/") ? "image" : "file"; if (isVideo(mimeType) && mode === "player") kind = "video"; return `[${kind}=${filename},${safeLabel}]`; } function clipboardFiles(event) { const itemFiles = [...(event.clipboardData?.items || [])] .filter(item => item.kind === "file") .map(item => item.getAsFile()) .filter(Boolean); if (itemFiles.length) return itemFiles; return [...(event.clipboardData?.files || [])]; } function pasteInsertionRange(editor, target) { if (target === editor) return { start: editor.selectionStart, end: editor.selectionEnd }; const sourceLine = target instanceof Element ? target.closest("[data-source-line]") : null; const lineIndex = Number(sourceLine?.dataset.sourceLine) - 1; if (!Number.isInteger(lineIndex) || lineIndex < 0) { return { start: editor.selectionStart, end: editor.selectionEnd }; } const lines = editor.value.split("\n"); if (lineIndex >= lines.length) return { start: editor.selectionStart, end: editor.selectionEnd }; let end = 0; for (let index = 0; index <= lineIndex; index++) end += lines[index].length + (index < lineIndex ? 1 : 0); return { start: end, end }; } function markdownCode(url, label, mimeType) { return String(mimeType || "").startsWith("image/") ? `![${label}](${url})` : `[${label}](${url})`; } function safeAttachmentUrl(value) { const raw = String(value || "").trim(); return raw.startsWith("/") ? safeAppUrl(raw) : safePublicUrl(raw, { allowMailto: false }); } function downloadAttachmentUrl(value) { const safe = safeAttachmentUrl(value); try { const url = new URL(safe, location.origin); if (/^\/f\/[^/]+\/[^/]+$/.test(url.pathname)) url.searchParams.set("download", "1"); return url.href; } catch { return safe; } } function createVideoInsertDialog() { const dialog = document.createElement("dialog"); dialog.className = "app-dialog video-insert-dialog"; dialog.innerHTML = `

Video file

How should it be added?

`; document.body.append(dialog); return dialog; } export function bindNoteFiles({ editor, endpoints, getAccessToken, getUploadMaxSize = () => 0, canDelete, canUpload, canEdit = () => true, getDeleteRestrictionMessage = () => "", toast, onFilesChanged = () => { } }) { const notify = (type, message, options = {}) => toast(message, { ...options, type }); const dialog = document.querySelector("#files-dialog"); const list = document.querySelector("#files-list"); const input = document.querySelector("#file-input"); const footer = document.querySelector("#footer-files"); const retryableStatuses = new Set([408, 425, 429, 500, 502, 503, 504]); let videoInsertDialog; function chooseVideoInsertMode(filename) { videoInsertDialog ||= createVideoInsertDialog(); videoInsertDialog.querySelector("[data-video-file-name]").textContent = filename; if (!videoInsertDialog.open) videoInsertDialog.showModal(); return new Promise(resolve => { let settled = false; const finish = value => { if (settled) return; settled = true; videoInsertDialog.removeEventListener("click", handleClick); videoInsertDialog.removeEventListener("cancel", handleCancel); if (videoInsertDialog.open) videoInsertDialog.close(); resolve(value); }; const handleClick = event => { const choice = event.target.closest("[data-video-choice]")?.dataset.videoChoice; if (!choice) return; finish(choice === "player" || choice === "download" ? choice : null); }; const handleCancel = event => { event.preventDefault(); finish(null); }; videoInsertDialog.addEventListener("click", handleClick); videoInsertDialog.addEventListener("cancel", handleCancel); }); } function fileActionButtons(file) { const attributes = `data-url="${escapeHtml(file.url)}" data-name="${escapeHtml(file.filename)}" data-mime="${escapeHtml(file.mime_type)}"`; const primaryInsert = isVideo(file.mime_type) ? `` : ``; const secondaryInsert = isVideo(file.mime_type) ? `` : ""; const codeButtons = isVideo(file.mime_type) ? ` ` : ` `; const deleteButton = canDelete() ? `` : ""; return { primary: `${primaryInsert}`, secondary: `${secondaryInsert}${codeButtons}${deleteButton}`, }; } const summary = document.querySelector("#files-dialog-summary"); const search = document.querySelector("#files-search"); let currentFiles = []; let filesLoaded = false; let loadSequence = 0; function summaryText(files, shown = files.length) { const totalSize = files.reduce((sum, file) => sum + (Number(file.size_bytes) || 0), 0); if (shown !== files.length) { return t("files.filteredSummary", { shown, count: files.length, size: formatBytes(totalSize) }, `${shown} of ${files.length} files · ${formatBytes(totalSize)}`); } return tp("files.summary", files.length, { size: formatBytes(totalSize) }, `${files.length} ${files.length === 1 ? "file" : "files"} · ${formatBytes(totalSize)}`); } function fileSearchText(file) { return `${file.filename || ""} ${file.mime_type || ""}`.toLocaleLowerCase(); } function fileRow(file) { const created = file.created_at ? formatDate(file.created_at) : ""; const status = file.is_attached ? t("files.inNote", {}, "in note") : t("files.removedContent", {}, "removed from content"); const generatedCodeLabel = t("files.generatedCode", {}, "Generated file code"); const actions = fileActionButtons(file); return `
${escapeHtml(file.filename)}
${formatBytes(file.size_bytes)} ${escapeHtml(file.mime_type || "file")} ${created ? `${escapeHtml(created)}` : ""} ${escapeHtml(status)}
${actions.primary}
`; } function renderFiles() { if (!filesLoaded) return; if (search) search.disabled = false; const query = String(search?.value || "").trim().toLocaleLowerCase(); const restrictionMessage = getDeleteRestrictionMessage(); const restrictionNotice = restrictionMessage ? `

${escapeHtml(restrictionMessage)}

` : ""; const visible = query ? currentFiles.filter(file => fileSearchText(file).includes(query)) : currentFiles; if (summary) summary.textContent = summaryText(currentFiles, visible.length); if (!currentFiles.length) { list.innerHTML = `${restrictionNotice}
${escapeHtml(t("files.empty", {}, "No files uploaded."))}${escapeHtml(t("files.emptyHelp", {}, "Upload a file to keep it with this note and insert it into the document."))}
`; return; } if (!visible.length) { list.innerHTML = `${restrictionNotice}
${escapeHtml(t("files.noMatches", {}, "No files match this filter."))}${escapeHtml(t("files.noMatchesHelp", {}, "Try a different file name or type."))}
`; return; } list.innerHTML = restrictionNotice + visible.map(fileRow).join(""); } function renderLoading({ refreshing = false } = {}) { if (search && !filesLoaded) search.disabled = true; if (summary) { summary.innerHTML = `${escapeHtml(t(refreshing ? "files.refreshing" : "files.loading", {}, refreshing ? "Refreshing…" : "Loading files…"))}`; } if (refreshing && filesLoaded) return; list.innerHTML = Array.from({ length: 3 }, () => ` `).join(""); } function renderLoadError(error) { if (search && !filesLoaded) search.disabled = true; if (summary) summary.textContent = t("files.loadFailed", {}, "Could not load files"); if (filesLoaded) return; list.innerHTML = `
${escapeHtml(t("files.loadFailed", {}, "Could not load files"))}${escapeHtml(error?.message || "")}
`; } async function loadFiles({ open = false } = {}) { if (open && !dialog.open) dialog.showModal(); const sequence = ++loadSequence; renderLoading({ refreshing: filesLoaded }); try { const files = await api(endpoints.list, { method: "PUT", body: JSON.stringify({ access_token: getAccessToken() || null }) }); if (sequence !== loadSequence) return; currentFiles = Array.isArray(files) ? files : []; filesLoaded = true; const totalSize = currentFiles.reduce((sum, file) => sum + (Number(file.size_bytes) || 0), 0); footer.textContent = tp("files.summary", currentFiles.length, { size: formatBytes(totalSize) }, `${currentFiles.length} ${currentFiles.length === 1 ? "file" : "files"} · ${formatBytes(totalSize)}`); renderFiles(); onFilesChanged(currentFiles); } catch (error) { if (sequence !== loadSequence) return; renderLoadError(error); if (open) notify("danger", error.message, { title: t("files.loadFailed", {}, "Could not load files") }); } } function insertText(text, range = null, inputType = "insertText") { const start = Math.max(0, Math.min(range?.start ?? editor.selectionStart, editor.value.length)); const end = Math.max(start, Math.min(range?.end ?? editor.selectionEnd, editor.value.length)); editor.setRangeText(text, start, end, "end"); editor.dispatchEvent(new InputEvent("input", { bubbles: true, inputType, data: text })); return { start: start + text.length, end: start + text.length }; } function moveCursorToNextLine(position, inputType = "insertText") { const cursor = Math.max(0, Math.min(position, editor.value.length)); if (editor.value[cursor] === "\n") { editor.setSelectionRange(cursor + 1, cursor + 1); return { start: cursor + 1, end: cursor + 1 }; } return insertText("\n", { start: cursor, end: cursor }, inputType); } function insertAttachmentText(text, range = null, inputType = "insertText") { const inserted = insertText(text, range, inputType); return moveCursorToNextLine(inserted.end, inputType); } function insertVideoPlayer(text, range = null, inputType = "insertText") { const start = Math.max(0, Math.min(range?.start ?? editor.selectionStart, editor.value.length)); const end = Math.max(start, Math.min(range?.end ?? editor.selectionEnd, editor.value.length)); const prefix = start > 0 && editor.value[start - 1] !== "\n" ? "\n" : ""; return insertAttachmentText(`${prefix}${text}`, { start, end }, inputType); } async function uploadFile(file, onUploaded, insertMode = "auto") { const uploadToast = createUploadToast(file.name); let completed = false; const run = async () => { uploadToast.start(); const form = new FormData(); form.append("access_token", getAccessToken() || ""); form.append("file", file); try { const result = await uploadWithProgress(endpoints.upload, { method: "POST", body: form, headers: {}, uploadMaxSizeBytes: getUploadMaxSize(), onProgress: progress => uploadToast.update(progress), }); if (completed) return; completed = true; const text = aliasCode(result.name, file.name, result.mime_type || file.type, insertMode); await onUploaded(text); uploadToast.success(); await loadFiles(); } catch (error) { const retryable = !error.status || retryableStatuses.has(error.status); uploadToast.fail(error.message, { retryable, onRetry: run }); } }; await run(); } function requestUpload() { if (!canUpload() || !canEdit()) { notify("warning", "Read-write access and file uploads are required to upload files.", { title: "Upload unavailable" }); return; } input.click(); } document.querySelector("#upload-button")?.addEventListener("click", requestUpload); document.querySelector("#mobile-upload-button")?.addEventListener("click", requestUpload); document.querySelector("#files-upload-button")?.addEventListener("click", requestUpload); input.addEventListener("change", async event => { let file = event.target.files[0]; if (!file) return; input.value = ""; if (file.type.startsWith("image/")) { try { file = await prepareImageFile(file); } catch (error) { notify("danger", error.message, { title: "Could not prepare image" }); return; } if (!file) return; } const insertMode = isLikelyVideoFile(file) ? await chooseVideoInsertMode(file.name) : "auto"; if (!insertMode) return; const range = { start: editor.selectionStart, end: editor.selectionEnd }; await uploadFile(file, text => insertMode === "player" ? insertVideoPlayer(text, range) : insertAttachmentText(text, range), insertMode); }); document.querySelector("#editor-workspace")?.addEventListener("paste", async event => { const files = clipboardFiles(event); if (!files.length) return; event.preventDefault(); if (!canUpload() || !canEdit()) { notify("warning", "Read-write access and file uploads are required to paste files.", { title: "Paste upload unavailable" }); return; } const initialRange = pasteInsertionRange(editor, event.target); const state = { cursor: initialRange.start, replaceEnd: initialRange.end, inserted: false }; const insertPastedAlias = text => { const value = editor.value; if (!state.inserted) { const prefix = state.cursor > 0 && value[state.cursor - 1] !== "\n" ? "\n" : ""; const suffix = state.replaceEnd < value.length && value[state.replaceEnd] !== "\n" ? "\n" : ""; insertText(`${prefix}${text}${suffix}`, { start: state.cursor, end: state.replaceEnd }, "insertFromPaste"); state.cursor += prefix.length + text.length; state.replaceEnd = state.cursor; state.inserted = true; return; } const inserted = `\n${text}`; insertText(inserted, { start: state.cursor, end: state.cursor }, "insertFromPaste"); state.cursor += inserted.length; state.replaceEnd = state.cursor; }; for (const file of files) { const insertMode = isLikelyVideoFile(file) ? await chooseVideoInsertMode(file.name) : "auto"; if (insertMode) await uploadFile(file, insertPastedAlias, insertMode); } if (state.inserted) moveCursorToNextLine(state.cursor, "insertFromPaste"); }); document.querySelector("#files-button").addEventListener("click", () => loadFiles({ open: true })); footer.addEventListener("click", () => loadFiles({ open: true })); document.querySelector("#close-files").addEventListener("click", () => dialog.close()); search?.addEventListener("input", renderFiles); list.addEventListener("click", async event => { const retryButton = event.target.closest("[data-files-retry]"); if (retryButton) { void loadFiles(); return; } const toggleButton = event.target.closest("[data-toggle-file-actions]"); if (toggleButton) { const row = toggleButton.closest(".file-row"); const panel = row?.querySelector(".file-secondary-actions"); if (!panel) return; const expanded = panel.hidden; panel.hidden = !expanded; toggleButton.setAttribute("aria-expanded", String(expanded)); toggleButton.textContent = expanded ? t("files.less", {}, "Less") : t("common.more", {}, "More"); if (!expanded) { const codePanel = row.querySelector(".file-code"); if (codePanel) codePanel.hidden = true; row.querySelectorAll("[data-show-file-code][aria-pressed="true"]").forEach(button => button.setAttribute("aria-pressed", "false")); } return; } const addButton = event.target.closest("[data-add-file-to-note]"); if (addButton) { const mode = addButton.dataset.insertMode; const text = aliasCode(addButton.dataset.name, addButton.dataset.name, addButton.dataset.mime, mode); if (mode === "player") insertVideoPlayer(text); else insertAttachmentText(text); notify("success", "The file reference was inserted into the note.", { title: "Added to note" }); return; } const showButton = event.target.closest("[data-show-file-code]"); if (showButton) { const row = showButton.closest(".file-row"); const panel = row.querySelector(".file-code"); const output = panel.querySelector(".file-code-value"); const safeUrl = safeAttachmentUrl(showButton.dataset.url); const absolute = new URL(safeUrl, location.origin).href; let text = showButton.dataset.showFileCode === "link" && isVideo(showButton.dataset.mime) ? downloadAttachmentUrl(showButton.dataset.url) : absolute; if (showButton.dataset.showFileCode === "alias") { text = aliasCode(showButton.dataset.name, showButton.dataset.name, showButton.dataset.mime); } else if (showButton.dataset.showFileCode === "markdown") { text = markdownCode(safeUrl, showButton.dataset.name, showButton.dataset.mime); } else if (showButton.dataset.showFileCode === "player") { text = aliasCode(showButton.dataset.name, showButton.dataset.name, showButton.dataset.mime, "player"); } else if (showButton.dataset.showFileCode === "download") { text = aliasCode(showButton.dataset.name, showButton.dataset.name, showButton.dataset.mime, "download"); } output.value = text; panel.hidden = false; row.querySelectorAll("[data-show-file-code]").forEach(button => button.setAttribute("aria-pressed", String(button === showButton))); output.focus(); output.select(); return; } const copyButton = event.target.closest("[data-copy-generated]"); if (copyButton) { try { await copyText(copyButton.closest(".file-code").querySelector(".file-code-value").value); notify("success", "Generated file code copied to the clipboard.", { title: "Copied" }); } catch (error) { notify("danger", error.message, { title: "Could not copy file code" }); } return; } const deleteButton = event.target.closest("[data-delete-file]"); if (!deleteButton) return; if (!await askConfirm(`Delete file "${deleteButton.dataset.fileName}" permanently?`, { title: "Delete file", confirmText: "Delete", danger: true })) return; try { await api(endpoints.remove(deleteButton.dataset.deleteFile), { method: "DELETE", headers: {}, body: JSON.stringify({ access_token: getAccessToken() || null }) }); notify("success", "The file was permanently deleted.", { title: "File deleted" }); await loadFiles(); } catch (error) { notify("danger", error.message, { title: "Could not delete file" }); } }); return { loadFiles }; }