Notes/src/services/note_cache.js

1145 lines
32 KiB
JavaScript

const sql = require('./sql');
const sqlInit = require('./sql_init');
const eventService = require('./events');
const protectedSessionService = require('./protected_session');
const utils = require('./utils');
const hoistedNoteService = require('./hoisted_note');
const stringSimilarity = require('string-similarity');
/** @type {Object.<String, Note>} */
let notes;
/** @type {Object.<String, Branch>} */
let branches
/** @type {Object.<String, Attribute>} */
let attributes;
/** @type {Object.<String, Attribute[]>} Points from attribute type-name to list of attributes them */
let attributeIndex;
/** @return {Attribute[]} */
function findAttributes(type, name) {
return attributeIndex[`${type}-${name}`] || [];
}
let childParentToBranch = {};
class Note {
constructor(row) {
/** @param {string} */
this.noteId = row.noteId;
/** @param {string} */
this.title = row.title;
/** @param {boolean} */
this.isProtected = !!row.isProtected;
/** @param {boolean} */
this.isDecrypted = !row.isProtected || !!row.isContentAvailable;
/** @param {Branch[]} */
this.parentBranches = [];
/** @param {Note[]} */
this.parents = [];
/** @param {Note[]} */
this.children = [];
/** @param {Attribute[]} */
this.ownedAttributes = [];
/** @param {Attribute[]|null} */
this.attributeCache = null;
/** @param {Attribute[]|null} */
this.inheritableAttributeCache = null;
/** @param {Attribute[]} */
this.targetRelations = [];
/** @param {string|null} */
this.flatTextCache = null;
if (protectedSessionService.isProtectedSessionAvailable()) {
decryptProtectedNote(this);
}
}
/** @return {Attribute[]} */
get attributes() {
if (!this.attributeCache) {
const parentAttributes = this.ownedAttributes.slice();
if (this.noteId !== 'root') {
for (const parentNote of this.parents) {
parentAttributes.push(...parentNote.inheritableAttributes);
}
}
const templateAttributes = [];
for (const ownedAttr of parentAttributes) { // parentAttributes so we process also inherited templates
if (ownedAttr.type === 'relation' && ownedAttr.name === 'template') {
const templateNote = notes[ownedAttr.value];
if (templateNote) {
templateAttributes.push(...templateNote.attributes);
}
}
}
this.attributeCache = parentAttributes.concat(templateAttributes);
this.inheritableAttributeCache = [];
for (const attr of this.attributeCache) {
if (attr.isInheritable) {
this.inheritableAttributeCache.push(attr);
}
}
}
return this.attributeCache;
}
/** @return {Attribute[]} */
get inheritableAttributes() {
if (!this.inheritableAttributeCache) {
this.attributes; // will refresh also this.inheritableAttributeCache
}
return this.inheritableAttributeCache;
}
hasAttribute(type, name) {
return this.attributes.find(attr => attr.type === type && attr.name === name);
}
get isArchived() {
return this.hasAttribute('label', 'archived');
}
get isHideInAutocompleteOrArchived() {
return this.attributes.find(attr =>
attr.type === 'label'
&& ["archived", "hideInAutocomplete"].includes(attr.name));
}
get hasInheritableOwnedArchivedLabel() {
return !!this.ownedAttributes.find(attr => attr.type === 'label' && attr.name === 'archived' && attr.isInheritable);
}
// will sort the parents so that non-archived are first and archived at the end
// this is done so that non-archived paths are always explored as first when searching for note path
resortParents() {
this.parents.sort((a, b) => a.hasInheritableOwnedArchivedLabel ? 1 : -1);
}
/**
* @return {string} - returns flattened textual representation of note, prefixes and attributes usable for searching
*/
get flatText() {
if (!this.flatTextCache) {
if (this.isHideInAutocompleteOrArchived) {
this.flatTextCache = " "; // can't be empty
return this.flatTextCache;
}
this.flatTextCache = this.title.toLowerCase();
for (const branch of this.parentBranches) {
if (branch.prefix) {
this.flatTextCache += ' ' + branch.prefix;
}
}
for (const attr of this.attributes) {
// it's best to use space as separator since spaces are filtered from the search string by the tokenization into words
this.flatTextCache += ' ' + attr.name.toLowerCase();
if (attr.value) {
this.flatTextCache += ' ' + attr.value.toLowerCase();
}
}
}
return this.flatTextCache;
}
invalidateThisCache() {
this.flatTextCache = null;
this.attributeCache = null;
this.inheritableAttributeCache = null;
}
invalidateSubtreeCaches() {
this.invalidateThisCache();
for (const childNote of this.children) {
childNote.invalidateSubtreeCaches();
}
for (const targetRelation of this.targetRelations) {
if (targetRelation.name === 'template') {
const note = targetRelation.note;
if (note) {
note.invalidateSubtreeCaches();
}
}
}
}
invalidateSubtreeFlatText() {
this.flatTextCache = null;
for (const childNote of this.children) {
childNote.invalidateSubtreeFlatText();
}
for (const targetRelation of this.targetRelations) {
if (targetRelation.name === 'template') {
const note = targetRelation.note;
if (note) {
note.invalidateSubtreeFlatText();
}
}
}
}
get isTemplate() {
return !!this.targetRelations.find(rel => rel.name === 'template');
}
/** @return {Note[]} */
get subtreeNotes() {
const arr = [[this]];
for (const childNote of this.children) {
arr.push(childNote.subtreeNotes);
}
for (const targetRelation of this.targetRelations) {
if (targetRelation.name === 'template') {
const note = targetRelation.note;
if (note) {
arr.push(note.subtreeNotes);
}
}
}
return arr.flat();
}
/** @return {Note[]} - returns only notes which are templated, does not include their subtrees
* in effect returns notes which are influenced by note's non-inheritable attributes */
get templatedNotes() {
const arr = [this];
for (const targetRelation of this.targetRelations) {
if (targetRelation.name === 'template') {
const note = targetRelation.note;
if (note) {
arr.push(note);
}
}
}
return arr;
}
}
class Branch {
constructor(row) {
/** @param {string} */
this.branchId = row.branchId;
/** @param {string} */
this.noteId = row.noteId;
/** @param {string} */
this.parentNoteId = row.parentNoteId;
/** @param {string} */
this.prefix = row.prefix;
if (this.branchId === 'root') {
return;
}
const childNote = notes[this.noteId];
const parentNote = this.parentNote;
if (!childNote) {
console.log(`Cannot find child note ${this.noteId} of a branch ${this.branchId}`);
return;
}
childNote.parents.push(parentNote);
childNote.parentBranches.push(this);
parentNote.children.push(childNote);
childParentToBranch[`${this.noteId}-${this.parentNoteId}`] = this;
}
/** @return {Note} */
get parentNote() {
const note = notes[this.parentNoteId];
if (!note) {
console.log(`Cannot find note ${this.parentNoteId}`);
}
return note;
}
}
class Attribute {
constructor(row) {
/** @param {string} */
this.attributeId = row.attributeId;
/** @param {string} */
this.noteId = row.noteId;
/** @param {string} */
this.type = row.type;
/** @param {string} */
this.name = row.name;
/** @param {string} */
this.value = row.value;
/** @param {boolean} */
this.isInheritable = !!row.isInheritable;
notes[this.noteId].ownedAttributes.push(this);
const key = `${this.type-this.name}`;
attributeIndex[key] = attributeIndex[key] || [];
attributeIndex[key].push(this);
const targetNote = this.targetNote;
if (targetNote) {
targetNote.targetRelations.push(this);
}
}
get isAffectingSubtree() {
return this.isInheritable
|| (this.type === 'relation' && this.name === 'template');
}
get note() {
return notes[this.noteId];
}
get targetNote() {
if (this.type === 'relation') {
return notes[this.value];
}
}
}
let loaded = false;
let loadedPromiseResolve;
/** Is resolved after the initial load */
let loadedPromise = new Promise(res => loadedPromiseResolve = res);
async function getMappedRows(query, cb) {
const map = {};
const results = await sql.getRows(query, []);
for (const row of results) {
const keys = Object.keys(row);
map[row[keys[0]]] = cb(row);
}
return map;
}
async function load() {
notes = await getMappedRows(`SELECT noteId, title, isProtected FROM notes WHERE isDeleted = 0`,
row => new Note(row));
branches = await getMappedRows(`SELECT branchId, noteId, parentNoteId, prefix FROM branches WHERE isDeleted = 0`,
row => new Branch(row));
attributes = await getMappedRows(`SELECT attributeId, noteId, type, name, value, isInheritable FROM attributes WHERE isDeleted = 0`,
row => new Attribute(row));
loaded = true;
loadedPromiseResolve();
}
const expression = {
operator: 'and',
operands: [
{
operator: 'exists',
fieldName: 'hokus'
}
]
};
class AndOp {
constructor(subExpressions) {
this.subExpressions = subExpressions;
}
execute(noteSet) {
for (const subExpression of this.subExpressions) {
noteSet = subExpression.execute(noteSet);
}
return noteSet;
}
}
class OrOp {
constructor(subExpressions) {
this.subExpressions = subExpressions;
}
execute(noteSet) {
const resultNoteSet = new NoteSet();
for (const subExpression of this.subExpressions) {
resultNoteSet.mergeIn(subExpression.execute(noteSet));
}
return resultNoteSet;
}
}
class NoteSet {
constructor(arr = []) {
this.arr = arr;
}
add(note) {
this.arr.push(note);
}
addAll(notes) {
this.arr.push(...notes);
}
hasNoteId(noteId) {
// TODO: optimize
return !!this.arr.find(note => note.noteId === noteId);
}
mergeIn(anotherNoteSet) {
this.arr = this.arr.concat(anotherNoteSet.arr);
}
}
class ExistsOp {
constructor(attributeType, attributeName) {
this.attributeType = attributeType;
this.attributeName = attributeName;
}
execute(noteSet) {
const attrs = findAttributes(this.attributeType, this.attributeName);
const resultNoteSet = new NoteSet();
for (const attr of attrs) {
const note = attr.note;
if (noteSet.hasNoteId(note.noteId)) {
if (attr.isInheritable) {
resultNoteSet.addAll(note.subtreeNotes);
}
else if (note.isTemplate) {
resultNoteSet.addAll(note.templatedNotes);
}
else {
resultNoteSet.add(note);
}
}
}
}
}
class EqualsOp {
constructor(attributeType, attributeName, attributeValue) {
this.attributeType = attributeType;
this.attributeName = attributeName;
this.attributeValue = attributeValue;
}
execute(noteSet) {
const attrs = findAttributes(this.attributeType, this.attributeName);
const resultNoteSet = new NoteSet();
for (const attr of attrs) {
const note = attr.note;
if (noteSet.hasNoteId(note.noteId) && attr.value === this.attributeValue) {
if (attr.isInheritable) {
resultNoteSet.addAll(note.subtreeNotes);
}
else if (note.isTemplate) {
resultNoteSet.addAll(note.templatedNotes);
}
else {
resultNoteSet.add(note);
}
}
}
}
}
async function findNotesWithExpression(expression) {
const allNoteSet = new NoteSet(Object.values(notes));
expression.execute(allNoteSet);
}
async function findNotesWithFulltext(query, searchInContent) {
if (!query.trim().length) {
return [];
}
const tokens = query
.trim() // necessary because even with .split() trailing spaces are tokens which causes havoc
.toLowerCase()
.split(/[ -]/)
.filter(token => token !== '/'); // '/' is used as separator
const cacheResults = findInNoteCache(tokens);
const contentResults = searchInContent ? await findInNoteContent(tokens) : [];
let results = cacheResults.concat(contentResults);
if (hoistedNoteService.getHoistedNoteId() !== 'root') {
results = results.filter(res => res.pathArray.includes(hoistedNoteService.getHoistedNoteId()));
}
// sort results by depth of the note. This is based on the assumption that more important results
// are closer to the note root.
results.sort((a, b) => {
if (a.pathArray.length === b.pathArray.length) {
return a.title < b.title ? -1 : 1;
}
return a.pathArray.length < b.pathArray.length ? -1 : 1;
});
const apiResults = results.slice(0, 200).map(res => {
const notePath = res.pathArray.join('/');
return {
noteId: res.noteId,
branchId: res.branchId,
path: notePath,
pathTitle: res.titleArray.join(' / '),
noteTitle: getNoteTitleFromPath(notePath)
};
});
highlightResults(apiResults, tokens);
return apiResults;
}
/**
* Returns noteIds which have at least one matching tokens
*
* @param tokens
* @return {String[]}
*/
function getCandidateNotes(tokens) {
const candidateNotes = [];
for (const note of Object.values(notes)) {
for (const token of tokens) {
if (note.flatText.includes(token)) {
candidateNotes.push(note);
break;
}
}
}
return candidateNotes;
}
function findInNoteCache(tokens) {
let results = [];
const candidateNotes = getCandidateNotes(tokens);
for (const note of candidateNotes) {
// autocomplete should be able to find notes by their noteIds as well (only leafs)
if (tokens.length === 1 && note.noteId === tokens[0]) {
search(note, [], [], results);
continue;
}
// for leaf note it doesn't matter if "archived" label is inheritable or not
if (note.isArchived) {
continue;
}
const foundAttrTokens = [];
for (const attribute of note.ownedAttributes) {
for (const token of tokens) {
if (attribute.name.toLowerCase().includes(token)
|| attribute.value.toLowerCase().includes(token)) {
foundAttrTokens.push(token);
}
}
}
for (const parentNote of note.parents) {
const title = getNoteTitle(note.noteId, parentNote.noteId).toLowerCase();
const foundTokens = foundAttrTokens.slice();
for (const token of tokens) {
if (title.includes(token)) {
foundTokens.push(token);
}
}
if (foundTokens.length > 0) {
const remainingTokens = tokens.filter(token => !foundTokens.includes(token));
search(parentNote, remainingTokens, [note.noteId], results);
}
}
}
return results;
}
async function findInNoteContent(tokens) {
const wheres = tokens.map(token => "note_contents.content LIKE " + utils.prepareSqlForLike('%', token, '%'));
const noteIds = await sql.getColumn(`
SELECT notes.noteId
FROM notes
JOIN note_contents ON notes.noteId = note_contents.noteId
WHERE isDeleted = 0 AND isProtected = 0 AND ${wheres.join(' AND ')}`);
const results = [];
for (const noteId of noteIds) {
const note = notes[noteId];
if (!note) {
continue;
}
const notePath = getSomePath(note);
const parentNoteId = notePath.length > 1 ? notePath[notePath.length - 2] : null;
results.push({
noteId: noteId,
branchId: getBranch(noteId, parentNoteId),
pathArray: notePath,
titleArray: getNoteTitleArrayForPath(notePath)
});
}
return results;
}
function search(note, tokens, path, results) {
if (tokens.length === 0) {
const retPath = getSomePath(note, path);
if (retPath) {
const thisNoteId = retPath[retPath.length - 1];
const thisParentNoteId = retPath.length > 1 ? retPath[retPath.length - 2] : null;
results.push({
noteId: thisNoteId,
branchId: getBranch(thisNoteId, thisParentNoteId),
pathArray: retPath,
titleArray: getNoteTitleArrayForPath(retPath)
});
}
return;
}
if (!note.parents.length === 0 || note.noteId === 'root') {
return;
}
const foundAttrTokens = [];
for (const attribute of note.ownedAttributes) {
for (const token of tokens) {
if (attribute.name.toLowerCase().includes(token)
|| attribute.value.toLowerCase().includes(token)) {
foundAttrTokens.push(token);
}
}
}
for (const parentNote of note.parents) {
const title = getNoteTitle(note.noteId, parentNote.noteId).toLowerCase();
const foundTokens = foundAttrTokens.slice();
for (const token of tokens) {
if (title.includes(token)) {
foundTokens.push(token);
}
}
if (foundTokens.length > 0) {
const remainingTokens = tokens.filter(token => !foundTokens.includes(token));
search(parentNote, remainingTokens, path.concat([note.noteId]), results);
}
else {
search(parentNote, tokens, path.concat([note.noteId]), results);
}
}
}
function highlightResults(results, allTokens) {
// we remove < signs because they can cause trouble in matching and overwriting existing highlighted chunks
// which would make the resulting HTML string invalid.
// { and } are used for marking <b> and </b> tag (to avoid matches on single 'b' character)
allTokens = allTokens.map(token => token.replace('/[<\{\}]/g', ''));
// sort by the longest so we first highlight longest matches
allTokens.sort((a, b) => a.length > b.length ? -1 : 1);
for (const result of results) {
const note = notes[result.noteId];
for (const attr of note.attributes) {
if (allTokens.find(token => attr.name.includes(token) || attr.value.includes(token))) {
result.pathTitle += ` <small>${formatAttribute(attr)}</small>`;
}
}
result.highlightedTitle = result.pathTitle;
}
for (const token of allTokens) {
const tokenRegex = new RegExp("(" + utils.escapeRegExp(token) + ")", "gi");
for (const result of results) {
result.highlightedTitle = result.highlightedTitle.replace(tokenRegex, "{$1}");
}
}
for (const result of results) {
result.highlightedTitle = result.highlightedTitle
.replace(/{/g, "<b>")
.replace(/}/g, "</b>");
}
}
function decryptProtectedNote(note) {
if (note.isProtected && !note.isDecrypted && protectedSessionService.isProtectedSessionAvailable()) {
note.title = protectedSessionService.decryptString(note.title);
note.isDecrypted = true;
}
}
async function decryptProtectedNotes() {
for (const note of Object.values(notes)) {
decryptProtectedNote(note);
}
}
function formatAttribute(attr) {
if (attr.type === 'relation') {
return '@' + utils.escapeHtml(attr.name) + "=…";
}
else if (attr.type === 'label') {
let label = '#' + utils.escapeHtml(attr.name);
if (attr.value) {
const val = /[^\w_-]/.test(attr.value) ? '"' + attr.value + '"' : attr.value;
label += '=' + utils.escapeHtml(val);
}
return label;
}
}
function getBranch(childNoteId, parentNoteId) {
return childParentToBranch[`${childNoteId}-${parentNoteId}`];
}
function isNotePathArchived(notePath) {
const noteId = notePath[notePath.length - 1];
const note = notes[noteId];
if (note.isArchived) {
return true;
}
for (let i = 0; i < notePath.length - 1; i++) {
const note = notes[notePath[i]];
// this is going through parents so archived must be inheritable
if (note.hasInheritableOwnedArchivedLabel) {
return true;
}
}
return false;
}
/**
* This assumes that note is available. "archived" note means that there isn't a single non-archived note-path
* leading to this note.
*
* @param noteId
*/
function isArchived(noteId) {
const notePath = getSomePath(noteId);
return isNotePathArchived(notePath);
}
/**
* @param {string} noteId
* @param {string} ancestorNoteId
* @return {boolean} - true if given noteId has ancestorNoteId in any of its paths (even archived)
*/
function isInAncestor(noteId, ancestorNoteId) {
if (ancestorNoteId === 'root' || ancestorNoteId === noteId) {
return true;
}
const note = notes[noteId];
for (const parentNote of note.parents) {
if (isInAncestor(parentNote.noteId, ancestorNoteId)) {
return true;
}
}
return false;
}
function getNoteTitleFromPath(notePath) {
const pathArr = notePath.split("/");
if (pathArr.length === 1) {
return getNoteTitle(pathArr[0], 'root');
}
else {
return getNoteTitle(pathArr[pathArr.length - 1], pathArr[pathArr.length - 2]);
}
}
function getNoteTitle(childNoteId, parentNoteId) {
const childNote = notes[childNoteId];
const parentNote = notes[parentNoteId];
let title;
if (childNote.isProtected) {
title = protectedSessionService.isProtectedSessionAvailable() ? childNote.title : '[protected]';
}
else {
title = childNote.title;
}
const branch = parentNote ? getBranch(childNote.noteId, parentNote.noteId) : null;
return ((branch && branch.prefix) ? `${branch.prefix} - ` : '') + title;
}
function getNoteTitleArrayForPath(path) {
const titles = [];
if (path[0] === hoistedNoteService.getHoistedNoteId() && path.length === 1) {
return [ getNoteTitle(hoistedNoteService.getHoistedNoteId()) ];
}
let parentNoteId = 'root';
let hoistedNotePassed = false;
for (const noteId of path) {
// start collecting path segment titles only after hoisted note
if (hoistedNotePassed) {
const title = getNoteTitle(noteId, parentNoteId);
titles.push(title);
}
if (noteId === hoistedNoteService.getHoistedNoteId()) {
hoistedNotePassed = true;
}
parentNoteId = noteId;
}
return titles;
}
function getNoteTitleForPath(path) {
const titles = getNoteTitleArrayForPath(path);
return titles.join(' / ');
}
/**
* Returns notePath for noteId from cache. Note hoisting is respected.
* Archived notes are also returned, but non-archived paths are preferred if available
* - this means that archived paths is returned only if there's no non-archived path
* - you can check whether returned path is archived using isArchived()
*/
function getSomePath(note, path = []) {
if (note.noteId === 'root') {
path.push(note.noteId);
path.reverse();
if (!path.includes(hoistedNoteService.getHoistedNoteId())) {
return false;
}
return path;
}
const parents = note.parents;
if (parents.length === 0) {
return false;
}
for (const parentNote of parents) {
const retPath = getSomePath(parentNote, path.concat([note.noteId]));
if (retPath) {
return retPath;
}
}
return false;
}
function getNotePath(noteId) {
const note = notes[noteId];
const retPath = getSomePath(note);
if (retPath) {
const noteTitle = getNoteTitleForPath(retPath);
const parentNote = note.parents[0];
return {
noteId: noteId,
branchId: getBranch(noteId, parentNote.noteId).branchId,
title: noteTitle,
notePath: retPath,
path: retPath.join('/')
};
}
}
function evaluateSimilarity(sourceNote, candidateNote, results) {
let coeff = stringSimilarity.compareTwoStrings(sourceNote.flatText, candidateNote.flatText);
if (coeff > 0.4) {
const notePath = getSomePath(candidateNote);
// this takes care of note hoisting
if (!notePath) {
return;
}
if (isNotePathArchived(notePath)) {
coeff -= 0.2; // archived penalization
}
results.push({coeff, notePath, noteId: candidateNote.noteId});
}
}
/**
* Point of this is to break up long running sync process to avoid blocking
* see https://snyk.io/blog/nodejs-how-even-quick-async-functions-can-block-the-event-loop-starve-io/
*/
function setImmediatePromise() {
return new Promise((resolve) => {
setTimeout(() => resolve(), 0);
});
}
async function findSimilarNotes(noteId) {
const results = [];
let i = 0;
const origNote = notes[noteId];
if (!origNote) {
return [];
}
for (const note of Object.values(notes)) {
if (note.isProtected && !note.isDecrypted) {
continue;
}
evaluateSimilarity(origNote, note, results);
i++;
if (i % 200 === 0) {
await setImmediatePromise();
}
}
results.sort((a, b) => a.coeff > b.coeff ? -1 : 1);
return results.length > 50 ? results.slice(0, 50) : results;
}
eventService.subscribe([eventService.ENTITY_CHANGED, eventService.ENTITY_DELETED, eventService.ENTITY_SYNCED], async ({entityName, entity}) => {
// note that entity can also be just POJO without methods if coming from sync
if (!loaded) {
return;
}
if (entityName === 'notes') {
const {noteId} = entity;
if (entity.isDeleted) {
delete notes[noteId];
}
else if (noteId in notes) {
const note = notes[noteId];
// we can assume we have protected session since we managed to update
note.title = entity.title;
note.isProtected = entity.isProtected;
note.isDecrypted = !entity.isProtected || !!entity.isContentAvailable;
note.flatTextCache = null;
decryptProtectedNote(note);
}
else {
const note = new Note(entity);
notes[noteId] = note;
decryptProtectedNote(note);
}
}
else if (entityName === 'branches') {
const {branchId, noteId, parentNoteId} = entity;
const childNote = notes[noteId];
if (entity.isDeleted) {
if (childNote) {
childNote.parents = childNote.parents.filter(parent => parent.noteId !== parentNoteId);
childNote.parentBranches = childNote.parentBranches.filter(branch => branch.branchId !== branchId);
if (childNote.parents.length > 0) {
childNote.invalidateSubtreeCaches();
}
}
const parentNote = notes[parentNoteId];
if (parentNote) {
parentNote.children = parentNote.children.filter(child => child.noteId !== noteId);
}
delete childParentToBranch[`${noteId}-${parentNoteId}`];
delete branches[branchId];
}
else if (branchId in branches) {
// only relevant thing which can change in a branch is prefix
branches[branchId].prefix = entity.prefix;
if (childNote) {
childNote.flatTextCache = null;
}
}
else {
branches[branchId] = new Branch(entity);
if (childNote) {
childNote.resortParents();
}
}
}
else if (entityName === 'attributes') {
const {attributeId, noteId} = entity;
const note = notes[noteId];
const attr = attributes[attributeId];
if (entity.isDeleted) {
if (note && attr) {
// first invalidate and only then remove the attribute (otherwise invalidation wouldn't be complete)
if (attr.isAffectingSubtree || note.isTemplate) {
note.invalidateSubtreeCaches();
}
note.ownedAttributes = note.ownedAttributes.filter(attr => attr.attributeId !== attributeId);
const targetNote = attr.targetNote;
if (targetNote) {
targetNote.targetRelations = targetNote.targetRelations.filter(rel => rel.attributeId !== attributeId);
}
}
delete attributes[attributeId];
delete attributeIndex[`${attr.type}-${attr.name}`];
}
else if (attributeId in attributes) {
const attr = attributes[attributeId];
// attr name and isInheritable are immutable
attr.value = entity.value;
if (attr.isAffectingSubtree || note.isTemplate) {
note.invalidateSubtreeFlatText();
}
else {
note.flatTextCache = null;
}
}
else {
const attr = new Attribute(entity);
attributes[attributeId] = attr;
if (note) {
if (attr.isAffectingSubtree || note.isTemplate) {
note.invalidateSubtreeCaches();
}
else {
this.invalidateThisCache();
}
}
}
}
});
/**
* @param noteId
* @returns {boolean} - true if note exists (is not deleted) and is available in current note hoisting
*/
function isAvailable(noteId) {
const notePath = getNotePath(noteId);
return !!notePath;
}
eventService.subscribe(eventService.ENTER_PROTECTED_SESSION, () => {
loadedPromise.then(() => decryptProtectedNotes());
});
sqlInit.dbReady.then(() => utils.stopWatch("Note cache load", load));
module.exports = {
loadedPromise,
findNotesWithFulltext,
getNotePath,
getNoteTitleForPath,
getNoteTitleFromPath,
isAvailable,
isArchived,
isInAncestor,
load,
findSimilarNotes
};