mirror of
https://github.com/zadam/trilium.git
synced 2025-03-01 14:22:32 +01:00
1716 lines
55 KiB
TypeScript
1716 lines
55 KiB
TypeScript
"use strict";
|
|
|
|
import protectedSessionService = require('../../services/protected_session');
|
|
import log = require('../../services/log');
|
|
import sql = require('../../services/sql');
|
|
import utils = require('../../services/utils');
|
|
import dateUtils = require('../../services/date_utils');
|
|
import AbstractBeccaEntity = require('./abstract_becca_entity');
|
|
import BRevision = require('./brevision');
|
|
import BAttachment = require('./battachment');
|
|
import TaskContext = require('../../services/task_context');
|
|
import dayjs = require("dayjs");
|
|
import utc = require('dayjs/plugin/utc');
|
|
import eventService = require('../../services/events');
|
|
import { AttachmentRow, NoteRow, NoteType, RevisionRow } from './rows';
|
|
import BBranch = require('./bbranch');
|
|
import BAttribute = require('./battribute');
|
|
dayjs.extend(utc);
|
|
|
|
const LABEL = 'label';
|
|
const RELATION = 'relation';
|
|
|
|
interface NotePathRecord {
|
|
isArchived: boolean;
|
|
isInHoistedSubTree: boolean;
|
|
notePath: string[];
|
|
isHidden: boolean;
|
|
}
|
|
|
|
interface ContentOpts {
|
|
/** will also save this BNote entity */
|
|
forceSave?: boolean;
|
|
/** override frontend heuristics on when to reload, instruct to reload */
|
|
forceFrontendReload?: boolean;
|
|
}
|
|
|
|
interface AttachmentOpts {
|
|
includeContentLength?: boolean;
|
|
}
|
|
|
|
interface Relationship {
|
|
parentNoteId: string;
|
|
childNoteId: string
|
|
}
|
|
|
|
interface ConvertOpts {
|
|
/** if true, the action is not triggered by user, but e.g. by migration, and only perfect candidates will be migrated */
|
|
autoConversion?: boolean;
|
|
}
|
|
|
|
/**
|
|
* Trilium's main entity, which can represent text note, image, code note, file attachment etc.
|
|
*/
|
|
class BNote extends AbstractBeccaEntity<BNote> {
|
|
static get entityName() { return "notes"; }
|
|
static get primaryKeyName() { return "noteId"; }
|
|
static get hashedProperties() { return ["noteId", "title", "isProtected", "type", "mime", "blobId"]; }
|
|
|
|
noteId!: string;
|
|
title!: string;
|
|
type!: NoteType;
|
|
mime!: string;
|
|
/** set during the deletion operation, before it is completed (removed from becca completely). */
|
|
isBeingDeleted!: boolean;
|
|
isDecrypted!: boolean;
|
|
|
|
ownedAttributes!: BAttribute[];
|
|
parentBranches!: BBranch[];
|
|
parents!: BNote[];
|
|
children!: BNote[];
|
|
targetRelations!: BAttribute[];
|
|
|
|
__flatTextCache!: string | null;
|
|
|
|
private __attributeCache!: BAttribute[] | null;
|
|
private __inheritableAttributeCache!: BAttribute[] | null;
|
|
private __ancestorCache!: BNote[] | null;
|
|
|
|
// following attributes are filled during searching in the database
|
|
/** size of the content in bytes */
|
|
contentSize!: number | null;
|
|
/** size of the note content, attachment contents in bytes */
|
|
contentAndAttachmentsSize!: number | null;
|
|
/** size of the note content, attachment contents and revision contents in bytes */
|
|
contentAndAttachmentsAndRevisionsSize!: number | null;
|
|
/** number of note revisions for this note */
|
|
revisionCount!: number | null;
|
|
|
|
constructor(row?: Partial<NoteRow>) {
|
|
super();
|
|
|
|
if (!row) {
|
|
return;
|
|
}
|
|
|
|
this.updateFromRow(row);
|
|
this.init();
|
|
}
|
|
|
|
updateFromRow(row: Partial<NoteRow>) {
|
|
this.update([
|
|
row.noteId,
|
|
row.title,
|
|
row.type,
|
|
row.mime,
|
|
row.isProtected,
|
|
row.blobId,
|
|
row.dateCreated,
|
|
row.dateModified,
|
|
row.utcDateCreated,
|
|
row.utcDateModified
|
|
]);
|
|
}
|
|
|
|
update([noteId, title, type, mime, isProtected, blobId, dateCreated, dateModified, utcDateCreated, utcDateModified]: any) {
|
|
// ------ Database persisted attributes ------
|
|
|
|
this.noteId = noteId;
|
|
this.title = title;
|
|
this.type = type;
|
|
this.mime = mime;
|
|
this.isProtected = !!isProtected;
|
|
this.blobId = blobId;
|
|
this.dateCreated = dateCreated || dateUtils.localNowDateTime();
|
|
this.dateModified = dateModified;
|
|
this.utcDateCreated = utcDateCreated || dateUtils.utcNowDateTime();
|
|
this.utcDateModified = utcDateModified;
|
|
this.isBeingDeleted = false;
|
|
|
|
// ------ Derived attributes ------
|
|
|
|
this.isDecrypted = !this.noteId || !this.isProtected;
|
|
|
|
this.decrypt();
|
|
|
|
this.__flatTextCache = null;
|
|
|
|
return this;
|
|
}
|
|
|
|
init() {
|
|
this.parentBranches = [];
|
|
this.parents = [];
|
|
this.children = [];
|
|
this.ownedAttributes = [];
|
|
this.__attributeCache = null;
|
|
this.__inheritableAttributeCache = null;
|
|
this.targetRelations = [];
|
|
|
|
this.becca.addNote(this.noteId, this);
|
|
this.__ancestorCache = null;
|
|
|
|
this.contentSize = null;
|
|
this.contentAndAttachmentsSize = null;
|
|
this.contentAndAttachmentsAndRevisionsSize = null;
|
|
this.revisionCount = null;
|
|
}
|
|
|
|
isContentAvailable() {
|
|
return !this.noteId // new note which was not encrypted yet
|
|
|| !this.isProtected
|
|
|| protectedSessionService.isProtectedSessionAvailable()
|
|
}
|
|
|
|
getTitleOrProtected() {
|
|
return this.isContentAvailable() ? this.title : '[protected]';
|
|
}
|
|
|
|
/** @returns {BBranch[]} */
|
|
getParentBranches() {
|
|
return this.parentBranches;
|
|
}
|
|
|
|
/**
|
|
* Returns <i>strong</i> (as opposed to <i>weak</i>) parent branches. See isWeak for details.
|
|
*
|
|
* @returns {BBranch[]}
|
|
*/
|
|
getStrongParentBranches() {
|
|
return this.getParentBranches().filter(branch => !branch.isWeak);
|
|
}
|
|
|
|
/**
|
|
* @returns {BBranch[]}
|
|
* @deprecated use getParentBranches() instead
|
|
*/
|
|
getBranches() {
|
|
return this.parentBranches;
|
|
}
|
|
|
|
/** @returns {BNote[]} */
|
|
getParentNotes() {
|
|
return this.parents;
|
|
}
|
|
|
|
/** @returns {BNote[]} */
|
|
getChildNotes() {
|
|
return this.children;
|
|
}
|
|
|
|
/** @returns {boolean} */
|
|
hasChildren() {
|
|
return this.children && this.children.length > 0;
|
|
}
|
|
|
|
getChildBranches(): BBranch[] {
|
|
return this.children
|
|
.map(childNote => this.becca.getBranchFromChildAndParent(childNote.noteId, this.noteId)) as BBranch[];
|
|
}
|
|
|
|
/*
|
|
* Note content has quite special handling - it's not a separate entity, but a lazily loaded
|
|
* part of Note entity with its own sync. Reasons behind this hybrid design has been:
|
|
*
|
|
* - content can be quite large, and it's not necessary to load it / fill memory for any note access even if we don't need a content, especially for bulk operations like search
|
|
* - changes in the note metadata or title should not trigger note content sync (so we keep separate utcDateModified and entity changes records)
|
|
* - but to the user note content and title changes are one and the same - single dateModified (so all changes must go through Note and content is not a separate entity)
|
|
*/
|
|
getContent() {
|
|
return this._getContent();
|
|
}
|
|
|
|
/**
|
|
* @throws Error in case of invalid JSON */
|
|
getJsonContent(): {} | null {
|
|
const content = this.getContent();
|
|
|
|
if (typeof content !== "string" || !content || !content.trim()) {
|
|
return null;
|
|
}
|
|
|
|
return JSON.parse(content);
|
|
}
|
|
|
|
/** @returns {*|null} valid object or null if the content cannot be parsed as JSON */
|
|
getJsonContentSafely() {
|
|
try {
|
|
return this.getJsonContent();
|
|
}
|
|
catch (e) {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
setContent(content: Buffer | string, opts: ContentOpts = {}) {
|
|
this._setContent(content, opts);
|
|
|
|
eventService.emit(eventService.NOTE_CONTENT_CHANGE, { entity: this });
|
|
}
|
|
|
|
setJsonContent(content: {}) {
|
|
this.setContent(JSON.stringify(content, null, '\t'));
|
|
}
|
|
|
|
get dateCreatedObj() {
|
|
return this.dateCreated === null ? null : dayjs(this.dateCreated);
|
|
}
|
|
|
|
get utcDateCreatedObj() {
|
|
return this.utcDateCreated === null ? null : dayjs.utc(this.utcDateCreated);
|
|
}
|
|
|
|
get dateModifiedObj() {
|
|
return this.dateModified === null ? null : dayjs(this.dateModified);
|
|
}
|
|
|
|
get utcDateModifiedObj() {
|
|
return this.utcDateModified === null ? null : dayjs.utc(this.utcDateModified);
|
|
}
|
|
|
|
/** @returns {boolean} true if this note is the root of the note tree. Root note has "root" noteId */
|
|
isRoot() {
|
|
return this.noteId === 'root';
|
|
}
|
|
|
|
/** @returns {boolean} true if this note is of application/json content type */
|
|
isJson() {
|
|
return this.mime === "application/json";
|
|
}
|
|
|
|
/** @returns {boolean} true if this note is JavaScript (code or attachment) */
|
|
isJavaScript() {
|
|
return (this.type === "code" || this.type === "file" || this.type === 'launcher')
|
|
&& (this.mime.startsWith("application/javascript")
|
|
|| this.mime === "application/x-javascript"
|
|
|| this.mime === "text/javascript");
|
|
}
|
|
|
|
/** @returns {boolean} true if this note is HTML */
|
|
isHtml() {
|
|
return ["code", "file", "render"].includes(this.type)
|
|
&& this.mime === "text/html";
|
|
}
|
|
|
|
/** @returns {boolean} true if this note is an image */
|
|
isImage() {
|
|
return this.type === 'image'
|
|
|| (this.type === 'file' && this.mime?.startsWith('image/'));
|
|
}
|
|
|
|
/** @deprecated use hasStringContent() instead */
|
|
isStringNote() {
|
|
return this.hasStringContent();
|
|
}
|
|
|
|
/** @returns {boolean} true if the note has string content (not binary) */
|
|
hasStringContent() {
|
|
return utils.isStringNote(this.type, this.mime);
|
|
}
|
|
|
|
/** @returns {string|null} JS script environment - either "frontend" or "backend" */
|
|
getScriptEnv() {
|
|
if (this.isHtml() || (this.isJavaScript() && this.mime.endsWith('env=frontend'))) {
|
|
return "frontend";
|
|
}
|
|
|
|
if (this.type === 'render') {
|
|
return "frontend";
|
|
}
|
|
|
|
if (this.isJavaScript() && this.mime.endsWith('env=backend')) {
|
|
return "backend";
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* Beware that the method must not create a copy of the array, but actually returns its internal array
|
|
* (for performance reasons)
|
|
*
|
|
* @param type - (optional) attribute type to filter
|
|
* @param name - (optional) attribute name to filter
|
|
* @returns all note's attributes, including inherited ones
|
|
*/
|
|
getAttributes(type?: string, name?: string): BAttribute[] {
|
|
this.__validateTypeName(type, name);
|
|
this.__ensureAttributeCacheIsAvailable();
|
|
|
|
if (!this.__attributeCache) {
|
|
throw new Error("Attribute cache not available.");
|
|
}
|
|
|
|
if (type && name) {
|
|
return this.__attributeCache.filter(attr => attr.name === name && attr.type === type);
|
|
}
|
|
else if (type) {
|
|
return this.__attributeCache.filter(attr => attr.type === type);
|
|
}
|
|
else if (name) {
|
|
return this.__attributeCache.filter(attr => attr.name === name);
|
|
}
|
|
else {
|
|
return this.__attributeCache;
|
|
}
|
|
}
|
|
|
|
private __ensureAttributeCacheIsAvailable() {
|
|
if (!this.__attributeCache) {
|
|
this.__getAttributes([]);
|
|
}
|
|
}
|
|
|
|
private __getAttributes(path: string[]) {
|
|
if (path.includes(this.noteId)) {
|
|
return [];
|
|
}
|
|
|
|
if (!this.__attributeCache) {
|
|
const parentAttributes = this.ownedAttributes.slice();
|
|
const newPath = [...path, this.noteId];
|
|
|
|
// inheritable attrs on root are typically not intended to be applied to hidden subtree #3537
|
|
if (this.noteId !== 'root' && this.noteId !== '_hidden') {
|
|
for (const parentNote of this.parents) {
|
|
parentAttributes.push(...parentNote.__getInheritableAttributes(newPath));
|
|
}
|
|
}
|
|
|
|
const templateAttributes = [];
|
|
|
|
for (const ownedAttr of parentAttributes) { // parentAttributes so we process also inherited templates
|
|
if (ownedAttr.type === 'relation' && ['template', 'inherit'].includes(ownedAttr.name)) {
|
|
const templateNote = this.becca.notes[ownedAttr.value];
|
|
|
|
if (templateNote) {
|
|
templateAttributes.push(
|
|
...templateNote.__getAttributes(newPath)
|
|
// template attr is used as a marker for templates, but it's not meant to be inherited
|
|
.filter(attr => !(attr.type === 'label' && (attr.name === 'template' || attr.name === 'workspacetemplate')))
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
this.__attributeCache = [];
|
|
|
|
const addedAttributeIds = new Set();
|
|
|
|
for (const attr of parentAttributes.concat(templateAttributes)) {
|
|
if (!addedAttributeIds.has(attr.attributeId)) {
|
|
addedAttributeIds.add(attr.attributeId);
|
|
|
|
this.__attributeCache.push(attr);
|
|
}
|
|
}
|
|
|
|
this.__inheritableAttributeCache = [];
|
|
|
|
for (const attr of this.__attributeCache) {
|
|
if (attr.isInheritable) {
|
|
this.__inheritableAttributeCache.push(attr);
|
|
}
|
|
}
|
|
}
|
|
|
|
return this.__attributeCache;
|
|
}
|
|
|
|
private __getInheritableAttributes(path: string[]): BAttribute[] {
|
|
if (path.includes(this.noteId)) {
|
|
return [];
|
|
}
|
|
|
|
if (!this.__inheritableAttributeCache) {
|
|
this.__getAttributes(path); // will refresh also this.__inheritableAttributeCache
|
|
}
|
|
|
|
return this.__inheritableAttributeCache || [];
|
|
}
|
|
|
|
__validateTypeName(type?: string | null, name?: string | null) {
|
|
if (type && type !== 'label' && type !== 'relation') {
|
|
throw new Error(`Unrecognized attribute type '${type}'. Only 'label' and 'relation' are possible values.`);
|
|
}
|
|
|
|
if (name) {
|
|
const firstLetter = name.charAt(0);
|
|
if (firstLetter === '#' || firstLetter === '~') {
|
|
throw new Error(`Detect '#' or '~' in the attribute's name. In the API, attribute names should be set without these characters.`);
|
|
}
|
|
}
|
|
}
|
|
|
|
hasAttribute(type: string, name: string, value: string | null = null): boolean {
|
|
return !!this.getAttributes().find(attr =>
|
|
attr.name === name
|
|
&& (value === undefined || value === null || attr.value === value)
|
|
&& attr.type === type
|
|
);
|
|
}
|
|
|
|
getAttributeCaseInsensitive(type: string, name: string, value?: string | null) {
|
|
name = name.toLowerCase();
|
|
value = value ? value.toLowerCase() : null;
|
|
|
|
return this.getAttributes().find(
|
|
attr => attr.name.toLowerCase() === name
|
|
&& (!value || attr.value.toLowerCase() === value)
|
|
&& attr.type === type);
|
|
}
|
|
|
|
getRelationTarget(name: string) {
|
|
const relation = this.getAttributes().find(attr => attr.name === name && attr.type === 'relation');
|
|
|
|
return relation ? relation.targetNote : null;
|
|
}
|
|
|
|
/**
|
|
* @param name - label name
|
|
* @param value - label value
|
|
* @returns true if label exists (including inherited)
|
|
*/
|
|
hasLabel(name: string, value?: string): boolean {
|
|
return this.hasAttribute(LABEL, name, value);
|
|
}
|
|
|
|
/**
|
|
* @param name - label name
|
|
* @returns true if label exists (including inherited) and does not have "false" value.
|
|
*/
|
|
isLabelTruthy(name: string): boolean {
|
|
const label = this.getLabel(name);
|
|
|
|
if (!label) {
|
|
return false;
|
|
}
|
|
|
|
return label && label.value !== 'false';
|
|
}
|
|
|
|
/**
|
|
* @param name - label name
|
|
* @param value - label value
|
|
* @returns true if label exists (excluding inherited)
|
|
*/
|
|
hasOwnedLabel(name: string, value?: string): boolean {
|
|
return this.hasOwnedAttribute(LABEL, name, value);
|
|
}
|
|
|
|
/**
|
|
* @param name - relation name
|
|
* @param value - relation value
|
|
* @returns true if relation exists (including inherited)
|
|
*/
|
|
hasRelation(name: string, value?: string): boolean {
|
|
return this.hasAttribute(RELATION, name, value);
|
|
}
|
|
|
|
/**
|
|
* @param name - relation name
|
|
* @param value - relation value
|
|
* @returns true if relation exists (excluding inherited)
|
|
*/
|
|
hasOwnedRelation(name: string, value?: string): boolean {
|
|
return this.hasOwnedAttribute(RELATION, name, value);
|
|
}
|
|
|
|
/**
|
|
* @param {string} name - label name
|
|
* @returns {BAttribute|null} label if it exists, null otherwise
|
|
*/
|
|
getLabel(name: string): BAttribute | null {
|
|
return this.getAttribute(LABEL, name);
|
|
}
|
|
|
|
/**
|
|
* @param name - label name
|
|
* @returns label if it exists, null otherwise
|
|
*/
|
|
getOwnedLabel(name: string): BAttribute | null {
|
|
return this.getOwnedAttribute(LABEL, name);
|
|
}
|
|
|
|
/**
|
|
* @param name - relation name
|
|
* @returns relation if it exists, null otherwise
|
|
*/
|
|
getRelation(name: string): BAttribute | null {
|
|
return this.getAttribute(RELATION, name);
|
|
}
|
|
|
|
/**
|
|
* @param name - relation name
|
|
* @returns relation if it exists, null otherwise
|
|
*/
|
|
getOwnedRelation(name: string): BAttribute | null {
|
|
return this.getOwnedAttribute(RELATION, name);
|
|
}
|
|
|
|
/**
|
|
* @param name - label name
|
|
* @returns label value if label exists, null otherwise
|
|
*/
|
|
getLabelValue(name: string): string | null {
|
|
return this.getAttributeValue(LABEL, name);
|
|
}
|
|
|
|
/**
|
|
* @param name - label name
|
|
* @returns label value if label exists, null otherwise
|
|
*/
|
|
getOwnedLabelValue(name: string): string | null {
|
|
return this.getOwnedAttributeValue(LABEL, name);
|
|
}
|
|
|
|
/**
|
|
* @param name - relation name
|
|
* @returns relation value if relation exists, null otherwise
|
|
*/
|
|
getRelationValue(name: string): string | null {
|
|
return this.getAttributeValue(RELATION, name);
|
|
}
|
|
|
|
/**
|
|
* @param name - relation name
|
|
* @returns relation value if relation exists, null otherwise
|
|
*/
|
|
getOwnedRelationValue(name: string): string | null {
|
|
return this.getOwnedAttributeValue(RELATION, name);
|
|
}
|
|
|
|
/**
|
|
* @param attribute type (label, relation, etc.)
|
|
* @param name - attribute name
|
|
* @param value - attribute value
|
|
* @returns true if note has an attribute with given type and name (excluding inherited)
|
|
*/
|
|
hasOwnedAttribute(type: string, name: string, value?: string): boolean {
|
|
return !!this.getOwnedAttribute(type, name, value);
|
|
}
|
|
|
|
/**
|
|
* @param type - attribute type (label, relation, etc.)
|
|
* @param name - attribute name
|
|
* @returns attribute of the given type and name. If there are more such attributes, first is returned.
|
|
* Returns null if there's no such attribute belonging to this note.
|
|
*/
|
|
getAttribute(type: string, name: string): BAttribute | null {
|
|
const attributes = this.getAttributes();
|
|
|
|
return attributes.find(attr => attr.name === name && attr.type === type) || null;
|
|
}
|
|
|
|
/**
|
|
* @param type - attribute type (label, relation, etc.)
|
|
* @param name - attribute name
|
|
* @returns attribute value of given type and name or null if no such attribute exists.
|
|
*/
|
|
getAttributeValue(type: string, name: string): string | null {
|
|
const attr = this.getAttribute(type, name);
|
|
|
|
return attr ? attr.value : null;
|
|
}
|
|
|
|
/**
|
|
* @param type - attribute type (label, relation, etc.)
|
|
* @param name - attribute name
|
|
* @returns attribute value of given type and name or null if no such attribute exists.
|
|
*/
|
|
getOwnedAttributeValue(type: string, name: string): string | null {
|
|
const attr = this.getOwnedAttribute(type, name);
|
|
|
|
return attr ? attr.value : null;
|
|
}
|
|
|
|
/**
|
|
* @param name - label name to filter
|
|
* @returns all note's labels (attributes with type label), including inherited ones
|
|
*/
|
|
getLabels(name?: string): BAttribute[] {
|
|
return this.getAttributes(LABEL, name);
|
|
}
|
|
|
|
/**
|
|
* @param name - label name to filter
|
|
* @returns all note's label values, including inherited ones
|
|
*/
|
|
getLabelValues(name: string): string[] {
|
|
return this.getLabels(name).map(l => l.value);
|
|
}
|
|
|
|
/**
|
|
* @param name - label name to filter
|
|
* @returns all note's labels (attributes with type label), excluding inherited ones
|
|
*/
|
|
getOwnedLabels(name: string): BAttribute[] {
|
|
return this.getOwnedAttributes(LABEL, name);
|
|
}
|
|
|
|
/**
|
|
* @param name - label name to filter
|
|
* @returns all note's label values, excluding inherited ones
|
|
*/
|
|
getOwnedLabelValues(name: string): string[] {
|
|
return this.getOwnedAttributes(LABEL, name).map(l => l.value);
|
|
}
|
|
|
|
/**
|
|
* @param name - relation name to filter
|
|
* @returns all note's relations (attributes with type relation), including inherited ones
|
|
*/
|
|
getRelations(name?: string): BAttribute[] {
|
|
return this.getAttributes(RELATION, name);
|
|
}
|
|
|
|
/**
|
|
* @param name - relation name to filter
|
|
* @returns all note's relations (attributes with type relation), excluding inherited ones
|
|
*/
|
|
getOwnedRelations(name?: string | null): BAttribute[] {
|
|
return this.getOwnedAttributes(RELATION, name);
|
|
}
|
|
|
|
/**
|
|
* Beware that the method must not create a copy of the array, but actually returns its internal array
|
|
* (for performance reasons)
|
|
*
|
|
* @param type - (optional) attribute type to filter
|
|
* @param name - (optional) attribute name to filter
|
|
* @param value - (optional) attribute value to filter
|
|
* @returns {BAttribute[]} note's "owned" attributes - excluding inherited ones
|
|
*/
|
|
getOwnedAttributes(type: string | null = null, name: string | null = null, value: string | null = null) {
|
|
this.__validateTypeName(type, name);
|
|
|
|
if (type && name && value !== undefined && value !== null) {
|
|
return this.ownedAttributes.filter(attr => attr.name === name && attr.value === value && attr.type === type);
|
|
}
|
|
else if (type && name) {
|
|
return this.ownedAttributes.filter(attr => attr.name === name && attr.type === type);
|
|
}
|
|
else if (type) {
|
|
return this.ownedAttributes.filter(attr => attr.type === type);
|
|
}
|
|
else if (name) {
|
|
return this.ownedAttributes.filter(attr => attr.name === name);
|
|
}
|
|
else {
|
|
return this.ownedAttributes;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* @returns {BAttribute} attribute belonging to this specific note (excludes inherited attributes)
|
|
*
|
|
* This method can be significantly faster than the getAttribute()
|
|
*/
|
|
getOwnedAttribute(type: string, name: string, value: string | null = null) {
|
|
const attrs = this.getOwnedAttributes(type, name, value);
|
|
|
|
return attrs.length > 0 ? attrs[0] : null;
|
|
}
|
|
|
|
get isArchived() {
|
|
return this.hasAttribute('label', 'archived');
|
|
}
|
|
|
|
areAllNotePathsArchived() {
|
|
// there's a slight difference between note being itself archived and all its note paths being archived
|
|
// - note is archived when it itself has an archived label or inherits it
|
|
// - note does not have or inherit archived label, but each note path contains a note with (non-inheritable)
|
|
// archived label
|
|
|
|
const bestNotePathRecord = this.getSortedNotePathRecords()[0];
|
|
|
|
if (!bestNotePathRecord) {
|
|
throw new Error(`No note path available for note '${this.noteId}'`);
|
|
}
|
|
|
|
return bestNotePathRecord.isArchived;
|
|
}
|
|
|
|
hasInheritableArchivedLabel() {
|
|
for (const attr of this.getAttributes()) {
|
|
if (attr.name === 'archived' && attr.type === LABEL && attr.isInheritable) {
|
|
return true;
|
|
}
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
// will sort the parents so that the non-archived are first and archived at the end
|
|
// this is done so that the non-archived paths are always explored as first when looking for note path
|
|
sortParents() {
|
|
this.parentBranches.sort((a, b) => {
|
|
if (a.parentNote?.isArchived) {
|
|
return 1;
|
|
} else if (a.parentNote?.isHiddenCompletely()) {
|
|
return 1;
|
|
} else {
|
|
return 0;
|
|
}
|
|
});
|
|
|
|
this.parents = this.parentBranches
|
|
.map(branch => branch.parentNote)
|
|
.filter(note => !!note) as BNote[];
|
|
}
|
|
|
|
sortChildren() {
|
|
if (this.children.length === 0) {
|
|
return;
|
|
}
|
|
|
|
const becca = this.becca;
|
|
|
|
this.children.sort((a, b) => {
|
|
const aBranch = becca.getBranchFromChildAndParent(a.noteId, this.noteId);
|
|
const bBranch = becca.getBranchFromChildAndParent(b.noteId, this.noteId);
|
|
|
|
return ((aBranch?.notePosition || 0) - (bBranch?.notePosition || 0)) || 0;
|
|
});
|
|
}
|
|
|
|
/**
|
|
* This is used for:
|
|
* - fast searching
|
|
* - note similarity evaluation
|
|
*
|
|
* @returns {string} - returns flattened textual representation of note, prefixes and attributes
|
|
*/
|
|
getFlatText() {
|
|
if (!this.__flatTextCache) {
|
|
this.__flatTextCache = `${this.noteId} ${this.type} ${this.mime} `;
|
|
|
|
for (const branch of this.parentBranches) {
|
|
if (branch.prefix) {
|
|
this.__flatTextCache += `${branch.prefix} `;
|
|
}
|
|
}
|
|
|
|
this.__flatTextCache += `${this.title} `;
|
|
|
|
for (const attr of this.getAttributes()) {
|
|
// it's best to use space as separator since spaces are filtered from the search string by the tokenization into words
|
|
this.__flatTextCache += `${attr.type === 'label' ? '#' : '~'}${attr.name}`;
|
|
|
|
if (attr.value) {
|
|
this.__flatTextCache += `=${attr.value}`;
|
|
}
|
|
|
|
this.__flatTextCache += ' ';
|
|
}
|
|
|
|
this.__flatTextCache = utils.normalize(this.__flatTextCache);
|
|
}
|
|
|
|
return this.__flatTextCache;
|
|
}
|
|
|
|
invalidateThisCache() {
|
|
this.__flatTextCache = null;
|
|
|
|
this.__attributeCache = null;
|
|
this.__inheritableAttributeCache = null;
|
|
this.__ancestorCache = null;
|
|
}
|
|
|
|
invalidateSubTree(path: string[] = []) {
|
|
if (path.includes(this.noteId)) {
|
|
return;
|
|
}
|
|
|
|
this.invalidateThisCache();
|
|
|
|
if (this.children.length || this.targetRelations.length) {
|
|
path = [...path, this.noteId];
|
|
}
|
|
|
|
for (const childNote of this.children) {
|
|
childNote.invalidateSubTree(path);
|
|
}
|
|
|
|
for (const targetRelation of this.targetRelations) {
|
|
if (targetRelation.name === 'template' || targetRelation.name === 'inherit') {
|
|
const note = targetRelation.note;
|
|
|
|
if (note) {
|
|
note.invalidateSubTree(path);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
getRelationDefinitions() {
|
|
return this.getLabels()
|
|
.filter(l => l.name.startsWith("relation:"));
|
|
}
|
|
|
|
getLabelDefinitions() {
|
|
return this.getLabels()
|
|
.filter(l => l.name.startsWith("relation:"));
|
|
}
|
|
|
|
isInherited() {
|
|
return !!this.targetRelations.find(rel => rel.name === 'template' || rel.name === 'inherit');
|
|
}
|
|
|
|
getSubtreeNotesIncludingTemplated(): BNote[] {
|
|
const set = new Set<BNote>();
|
|
|
|
function inner(note: BNote) {
|
|
// _hidden is not counted as subtree for the purpose of inheritance
|
|
if (set.has(note) || note.noteId === '_hidden') {
|
|
return;
|
|
}
|
|
|
|
set.add(note);
|
|
|
|
for (const childNote of note.children) {
|
|
inner(childNote);
|
|
}
|
|
|
|
for (const targetRelation of note.targetRelations) {
|
|
if (targetRelation.name === 'template' || targetRelation.name === 'inherit') {
|
|
const targetNote = targetRelation.note;
|
|
|
|
if (targetNote) {
|
|
inner(targetNote);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
inner(this);
|
|
|
|
return Array.from(set);
|
|
}
|
|
|
|
getSearchResultNotes(): BNote[] {
|
|
if (this.type !== 'search') {
|
|
return [];
|
|
}
|
|
|
|
try {
|
|
const searchService = require('../../services/search/services/search');
|
|
const {searchResultNoteIds} = searchService.searchFromNote(this);
|
|
|
|
const becca = this.becca;
|
|
return (searchResultNoteIds as string[]) // TODO: remove cast once search is converted
|
|
.map(resultNoteId => becca.notes[resultNoteId])
|
|
.filter(note => !!note);
|
|
}
|
|
catch (e: any) {
|
|
log.error(`Could not resolve search note ${this.noteId}: ${e.message}`);
|
|
return [];
|
|
}
|
|
}
|
|
|
|
getSubtree({includeArchived = true, includeHidden = false, resolveSearch = false} = {}): {
|
|
notes: BNote[],
|
|
relationships: Relationship[]
|
|
} {
|
|
const noteSet = new Set<BNote>();
|
|
const relationships: Relationship[] = []; // list of tuples parentNoteId -> childNoteId
|
|
|
|
function resolveSearchNote(searchNote: BNote) {
|
|
try {
|
|
for (const resultNote of searchNote.getSearchResultNotes()) {
|
|
addSubtreeNotesInner(resultNote, searchNote);
|
|
}
|
|
}
|
|
catch (e: any) {
|
|
log.error(`Could not resolve search note ${searchNote?.noteId}: ${e.message}`);
|
|
}
|
|
}
|
|
|
|
function addSubtreeNotesInner(note: BNote, parentNote: BNote | null = null) {
|
|
if (note.noteId === '_hidden' && !includeHidden) {
|
|
return;
|
|
}
|
|
|
|
if (parentNote) {
|
|
// this needs to happen first before noteSet check to include all clone relationships
|
|
relationships.push({
|
|
parentNoteId: parentNote.noteId,
|
|
childNoteId: note.noteId
|
|
});
|
|
}
|
|
|
|
if (noteSet.has(note)) {
|
|
return;
|
|
}
|
|
|
|
if (!includeArchived && note.isArchived) {
|
|
return;
|
|
}
|
|
|
|
noteSet.add(note);
|
|
|
|
if (note.type === 'search') {
|
|
if (resolveSearch) {
|
|
resolveSearchNote(note);
|
|
}
|
|
}
|
|
else {
|
|
for (const childNote of note.children) {
|
|
addSubtreeNotesInner(childNote, note);
|
|
}
|
|
}
|
|
}
|
|
|
|
addSubtreeNotesInner(this);
|
|
|
|
return {
|
|
notes: Array.from(noteSet),
|
|
relationships
|
|
};
|
|
}
|
|
|
|
/** @returns {string[]} - includes the subtree root note as well */
|
|
getSubtreeNoteIds({includeArchived = true, includeHidden = false, resolveSearch = false} = {}) {
|
|
return this.getSubtree({includeArchived, includeHidden, resolveSearch})
|
|
.notes
|
|
.map(note => note.noteId);
|
|
}
|
|
|
|
/** @deprecated use getSubtreeNoteIds() instead */
|
|
getDescendantNoteIds() {
|
|
return this.getSubtreeNoteIds();
|
|
}
|
|
|
|
get parentCount() {
|
|
return this.parents.length;
|
|
}
|
|
|
|
get childrenCount() {
|
|
return this.children.length;
|
|
}
|
|
|
|
get labelCount() {
|
|
return this.getAttributes().filter(attr => attr.type === 'label').length;
|
|
}
|
|
|
|
get ownedLabelCount() {
|
|
return this.ownedAttributes.filter(attr => attr.type === 'label').length;
|
|
}
|
|
|
|
get relationCount() {
|
|
return this.getAttributes().filter(attr => attr.type === 'relation' && !attr.isAutoLink()).length;
|
|
}
|
|
|
|
get relationCountIncludingLinks() {
|
|
return this.getAttributes().filter(attr => attr.type === 'relation').length;
|
|
}
|
|
|
|
get ownedRelationCount() {
|
|
return this.ownedAttributes.filter(attr => attr.type === 'relation' && !attr.isAutoLink()).length;
|
|
}
|
|
|
|
get ownedRelationCountIncludingLinks() {
|
|
return this.ownedAttributes.filter(attr => attr.type === 'relation').length;
|
|
}
|
|
|
|
get targetRelationCount() {
|
|
return this.targetRelations.filter(attr => !attr.isAutoLink()).length;
|
|
}
|
|
|
|
get targetRelationCountIncludingLinks() {
|
|
return this.targetRelations.length;
|
|
}
|
|
|
|
get attributeCount() {
|
|
return this.getAttributes().length;
|
|
}
|
|
|
|
get ownedAttributeCount() {
|
|
return this.getOwnedAttributes().length;
|
|
}
|
|
|
|
/** @returns {BNote[]} */
|
|
getAncestors() {
|
|
if (!this.__ancestorCache) {
|
|
const noteIds = new Set();
|
|
this.__ancestorCache = [];
|
|
|
|
for (const parent of this.parents) {
|
|
if (noteIds.has(parent.noteId)) {
|
|
continue;
|
|
}
|
|
|
|
this.__ancestorCache.push(parent);
|
|
noteIds.add(parent.noteId);
|
|
|
|
for (const ancestorNote of parent.getAncestors()) {
|
|
if (!noteIds.has(ancestorNote.noteId)) {
|
|
this.__ancestorCache.push(ancestorNote);
|
|
noteIds.add(ancestorNote.noteId);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
return this.__ancestorCache;
|
|
}
|
|
|
|
getAncestorNoteIds(): string[] {
|
|
return this.getAncestors().map(note => note.noteId);
|
|
}
|
|
|
|
hasAncestor(ancestorNoteId: string): boolean {
|
|
for (const ancestorNote of this.getAncestors()) {
|
|
if (ancestorNote.noteId === ancestorNoteId) {
|
|
return true;
|
|
}
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
isInHiddenSubtree() {
|
|
return this.noteId === '_hidden' || this.hasAncestor('_hidden');
|
|
}
|
|
|
|
/** @returns {BAttribute[]} */
|
|
getTargetRelations() {
|
|
return this.targetRelations;
|
|
}
|
|
|
|
/** @returns returns only notes which are templated, does not include their subtrees
|
|
* in effect returns notes which are influenced by note's non-inheritable attributes */
|
|
getInheritingNotes(): BNote[] {
|
|
const arr: BNote[] = [this];
|
|
|
|
for (const targetRelation of this.targetRelations) {
|
|
if (targetRelation.name === 'template' || targetRelation.name === 'inherit') {
|
|
const note = targetRelation.note;
|
|
|
|
if (note) {
|
|
arr.push(note);
|
|
}
|
|
}
|
|
}
|
|
|
|
return arr;
|
|
}
|
|
|
|
getDistanceToAncestor(ancestorNoteId: string) {
|
|
if (this.noteId === ancestorNoteId) {
|
|
return 0;
|
|
}
|
|
|
|
let minDistance = 999999;
|
|
|
|
for (const parent of this.parents) {
|
|
minDistance = Math.min(minDistance, parent.getDistanceToAncestor(ancestorNoteId) + 1);
|
|
}
|
|
|
|
return minDistance;
|
|
}
|
|
|
|
getRevisions(): BRevision[] {
|
|
return sql.getRows<RevisionRow>("SELECT * FROM revisions WHERE noteId = ?", [this.noteId])
|
|
.map(row => new BRevision(row));
|
|
}
|
|
|
|
/** @returns {BAttachment[]} */
|
|
getAttachments(opts: AttachmentOpts = {}) {
|
|
opts.includeContentLength = !!opts.includeContentLength;
|
|
// from testing, it looks like calculating length does not make a difference in performance even on large-ish DB
|
|
// given that we're always fetching attachments only for a specific note, we might just do it always
|
|
|
|
const query = opts.includeContentLength
|
|
? `SELECT attachments.*, LENGTH(blobs.content) AS contentLength
|
|
FROM attachments
|
|
JOIN blobs USING (blobId)
|
|
WHERE ownerId = ? AND isDeleted = 0
|
|
ORDER BY position`
|
|
: `SELECT * FROM attachments WHERE ownerId = ? AND isDeleted = 0 ORDER BY position`;
|
|
|
|
return sql.getRows<AttachmentRow>(query, [this.noteId])
|
|
.map(row => new BAttachment(row));
|
|
}
|
|
|
|
/** @returns {BAttachment|null} */
|
|
getAttachmentById(attachmentId: string, opts: AttachmentOpts = {}) {
|
|
opts.includeContentLength = !!opts.includeContentLength;
|
|
|
|
const query = opts.includeContentLength
|
|
? `SELECT attachments.*, LENGTH(blobs.content) AS contentLength
|
|
FROM attachments
|
|
JOIN blobs USING (blobId)
|
|
WHERE ownerId = ? AND attachmentId = ? AND isDeleted = 0`
|
|
: `SELECT * FROM attachments WHERE ownerId = ? AND attachmentId = ? AND isDeleted = 0`;
|
|
|
|
return sql.getRows<AttachmentRow>(query, [this.noteId, attachmentId])
|
|
.map(row => new BAttachment(row))[0];
|
|
}
|
|
|
|
getAttachmentsByRole(role: string): BAttachment[] {
|
|
return sql.getRows<AttachmentRow>(`
|
|
SELECT attachments.*
|
|
FROM attachments
|
|
WHERE ownerId = ?
|
|
AND role = ?
|
|
AND isDeleted = 0
|
|
ORDER BY position`, [this.noteId, role])
|
|
.map(row => new BAttachment(row));
|
|
}
|
|
|
|
getAttachmentByTitle(title: string): BAttachment {
|
|
// cannot use SQL to filter by title since it can be encrypted
|
|
return this.getAttachments().filter(attachment => attachment.title === title)[0];
|
|
}
|
|
|
|
/**
|
|
* Gives all possible note paths leading to this note. Paths containing search note are ignored (could form cycles)
|
|
*
|
|
* @returns array of notePaths (each represented by array of noteIds constituting the particular note path)
|
|
*/
|
|
getAllNotePaths(): string[][] {
|
|
if (this.noteId === 'root') {
|
|
return [['root']];
|
|
}
|
|
|
|
const parentNotes = this.getParentNotes();
|
|
|
|
const notePaths = parentNotes.length === 1
|
|
? parentNotes[0].getAllNotePaths() // optimization for the most common case
|
|
: parentNotes.flatMap(parentNote => parentNote.getAllNotePaths());
|
|
|
|
for (const notePath of notePaths) {
|
|
notePath.push(this.noteId);
|
|
}
|
|
|
|
return notePaths;
|
|
}
|
|
|
|
getSortedNotePathRecords(hoistedNoteId: string = 'root'): NotePathRecord[] {
|
|
const isHoistedRoot = hoistedNoteId === 'root';
|
|
|
|
const notePaths = this.getAllNotePaths().map(path => ({
|
|
notePath: path,
|
|
isInHoistedSubTree: isHoistedRoot || path.includes(hoistedNoteId),
|
|
isArchived: path.some(noteId => this.becca.notes[noteId].isArchived),
|
|
isHidden: path.includes('_hidden')
|
|
}));
|
|
|
|
notePaths.sort((a, b) => {
|
|
if (a.isInHoistedSubTree !== b.isInHoistedSubTree) {
|
|
return a.isInHoistedSubTree ? -1 : 1;
|
|
} else if (a.isArchived !== b.isArchived) {
|
|
return a.isArchived ? 1 : -1;
|
|
} else if (a.isHidden !== b.isHidden) {
|
|
return a.isHidden ? 1 : -1;
|
|
} else {
|
|
return a.notePath.length - b.notePath.length;
|
|
}
|
|
});
|
|
|
|
return notePaths;
|
|
}
|
|
|
|
/**
|
|
* Returns a note path considered to be the "best"
|
|
*
|
|
* @return array of noteIds constituting the particular note path
|
|
*/
|
|
getBestNotePath(hoistedNoteId: string = 'root'): string[] {
|
|
return this.getSortedNotePathRecords(hoistedNoteId)[0]?.notePath;
|
|
}
|
|
|
|
/**
|
|
* Returns a note path considered to be the "best"
|
|
*
|
|
* @return serialized note path (e.g. 'root/a1h315/js725h')
|
|
*/
|
|
getBestNotePathString(hoistedNoteId: string = 'root'): string {
|
|
const notePath = this.getBestNotePath(hoistedNoteId);
|
|
|
|
return notePath?.join("/");
|
|
}
|
|
|
|
/**
|
|
* @return boolean - true if there's no non-hidden path, note is not cloned to the visible tree
|
|
*/
|
|
isHiddenCompletely() {
|
|
if (this.noteId === 'root') {
|
|
return false;
|
|
}
|
|
|
|
for (const parentNote of this.parents) {
|
|
if (parentNote.noteId === 'root') {
|
|
return false;
|
|
} else if (parentNote.noteId === '_hidden') {
|
|
continue;
|
|
} else if (!parentNote.isHiddenCompletely()) {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
/**
|
|
* @returns true if ancestorNoteId occurs in at least one of the note's paths
|
|
*/
|
|
isDescendantOfNote(ancestorNoteId: string): boolean {
|
|
const notePaths = this.getAllNotePaths();
|
|
|
|
return notePaths.some(path => path.includes(ancestorNoteId));
|
|
}
|
|
|
|
/**
|
|
* Update's given attribute's value or creates it if it doesn't exist
|
|
*
|
|
* @param type - attribute type (label, relation, etc.)
|
|
* @param name - attribute name
|
|
* @param value - attribute value (optional)
|
|
*/
|
|
setAttribute(type: string, name: string, value?: string) {
|
|
const attributes = this.getOwnedAttributes();
|
|
const attr = attributes.find(attr => attr.type === type && attr.name === name);
|
|
|
|
value = value?.toString() || "";
|
|
|
|
if (attr) {
|
|
if (attr.value !== value) {
|
|
attr.value = value;
|
|
attr.save();
|
|
}
|
|
}
|
|
else {
|
|
const BAttribute = require('./battribute');
|
|
|
|
new BAttribute({
|
|
noteId: this.noteId,
|
|
type: type,
|
|
name: name,
|
|
value: value
|
|
}).save();
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Removes given attribute name-value pair if it exists.
|
|
*
|
|
* @param type - attribute type (label, relation, etc.)
|
|
* @param name - attribute name
|
|
* @param value - attribute value (optional)
|
|
*/
|
|
removeAttribute(type: string, name: string, value?: string) {
|
|
const attributes = this.getOwnedAttributes();
|
|
|
|
for (const attribute of attributes) {
|
|
if (attribute.type === type && attribute.name === name && (value === undefined || value === attribute.value)) {
|
|
attribute.markAsDeleted();
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Adds a new attribute to this note. The attribute is saved and returned.
|
|
* See addLabel, addRelation for more specific methods.
|
|
*
|
|
* @param type - attribute type (label / relation)
|
|
* @param name - name of the attribute, not including the leading ~/#
|
|
* @param value - value of the attribute - text for labels, target note ID for relations; optional.
|
|
*/
|
|
addAttribute(type: string, name: string, value: string = "", isInheritable: boolean = false, position: number | null = null): BAttribute {
|
|
const BAttribute = require('./battribute');
|
|
|
|
return new BAttribute({
|
|
noteId: this.noteId,
|
|
type: type,
|
|
name: name,
|
|
value: value,
|
|
isInheritable: isInheritable,
|
|
position: position
|
|
}).save();
|
|
}
|
|
|
|
/**
|
|
* Adds a new label to this note. The label attribute is saved and returned.
|
|
*
|
|
* @param name - name of the label, not including the leading #
|
|
* @param value - text value of the label; optional
|
|
*/
|
|
addLabel(name: string, value: string = "", isInheritable: boolean = false): BAttribute {
|
|
return this.addAttribute(LABEL, name, value, isInheritable);
|
|
}
|
|
|
|
/**
|
|
* Adds a new relation to this note. The relation attribute is saved and
|
|
* returned.
|
|
*
|
|
* @param name - name of the relation, not including the leading ~
|
|
*/
|
|
addRelation(name: string, targetNoteId: string, isInheritable: boolean = false): BAttribute {
|
|
return this.addAttribute(RELATION, name, targetNoteId, isInheritable);
|
|
}
|
|
|
|
/**
|
|
* Based on enabled, the attribute is either set or removed.
|
|
*
|
|
* @param type - attribute type ('relation', 'label' etc.)
|
|
* @param enabled - toggle On or Off
|
|
* @param name - attribute name
|
|
* @param value - attribute value (optional)
|
|
*/
|
|
toggleAttribute(type: string, enabled: boolean, name: string, value?: string) {
|
|
if (enabled) {
|
|
this.setAttribute(type, name, value);
|
|
}
|
|
else {
|
|
this.removeAttribute(type, name, value);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Based on enabled, label is either set or removed.
|
|
*
|
|
* @param enabled - toggle On or Off
|
|
* @param name - label name
|
|
* @param value - label value (optional)
|
|
*/
|
|
toggleLabel(enabled: boolean, name: string, value?: string) {
|
|
return this.toggleAttribute(LABEL, enabled, name, value);
|
|
}
|
|
|
|
/**
|
|
* Based on enabled, relation is either set or removed.
|
|
*
|
|
* @param enabled - toggle On or Off
|
|
* @param name - relation name
|
|
* @param value - relation value (noteId)
|
|
*/
|
|
toggleRelation(enabled: boolean, name: string, value?: string) {
|
|
return this.toggleAttribute(RELATION, enabled, name, value);
|
|
}
|
|
|
|
/**
|
|
* Update's given label's value or creates it if it doesn't exist
|
|
*
|
|
* @param name - label name
|
|
* @param value label value
|
|
*/
|
|
setLabel(name: string, value?: string) {
|
|
return this.setAttribute(LABEL, name, value);
|
|
}
|
|
|
|
/**
|
|
* Update's given relation's value or creates it if it doesn't exist
|
|
*
|
|
* @param name - relation name
|
|
* @param value - relation value (noteId)
|
|
*/
|
|
setRelation(name: string, value?: string) {
|
|
return this.setAttribute(RELATION, name, value);
|
|
}
|
|
|
|
/**
|
|
* Remove label name-value pair, if it exists.
|
|
*
|
|
* @param name - label name
|
|
* @param value - label value
|
|
*/
|
|
removeLabel(name: string, value?: string) {
|
|
return this.removeAttribute(LABEL, name, value);
|
|
}
|
|
|
|
/**
|
|
* Remove the relation name-value pair, if it exists.
|
|
*
|
|
* @param name - relation name
|
|
* @param value - relation value (noteId)
|
|
*/
|
|
removeRelation(name: string, value?: string) {
|
|
return this.removeAttribute(RELATION, name, value);
|
|
}
|
|
|
|
searchNotesInSubtree(searchString: string) {
|
|
const searchService = require('../../services/search/services/search');
|
|
|
|
return searchService.searchNotes(searchString) as BNote[];
|
|
}
|
|
|
|
searchNoteInSubtree(searchString: string) {
|
|
return this.searchNotesInSubtree(searchString)[0];
|
|
}
|
|
|
|
cloneTo(parentNoteId: string) {
|
|
const cloningService = require('../../services/cloning');
|
|
|
|
const branch = this.becca.getNote(parentNoteId)?.getParentBranches()[0];
|
|
|
|
return cloningService.cloneNoteToBranch(this.noteId, branch?.branchId);
|
|
}
|
|
|
|
isEligibleForConversionToAttachment(opts: ConvertOpts = { autoConversion: false }) {
|
|
if (this.type !== 'image' || !this.isContentAvailable() || this.hasChildren() || this.getParentBranches().length !== 1) {
|
|
return false;
|
|
}
|
|
|
|
const targetRelations = this.getTargetRelations().filter(relation => relation.name === 'imageLink');
|
|
|
|
if (opts.autoConversion && targetRelations.length === 0) {
|
|
return false;
|
|
} else if (targetRelations.length > 1) {
|
|
return false;
|
|
}
|
|
|
|
const parentNote = this.getParentNotes()[0]; // at this point note can have only one parent
|
|
const referencingNote = targetRelations[0]?.getNote();
|
|
|
|
if (referencingNote && parentNote !== referencingNote) {
|
|
return false;
|
|
} else if (parentNote.type !== 'text' || !parentNote.isContentAvailable()) {
|
|
return false;
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
/**
|
|
* Some notes are eligible for conversion into an attachment of its parent, note must have these properties:
|
|
* - it has exactly one target relation
|
|
* - it has a relation from its parent note
|
|
* - it has no children
|
|
* - it has no clones
|
|
* - the parent is of type text
|
|
* - both notes are either unprotected or user is in protected session
|
|
*
|
|
* Currently, works only for image notes.
|
|
*
|
|
* In the future, this functionality might get more generic and some of the requirements relaxed.
|
|
*
|
|
* @returns null if note is not eligible for conversion
|
|
*/
|
|
convertToParentAttachment(opts: ConvertOpts = { autoConversion: false }): BAttachment | null {
|
|
if (!this.isEligibleForConversionToAttachment(opts)) {
|
|
return null;
|
|
}
|
|
|
|
const content = this.getContent();
|
|
|
|
const parentNote = this.getParentNotes()[0];
|
|
const attachment = parentNote.saveAttachment({
|
|
role: 'image',
|
|
mime: this.mime,
|
|
title: this.title,
|
|
content: content
|
|
});
|
|
|
|
let parentContent = parentNote.getContent();
|
|
|
|
const oldNoteUrl = `api/images/${this.noteId}/`;
|
|
const newAttachmentUrl = `api/attachments/${attachment.attachmentId}/image/`;
|
|
|
|
if (typeof parentContent !== "string") {
|
|
throw new Error("Unable to convert image note into attachment because parent note does not have a string content.");
|
|
}
|
|
|
|
const fixedContent = utils.replaceAll(parentContent, oldNoteUrl, newAttachmentUrl);
|
|
|
|
parentNote.setContent(fixedContent);
|
|
|
|
const noteService = require('../../services/notes');
|
|
noteService.asyncPostProcessContent(parentNote, fixedContent); // to mark an unused attachment for deletion
|
|
|
|
this.deleteNote();
|
|
|
|
return attachment;
|
|
}
|
|
|
|
/**
|
|
* (Soft) delete a note and all its descendants.
|
|
*
|
|
* @param deleteId - optional delete identified
|
|
*/
|
|
deleteNote(deleteId: string | null = null, taskContext: TaskContext | null = null) {
|
|
if (this.isDeleted) {
|
|
return;
|
|
}
|
|
|
|
if (!deleteId) {
|
|
deleteId = utils.randomString(10);
|
|
}
|
|
|
|
if (!taskContext) {
|
|
taskContext = new TaskContext('no-progress-reporting');
|
|
}
|
|
|
|
// needs to be run before branches and attributes are deleted and thus attached relations disappear
|
|
const handlers = require('../../services/handlers');
|
|
handlers.runAttachedRelations(this, 'runOnNoteDeletion', this);
|
|
taskContext.noteDeletionHandlerTriggered = true;
|
|
|
|
for (const branch of this.getParentBranches()) {
|
|
branch.deleteBranch(deleteId, taskContext);
|
|
}
|
|
}
|
|
|
|
decrypt() {
|
|
if (this.isProtected && !this.isDecrypted && protectedSessionService.isProtectedSessionAvailable()) {
|
|
try {
|
|
this.title = protectedSessionService.decryptString(this.title) || "";
|
|
this.__flatTextCache = null;
|
|
|
|
this.isDecrypted = true;
|
|
}
|
|
catch (e: any) {
|
|
log.error(`Could not decrypt note ${this.noteId}: ${e.message} ${e.stack}`);
|
|
}
|
|
}
|
|
}
|
|
|
|
isLaunchBarConfig() {
|
|
return this.type === 'launcher' || ['_lbRoot', '_lbAvailableLaunchers', '_lbVisibleLaunchers'].includes(this.noteId);
|
|
}
|
|
|
|
isOptions() {
|
|
return this.noteId.startsWith("_options");
|
|
}
|
|
|
|
get isDeleted() {
|
|
// isBeingDeleted is relevant only in the transition period when the deletion process has begun, but not yet
|
|
// finished (note is still in becca)
|
|
return !(this.noteId in this.becca.notes) || this.isBeingDeleted;
|
|
}
|
|
|
|
/**
|
|
* @returns {BRevision|null}
|
|
*/
|
|
saveRevision() {
|
|
return sql.transactional(() => {
|
|
let noteContent = this.getContent();
|
|
|
|
const revision = new BRevision({
|
|
noteId: this.noteId,
|
|
// title and text should be decrypted now
|
|
title: this.title,
|
|
type: this.type,
|
|
mime: this.mime,
|
|
isProtected: this.isProtected,
|
|
utcDateLastEdited: this.utcDateModified,
|
|
utcDateCreated: dateUtils.utcNowDateTime(),
|
|
utcDateModified: dateUtils.utcNowDateTime(),
|
|
dateLastEdited: this.dateModified,
|
|
dateCreated: dateUtils.localNowDateTime()
|
|
}, true);
|
|
|
|
revision.save(); // to generate revisionId, which is then used to save attachments
|
|
|
|
for (const noteAttachment of this.getAttachments()) {
|
|
const revisionAttachment = noteAttachment.copy();
|
|
|
|
if (!revision.revisionId) {
|
|
throw new Error("Revision ID is missing.");
|
|
}
|
|
|
|
revisionAttachment.ownerId = revision.revisionId;
|
|
revisionAttachment.setContent(noteAttachment.getContent(), { forceSave: true });
|
|
|
|
if (this.type === 'text' && typeof noteContent === "string") {
|
|
// content is rewritten to point to the revision attachments
|
|
noteContent = noteContent.replaceAll(`attachments/${noteAttachment.attachmentId}`,
|
|
`attachments/${revisionAttachment.attachmentId}`);
|
|
|
|
noteContent = noteContent.replaceAll(new RegExp(`href="[^"]*attachmentId=${noteAttachment.attachmentId}[^"]*"`, 'gi'),
|
|
`href="api/attachments/${revisionAttachment.attachmentId}/download"`);
|
|
}
|
|
}
|
|
|
|
revision.setContent(noteContent);
|
|
|
|
return revision;
|
|
});
|
|
}
|
|
|
|
/**
|
|
* @param {string} matchBy - choose by which property we detect if to update an existing attachment.
|
|
* Supported values are either 'attachmentId' (default) or 'title'
|
|
* @returns {BAttachment}
|
|
*/
|
|
saveAttachment({attachmentId, role, mime, title, content, position}: AttachmentRow, matchBy = 'attachmentId') {
|
|
if (!['attachmentId', 'title'].includes(matchBy)) {
|
|
throw new Error(`Unsupported value '${matchBy}' for matchBy param, has to be either 'attachmentId' or 'title'.`);
|
|
}
|
|
|
|
let attachment;
|
|
|
|
if (matchBy === 'title' && title) {
|
|
attachment = this.getAttachmentByTitle(title);
|
|
} else if (matchBy === 'attachmentId' && attachmentId) {
|
|
attachment = this.becca.getAttachmentOrThrow(attachmentId);
|
|
}
|
|
|
|
attachment = attachment || new BAttachment({
|
|
ownerId: this.noteId,
|
|
title,
|
|
role,
|
|
mime,
|
|
isProtected: this.isProtected,
|
|
position
|
|
});
|
|
|
|
if (!content) {
|
|
throw new Error("Attempted to save an attachment with no content.");
|
|
}
|
|
|
|
attachment.setContent(content, {forceSave: true});
|
|
|
|
return attachment;
|
|
}
|
|
|
|
getFileName() {
|
|
return utils.formatDownloadTitle(this.title, this.type, this.mime);
|
|
}
|
|
|
|
beforeSaving() {
|
|
super.beforeSaving();
|
|
|
|
this.becca.addNote(this.noteId, this);
|
|
|
|
this.dateModified = dateUtils.localNowDateTime();
|
|
this.utcDateModified = dateUtils.utcNowDateTime();
|
|
}
|
|
|
|
getPojo() {
|
|
return {
|
|
noteId: this.noteId,
|
|
title: this.title || undefined,
|
|
isProtected: this.isProtected,
|
|
type: this.type,
|
|
mime: this.mime,
|
|
blobId: this.blobId,
|
|
isDeleted: false,
|
|
dateCreated: this.dateCreated,
|
|
dateModified: this.dateModified,
|
|
utcDateCreated: this.utcDateCreated,
|
|
utcDateModified: this.utcDateModified
|
|
};
|
|
}
|
|
|
|
getPojoToSave() {
|
|
const pojo = this.getPojo();
|
|
|
|
if (pojo.isProtected) {
|
|
if (this.isDecrypted && pojo.title) {
|
|
pojo.title = protectedSessionService.encrypt(pojo.title) || undefined;
|
|
}
|
|
else {
|
|
// updating protected note outside of protected session means we will keep original ciphertexts
|
|
delete pojo.title;
|
|
}
|
|
}
|
|
|
|
return pojo;
|
|
}
|
|
}
|
|
|
|
export = BNote;
|