attachment actions

This commit is contained in:
zadam 2023-05-03 10:23:20 +02:00
parent d232694dec
commit d8bc9c2982
22 changed files with 466 additions and 247 deletions

View File

@ -136,6 +136,7 @@ class AbstractBeccaEntity {
// client code asks to save entity even if blobId didn't change (something else was changed)
opts.forceSave = !!opts.forceSave;
opts.forceCold = !!opts.forceCold;
opts.forceFrontendReload = !!opts.forceFrontendReload;
if (content === null || content === undefined) {
throw new Error(`Cannot set null content to ${this.constructor.primaryKeyName} '${this[this.constructor.primaryKeyName]}'`);
@ -176,7 +177,7 @@ class AbstractBeccaEntity {
}
/** @protected */
_saveBlob(content, unencryptedContentForHashCalculation, opts) {
_saveBlob(content, unencryptedContentForHashCalculation, opts = {}) {
let newBlobId;
let blobNeedsInsert;
@ -212,7 +213,10 @@ class AbstractBeccaEntity {
hash: hash,
isErased: false,
utcDateChanged: pojo.utcDateModified,
isSynced: true
isSynced: true,
// overriding componentId will cause frontend to think the change is coming from a different component
// and thus reload
componentId: opts.forceFrontendReload ? utils.randomString(10) : null
});
eventService.emit(eventService.ENTITY_CHANGED, {

View File

@ -96,6 +96,7 @@ class BAttachment extends AbstractBeccaEntity {
* @param {object} [opts]
* @param {object} [opts.forceSave=false] - will also save this BAttachment entity
* @param {object} [opts.forceCold=false] - blob has to be saved as cold
* @param {object} [opts.forceFrontendReload=false] - override frontend heuristics on when to reload, instruct to reload
*/
setContent(content, opts) {
this._setContent(content, opts);
@ -154,6 +155,12 @@ class BAttachment extends AbstractBeccaEntity {
return { note, branch };
}
getFileName() {
const type = this.role === 'image' ? 'image' : 'file';
return utils.formatDownloadTitle(this.title, type, this.mime);
}
beforeSaving() {
super.beforeSaving();

View File

@ -242,6 +242,7 @@ class BNote extends AbstractBeccaEntity {
* @param {object} [opts]
* @param {object} [opts.forceSave=false] - will also save this BNote entity
* @param {object} [opts.forceCold=false] - blob has to be saved as cold
* @param {object} [opts.forceFrontendReload=false] - override frontend heuristics on when to reload, instruct to reload
*/
setContent(content, opts) {
this._setContent(content, opts);
@ -1642,6 +1643,10 @@ class BNote extends AbstractBeccaEntity {
return attachment;
}
getFileName() {
return utils.formatDownloadTitle(this.title, this.type, this.mime);
}
beforeSaving() {
super.beforeSaving();

View File

@ -22,7 +22,7 @@ import NoteIconWidget from "../widgets/note_icon.js";
import SearchResultWidget from "../widgets/search_result.js";
import ScrollingContainer from "../widgets/containers/scrolling_container.js";
import RootContainer from "../widgets/containers/root_container.js";
import NoteUpdateStatusWidget from "../widgets/note_update_status.js";
import WatchedFileUpdateStatusWidget from "../widgets/watched_file_update_status.js";
import SpacerWidget from "../widgets/spacer.js";
import QuickSearchWidget from "../widgets/quick_search.js";
import SplitNoteContainer from "../widgets/containers/split_note_container.js";
@ -150,7 +150,7 @@ export default class DesktopLayout {
.button(new NoteActionsWidget())
)
.child(new SharedInfoWidget())
.child(new NoteUpdateStatusWidget())
.child(new WatchedFileUpdateStatusWidget())
.child(new FloatingButtons()
.child(new EditButton())
.child(new CodeButtonsWidget())

View File

@ -1,18 +1,33 @@
import ws from "./ws.js";
import appContext from "../components/app_context.js";
const fileModificationStatus = {};
const fileModificationStatus = {
notes: {},
attachments: {}
};
function getFileModificationStatus(noteId) {
return fileModificationStatus[noteId];
function checkType(type) {
if (type !== 'notes' && type !== 'attachments') {
throw new Error(`Unrecognized type '${type}', should be 'notes' or 'attachments'`);
}
}
function fileModificationUploaded(noteId) {
delete fileModificationStatus[noteId];
function getFileModificationStatus(entityType, entityId) {
checkType(entityType);
return fileModificationStatus[entityType][entityId];
}
function ignoreModification(noteId) {
delete fileModificationStatus[noteId];
function fileModificationUploaded(entityType, entityId) {
checkType(entityType);
delete fileModificationStatus[entityType][entityId];
}
function ignoreModification(entityType, entityId) {
checkType(entityType);
delete fileModificationStatus[entityType][entityId];
}
ws.subscribeToMessages(async message => {
@ -20,10 +35,13 @@ ws.subscribeToMessages(async message => {
return;
}
fileModificationStatus[message.noteId] = message;
checkType(message.entityType);
fileModificationStatus[message.entityType][message.entityId] = message;
appContext.triggerEvent('openedFileUpdated', {
noteId: message.noteId,
entityType: message.entityType,
entityId: message.entityId,
lastModifiedMs: message.lastModifiedMs,
filePath: message.filePath
});

View File

@ -39,7 +39,7 @@ async function processEntityChanges(entityChanges) {
// NOOP
}
else {
throw new Error(`Unknown entityName ${ec.entityName}`);
throw new Error(`Unknown entityName '${ec.entityName}'`);
}
}
catch (e) {
@ -92,7 +92,7 @@ function processNoteChange(loadResults, ec) {
loadResults.addNote(ec.entityId, ec.componentId);
if (ec.isErased && ec.entityId in froca.notes) {
utils.reloadFrontendApp(`${ec.entityName} ${ec.entityId} is erased, need to do complete reload.`);
utils.reloadFrontendApp(`${ec.entityName} '${ec.entityId}' is erased, need to do complete reload.`);
return;
}
@ -106,7 +106,7 @@ function processNoteChange(loadResults, ec) {
async function processBranchChange(loadResults, ec) {
if (ec.isErased && ec.entityId in froca.branches) {
utils.reloadFrontendApp(`${ec.entityName} ${ec.entityId} is erased, need to do complete reload.`);
utils.reloadFrontendApp(`${ec.entityName} '${ec.entityId}' is erased, need to do complete reload.`);
return;
}
@ -192,7 +192,7 @@ function processAttributeChange(loadResults, ec) {
let attribute = froca.attributes[ec.entityId];
if (ec.isErased && ec.entityId in froca.attributes) {
utils.reloadFrontendApp(`${ec.entityName} ${ec.entityId} is erased, need to do complete reload.`);
utils.reloadFrontendApp(`${ec.entityName} '${ec.entityId}' is erased, need to do complete reload.`);
return;
}
@ -241,7 +241,7 @@ function processAttributeChange(loadResults, ec) {
function processAttachment(loadResults, ec) {
if (ec.isErased && ec.entityId in froca.attachments) {
utils.reloadFrontendApp(`${ec.entityName} ${ec.entityId} is erased, need to do complete reload.`);
utils.reloadFrontendApp(`${ec.entityName} '${ec.entityId}' is erased, need to do complete reload.`);
return;
}

View File

@ -0,0 +1,32 @@
import toastService from "./toast.js";
function copyImageReferenceToClipboard($imageWrapper) {
try {
$imageWrapper.attr('contenteditable', 'true');
selectImage($imageWrapper.get(0));
const success = document.execCommand('copy');
if (success) {
toastService.showMessage("Image copied to the clipboard");
} else {
toastService.showAndLogError("Could not copy the image to clipboard.");
}
}
finally {
window.getSelection().removeAllRanges();
$imageWrapper.removeAttr('contenteditable');
}
}
function selectImage(element) {
const selection = window.getSelection();
const range = document.createRange();
range.selectNodeContents(element);
selection.removeAllRanges();
selection.addRange(range);
}
export default {
copyImageReferenceToClipboard
};

View File

@ -125,8 +125,6 @@ function calculateHash({notePath, ntxId, hoistedNoteId, viewScope = {}}) {
hash += `?${paramStr}`;
}
console.log(hash);
return hash;
}

View File

@ -1,11 +1,22 @@
import utils from "./utils.js";
import server from "./server.js";
function getFileUrl(noteId) {
return getUrlForDownload(`api/notes/${noteId}/download`);
function checkType(type) {
if (type !== 'notes' && type !== 'attachments') {
throw new Error(`Unrecognized type '${type}', should be 'notes' or 'attachments'`);
}
}
function getOpenFileUrl(noteId) {
return getUrlForDownload(`api/notes/${noteId}/open`);
function getFileUrl(type, noteId) {
checkType(type);
return getUrlForDownload(`api/${type}/${noteId}/download`);
}
function getOpenFileUrl(type, noteId) {
checkType(type);
return getUrlForDownload(`api/${type}/${noteId}/open`);
}
function download(url) {
@ -19,32 +30,15 @@ function download(url) {
}
function downloadFileNote(noteId) {
const url = `${getFileUrl(noteId)}?${Date.now()}`; // don't use cache
const url = `${getFileUrl('notes', noteId)}?${Date.now()}`; // don't use cache
download(url);
}
async function openNoteExternally(noteId, mime) {
if (utils.isElectron()) {
const resp = await server.post(`notes/${noteId}/save-to-tmp-dir`);
function downloadAttachment(attachmentId) {
const url = `${getFileUrl('attachments', attachmentId)}?${Date.now()}`; // don't use cache
const electron = utils.dynamicRequire('electron');
const res = await electron.shell.openPath(resp.tmpFilePath);
if (res) {
// fallback in case there's no default application for this file
open(getFileUrl(noteId), {url: true});
}
}
else {
// allow browser to handle opening common file
if (mime === "application/pdf" || mime.startsWith("image") || mime.startsWith("audio") || mime.startsWith("video")){
window.open(getOpenFileUrl(noteId));
}
else {
window.location.href = getFileUrl(noteId);
}
}
download(url);
}
function downloadNoteRevision(noteId, noteRevisionId) {
@ -67,6 +61,40 @@ function getUrlForDownload(url) {
}
}
function canOpenInBrowser(mime) {
return mime === "application/pdf"
|| mime.startsWith("image")
|| mime.startsWith("audio")
|| mime.startsWith("video");
}
async function openExternally(type, entityId, mime) {
checkType(type);
if (utils.isElectron()) {
const resp = await server.post(`${type}/${entityId}/save-to-tmp-dir`);
const electron = utils.dynamicRequire('electron');
const res = await electron.shell.openPath(resp.tmpFilePath);
if (res) {
// fallback in case there's no default application for this file
window.open(getFileUrl(type, entityId), { url: true });
}
}
else {
// allow browser to handle opening common file
if (canOpenInBrowser(mime)) {
window.open(getOpenFileUrl(type, entityId));
} else {
window.location.href = getFileUrl(type, entityId);
}
}
}
const openNoteExternally = async (noteId, mime) => await openExternally('notes', noteId, mime);
const openAttachmentExternally = async (attachmentId, mime) => await openExternally('attachments', attachmentId, mime);
function getHost() {
const url = new URL(window.location.href);
return `${url.protocol}//${url.hostname}:${url.port}`;
@ -75,7 +103,9 @@ function getHost() {
export default {
download,
downloadFileNote,
openNoteExternally,
downloadNoteRevision,
getUrlForDownload
downloadAttachment,
getUrlForDownload,
openNoteExternally,
openAttachmentExternally,
}

View File

@ -3,6 +3,7 @@ import AttachmentActionsWidget from "./buttons/attachments_actions.js";
import BasicWidget from "./basic_widget.js";
import server from "../services/server.js";
import options from "../services/options.js";
import imageService from "../services/image.js";
const TPL = `
<div class="attachment-detail">
@ -148,6 +149,10 @@ export default class AttachmentDetailWidget extends BasicWidget {
}
}
copyAttachmentReferenceToClipboard() {
imageService.copyImageReferenceToClipboard(this.$wrapper.find('.attachment-content'));
}
async entitiesReloadedEvent({loadResults}) {
const attachmentChange = loadResults.getAttachments().find(att => att.attachmentId === this.attachment.attachmentId);

View File

@ -4,6 +4,7 @@ import dialogService from "../../services/dialog.js";
import toastService from "../../services/toast.js";
import ws from "../../services/ws.js";
import appContext from "../../components/app_context.js";
import openService from "../../services/open.js";
const TPL = `
<div class="dropdown attachment-actions">
@ -28,8 +29,15 @@ const TPL = `
aria-expanded="false" class="icon-action icon-action-always-border bx bx-dots-vertical-rounded"></button>
<div class="dropdown-menu dropdown-menu-right">
<a data-trigger-command="deleteAttachment" class="dropdown-item">Delete attachment</a>
<a data-trigger-command="openAttachment" class="dropdown-item">Open</a>
<a data-trigger-command="openAttachmentExternally" class="dropdown-item"
title="File will be open in an external application and watched for changes. You'll then be able to upload the modified version back to Trilium.">
Open externally</a>
<a data-trigger-command="downloadAttachment" class="dropdown-item">Download</a>
<a data-trigger-command="uploadNewAttachmentRevision" class="dropdown-item">Upload new revision</a>
<a data-trigger-command="copyAttachmentReferenceToClipboard" class="dropdown-item">Copy reference to clipboard</a>
<a data-trigger-command="convertAttachmentIntoNote" class="dropdown-item">Convert attachment into note</a>
<a data-trigger-command="deleteAttachment" class="dropdown-item">Delete attachment</a>
</div>
</div>`;
@ -40,9 +48,30 @@ export default class AttachmentActionsWidget extends BasicWidget {
this.attachment = attachment;
}
get attachmentId() {
return this.attachment.attachmentId;
}
doRender() {
this.$widget = $(TPL);
this.$widget.on('click', '.dropdown-item', () => this.$widget.find("[data-toggle='dropdown']").dropdown('toggle'));
this.$widget.find("[data-trigger-command='copyAttachmentReferenceToClipboard']").toggle(this.attachment.role === 'image');
}
async openAttachmentCommand() {
await openService.openAttachmentExternally(this.attachmentId, this.attachment.mime);
}
async downloadAttachmentCommand() {
await openService.downloadAttachment(this.attachmentId);
}
async copyAttachmentReferenceToClipboardCommand() {
this.parent.copyAttachmentReferenceToClipboard();
}
async openAttachmentExternallyCommand() {
await openService.openAttachmentExternally(this.attachmentId, this.attachment.mime);
}
async deleteAttachmentCommand() {
@ -50,7 +79,7 @@ export default class AttachmentActionsWidget extends BasicWidget {
return;
}
await server.remove(`attachments/${this.attachment.attachmentId}`);
await server.remove(`attachments/${this.attachmentId}`);
toastService.showMessage(`Attachment '${this.attachment.title}' has been deleted.`);
}
@ -59,7 +88,7 @@ export default class AttachmentActionsWidget extends BasicWidget {
return;
}
const {note: newNote} = await server.post(`attachments/${this.attachment.attachmentId}/convert-to-note`)
const {note: newNote} = await server.post(`attachments/${this.attachmentId}/convert-to-note`)
toastService.showMessage(`Attachment '${this.attachment.title}' has been converted to note.`);
await ws.waitForMaxKnownEntityChangeId();
await appContext.tabManager.getActiveContext().setNote(newNote.noteId);

View File

@ -35,7 +35,11 @@ const TPL = `
<a data-trigger-command="findInText" class="dropdown-item find-in-text-button">Search in note <kbd data-command="findInText"></a>
<a data-trigger-command="showNoteSource" class="dropdown-item show-source-button"><kbd data-command="showNoteSource"></kbd> Note source</a>
<a data-trigger-command="showAttachments" class="dropdown-item"><kbd data-command="showAttachments"></kbd> Note attachments</a>
<a data-trigger-command="openNoteExternally" class="dropdown-item open-note-externally-button"><kbd data-command="openNoteExternally"></kbd> Open note externally</a>
<a data-trigger-command="openNoteExternally" class="dropdown-item open-note-externally-button"
title="File will be open in an external application and watched for changes. You'll then be able to upload the modified version back to Trilium.">
<kbd data-command="openNoteExternally"></kbd>
Open note externally
</a>
<a class="dropdown-item import-files-button">Import files</a>
<a class="dropdown-item export-note-button">Export note</a>
<a class="dropdown-item delete-note-button">Delete note</a>

View File

@ -1,64 +0,0 @@
import NoteContextAwareWidget from "./note_context_aware_widget.js";
import server from "../services/server.js";
import fileWatcher from "../services/file_watcher.js";
const TPL = `
<div class="dropdown note-update-status-widget alert alert-warning">
<style>
.note-update-status-widget {
margin: 10px;
contain: none;
}
</style>
<p>File <code class="file-path"></code> has been last modified on <span class="file-last-modified"></span>.</p>
<div style="display: flex; flex-direction: row; justify-content: space-evenly;">
<button class="btn btn-sm file-upload-button">Upload modified file</button>
<button class="btn btn-sm ignore-this-change-button">Ignore this change</button>
</div>
</div>`;
export default class NoteUpdateStatusWidget extends NoteContextAwareWidget {
isEnabled() {
return super.isEnabled()
&& !!fileWatcher.getFileModificationStatus(this.noteId);
}
doRender() {
this.$widget = $(TPL);
this.$filePath = this.$widget.find(".file-path");
this.$fileLastModified = this.$widget.find(".file-last-modified");
this.$fileUploadButton = this.$widget.find(".file-upload-button");
this.$fileUploadButton.on("click", async () => {
await server.post(`notes/${this.noteId}/upload-modified-file`, {
filePath: this.$filePath.text()
});
fileWatcher.fileModificationUploaded(this.noteId);
this.refresh();
});
this.$ignoreThisChangeButton = this.$widget.find(".ignore-this-change-button");
this.$ignoreThisChangeButton.on('click', () => {
fileWatcher.ignoreModification(this.noteId);
this.refresh();
});
}
refreshWithNote(note) {
const status = fileWatcher.getFileModificationStatus(note.noteId);
this.$filePath.text(status.filePath);
this.$fileLastModified.text(dayjs.unix(status.lastModifiedMs / 1000).format("HH:mm:ss"));
}
openedFileUpdatedEvent(data) {
if (data.noteId === this.noteId) {
this.refresh();
}
}
}

View File

@ -69,7 +69,7 @@ export default class ImagePropertiesWidget extends NoteContextAwareWidget {
this.$fileSize = this.$widget.find(".image-filesize");
this.$openButton = this.$widget.find(".image-open");
this.$openButton.on('click', () => openService.openNoteExternally(this.noteId, this.note.mime ));
this.$openButton.on('click', () => openService.openNoteExternally(this.noteId, this.note.mime));
this.$imageDownloadButton = this.$widget.find(".image-download");
this.$imageDownloadButton.on('click', () => openService.downloadFileNote(this.noteId));

View File

@ -1,8 +1,8 @@
import utils from "../../services/utils.js";
import toastService from "../../services/toast.js";
import TypeWidget from "./type_widget.js";
import libraryLoader from "../../services/library_loader.js";
import contextMenu from "../../menus/context_menu.js";
import imageService from "../../services/image.js";
const TPL = `
<div class="note-detail-image note-detail-printable">
@ -73,7 +73,7 @@ class ImageTypeWidget extends TypeWidget {
],
selectMenuItemHandler: ({command}) => {
if (command === 'copyImageReferenceToClipboard') {
this.copyImageReferenceToClipboard();
imageService.copyImageReferenceToClipboard(this.$imageWrapper);
} else if (command === 'copyImageToClipboard') {
const webContents = utils.dynamicRequire('@electron/remote').getCurrentWebContents();
utils.dynamicRequire('electron');
@ -98,36 +98,7 @@ class ImageTypeWidget extends TypeWidget {
return;
}
this.copyImageReferenceToClipboard();
}
copyImageReferenceToClipboard() {
this.$imageWrapper.attr('contenteditable','true');
try {
this.selectImage(this.$imageWrapper.get(0));
const success = document.execCommand('copy');
if (success) {
toastService.showMessage("Image copied to the clipboard");
}
else {
toastService.showAndLogError("Could not copy the image to clipboard.");
}
}
finally {
window.getSelection().removeAllRanges();
this.$imageWrapper.removeAttr('contenteditable');
}
}
selectImage(element) {
const selection = window.getSelection();
const range = document.createRange();
range.selectNodeContents(element);
selection.removeAllRanges();
selection.addRange(range);
imageService.copyImageReferenceToClipboard(this.$imageWrapper);
}
}

View File

@ -0,0 +1,96 @@
import NoteContextAwareWidget from "./note_context_aware_widget.js";
import server from "../services/server.js";
import fileWatcher from "../services/file_watcher.js";
const TPL = `
<div class="dropdown watched-file-update-status-widget alert alert-warning">
<style>
.watched-file-update-status-widget {
margin: 10px;
contain: none;
}
</style>
<p>File <code class="file-path"></code> has been last modified on <span class="file-last-modified"></span>.</p>
<div style="display: flex; flex-direction: row; justify-content: space-evenly;">
<button class="btn btn-sm file-upload-button">Upload modified file</button>
<button class="btn btn-sm ignore-this-change-button">Ignore this change</button>
</div>
</div>`;
export default class WatchedFileUpdateStatusWidget extends NoteContextAwareWidget {
isEnabled() {
const { entityType, entityId } = this.getEntity();
console.log(entityType, entityId);
return super.isEnabled() && !!fileWatcher.getFileModificationStatus(entityType, entityId);
}
doRender() {
this.$widget = $(TPL);
this.$filePath = this.$widget.find(".file-path");
this.$fileLastModified = this.$widget.find(".file-last-modified");
this.$fileUploadButton = this.$widget.find(".file-upload-button");
this.$fileUploadButton.on("click", async () => {
const { entityType, entityId } = this.getEntity();
await server.post(`${entityType}/${entityId}/upload-modified-file`, {
filePath: this.$filePath.text()
});
fileWatcher.fileModificationUploaded(entityType, entityId);
this.refresh();
});
this.$ignoreThisChangeButton = this.$widget.find(".ignore-this-change-button");
this.$ignoreThisChangeButton.on('click', () => {
const { entityType, entityId } = this.getEntity();
fileWatcher.ignoreModification(entityType, entityId);
this.refresh();
});
}
refreshWithNote(note) {
const { entityType, entityId } = this.getEntity();
const status = fileWatcher.getFileModificationStatus(entityType, entityId);
console.log("status", status);
this.$filePath.text(status.filePath);
this.$fileLastModified.text(dayjs.unix(status.lastModifiedMs / 1000).format("HH:mm:ss"));
}
getEntity() {
if (!this.noteContext) {
return {};
}
const { viewScope } = this.noteContext;
if (viewScope.viewMode === 'attachments' && viewScope.attachmentId) {
return {
entityType: 'attachments',
entityId: viewScope.attachmentId
};
} else {
return {
entityType: 'notes',
entityId: this.noteId
};
}
}
openedFileUpdatedEvent(data) {console.log(data);
const { entityType, entityId } = this.getEntity();
if (data.entityType === entityType && data.entityId === entityId) {
this.refresh();
}
}
}

View File

@ -11,6 +11,7 @@ const chokidar = require('chokidar');
const ws = require('../../services/ws');
const becca = require("../../becca/becca");
const NotFoundError = require("../../errors/not_found_error");
const ValidationError = require("../../errors/validation_error.js");
function updateFile(req) {
const {noteId} = req.params;
@ -38,61 +39,84 @@ function updateFile(req) {
};
}
function getFilename(note) {
// (one) reason we're not using the originFileName (available as label) is that it's not
// available for older note revisions and thus would be inconsistent
return utils.formatDownloadTitle(note.title, note.type, note.mime);
/**
* @param {BNote|BAttachment} noteOrAttachment
* @param res
* @param {boolean} contentDisposition
*/
function downloadData(noteOrAttachment, res, contentDisposition) {
if (noteOrAttachment.isProtected && !protectedSessionService.isProtectedSessionAvailable()) {
return res.status(401).send("Protected session not available");
}
if (contentDisposition) {
const fileName = noteOrAttachment.getFileName();
res.setHeader('Content-Disposition', utils.getContentDisposition(fileName));
}
res.setHeader("Cache-Control", "no-cache, no-store, must-revalidate");
res.setHeader('Content-Type', noteOrAttachment.mime);
res.send(noteOrAttachment.getContent());
}
function downloadNoteFile(noteId, res, contentDisposition = true) {
function downloadNoteInt(noteId, res, contentDisposition = true) {
const note = becca.getNote(noteId);
if (!note) {
return res.setHeader("Content-Type", "text/plain")
.status(404)
.send(`Note ${noteId} doesn't exist.`);
.send(`Note '${noteId}' doesn't exist.`);
}
if (note.isProtected && !protectedSessionService.isProtectedSessionAvailable()) {
return res.status(401).send("Protected session not available");
return downloadData(note, res, contentDisposition);
}
function downloadAttachmentInt(attachmentId, res, contentDisposition = true) {
const attachment = becca.getAttachment(attachmentId);
if (!attachment) {
return res.setHeader("Content-Type", "text/plain")
.status(404)
.send(`Attachment '${attachmentId}' doesn't exist.`);
}
if (contentDisposition) {
const filename = getFilename(note);
res.setHeader('Content-Disposition', utils.getContentDisposition(filename));
}
res.setHeader("Cache-Control", "no-cache, no-store, must-revalidate");
res.setHeader('Content-Type', note.mime);
res.send(note.getContent());
return downloadData(attachment, res, contentDisposition);
}
function downloadFile(req, res) {
const noteId = req.params.noteId;
const downloadFile = (req, res) => downloadNoteInt(req.params.noteId, res, true);
const openFile = (req, res) => downloadNoteInt(req.params.noteId, res, false);
return downloadNoteFile(noteId, res);
}
function openFile(req, res) {
const noteId = req.params.noteId;
return downloadNoteFile(noteId, res, false);
}
const downloadAttachment = (req, res) => downloadAttachmentInt(req.params.attachmentId, res, true);
const openAttachment = (req, res) => downloadAttachmentInt(req.params.attachmentId, res, false);
function fileContentProvider(req) {
// Read file name from route params.
const note = becca.getNote(req.params.noteId);
const fileName = getFilename(note);
let content = note.getContent();
if (!note) {
throw new NotFoundError(`Note '${req.params.noteId}' doesn't exist.`);
}
return streamContent(note.getContent(), note.getFileName(), note.mime);
}
function attachmentContentProvider(req) {
// Read file name from route params.
const attachment = becca.getAttachment(req.params.attachmentId);
if (!attachment) {
throw new NotFoundError(`Attachment '${req.params.attachmentId}' doesn't exist.`);
}
return streamContent(attachment.getContent(), attachment.getFileName(), attachment.mime);
}
function streamContent(content, fileName, mimeType) {
if (typeof content === "string") {
content = Buffer.from(content, 'utf8');
content = Buffer.from(content, 'utf8');
}
const totalSize = content.byteLength;
const mimeType = note.mime;
const getStream = range => {
if (!range) {
@ -100,7 +124,7 @@ function fileContentProvider(req) {
return Readable.from(content);
}
// Partial content request.
const { start, end } = range;
const {start, end} = range;
return Readable.from(content.slice(start, end + 1));
}
@ -113,27 +137,44 @@ function fileContentProvider(req) {
};
}
function saveToTmpDir(req) {
const noteId = req.params.noteId;
const note = becca.getNote(noteId);
function saveNoteToTmpDir(req) {
const note = becca.getNote(req.params.noteId);
if (!note) {
throw new NotFoundError(`Note '${noteId}' doesn't exist.`);
throw new NotFoundError(`Note '${req.params.noteId}' doesn't exist.`);
}
const tmpObj = tmp.fileSync({postfix: getFilename(note)});
const fileName = note.getFileName();
const content = note.getContent();
fs.writeSync(tmpObj.fd, note.getContent());
return saveToTmpDir(fileName, content, 'notes', note.noteId);
}
function saveAttachmentToTmpDir(req) {
const attachment = becca.getAttachment(req.params.attachmentId);
if (!attachment) {
throw new NotFoundError(`Attachment '${req.params.attachmentId}' doesn't exist.`);
}
const fileName = attachment.getFileName();
const content = attachment.getContent();
return saveToTmpDir(fileName, content, 'attachments', attachment.attachmentId);
}
function saveToTmpDir(fileName, content, entityType, entityId) {
const tmpObj = tmp.fileSync({ postfix: fileName });
fs.writeSync(tmpObj.fd, content);
fs.closeSync(tmpObj.fd);
log.info(`Saved temporary file for note ${noteId} into ${tmpObj.name}`);
log.info(`Saved temporary file ${tmpObj.name}`);
if (utils.isElectron()) {
chokidar.watch(tmpObj.name).on('change', (path, stats) => {
ws.sendMessageToAllClients({
type: 'openedFileUpdated',
noteId: noteId,
entityType: entityType,
entityId: entityId,
lastModifiedMs: stats.atimeMs,
filePath: tmpObj.name
});
@ -145,11 +186,63 @@ function saveToTmpDir(req) {
};
}
function uploadModifiedFileToNote(req) {
const noteId = req.params.noteId;
const {filePath} = req.body;
const note = becca.getNote(noteId);
if (!note) {
throw new NotFoundError(`Note '${noteId}' has not been found`);
}
log.info(`Updating note '${noteId}' with content from '${filePath}'`);
note.saveNoteRevision();
const fileContent = fs.readFileSync(filePath);
if (!fileContent) {
throw new ValidationError(`File '${fileContent}' is empty`);
}
note.setContent(fileContent);
}
function uploadModifiedFileToAttachment(req) {
const {attachmentId} = req.params;
const {filePath} = req.body;
const attachment = becca.getAttachment(attachmentId);
if (!attachment) {
throw new NotFoundError(`Attachment '${attachmentId}' has not been found`);
}
log.info(`Updating attachment '${attachmentId}' with content from '${filePath}'`);
attachment.getNote().saveNoteRevision();
const fileContent = fs.readFileSync(filePath);
if (!fileContent) {
throw new ValidationError(`File '${fileContent}' is empty`);
}
attachment.setContent(fileContent);
}
module.exports = {
updateFile,
openFile,
fileContentProvider,
downloadFile,
downloadNoteFile,
saveToTmpDir
downloadNoteInt,
saveNoteToTmpDir,
openAttachment,
downloadAttachment,
saveAttachmentToTmpDir,
attachmentContentProvider,
uploadModifiedFileToNote,
uploadModifiedFileToAttachment
};

View File

@ -229,29 +229,6 @@ function getDeleteNotesPreview(req) {
};
}
function uploadModifiedFile(req) {
const noteId = req.params.noteId;
const {filePath} = req.body;
const note = becca.getNote(noteId);
if (!note) {
throw new NotFoundError(`Note '${noteId}' has not been found`);
}
log.info(`Updating note '${noteId}' with content from ${filePath}`);
note.saveNoteRevision();
const fileContent = fs.readFileSync(filePath);
if (!fileContent) {
throw new ValidationError(`File '${fileContent}' is empty`);
}
note.setContent(fileContent);
}
function forceSaveNoteRevision(req) {
const {noteId} = req.params;
const note = becca.getNote(noteId);
@ -294,7 +271,6 @@ module.exports = {
eraseDeletedNotesNow,
eraseUnusedAttachmentsNow,
getDeleteNotesPreview,
uploadModifiedFile,
forceSaveNoteRevision,
convertNoteToAttachment
};

View File

@ -1,5 +1,5 @@
const log = require('../services/log');
const fileUploadService = require('./api/files');
const fileService = require('./api/files');
const scriptService = require('../services/script');
const cls = require('../services/cls');
const sql = require("../services/sql");
@ -26,7 +26,7 @@ function handleRequest(req, res) {
match = path.match(regex);
}
catch (e) {
log.error(`Testing path for label ${attr.attributeId}, regex=${attr.value} failed with error ${e.stack}`);
log.error(`Testing path for label '${attr.attributeId}', regex '${attr.value}' failed with error: ${e.message}, stack: ${e.stack}`);
continue;
}
@ -37,7 +37,7 @@ function handleRequest(req, res) {
if (attr.name === 'customRequestHandler') {
const note = attr.getNote();
log.info(`Handling custom request "${path}" with note ${note.noteId}`);
log.info(`Handling custom request '${path}' with note '${note.noteId}'`);
try {
scriptService.executeNote(note, {
@ -47,7 +47,7 @@ function handleRequest(req, res) {
});
}
catch (e) {
log.error(`Custom handler ${note.noteId} failed with ${e.message}`);
log.error(`Custom handler '${note.noteId}' failed with: ${e.message}, ${e.stack}`);
res.setHeader("Content-Type", "text/plain")
.status(500)
@ -55,16 +55,16 @@ function handleRequest(req, res) {
}
}
else if (attr.name === 'customResourceProvider') {
fileUploadService.downloadNoteFile(attr.noteId, res);
fileService.downloadNoteInt(attr.noteId, res);
}
else {
throw new Error(`Unrecognized attribute name ${attr.name}`);
throw new Error(`Unrecognized attribute name '${attr.name}'`);
}
return; // only first handler is executed
}
const message = `No handler matched for custom ${path} request.`;
const message = `No handler matched for custom '${path}' request.`;
log.info(message);
res.setHeader("Content-Type", "text/plain")

View File

@ -122,7 +122,6 @@ function register(app) {
apiRoute(PUT, '/api/notes/:noteId/type', notesApiRoute.setNoteTypeMime);
apiRoute(PUT, '/api/notes/:noteId/title', notesApiRoute.changeTitle);
apiRoute(PST, '/api/notes/:noteId/duplicate/:parentNoteId', notesApiRoute.duplicateSubtree);
apiRoute(PST, '/api/notes/:noteId/upload-modified-file', notesApiRoute.uploadModifiedFile);
apiRoute(PUT, '/api/notes/:noteId/clone-to-branch/:parentBranchId', cloningApiRoute.cloneNoteToBranch);
apiRoute(PUT, '/api/notes/:noteId/toggle-in-parent/:parentNoteId/:present', cloningApiRoute.toggleNoteInParent);
apiRoute(PUT, '/api/notes/:noteId/clone-to-note/:parentNoteId', cloningApiRoute.cloneNoteToParentNote);
@ -137,7 +136,8 @@ function register(app) {
route(GET, '/api/notes/:noteId/download', [auth.checkApiAuthOrElectron], filesRoute.downloadFile);
// this "hacky" path is used for easier referencing of CSS resources
route(GET, '/api/notes/download/:noteId', [auth.checkApiAuthOrElectron], filesRoute.downloadFile);
apiRoute(PST, '/api/notes/:noteId/save-to-tmp-dir', filesRoute.saveToTmpDir);
apiRoute(PST, '/api/notes/:noteId/save-to-tmp-dir', filesRoute.saveNoteToTmpDir);
apiRoute(PST, '/api/notes/:noteId/upload-modified-file', filesRoute.uploadModifiedFileToNote);
apiRoute(PST, '/api/notes/:noteId/convert-to-attachment', notesApiRoute.convertNoteToAttachment);
apiRoute(PUT, '/api/branches/:branchId/move-to/:parentBranchId', branchesApiRoute.moveBranchToParent);
@ -154,6 +154,16 @@ function register(app) {
apiRoute(PST, '/api/attachments/:attachmentId/convert-to-note', attachmentsApiRoute.convertAttachmentToNote);
apiRoute(DEL, '/api/attachments/:attachmentId', attachmentsApiRoute.deleteAttachment);
route(GET, '/api/attachments/:attachmentId/image/:filename', [auth.checkApiAuthOrElectron], imageRoute.returnAttachedImage);
route(GET, '/api/attachments/:attachmentId/open', [auth.checkApiAuthOrElectron], filesRoute.openAttachment);
route(GET, '/api/attachments/:attachmentId/open-partial', [auth.checkApiAuthOrElectron],
createPartialContentHandler(filesRoute.attachmentContentProvider, {
debug: (string, extra) => { console.log(string, extra); }
}));
route(GET, '/api/attachments/:attachmentId/download', [auth.checkApiAuthOrElectron], filesRoute.downloadAttachment);
// this "hacky" path is used for easier referencing of CSS resources
route(GET, '/api/attachments/download/:attachmentId', [auth.checkApiAuthOrElectron], filesRoute.downloadAttachment);
apiRoute(PST, '/api/attachments/:attachmentId/save-to-tmp-dir', filesRoute.saveAttachmentToTmpDir);
apiRoute(PST, '/api/attachments/:attachmentId/upload-modified-file', filesRoute.uploadModifiedFileToAttachment);
apiRoute(GET, '/api/notes/:noteId/revisions', noteRevisionsApiRoute.getNoteRevisions);
apiRoute(DEL, '/api/notes/:noteId/revisions', noteRevisionsApiRoute.eraseAllNoteRevisions);

View File

@ -362,8 +362,9 @@ function checkImageAttachments(note, content) {
const existingAttachmentIds = new Set(imageAttachments.map(att => att.attachmentId));
const unknownAttachmentIds = Array.from(foundAttachmentIds).filter(foundAttId => !existingAttachmentIds.has(foundAttId));
const unknownAttachments = becca.getAttachments(unknownAttachmentIds);
for (const unknownAttachment of becca.getAttachments(unknownAttachmentIds)) {
for (const unknownAttachment of unknownAttachments) {
// the attachment belongs to a different note (was copy pasted), we need to make a copy for this note.
const newAttachment = unknownAttachment.copy();
newAttachment.parentId = note.noteId;
@ -374,7 +375,10 @@ function checkImageAttachments(note, content) {
log.info(`Copied attachment '${unknownAttachment.attachmentId}' to new '${newAttachment.attachmentId}'`);
}
return content;
return {
forceFrontendReload: unknownAttachments.length > 0,
content
};
}
@ -591,6 +595,7 @@ function saveLinks(note, content) {
}
const foundLinks = [];
let forceFrontendReload = false;
if (note.type === 'text') {
content = downloadImages(note.noteId, content);
@ -599,7 +604,7 @@ function saveLinks(note, content) {
content = findInternalLinks(content, foundLinks);
content = findIncludeNoteLinks(content, foundLinks);
content = checkImageAttachments(note, content);
({forceFrontendReload, content} = checkImageAttachments(note, content));
}
else if (note.type === 'relationMap') {
findRelationMapLinks(content, foundLinks);
@ -643,7 +648,7 @@ function saveLinks(note, content) {
unusedLink.markAsDeleted();
}
return content;
return { forceFrontendReload, content };
}
/** @param {BNote} note */
@ -677,9 +682,9 @@ function updateNoteData(noteId, content) {
saveNoteRevisionIfNeeded(note);
content = saveLinks(note, content);
const { forceFrontendReload, content: newContent } = saveLinks(note, content);
note.setContent(content);
note.setContent(newContent, { forceFrontendReload });
}
/**
@ -780,15 +785,15 @@ function scanForLinks(note, content) {
try {
sql.transactional(() => {
const newContent = saveLinks(note, content);
const { forceFrontendReload, content: newContent } = saveLinks(note, content);
if (content !== newContent) {
note.setContent(newContent);
note.setContent(newContent, { forceFrontendReload });
}
});
}
catch (e) {
log.error(`Could not scan for links note ${note.noteId}: ${e.message} ${e.stack}`);
log.error(`Could not scan for links note '${note.noteId}': ${e.message} ${e.stack}`);
}
}

View File

@ -199,33 +199,33 @@ function replaceAll(string, replaceWhat, replaceWith) {
return string.replace(new RegExp(quotedReplaceWhat, "g"), replaceWith);
}
function formatDownloadTitle(filename, type, mime) {
if (!filename) {
filename = "untitled";
function formatDownloadTitle(fileName, type, mime) {
if (!fileName) {
fileName = "untitled";
}
filename = sanitize(filename);
fileName = sanitize(fileName);
if (type === 'text') {
return `${filename}.html`;
return `${fileName}.html`;
} else if (['relationMap', 'canvas', 'search'].includes(type)) {
return `${filename}.json`;
return `${fileName}.json`;
} else {
if (!mime) {
return filename;
return fileName;
}
mime = mime.toLowerCase();
const filenameLc = filename.toLowerCase();
const filenameLc = fileName.toLowerCase();
const extensions = mimeTypes.extensions[mime];
if (!extensions || extensions.length === 0) {
return filename;
return fileName;
}
for (const ext of extensions) {
if (filenameLc.endsWith(`.${ext}`)) {
return filename;
return fileName;
}
}
@ -234,10 +234,10 @@ function formatDownloadTitle(filename, type, mime) {
// the current name without fake extension. It's possible that the title still preserves to correct
// extension too
return filename;
return fileName;
}
return `${filename}.${extensions[0]}`;
return `${fileName}.${extensions[0]}`;
}
}