/*
* 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})`;
}
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?
×
Embedded player Play the video directly in the note.
Download link Insert a link that downloads the original file.
`;
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)
? `${escapeHtml(t("files.addPlayer", {}, "Add player"))} `
: `${escapeHtml(t("files.addToNote", {}, "Add to note"))} `;
const secondaryInsert = isVideo(file.mime_type)
? `${escapeHtml(t("files.addDownload", {}, "Add download"))} `
: "";
const codeButtons = isVideo(file.mime_type)
? `${escapeHtml(t("files.link", {}, "Link"))}
${escapeHtml(t("files.playerCode", {}, "Player code"))}
${escapeHtml(t("files.downloadCode", {}, "Download code"))} `
: `${escapeHtml(t("files.link", {}, "Link"))}
${escapeHtml(t("files.alias", {}, "Alias"))}
${escapeHtml(t("files.markdown", {}, "Markdown"))} `;
const deleteButton = canDelete()
? `${escapeHtml(t("files.delete", {}, "Delete file"))} `
: "";
return {
primary: `${primaryInsert}${escapeHtml(t("common.more", {}, "More"))} `,
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 || "")} ${escapeHtml(t("files.retry", {}, "Try again"))} `;
}
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 };
}