exposing blobs in APIs

This commit is contained in:
zadam 2023-05-05 22:21:51 +02:00
parent 5e1f81e53e
commit 0af6f91d21
26 changed files with 95 additions and 92 deletions

View File

@ -139,8 +139,7 @@ class Becca {
/** @returns {BBlob|null} */ /** @returns {BBlob|null} */
getBlob(blobId) { getBlob(blobId) {
const row = sql.getRow("SELECT *, LENGTH(content) AS contentLength " + const row = sql.getRow("SELECT *, LENGTH(content) AS contentLength FROM blobs WHERE blobId = ?", [blobId]);
"FROM blob WHERE blobId = ?", [blobId]);
const BBlob = require("./entities/bblob"); // avoiding circular dependency problems const BBlob = require("./entities/bblob"); // avoiding circular dependency problems
return row ? new BBlob(row) : null; return row ? new BBlob(row) : null;

View File

@ -212,15 +212,9 @@ class BNote extends AbstractBeccaEntity {
return this._getContent(); return this._getContent();
} }
/** @returns {{contentLength, dateModified, utcDateModified}} */ /** @returns {{dateModified, utcDateModified}} */
getContentMetadata() { getContentMetadata() {
return sql.getRow(` return sql.getRow(`SELECT dateModified, utcDateModified FROM blobs WHERE blobId = ?`, [this.blobId]);
SELECT
LENGTH(content) AS contentLength,
dateModified,
utcDateModified
FROM blobs
WHERE blobId = ?`, [this.blobId]);
} }
/** @returns {*} */ /** @returns {*} */

View File

@ -207,6 +207,7 @@ class NoteContext extends Component {
}); });
} }
/** @returns {Promise<boolean>} */
async isReadOnly() { async isReadOnly() {
if (this.viewScope.readOnlyTemporarilyDisabled) { if (this.viewScope.readOnlyTemporarilyDisabled) {
return false; return false;
@ -221,14 +222,13 @@ class NoteContext extends Component {
return true; return true;
} }
const noteComplement = await this.getNoteComplement(); const blob = await this.note.getBlob();
const sizeLimit = this.note.type === 'text' const sizeLimit = this.note.type === 'text'
? options.getInt('autoReadonlySizeText') ? options.getInt('autoReadonlySizeText')
: options.getInt('autoReadonlySizeCode'); : options.getInt('autoReadonlySizeCode');
return noteComplement.content return blob.contentLength > sizeLimit
&& noteComplement.content.length > sizeLimit
&& !this.note.hasLabel('autoReadOnlyDisabled'); && !this.note.hasLabel('autoReadOnlyDisabled');
} }

View File

@ -32,7 +32,7 @@ class FAttachment {
} }
/** /**
* @param [opts.full=false] - force retrieval of the full note * @param [opts.preview=false] - retrieve only first 10 000 characters for a preview
* @return {FBlob} * @return {FBlob}
*/ */
async getBlob(opts = {}) { async getBlob(opts = {}) {

View File

@ -1,4 +1,4 @@
class FBlob { export default class FBlob {
constructor(row) { constructor(row) {
/** @type {string} */ /** @type {string} */
this.blobId = row.blobId; this.blobId = row.blobId;
@ -8,10 +8,11 @@ class FBlob {
* @type {string} * @type {string}
*/ */
this.content = row.content; this.content = row.content;
this.contentLength = row.contentLength;
/** @type {string} */ /** @type {string} */
this.dateModified = row.dateModified; this.dateModified = row.dateModified;
/** @type {string} */ /** @type {string} */
this.utcDateModified = row.utcDateModified; this.utcDateModified = row.utcDateModified;
} }
} }

View File

@ -843,7 +843,7 @@ class FNote {
/** /**
* Get relations which target this note * Get relations which target this note
* *
* @returns {FNote[]} * @returns {Promise<FNote[]>}
*/ */
async getTargetRelationSourceNotes() { async getTargetRelationSourceNotes() {
const targetRelations = this.getTargetRelations(); const targetRelations = this.getTargetRelations();
@ -851,14 +851,17 @@ class FNote {
return await this.froca.getNotes(targetRelations.map(tr => tr.noteId)); return await this.froca.getNotes(targetRelations.map(tr => tr.noteId));
} }
/** @deprecated use getBlob() instead */ /**
* @deprecated use getBlob() instead
* @return {Promise<FBlob>}
*/
async getNoteComplement() { async getNoteComplement() {
return this.getBlob({ full: true }); return this.getBlob();
} }
/** /**
* @param [opts.full=false] - force retrieval of the full note * @param [opts.preview=false] - retrieve only first 10 000 characters for a preview
* @return {FBlob} * @return {Promise<FBlob>}
*/ */
async getBlob(opts = {}) { async getBlob(opts = {}) {
return await this.froca.getBlob('notes', this.noteId, opts); return await this.froca.getBlob('notes', this.noteId, opts);

View File

@ -320,12 +320,13 @@ class Froca {
return attachmentRow ? new FAttachment(this, attachmentRow) : null; return attachmentRow ? new FAttachment(this, attachmentRow) : null;
} }
/** @returns {Promise<FBlob>} */
async getBlob(entityType, entityId, opts = {}) { async getBlob(entityType, entityId, opts = {}) {
opts.full = !!opts.full; opts.preview = !!opts.preview;
const key = `${entityType}-${entityId}`; const key = `${entityType}-${entityId}-${opts.preview}`;
if (!this.blobPromises[key]) { if (!this.blobPromises[key]) {
this.blobPromises[key] = server.get(`${entityType}/${entityId}/blob?full=${opts.full}`) this.blobPromises[key] = server.get(`${entityType}/${entityId}/blob?preview=${opts.preview}`)
.then(row => new FBlob(row)) .then(row => new FBlob(row))
.catch(e => console.error(`Cannot get blob for ${entityType} '${entityId}'`)); .catch(e => console.error(`Cannot get blob for ${entityType} '${entityId}'`));

View File

@ -11,6 +11,11 @@ import treeService from "./tree.js";
let idCounter = 1; let idCounter = 1;
/**
* @param {FNote} note
* @param {object} options
* @return {Promise<{type: string, $renderedContent: jQuery}>}
*/
async function getRenderedContent(note, options = {}) { async function getRenderedContent(note, options = {}) {
options = Object.assign({ options = Object.assign({
trim: false, trim: false,
@ -22,10 +27,10 @@ async function getRenderedContent(note, options = {}) {
const $renderedContent = $('<div class="rendered-note-content">'); const $renderedContent = $('<div class="rendered-note-content">');
if (type === 'text') { if (type === 'text') {
const noteComplement = await froca.getNoteComplement(note.noteId); const blob = await note.getBlob({ preview: options.trim });
if (!utils.isHtmlEmpty(noteComplement.content)) { if (!utils.isHtmlEmpty(blob.content)) {
$renderedContent.append($('<div class="ck-content">').html(trim(noteComplement.content, options.trim))); $renderedContent.append($('<div class="ck-content">').html(trim(blob.content, options.trim)));
if ($renderedContent.find('span.math-tex').length > 0) { if ($renderedContent.find('span.math-tex').length > 0) {
await libraryLoader.requireLibrary(libraryLoader.KATEX); await libraryLoader.requireLibrary(libraryLoader.KATEX);
@ -108,8 +113,8 @@ async function getRenderedContent(note, options = {}) {
else if (type === 'mermaid') { else if (type === 'mermaid') {
await libraryLoader.requireLibrary(libraryLoader.MERMAID); await libraryLoader.requireLibrary(libraryLoader.MERMAID);
const noteComplement = await froca.getNoteComplement(note.noteId); const blob = await note.getBlob();
const content = noteComplement.content || ""; const content = blob.content || "";
$renderedContent $renderedContent
.css("display", "flex") .css("display", "flex")
@ -140,8 +145,8 @@ async function getRenderedContent(note, options = {}) {
// make sure surrounding container has size of what is visible. Then image is shrinked to its boundaries // make sure surrounding container has size of what is visible. Then image is shrinked to its boundaries
$renderedContent.css({height: "100%", width:"100%"}); $renderedContent.css({height: "100%", width:"100%"});
const noteComplement = await froca.getNoteComplement(note.noteId); const blob = await note.getBlob();
const content = noteComplement.content || ""; const content = blob.content || "";
try { try {
const placeHolderSVG = "<svg />"; const placeHolderSVG = "<svg />";

View File

@ -99,8 +99,8 @@ export default class MermaidWidget extends NoteContextAwareWidget {
async renderSvg(cb) { async renderSvg(cb) {
idCounter++; idCounter++;
const noteComplement = await froca.getNoteComplement(this.noteId); const blob = await this.note.getBlob();
const content = noteComplement.content || ""; const content = blob.content || "";
// this can't be promisified since in case of error this both calls callback with error SVG and throws exception // this can't be promisified since in case of error this both calls callback with error SVG and throws exception
// with error details // with error details

View File

@ -19,18 +19,22 @@ export default class NoteContextAwareWidget extends BasicWidget {
return this.noteId === noteId; return this.noteId === noteId;
} }
/** @returns {FNote|undefined} */
get note() { get note() {
return this.noteContext?.note; return this.noteContext?.note;
} }
/** @returns {string|undefined} */
get noteId() { get noteId() {
return this.note?.noteId; return this.note?.noteId;
} }
/** @returns {string|undefined} */
get notePath() { get notePath() {
return this.noteContext?.notePath; return this.noteContext?.notePath;
} }
/** @returns {string} */
get hoistedNoteId() { get hoistedNoteId() {
return this.noteContext?.hoistedNoteId; return this.noteContext?.hoistedNoteId;
} }

View File

@ -143,9 +143,9 @@ export default class NoteTypeWidget extends NoteContextAwareWidget {
} }
async confirmChangeIfContent() { async confirmChangeIfContent() {
const noteComplement = await this.noteContext.getNoteComplement(); const blob = await this.note.getBlob();
if (!noteComplement.content || !noteComplement.content.trim().length) { if (!blob.content || !blob.content.trim().length) {
return true; return true;
} }

View File

@ -134,9 +134,9 @@ export default class FilePropertiesWidget extends NoteContextAwareWidget {
this.$fileName.text(attributeMap.originalFileName || "?"); this.$fileName.text(attributeMap.originalFileName || "?");
this.$fileType.text(note.mime); this.$fileType.text(note.mime);
const noteComplement = await this.noteContext.getNoteComplement(); const blob = await this.note.getBlob();
this.$fileSize.text(`${noteComplement.contentLength} bytes`); this.$fileSize.text(`${blob.contentLength} bytes`);
// open doesn't work for protected notes since it works through browser which isn't in protected session // open doesn't work for protected notes since it works through browser which isn't in protected session
this.$openButton.toggle(!note.isProtected); this.$openButton.toggle(!note.isProtected);

View File

@ -116,10 +116,10 @@ export default class ImagePropertiesWidget extends NoteContextAwareWidget {
this.$widget.show(); this.$widget.show();
const noteComplement = await this.noteContext.getNoteComplement(); const blob = await this.note.getBlob();
this.$fileName.text(attributeMap.originalFileName || "?"); this.$fileName.text(attributeMap.originalFileName || "?");
this.$fileSize.text(`${noteComplement.contentLength} bytes`); this.$fileSize.text(`${blob.contentLength} bytes`);
this.$fileType.text(note.mime); this.$fileType.text(note.mime);
} }
} }

View File

@ -120,23 +120,22 @@ export default class NoteInfoWidget extends NoteContextAwareWidget {
} }
async refreshWithNote(note) { async refreshWithNote(note) {
const noteComplement = await this.noteContext.getNoteComplement(); const metadata = await server.get(`notes/${this.noteId}/metadata`);
this.$noteId.text(note.noteId); this.$noteId.text(note.noteId);
this.$dateCreated this.$dateCreated
.text(noteComplement.dateCreated.substr(0, 16)) .text(metadata.dateCreated.substr(0, 16))
.attr("title", noteComplement.dateCreated); .attr("title", metadata.dateCreated);
this.$dateModified this.$dateModified
.text(noteComplement.combinedDateModified.substr(0, 16)) .text(metadata.combinedDateModified.substr(0, 16))
.attr("title", noteComplement.combinedDateModified); .attr("title", metadata.combinedDateModified);
this.$type.text(note.type); this.$type.text(note.type);
if (note.mime) { if (note.mime) {
this.$mime.text(`(${note.mime})`); this.$mime.text(`(${note.mime})`);
} } else {
else {
this.$mime.empty(); this.$mime.empty();
} }

View File

@ -156,7 +156,7 @@ export default class ExcalidrawTypeWidget extends TypeWidget {
this.currentNoteId = note.noteId; this.currentNoteId = note.noteId;
// get note from backend and put into canvas // get note from backend and put into canvas
const noteComplement = await froca.getNoteComplement(note.noteId); const blob = await note.getBlob();
// before we load content into excalidraw, make sure excalidraw has loaded // before we load content into excalidraw, make sure excalidraw has loaded
while (!this.excalidrawRef || !this.excalidrawRef.current) { while (!this.excalidrawRef || !this.excalidrawRef.current) {
@ -170,7 +170,7 @@ export default class ExcalidrawTypeWidget extends TypeWidget {
* note into this fresh note. Probably due to that this note-instance does not get * note into this fresh note. Probably due to that this note-instance does not get
* newly instantiated? * newly instantiated?
*/ */
if (this.excalidrawRef.current && noteComplement.content?.trim() === "") { if (this.excalidrawRef.current && blob.content?.trim() === "") {
const sceneData = { const sceneData = {
elements: [], elements: [],
appState: { appState: {
@ -181,16 +181,16 @@ export default class ExcalidrawTypeWidget extends TypeWidget {
this.excalidrawRef.current.updateScene(sceneData); this.excalidrawRef.current.updateScene(sceneData);
} }
else if (this.excalidrawRef.current && noteComplement.content) { else if (this.excalidrawRef.current && blob.content) {
// load saved content into excalidraw canvas // load saved content into excalidraw canvas
let content; let content;
try { try {
content = JSON.parse(noteComplement.content || ""); content = JSON.parse(blob.content || "");
} catch(err) { } catch(err) {
console.error("Error parsing content. Probably note.type changed", console.error("Error parsing content. Probably note.type changed",
"Starting with empty canvas" "Starting with empty canvas"
, note, noteComplement, err); , note, blob, err);
content = { content = {
elements: [], elements: [],

View File

@ -69,12 +69,12 @@ export default class EditableCodeTypeWidget extends TypeWidget {
} }
async doRefresh(note) { async doRefresh(note) {
const noteComplement = await this.noteContext.getNoteComplement(); const blob = await this.note.getBlob();
await this.spacedUpdate.allowUpdateWithoutChange(() => { await this.spacedUpdate.allowUpdateWithoutChange(() => {
// CodeMirror breaks pretty badly on null so even though it shouldn't happen (guarded by consistency check) // CodeMirror breaks pretty badly on null so even though it shouldn't happen (guarded by consistency check)
// we provide fallback // we provide fallback
this.codeEditor.setValue(noteComplement.content || ""); this.codeEditor.setValue(blob.content || "");
this.codeEditor.clearHistory(); this.codeEditor.clearHistory();
let info = CodeMirror.findModeByMIME(note.mime); let info = CodeMirror.findModeByMIME(note.mime);

View File

@ -183,10 +183,10 @@ export default class EditableTextTypeWidget extends AbstractTextTypeWidget {
} }
async doRefresh(note) { async doRefresh(note) {
const noteComplement = await froca.getNoteComplement(note.noteId); const blob = await note.getBlob();
await this.spacedUpdate.allowUpdateWithoutChange(() => { await this.spacedUpdate.allowUpdateWithoutChange(() => {
this.watchdog.editor.setData(noteComplement.content || ""); this.watchdog.editor.setData(blob.content || "");
}); });
} }

View File

@ -52,7 +52,7 @@ export default class FileTypeWidget extends TypeWidget {
async doRefresh(note) { async doRefresh(note) {
this.$widget.show(); this.$widget.show();
const noteComplement = await this.noteContext.getNoteComplement(); const blob = await this.note.getBlob();
this.$previewContent.empty().hide(); this.$previewContent.empty().hide();
this.$pdfPreview.attr('src', '').empty().hide(); this.$pdfPreview.attr('src', '').empty().hide();
@ -60,9 +60,9 @@ export default class FileTypeWidget extends TypeWidget {
this.$videoPreview.hide(); this.$videoPreview.hide();
this.$audioPreview.hide(); this.$audioPreview.hide();
if (noteComplement.content) { if (blob.content) {
this.$previewContent.show().scrollTop(0); this.$previewContent.show().scrollTop(0);
this.$previewContent.text(noteComplement.content); this.$previewContent.text(blob.content);
} }
else if (note.mime === 'application/pdf') { else if (note.mime === 'application/pdf') {
this.$pdfPreview.show().attr("src", openService.getUrlForDownload(`api/notes/${this.noteId}/open`)); this.$pdfPreview.show().attr("src", openService.getUrlForDownload(`api/notes/${this.noteId}/open`));

View File

@ -27,9 +27,9 @@ export default class ReadOnlyCodeTypeWidget extends TypeWidget {
} }
async doRefresh(note) { async doRefresh(note) {
const noteComplement = await this.noteContext.getNoteComplement(); const blob = await this.note.getBlob();
this.$content.text(noteComplement.content); this.$content.text(blob.content);
} }
async executeWithContentElementEvent({resolve, ntxId}) { async executeWithContentElementEvent({resolve, ntxId}) {

View File

@ -93,9 +93,9 @@ export default class ReadOnlyTextTypeWidget extends AbstractTextTypeWidget {
// (see https://github.com/zadam/trilium/issues/1590 for example of such conflict) // (see https://github.com/zadam/trilium/issues/1590 for example of such conflict)
await libraryLoader.requireLibrary(libraryLoader.CKEDITOR); await libraryLoader.requireLibrary(libraryLoader.CKEDITOR);
const noteComplement = await froca.getNoteComplement(note.noteId); const blob = await note.getBlob();
this.$content.html(noteComplement.content); this.$content.html(blob.content);
this.$content.find("a.reference-link").each(async (_, el) => { this.$content.find("a.reference-link").each(async (_, el) => {
const notePath = $(el).attr('href'); const notePath = $(el).attr('href');

View File

@ -197,11 +197,11 @@ export default class RelationMapTypeWidget extends TypeWidget {
} }
}; };
const noteComplement = await this.noteContext.getNoteComplement(); const blob = await this.note.getBlob();
if (noteComplement.content) { if (blob.content) {
try { try {
this.mapData = JSON.parse(noteComplement.content); this.mapData = JSON.parse(blob.content);
} catch (e) { } catch (e) {
console.log("Could not parse content: ", e); console.log("Could not parse content: ", e);
} }

View File

@ -4,9 +4,9 @@ const utils = require("../../services/utils");
const blobService = require("../../services/blob.js"); const blobService = require("../../services/blob.js");
function getAttachmentBlob(req) { function getAttachmentBlob(req) {
const full = req.query.full === 'true'; const preview = req.query.preview === 'true';
return blobService.getBlobPojo('attachments', req.params.attachmentId, { full }); return blobService.getBlobPojo('attachments', req.params.attachmentId, { preview });
} }
function getAttachments(req) { function getAttachments(req) {

View File

@ -10,9 +10,9 @@ const becca = require("../../becca/becca");
const blobService = require("../../services/blob.js"); const blobService = require("../../services/blob.js");
function getNoteRevisionBlob(req) { function getNoteRevisionBlob(req) {
const full = req.query.full === 'true'; const preview = req.query.preview === 'true';
return blobService.getBlobPojo('note_revisions', req.params.noteRevisionId, { full }); return blobService.getBlobPojo('note_revisions', req.params.noteRevisionId, { preview });
} }
function getNoteRevisions(req) { function getNoteRevisions(req) {

View File

@ -6,7 +6,6 @@ const sql = require('../../services/sql');
const utils = require('../../services/utils'); const utils = require('../../services/utils');
const log = require('../../services/log'); const log = require('../../services/log');
const TaskContext = require('../../services/task_context'); const TaskContext = require('../../services/task_context');
const fs = require('fs');
const becca = require("../../becca/becca"); const becca = require("../../becca/becca");
const ValidationError = require("../../errors/validation_error"); const ValidationError = require("../../errors/validation_error");
const NotFoundError = require("../../errors/not_found_error"); const NotFoundError = require("../../errors/not_found_error");
@ -18,31 +17,27 @@ function getNote(req) {
throw new NotFoundError(`Note '${req.params.noteId}' has not been found.`); throw new NotFoundError(`Note '${req.params.noteId}' has not been found.`);
} }
const pojo = note.getPojo(); return note;
}
if (note.hasStringContent()) { function getNoteBlob(req) {
pojo.content = note.getContent(); const preview = req.query.preview === 'true';
// FIXME: use blobs instead return blobService.getBlobPojo('notes', req.params.noteId, { preview });
if (note.type === 'file' && pojo.content.length > 10000) { }
pojo.content = `${pojo.content.substr(0, 10000)}\r\n\r\n... and ${pojo.content.length - 10000} more characters.`;
} function getNoteMetadata(req) {
const note = becca.getNote(req.params.noteId);
if (!note) {
throw new NotFoundError(`Note '${req.params.noteId}' has not been found.`);
} }
const contentMetadata = note.getContentMetadata(); const contentMetadata = note.getContentMetadata();
pojo.contentLength = contentMetadata.contentLength; return {
dateCreated: note.dateCreated,
pojo.combinedUtcDateModified = note.utcDateModified > contentMetadata.utcDateModified ? note.utcDateModified : contentMetadata.utcDateModified; combinedDateModified: note.utcDateModified > contentMetadata.utcDateModified ? note.dateModified : contentMetadata.dateModified
pojo.combinedDateModified = note.utcDateModified > contentMetadata.utcDateModified ? note.dateModified : contentMetadata.dateModified; };
return pojo;
}
function getNoteBlob(req) {
const full = req.query.full === 'true';
return blobService.getBlobPojo('notes', req.params.noteId, { full });
} }
function createNote(req) { function createNote(req) {
@ -266,6 +261,7 @@ function convertNoteToAttachment(req) {
module.exports = { module.exports = {
getNote, getNote,
getNoteBlob, getNoteBlob,
getNoteMetadata,
updateNoteData, updateNoteData,
deleteNote, deleteNote,
undeleteNote, undeleteNote,

View File

@ -113,6 +113,7 @@ function register(app) {
apiRoute(GET, '/api/notes/:noteId', notesApiRoute.getNote); apiRoute(GET, '/api/notes/:noteId', notesApiRoute.getNote);
apiRoute(GET, '/api/notes/:noteId/blob', notesApiRoute.getNoteBlob); apiRoute(GET, '/api/notes/:noteId/blob', notesApiRoute.getNoteBlob);
apiRoute(GET, '/api/notes/:noteId/metadata', notesApiRoute.getNoteMetadata);
apiRoute(PUT, '/api/notes/:noteId/data', notesApiRoute.updateNoteData); apiRoute(PUT, '/api/notes/:noteId/data', notesApiRoute.updateNoteData);
apiRoute(DEL, '/api/notes/:noteId', notesApiRoute.deleteNote); apiRoute(DEL, '/api/notes/:noteId', notesApiRoute.deleteNote);
apiRoute(PUT, '/api/notes/:noteId/undelete', notesApiRoute.undeleteNote); apiRoute(PUT, '/api/notes/:noteId/undelete', notesApiRoute.undeleteNote);

View File

@ -2,7 +2,7 @@ const becca = require('../becca/becca');
const NotFoundError = require("../errors/not_found_error"); const NotFoundError = require("../errors/not_found_error");
function getBlobPojo(entityName, entityId, opts = {}) { function getBlobPojo(entityName, entityId, opts = {}) {
opts.full = !!opts.full; opts.preview = !!opts.preview;
const entity = becca.getEntity(entityName, entityId); const entity = becca.getEntity(entityName, entityId);
@ -16,7 +16,7 @@ function getBlobPojo(entityName, entityId, opts = {}) {
if (!entity.hasStringContent()) { if (!entity.hasStringContent()) {
pojo.content = null; pojo.content = null;
} else if (!opts.full && pojo.content.length > 10000) { } else if (opts.preview && pojo.content.length > 10000) {
pojo.content = `${pojo.content.substr(0, 10000)}\r\n\r\n... and ${pojo.content.length - 10000} more characters.`; pojo.content = `${pojo.content.substr(0, 10000)}\r\n\r\n... and ${pojo.content.length - 10000} more characters.`;
} }
@ -25,4 +25,4 @@ function getBlobPojo(entityName, entityId, opts = {}) {
module.exports = { module.exports = {
getBlobPojo getBlobPojo
}; };