171 lines
8.5 KiB
JavaScript
171 lines
8.5 KiB
JavaScript
/*
|
|
* 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 { api, uploadWithProgress } from "@rustpad/api";
|
|
import { copyText } from "@rustpad/clipboard";
|
|
import { prepareImageFile } from "@rustpad/image-upload";
|
|
import { askConfirm } from "@rustpad/modal";
|
|
import { getAuthToken } from "@rustpad/session";
|
|
import { safeAppUrl } 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 `${bytes} B`;
|
|
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
|
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
|
}
|
|
|
|
function formatDate(value) {
|
|
const date = new Date(value);
|
|
return Number.isNaN(date.getTime()) ? "" : date.toLocaleString("pl-PL");
|
|
}
|
|
|
|
function aliasCode(filename, label, mimeType) {
|
|
const kind = String(mimeType || "").startsWith("image/") ? "image" : "file";
|
|
const safeLabel = String(label || filename).replace(/\]/g, ")").replace(/[\r\n]+/g, " ").trim() || filename;
|
|
return `[${kind}=${filename},${safeLabel}]`;
|
|
}
|
|
|
|
function markdownCode(url, label, mimeType) {
|
|
return String(mimeType || "").startsWith("image/") ? `` : `[${label}](${url})`;
|
|
}
|
|
|
|
export function bindNoteFiles({ editor, endpoints, getAccessToken, canDelete, toast, onFilesChanged = () => {} }) {
|
|
const dialog = document.querySelector("#files-dialog");
|
|
const list = document.querySelector("#files-list");
|
|
const input = document.querySelector("#file-input");
|
|
const footer = document.querySelector("#footer-files");
|
|
|
|
async function loadFiles({ open = false } = {}) {
|
|
try {
|
|
const files = await api(endpoints.list, { method: "PUT", body: JSON.stringify({ access_token: getAccessToken() || null }) });
|
|
const totalSize = files.reduce((sum, file) => sum + (Number(file.size_bytes) || 0), 0);
|
|
footer.textContent = `${files.length} ${files.length === 1 ? "file" : "files"} · ${formatBytes(totalSize)}`;
|
|
list.innerHTML = files.length ? files.map(file => `
|
|
<div class="file-row" data-file-row="${file.id}">
|
|
<div class="file-row-main">
|
|
<div class="file-name">${escapeHtml(file.filename)}</div>
|
|
<div class="file-meta">${formatBytes(file.size_bytes)} · ${escapeHtml(file.mime_type)}${file.created_at ? ` · ${formatDate(file.created_at)}` : ""} · <span class="file-flag${file.is_attached ? "" : " detached"}">${file.is_attached ? "in note" : "removed from content"}</span></div>
|
|
</div>
|
|
<div class="file-actions">
|
|
<button class="action-button action-button--secondary compact-button" data-show-file-code="link" data-url="${escapeHtml(file.url)}" data-name="${escapeHtml(file.filename)}" data-mime="${escapeHtml(file.mime_type)}">Link</button>
|
|
<button class="action-button action-button--primary compact-button" data-show-file-code="alias" data-url="${escapeHtml(file.url)}" data-name="${escapeHtml(file.filename)}" data-mime="${escapeHtml(file.mime_type)}">Alias</button>
|
|
<button class="action-button action-button--primary compact-button" data-show-file-code="markdown" data-url="${escapeHtml(file.url)}" data-name="${escapeHtml(file.filename)}" data-mime="${escapeHtml(file.mime_type)}">Markdown</button>
|
|
<button class="action-button action-button--primary compact-button" data-add-file-to-note data-url="${escapeHtml(file.url)}" data-name="${escapeHtml(file.filename)}" data-mime="${escapeHtml(file.mime_type)}">Add to note</button>
|
|
${canDelete() ? `<button class="action-button action-button--danger compact-button" data-delete-file="${file.id}" data-file-name="${escapeHtml(file.filename)}">Delete</button>` : ""}
|
|
</div>
|
|
<div class="file-code" hidden><textarea readonly aria-label="Generated file code"></textarea><button class="action-button action-button--primary compact-button" data-copy-generated>Copy</button></div>
|
|
</div>`).join("") : '<p class="dialog-copy">No files uploaded.</p>';
|
|
onFilesChanged(files);
|
|
if (open && !dialog.open) dialog.showModal();
|
|
} catch (error) {
|
|
if (open) toast(error.message);
|
|
}
|
|
}
|
|
|
|
document.querySelector("#upload-button").addEventListener("click", () => {
|
|
if (!getAuthToken() && !getAccessToken()) {
|
|
toast("Log in or use a read-write share link to upload files.");
|
|
return;
|
|
}
|
|
input.click();
|
|
});
|
|
input.addEventListener("change", async event => {
|
|
let file = event.target.files[0];
|
|
if (!file) return;
|
|
if (file.type.startsWith("image/")) {
|
|
try {
|
|
file = await prepareImageFile(file);
|
|
} catch (error) {
|
|
toast(error.message);
|
|
input.value = "";
|
|
return;
|
|
}
|
|
if (!file) { input.value = ""; return; }
|
|
}
|
|
|
|
const uploadToast = createUploadToast(file.name);
|
|
let completed = false;
|
|
const retryableStatuses = new Set([408, 425, 429, 500, 502, 503, 504]);
|
|
|
|
const uploadFile = 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: {},
|
|
onProgress: progress => uploadToast.update(progress),
|
|
});
|
|
if (completed) return;
|
|
completed = true;
|
|
const text = aliasCode(result.name, file.name, result.mime_type || file.type);
|
|
editor.setRangeText(text, editor.selectionStart, editor.selectionEnd, "end");
|
|
editor.dispatchEvent(new Event("input", { bubbles: true }));
|
|
uploadToast.success();
|
|
await loadFiles();
|
|
} catch (error) {
|
|
const retryable = !error.status || retryableStatuses.has(error.status);
|
|
uploadToast.fail(error.message, { retryable, onRetry: uploadFile });
|
|
}
|
|
};
|
|
|
|
input.value = "";
|
|
await uploadFile();
|
|
});
|
|
|
|
document.querySelector("#files-button").addEventListener("click", () => loadFiles({ open: true }));
|
|
footer.addEventListener("click", () => loadFiles({ open: true }));
|
|
document.querySelector("#close-files").addEventListener("click", () => dialog.close());
|
|
list.addEventListener("click", async event => {
|
|
const addButton = event.target.closest("[data-add-file-to-note]");
|
|
if (addButton) {
|
|
const text = aliasCode(addButton.dataset.name, addButton.dataset.name, addButton.dataset.mime);
|
|
editor.setRangeText(text, editor.selectionStart, editor.selectionEnd, "end");
|
|
editor.dispatchEvent(new Event("input", { bubbles: true }));
|
|
toast("Added to note");
|
|
return;
|
|
}
|
|
const showButton = event.target.closest("[data-show-file-code]");
|
|
if (showButton) {
|
|
const panel = showButton.closest(".file-row").querySelector(".file-code");
|
|
const output = panel.querySelector("textarea");
|
|
const absolute = new URL(safeAppUrl(showButton.dataset.url), location.origin).href;
|
|
let text = absolute;
|
|
if (showButton.dataset.showFileCode === "alias") {
|
|
text = aliasCode(showButton.dataset.name, showButton.dataset.name, showButton.dataset.mime);
|
|
} else if (showButton.dataset.showFileCode === "markdown") {
|
|
const relative = safeAppUrl(showButton.dataset.url);
|
|
text = markdownCode(relative, showButton.dataset.name, showButton.dataset.mime);
|
|
}
|
|
output.value = text; panel.hidden = false; output.focus(); output.select(); return;
|
|
}
|
|
const copyButton = event.target.closest("[data-copy-generated]");
|
|
if (copyButton) { try { await copyText(copyButton.closest(".file-code").querySelector("textarea").value); toast("Copied"); } catch (error) { toast(error.message); } 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 }) });
|
|
toast("File deleted");
|
|
await loadFiles();
|
|
} catch (error) { toast(error.message); }
|
|
});
|
|
|
|
return { loadFiles };
|
|
}
|