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) // client code asks to save entity even if blobId didn't change (something else was changed)
opts.forceSave = !!opts.forceSave; opts.forceSave = !!opts.forceSave;
opts.forceCold = !!opts.forceCold; opts.forceCold = !!opts.forceCold;
opts.forceFrontendReload = !!opts.forceFrontendReload;
if (content === null || content === undefined) { if (content === null || content === undefined) {
throw new Error(`Cannot set null content to ${this.constructor.primaryKeyName} '${this[this.constructor.primaryKeyName]}'`); throw new Error(`Cannot set null content to ${this.constructor.primaryKeyName} '${this[this.constructor.primaryKeyName]}'`);
@ -176,7 +177,7 @@ class AbstractBeccaEntity {
} }
/** @protected */ /** @protected */
_saveBlob(content, unencryptedContentForHashCalculation, opts) { _saveBlob(content, unencryptedContentForHashCalculation, opts = {}) {
let newBlobId; let newBlobId;
let blobNeedsInsert; let blobNeedsInsert;
@ -212,7 +213,10 @@ class AbstractBeccaEntity {
hash: hash, hash: hash,
isErased: false, isErased: false,
utcDateChanged: pojo.utcDateModified, 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, { eventService.emit(eventService.ENTITY_CHANGED, {

View File

@ -96,6 +96,7 @@ class BAttachment extends AbstractBeccaEntity {
* @param {object} [opts] * @param {object} [opts]
* @param {object} [opts.forceSave=false] - will also save this BAttachment entity * @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.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) { setContent(content, opts) {
this._setContent(content, opts); this._setContent(content, opts);
@ -154,6 +155,12 @@ class BAttachment extends AbstractBeccaEntity {
return { note, branch }; return { note, branch };
} }
getFileName() {
const type = this.role === 'image' ? 'image' : 'file';
return utils.formatDownloadTitle(this.title, type, this.mime);
}
beforeSaving() { beforeSaving() {
super.beforeSaving(); super.beforeSaving();

View File

@ -242,6 +242,7 @@ class BNote extends AbstractBeccaEntity {
* @param {object} [opts] * @param {object} [opts]
* @param {object} [opts.forceSave=false] - will also save this BNote entity * @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.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) { setContent(content, opts) {
this._setContent(content, opts); this._setContent(content, opts);
@ -1642,6 +1643,10 @@ class BNote extends AbstractBeccaEntity {
return attachment; return attachment;
} }
getFileName() {
return utils.formatDownloadTitle(this.title, this.type, this.mime);
}
beforeSaving() { beforeSaving() {
super.beforeSaving(); super.beforeSaving();

View File

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

View File

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

View File

@ -39,7 +39,7 @@ async function processEntityChanges(entityChanges) {
// NOOP // NOOP
} }
else { else {
throw new Error(`Unknown entityName ${ec.entityName}`); throw new Error(`Unknown entityName '${ec.entityName}'`);
} }
} }
catch (e) { catch (e) {
@ -92,7 +92,7 @@ function processNoteChange(loadResults, ec) {
loadResults.addNote(ec.entityId, ec.componentId); loadResults.addNote(ec.entityId, ec.componentId);
if (ec.isErased && ec.entityId in froca.notes) { 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; return;
} }
@ -106,7 +106,7 @@ function processNoteChange(loadResults, ec) {
async function processBranchChange(loadResults, ec) { async function processBranchChange(loadResults, ec) {
if (ec.isErased && ec.entityId in froca.branches) { 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; return;
} }
@ -192,7 +192,7 @@ function processAttributeChange(loadResults, ec) {
let attribute = froca.attributes[ec.entityId]; let attribute = froca.attributes[ec.entityId];
if (ec.isErased && ec.entityId in froca.attributes) { 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; return;
} }
@ -241,7 +241,7 @@ function processAttributeChange(loadResults, ec) {
function processAttachment(loadResults, ec) { function processAttachment(loadResults, ec) {
if (ec.isErased && ec.entityId in froca.attachments) { 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; 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}`; hash += `?${paramStr}`;
} }
console.log(hash);
return hash; return hash;
} }

View File

@ -1,11 +1,22 @@
import utils from "./utils.js"; import utils from "./utils.js";
import server from "./server.js"; import server from "./server.js";
function getFileUrl(noteId) { function checkType(type) {
return getUrlForDownload(`api/notes/${noteId}/download`); 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) { function download(url) {
@ -19,32 +30,15 @@ function download(url) {
} }
function downloadFileNote(noteId) { 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); download(url);
} }
async function openNoteExternally(noteId, mime) { function downloadAttachment(attachmentId) {
if (utils.isElectron()) { const url = `${getFileUrl('attachments', attachmentId)}?${Date.now()}`; // don't use cache
const resp = await server.post(`notes/${noteId}/save-to-tmp-dir`);
const electron = utils.dynamicRequire('electron'); download(url);
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);
}
}
} }
function downloadNoteRevision(noteId, noteRevisionId) { 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() { function getHost() {
const url = new URL(window.location.href); const url = new URL(window.location.href);
return `${url.protocol}//${url.hostname}:${url.port}`; return `${url.protocol}//${url.hostname}:${url.port}`;
@ -75,7 +103,9 @@ function getHost() {
export default { export default {
download, download,
downloadFileNote, downloadFileNote,
openNoteExternally,
downloadNoteRevision, 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 BasicWidget from "./basic_widget.js";
import server from "../services/server.js"; import server from "../services/server.js";
import options from "../services/options.js"; import options from "../services/options.js";
import imageService from "../services/image.js";
const TPL = ` const TPL = `
<div class="attachment-detail"> <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}) { async entitiesReloadedEvent({loadResults}) {
const attachmentChange = loadResults.getAttachments().find(att => att.attachmentId === this.attachment.attachmentId); 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 toastService from "../../services/toast.js";
import ws from "../../services/ws.js"; import ws from "../../services/ws.js";
import appContext from "../../components/app_context.js"; import appContext from "../../components/app_context.js";
import openService from "../../services/open.js";
const TPL = ` const TPL = `
<div class="dropdown attachment-actions"> <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> aria-expanded="false" class="icon-action icon-action-always-border bx bx-dots-vertical-rounded"></button>
<div class="dropdown-menu dropdown-menu-right"> <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="convertAttachmentIntoNote" class="dropdown-item">Convert attachment into note</a>
<a data-trigger-command="deleteAttachment" class="dropdown-item">Delete attachment</a>
</div> </div>
</div>`; </div>`;
@ -40,9 +48,30 @@ export default class AttachmentActionsWidget extends BasicWidget {
this.attachment = attachment; this.attachment = attachment;
} }
get attachmentId() {
return this.attachment.attachmentId;
}
doRender() { doRender() {
this.$widget = $(TPL); this.$widget = $(TPL);
this.$widget.on('click', '.dropdown-item', () => this.$widget.find("[data-toggle='dropdown']").dropdown('toggle')); 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() { async deleteAttachmentCommand() {
@ -50,7 +79,7 @@ export default class AttachmentActionsWidget extends BasicWidget {
return; return;
} }
await server.remove(`attachments/${this.attachment.attachmentId}`); await server.remove(`attachments/${this.attachmentId}`);
toastService.showMessage(`Attachment '${this.attachment.title}' has been deleted.`); toastService.showMessage(`Attachment '${this.attachment.title}' has been deleted.`);
} }
@ -59,7 +88,7 @@ export default class AttachmentActionsWidget extends BasicWidget {
return; 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.`); toastService.showMessage(`Attachment '${this.attachment.title}' has been converted to note.`);
await ws.waitForMaxKnownEntityChangeId(); await ws.waitForMaxKnownEntityChangeId();
await appContext.tabManager.getActiveContext().setNote(newNote.noteId); 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="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="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="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 import-files-button">Import files</a>
<a class="dropdown-item export-note-button">Export note</a> <a class="dropdown-item export-note-button">Export note</a>
<a class="dropdown-item delete-note-button">Delete 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

@ -1,8 +1,8 @@
import utils from "../../services/utils.js"; import utils from "../../services/utils.js";
import toastService from "../../services/toast.js";
import TypeWidget from "./type_widget.js"; import TypeWidget from "./type_widget.js";
import libraryLoader from "../../services/library_loader.js"; import libraryLoader from "../../services/library_loader.js";
import contextMenu from "../../menus/context_menu.js"; import contextMenu from "../../menus/context_menu.js";
import imageService from "../../services/image.js";
const TPL = ` const TPL = `
<div class="note-detail-image note-detail-printable"> <div class="note-detail-image note-detail-printable">
@ -73,7 +73,7 @@ class ImageTypeWidget extends TypeWidget {
], ],
selectMenuItemHandler: ({command}) => { selectMenuItemHandler: ({command}) => {
if (command === 'copyImageReferenceToClipboard') { if (command === 'copyImageReferenceToClipboard') {
this.copyImageReferenceToClipboard(); imageService.copyImageReferenceToClipboard(this.$imageWrapper);
} else if (command === 'copyImageToClipboard') { } else if (command === 'copyImageToClipboard') {
const webContents = utils.dynamicRequire('@electron/remote').getCurrentWebContents(); const webContents = utils.dynamicRequire('@electron/remote').getCurrentWebContents();
utils.dynamicRequire('electron'); utils.dynamicRequire('electron');
@ -98,36 +98,7 @@ class ImageTypeWidget extends TypeWidget {
return; return;
} }
this.copyImageReferenceToClipboard(); imageService.copyImageReferenceToClipboard(this.$imageWrapper);
}
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);
} }
} }

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 ws = require('../../services/ws');
const becca = require("../../becca/becca"); const becca = require("../../becca/becca");
const NotFoundError = require("../../errors/not_found_error"); const NotFoundError = require("../../errors/not_found_error");
const ValidationError = require("../../errors/validation_error.js");
function updateFile(req) { function updateFile(req) {
const {noteId} = req.params; 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 * @param {BNote|BAttachment} noteOrAttachment
// available for older note revisions and thus would be inconsistent * @param res
return utils.formatDownloadTitle(note.title, note.type, note.mime); * @param {boolean} contentDisposition
*/
function downloadData(noteOrAttachment, res, contentDisposition) {
if (noteOrAttachment.isProtected && !protectedSessionService.isProtectedSessionAvailable()) {
return res.status(401).send("Protected session not available");
} }
function downloadNoteFile(noteId, res, contentDisposition = true) { 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 downloadNoteInt(noteId, res, contentDisposition = true) {
const note = becca.getNote(noteId); const note = becca.getNote(noteId);
if (!note) { if (!note) {
return res.setHeader("Content-Type", "text/plain") return res.setHeader("Content-Type", "text/plain")
.status(404) .status(404)
.send(`Note ${noteId} doesn't exist.`); .send(`Note '${noteId}' doesn't exist.`);
} }
if (note.isProtected && !protectedSessionService.isProtectedSessionAvailable()) { return downloadData(note, res, contentDisposition);
return res.status(401).send("Protected session not available");
} }
if (contentDisposition) { function downloadAttachmentInt(attachmentId, res, contentDisposition = true) {
const filename = getFilename(note); const attachment = becca.getAttachment(attachmentId);
res.setHeader('Content-Disposition', utils.getContentDisposition(filename)); if (!attachment) {
return res.setHeader("Content-Type", "text/plain")
.status(404)
.send(`Attachment '${attachmentId}' doesn't exist.`);
} }
res.setHeader("Cache-Control", "no-cache, no-store, must-revalidate"); return downloadData(attachment, res, contentDisposition);
res.setHeader('Content-Type', note.mime);
res.send(note.getContent());
} }
function downloadFile(req, res) { const downloadFile = (req, res) => downloadNoteInt(req.params.noteId, res, true);
const noteId = req.params.noteId; const openFile = (req, res) => downloadNoteInt(req.params.noteId, res, false);
return downloadNoteFile(noteId, res); const downloadAttachment = (req, res) => downloadAttachmentInt(req.params.attachmentId, res, true);
} const openAttachment = (req, res) => downloadAttachmentInt(req.params.attachmentId, res, false);
function openFile(req, res) {
const noteId = req.params.noteId;
return downloadNoteFile(noteId, res, false);
}
function fileContentProvider(req) { function fileContentProvider(req) {
// Read file name from route params. // Read file name from route params.
const note = becca.getNote(req.params.noteId); const note = becca.getNote(req.params.noteId);
const fileName = getFilename(note); if (!note) {
let content = note.getContent(); 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") { if (typeof content === "string") {
content = Buffer.from(content, 'utf8'); content = Buffer.from(content, 'utf8');
} }
const totalSize = content.byteLength; const totalSize = content.byteLength;
const mimeType = note.mime;
const getStream = range => { const getStream = range => {
if (!range) { if (!range) {
@ -113,27 +137,44 @@ function fileContentProvider(req) {
}; };
} }
function saveToTmpDir(req) { function saveNoteToTmpDir(req) {
const noteId = req.params.noteId; const note = becca.getNote(req.params.noteId);
const note = becca.getNote(noteId);
if (!note) { 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); 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()) { if (utils.isElectron()) {
chokidar.watch(tmpObj.name).on('change', (path, stats) => { chokidar.watch(tmpObj.name).on('change', (path, stats) => {
ws.sendMessageToAllClients({ ws.sendMessageToAllClients({
type: 'openedFileUpdated', type: 'openedFileUpdated',
noteId: noteId, entityType: entityType,
entityId: entityId,
lastModifiedMs: stats.atimeMs, lastModifiedMs: stats.atimeMs,
filePath: tmpObj.name 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 = { module.exports = {
updateFile, updateFile,
openFile, openFile,
fileContentProvider, fileContentProvider,
downloadFile, downloadFile,
downloadNoteFile, downloadNoteInt,
saveToTmpDir 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) { function forceSaveNoteRevision(req) {
const {noteId} = req.params; const {noteId} = req.params;
const note = becca.getNote(noteId); const note = becca.getNote(noteId);
@ -294,7 +271,6 @@ module.exports = {
eraseDeletedNotesNow, eraseDeletedNotesNow,
eraseUnusedAttachmentsNow, eraseUnusedAttachmentsNow,
getDeleteNotesPreview, getDeleteNotesPreview,
uploadModifiedFile,
forceSaveNoteRevision, forceSaveNoteRevision,
convertNoteToAttachment convertNoteToAttachment
}; };

View File

@ -1,5 +1,5 @@
const log = require('../services/log'); const log = require('../services/log');
const fileUploadService = require('./api/files'); const fileService = require('./api/files');
const scriptService = require('../services/script'); const scriptService = require('../services/script');
const cls = require('../services/cls'); const cls = require('../services/cls');
const sql = require("../services/sql"); const sql = require("../services/sql");
@ -26,7 +26,7 @@ function handleRequest(req, res) {
match = path.match(regex); match = path.match(regex);
} }
catch (e) { 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; continue;
} }
@ -37,7 +37,7 @@ function handleRequest(req, res) {
if (attr.name === 'customRequestHandler') { if (attr.name === 'customRequestHandler') {
const note = attr.getNote(); 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 { try {
scriptService.executeNote(note, { scriptService.executeNote(note, {
@ -47,7 +47,7 @@ function handleRequest(req, res) {
}); });
} }
catch (e) { 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") res.setHeader("Content-Type", "text/plain")
.status(500) .status(500)
@ -55,16 +55,16 @@ function handleRequest(req, res) {
} }
} }
else if (attr.name === 'customResourceProvider') { else if (attr.name === 'customResourceProvider') {
fileUploadService.downloadNoteFile(attr.noteId, res); fileService.downloadNoteInt(attr.noteId, res);
} }
else { else {
throw new Error(`Unrecognized attribute name ${attr.name}`); throw new Error(`Unrecognized attribute name '${attr.name}'`);
} }
return; // only first handler is executed 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); log.info(message);
res.setHeader("Content-Type", "text/plain") 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/type', notesApiRoute.setNoteTypeMime);
apiRoute(PUT, '/api/notes/:noteId/title', notesApiRoute.changeTitle); apiRoute(PUT, '/api/notes/:noteId/title', notesApiRoute.changeTitle);
apiRoute(PST, '/api/notes/:noteId/duplicate/:parentNoteId', notesApiRoute.duplicateSubtree); 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/clone-to-branch/:parentBranchId', cloningApiRoute.cloneNoteToBranch);
apiRoute(PUT, '/api/notes/:noteId/toggle-in-parent/:parentNoteId/:present', cloningApiRoute.toggleNoteInParent); apiRoute(PUT, '/api/notes/:noteId/toggle-in-parent/:parentNoteId/:present', cloningApiRoute.toggleNoteInParent);
apiRoute(PUT, '/api/notes/:noteId/clone-to-note/:parentNoteId', cloningApiRoute.cloneNoteToParentNote); 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); route(GET, '/api/notes/:noteId/download', [auth.checkApiAuthOrElectron], filesRoute.downloadFile);
// this "hacky" path is used for easier referencing of CSS resources // this "hacky" path is used for easier referencing of CSS resources
route(GET, '/api/notes/download/:noteId', [auth.checkApiAuthOrElectron], filesRoute.downloadFile); 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(PST, '/api/notes/:noteId/convert-to-attachment', notesApiRoute.convertNoteToAttachment);
apiRoute(PUT, '/api/branches/:branchId/move-to/:parentBranchId', branchesApiRoute.moveBranchToParent); 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(PST, '/api/attachments/:attachmentId/convert-to-note', attachmentsApiRoute.convertAttachmentToNote);
apiRoute(DEL, '/api/attachments/:attachmentId', attachmentsApiRoute.deleteAttachment); apiRoute(DEL, '/api/attachments/:attachmentId', attachmentsApiRoute.deleteAttachment);
route(GET, '/api/attachments/:attachmentId/image/:filename', [auth.checkApiAuthOrElectron], imageRoute.returnAttachedImage); 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(GET, '/api/notes/:noteId/revisions', noteRevisionsApiRoute.getNoteRevisions);
apiRoute(DEL, '/api/notes/:noteId/revisions', noteRevisionsApiRoute.eraseAllNoteRevisions); 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 existingAttachmentIds = new Set(imageAttachments.map(att => att.attachmentId));
const unknownAttachmentIds = Array.from(foundAttachmentIds).filter(foundAttId => !existingAttachmentIds.has(foundAttId)); 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. // the attachment belongs to a different note (was copy pasted), we need to make a copy for this note.
const newAttachment = unknownAttachment.copy(); const newAttachment = unknownAttachment.copy();
newAttachment.parentId = note.noteId; newAttachment.parentId = note.noteId;
@ -374,7 +375,10 @@ function checkImageAttachments(note, content) {
log.info(`Copied attachment '${unknownAttachment.attachmentId}' to new '${newAttachment.attachmentId}'`); 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 = []; const foundLinks = [];
let forceFrontendReload = false;
if (note.type === 'text') { if (note.type === 'text') {
content = downloadImages(note.noteId, content); content = downloadImages(note.noteId, content);
@ -599,7 +604,7 @@ function saveLinks(note, content) {
content = findInternalLinks(content, foundLinks); content = findInternalLinks(content, foundLinks);
content = findIncludeNoteLinks(content, foundLinks); content = findIncludeNoteLinks(content, foundLinks);
content = checkImageAttachments(note, content); ({forceFrontendReload, content} = checkImageAttachments(note, content));
} }
else if (note.type === 'relationMap') { else if (note.type === 'relationMap') {
findRelationMapLinks(content, foundLinks); findRelationMapLinks(content, foundLinks);
@ -643,7 +648,7 @@ function saveLinks(note, content) {
unusedLink.markAsDeleted(); unusedLink.markAsDeleted();
} }
return content; return { forceFrontendReload, content };
} }
/** @param {BNote} note */ /** @param {BNote} note */
@ -677,9 +682,9 @@ function updateNoteData(noteId, content) {
saveNoteRevisionIfNeeded(note); 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 { try {
sql.transactional(() => { sql.transactional(() => {
const newContent = saveLinks(note, content); const { forceFrontendReload, content: newContent } = saveLinks(note, content);
if (content !== newContent) { if (content !== newContent) {
note.setContent(newContent); note.setContent(newContent, { forceFrontendReload });
} }
}); });
} }
catch (e) { 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); return string.replace(new RegExp(quotedReplaceWhat, "g"), replaceWith);
} }
function formatDownloadTitle(filename, type, mime) { function formatDownloadTitle(fileName, type, mime) {
if (!filename) { if (!fileName) {
filename = "untitled"; fileName = "untitled";
} }
filename = sanitize(filename); fileName = sanitize(fileName);
if (type === 'text') { if (type === 'text') {
return `${filename}.html`; return `${fileName}.html`;
} else if (['relationMap', 'canvas', 'search'].includes(type)) { } else if (['relationMap', 'canvas', 'search'].includes(type)) {
return `${filename}.json`; return `${fileName}.json`;
} else { } else {
if (!mime) { if (!mime) {
return filename; return fileName;
} }
mime = mime.toLowerCase(); mime = mime.toLowerCase();
const filenameLc = filename.toLowerCase(); const filenameLc = fileName.toLowerCase();
const extensions = mimeTypes.extensions[mime]; const extensions = mimeTypes.extensions[mime];
if (!extensions || extensions.length === 0) { if (!extensions || extensions.length === 0) {
return filename; return fileName;
} }
for (const ext of extensions) { for (const ext of extensions) {
if (filenameLc.endsWith(`.${ext}`)) { 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 // the current name without fake extension. It's possible that the title still preserves to correct
// extension too // extension too
return filename; return fileName;
} }
return `${filename}.${extensions[0]}`; return `${fileName}.${extensions[0]}`;
} }
} }