Files
rustpad/static/js/note-files.js
T
2026-09-20 14:44:10 +02:00

507 lines
25 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/*
* 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 => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#039;" }[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 = `
<div class="video-insert-dialog__panel">
<div class="dialog-heading-row">
<div><p class="eyebrow">Video file</p><h2>How should it be added?</h2></div>
<button type="button" class="icon-button" data-video-choice="cancel" aria-label="Cancel">×</button>
</div>
<p class="dialog-copy" data-video-file-name></p>
<div class="video-choice-actions">
<button type="button" class="action-button action-button--primary" data-video-choice="player">
<strong>Embedded player</strong><span>Play the video directly in the note.</span>
</button>
<button type="button" class="action-button action-button--secondary" data-video-choice="download">
<strong>Download link</strong><span>Insert a link that downloads the original file.</span>
</button>
</div>
</div>`;
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)
? `<button class="action-button action-button--primary compact-button file-primary-action" data-add-file-to-note data-insert-mode="player" ${attributes}>${escapeHtml(t("files.addPlayer", {}, "Add player"))}</button>`
: `<button class="action-button action-button--primary compact-button file-primary-action" data-add-file-to-note data-insert-mode="auto" ${attributes}>${escapeHtml(t("files.addToNote", {}, "Add to note"))}</button>`;
const secondaryInsert = isVideo(file.mime_type)
? `<button class="action-button action-button--secondary compact-button" data-add-file-to-note data-insert-mode="download" ${attributes}>${escapeHtml(t("files.addDownload", {}, "Add download"))}</button>`
: "";
const codeButtons = isVideo(file.mime_type)
? `<button class="action-button action-button--secondary compact-button" data-show-file-code="link" aria-pressed="false" ${attributes}>${escapeHtml(t("files.link", {}, "Link"))}</button>
<button class="action-button action-button--secondary compact-button" data-show-file-code="player" aria-pressed="false" ${attributes}>${escapeHtml(t("files.playerCode", {}, "Player code"))}</button>
<button class="action-button action-button--secondary compact-button" data-show-file-code="download" aria-pressed="false" ${attributes}>${escapeHtml(t("files.downloadCode", {}, "Download code"))}</button>`
: `<button class="action-button action-button--secondary compact-button" data-show-file-code="link" aria-pressed="false" ${attributes}>${escapeHtml(t("files.link", {}, "Link"))}</button>
<button class="action-button action-button--secondary compact-button" data-show-file-code="alias" aria-pressed="false" ${attributes}>${escapeHtml(t("files.alias", {}, "Alias"))}</button>
<button class="action-button action-button--secondary compact-button" data-show-file-code="markdown" aria-pressed="false" ${attributes}>${escapeHtml(t("files.markdown", {}, "Markdown"))}</button>`;
const deleteButton = canDelete()
? `<button class="action-button action-button--danger compact-button" data-delete-file="${file.id}" data-file-name="${escapeHtml(file.filename)}">${escapeHtml(t("files.delete", {}, "Delete file"))}</button>`
: "";
return {
primary: `${primaryInsert}<button class="action-button action-button--secondary compact-button file-more-button" type="button" data-toggle-file-actions aria-expanded="false">${escapeHtml(t("common.more", {}, "More"))}</button>`,
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 `
<article class="file-row" data-file-row="${file.id}" data-file-search="${escapeHtml(fileSearchText(file))}">
<div class="file-row-main">
<div class="file-copy">
<div class="file-name" title="${escapeHtml(file.filename)}">${escapeHtml(file.filename)}</div>
<div class="file-meta">
<span>${formatBytes(file.size_bytes)}</span>
<span>${escapeHtml(file.mime_type || "file")}</span>
${created ? `<span>${escapeHtml(created)}</span>` : ""}
<span class="file-flag${file.is_attached ? "" : " detached"}">${escapeHtml(status)}</span>
</div>
</div>
</div>
<div class="file-actions">${actions.primary}</div>
<div class="file-secondary-actions" hidden>
<div class="file-secondary-actions__buttons">${actions.secondary}</div>
<div class="file-code" hidden>
<input class="file-code-value" type="text" readonly aria-label="${escapeHtml(generatedCodeLabel)}" spellcheck="false">
<button class="action-button action-button--secondary file-code-copy" data-copy-generated>${escapeHtml(t("share.copy", {}, "Copy"))}</button>
</div>
</div>
</article>`;
}
function renderFiles() {
if (!filesLoaded) return;
if (search) search.disabled = false;
const query = String(search?.value || "").trim().toLocaleLowerCase();
const restrictionMessage = getDeleteRestrictionMessage();
const restrictionNotice = restrictionMessage
? `<p class="file-delete-notice">${escapeHtml(restrictionMessage)}</p>`
: "";
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}<div class="files-state files-state--empty"><div class="files-state-mark" aria-hidden="true">+</div><strong>${escapeHtml(t("files.empty", {}, "No files uploaded."))}</strong><span>${escapeHtml(t("files.emptyHelp", {}, "Upload a file to keep it with this note and insert it into the document."))}</span></div>`;
return;
}
if (!visible.length) {
list.innerHTML = `${restrictionNotice}<div class="files-state files-state--empty"><div class="files-state-mark files-state-mark--search" aria-hidden="true">⌕</div><strong>${escapeHtml(t("files.noMatches", {}, "No files match this filter."))}</strong><span>${escapeHtml(t("files.noMatchesHelp", {}, "Try a different file name or type."))}</span></div>`;
return;
}
list.innerHTML = restrictionNotice + visible.map(fileRow).join("");
}
function renderLoading({ refreshing = false } = {}) {
if (search && !filesLoaded) search.disabled = true;
if (summary) {
summary.innerHTML = `<span class="ui-spinner ui-spinner--small" aria-hidden="true"></span><span>${escapeHtml(t(refreshing ? "files.refreshing" : "files.loading", {}, refreshing ? "Refreshing…" : "Loading files…"))}</span>`;
}
if (refreshing && filesLoaded) return;
list.innerHTML = Array.from({ length: 3 }, () => `
<div class="file-row file-row--skeleton" aria-hidden="true">
<div class="file-row-main"><div class="file-copy"><span class="file-skeleton file-skeleton--name"></span><span class="file-skeleton file-skeleton--meta"></span></div></div>
<span class="file-skeleton file-skeleton--action"></span>
</div>`).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 = `<div class="files-state files-state--error"><div class="files-state-mark" aria-hidden="true">!</div><strong>${escapeHtml(t("files.loadFailed", {}, "Could not load files"))}</strong><span>${escapeHtml(error?.message || "")}</span><button class="action-button action-button--secondary compact-button" type="button" data-files-retry>${escapeHtml(t("files.retry", {}, "Try again"))}</button></div>`;
}
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 };
}