mirror of
https://github.com/zadam/trilium.git
synced 2025-03-01 14:22:32 +01:00
note cache breakup into classes, WIP
This commit is contained in:
parent
e3071e630a
commit
dcd371b5b1
@ -1,6 +1,6 @@
|
||||
"use strict";
|
||||
|
||||
const noteCacheService = require('../../services/note_cache');
|
||||
const noteCacheService = require('../../services/note_cache/note_cache.js');
|
||||
const repository = require('../../services/repository');
|
||||
const log = require('../../services/log');
|
||||
const utils = require('../../services/utils');
|
||||
|
@ -8,7 +8,7 @@ const zipImportService = require('../../services/import/zip');
|
||||
const singleImportService = require('../../services/import/single');
|
||||
const cls = require('../../services/cls');
|
||||
const path = require('path');
|
||||
const noteCacheService = require('../../services/note_cache');
|
||||
const noteCacheService = require('../../services/note_cache/note_cache.js');
|
||||
const log = require('../../services/log');
|
||||
const TaskContext = require('../../services/task_context.js');
|
||||
|
||||
@ -85,4 +85,4 @@ async function importToBranch(req) {
|
||||
|
||||
module.exports = {
|
||||
importToBranch
|
||||
};
|
||||
};
|
||||
|
@ -1,7 +1,7 @@
|
||||
"use strict";
|
||||
|
||||
const repository = require('../../services/repository');
|
||||
const noteCacheService = require('../../services/note_cache');
|
||||
const noteCacheService = require('../../services/note_cache/note_cache.js');
|
||||
const protectedSessionService = require('../../services/protected_session');
|
||||
const noteRevisionService = require('../../services/note_revisions');
|
||||
const utils = require('../../services/utils');
|
||||
|
@ -3,7 +3,7 @@
|
||||
const sql = require('../../services/sql');
|
||||
const protectedSessionService = require('../../services/protected_session');
|
||||
const noteService = require('../../services/notes');
|
||||
const noteCacheService = require('../../services/note_cache');
|
||||
const noteCacheService = require('../../services/note_cache/note_cache.js');
|
||||
|
||||
async function getRecentChanges(req) {
|
||||
const {ancestorNoteId} = req.params;
|
||||
@ -102,4 +102,4 @@ async function getRecentChanges(req) {
|
||||
|
||||
module.exports = {
|
||||
getRecentChanges
|
||||
};
|
||||
};
|
||||
|
@ -1,7 +1,7 @@
|
||||
"use strict";
|
||||
|
||||
const repository = require('../../services/repository');
|
||||
const noteCacheService = require('../../services/note_cache');
|
||||
const noteCacheService = require('../../services/note_cache/note_cache.js');
|
||||
const log = require('../../services/log');
|
||||
const scriptService = require('../../services/script');
|
||||
const searchService = require('../../services/search');
|
||||
@ -110,4 +110,4 @@ async function searchFromRelation(note, relationName) {
|
||||
module.exports = {
|
||||
searchNotes,
|
||||
searchFromNote
|
||||
};
|
||||
};
|
||||
|
@ -1,6 +1,6 @@
|
||||
"use strict";
|
||||
|
||||
const noteCacheService = require('../../services/note_cache');
|
||||
const noteCacheService = require('../../services/note_cache/note_cache.js');
|
||||
const repository = require('../../services/repository');
|
||||
|
||||
async function getSimilarNotes(req) {
|
||||
|
File diff suppressed because it is too large
Load Diff
43
src/services/note_cache/entities/attribute.js
Normal file
43
src/services/note_cache/entities/attribute.js
Normal file
@ -0,0 +1,43 @@
|
||||
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];
|
||||
}
|
||||
}
|
||||
}
|
42
src/services/note_cache/entities/branch.js
Normal file
42
src/services/note_cache/entities/branch.js
Normal file
@ -0,0 +1,42 @@
|
||||
export default 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;
|
||||
}
|
||||
}
|
236
src/services/note_cache/entities/note.js
Normal file
236
src/services/note_cache/entities/note.js
Normal file
@ -0,0 +1,236 @@
|
||||
export default 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 = '';
|
||||
|
||||
for (const branch of this.parentBranches) {
|
||||
if (branch.prefix) {
|
||||
this.flatTextCache += branch.prefix + ' - ';
|
||||
}
|
||||
}
|
||||
|
||||
this.flatTextCache += this.title;
|
||||
|
||||
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.type === 'label' ? '#' : '@') + attr.name;
|
||||
|
||||
if (attr.value) {
|
||||
this.flatTextCache += '=' + attr.value;
|
||||
}
|
||||
}
|
||||
|
||||
this.flatTextCache = this.flatTextCache.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 subtreeNotesIncludingTemplated() {
|
||||
const arr = [[this]];
|
||||
|
||||
for (const childNote of this.children) {
|
||||
arr.push(childNote.subtreeNotesIncludingTemplated);
|
||||
}
|
||||
|
||||
for (const targetRelation of this.targetRelations) {
|
||||
if (targetRelation.name === 'template') {
|
||||
const note = targetRelation.note;
|
||||
|
||||
if (note) {
|
||||
arr.push(note.subtreeNotesIncludingTemplated);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return arr.flat();
|
||||
}
|
||||
|
||||
/** @return {Note[]} */
|
||||
get subtreeNotes() {
|
||||
const arr = [[this]];
|
||||
|
||||
for (const childNote of this.children) {
|
||||
arr.push(childNote.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;
|
||||
}
|
||||
}
|
13
src/services/note_cache/expressions/and.js
Normal file
13
src/services/note_cache/expressions/and.js
Normal file
@ -0,0 +1,13 @@
|
||||
export default class AndExp {
|
||||
constructor(subExpressions) {
|
||||
this.subExpressions = subExpressions;
|
||||
}
|
||||
|
||||
execute(noteSet, searchContext) {
|
||||
for (const subExpression of this.subExpressions) {
|
||||
noteSet = subExpression.execute(noteSet, searchContext);
|
||||
}
|
||||
|
||||
return noteSet;
|
||||
}
|
||||
}
|
28
src/services/note_cache/expressions/equals.js
Normal file
28
src/services/note_cache/expressions/equals.js
Normal file
@ -0,0 +1,28 @@
|
||||
export default class EqualsExp {
|
||||
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.subtreeNotesIncludingTemplated);
|
||||
}
|
||||
else if (note.isTemplate) {
|
||||
resultNoteSet.addAll(note.templatedNotes);
|
||||
}
|
||||
else {
|
||||
resultNoteSet.add(note);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
27
src/services/note_cache/expressions/exists.js
Normal file
27
src/services/note_cache/expressions/exists.js
Normal file
@ -0,0 +1,27 @@
|
||||
export default class ExistsExp {
|
||||
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.subtreeNotesIncludingTemplated);
|
||||
}
|
||||
else if (note.isTemplate) {
|
||||
resultNoteSet.addAll(note.templatedNotes);
|
||||
}
|
||||
else {
|
||||
resultNoteSet.add(note);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
125
src/services/note_cache/expressions/note_cache_fulltext.js
Normal file
125
src/services/note_cache/expressions/note_cache_fulltext.js
Normal file
@ -0,0 +1,125 @@
|
||||
export default class NoteCacheFulltextExp {
|
||||
constructor(tokens) {
|
||||
this.tokens = tokens;
|
||||
}
|
||||
|
||||
execute(noteSet, searchContext) {
|
||||
const resultNoteSet = new NoteSet();
|
||||
|
||||
const candidateNotes = this.getCandidateNotes(noteSet);
|
||||
|
||||
for (const note of candidateNotes) {
|
||||
// autocomplete should be able to find notes by their noteIds as well (only leafs)
|
||||
if (this.tokens.length === 1 && note.noteId === this.tokens[0]) {
|
||||
this.searchDownThePath(note, [], [], resultNoteSet, searchContext);
|
||||
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 this.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 this.tokens) {
|
||||
if (title.includes(token)) {
|
||||
foundTokens.push(token);
|
||||
}
|
||||
}
|
||||
|
||||
if (foundTokens.length > 0) {
|
||||
const remainingTokens = this.tokens.filter(token => !foundTokens.includes(token));
|
||||
|
||||
this.searchDownThePath(parentNote, remainingTokens, [note.noteId], resultNoteSet, searchContext);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return resultNoteSet;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns noteIds which have at least one matching tokens
|
||||
*
|
||||
* @param {NoteSet} noteSet
|
||||
* @return {String[]}
|
||||
*/
|
||||
getCandidateNotes(noteSet) {
|
||||
const candidateNotes = [];
|
||||
|
||||
for (const note of noteSet.notes) {
|
||||
for (const token of this.tokens) {
|
||||
if (note.flatText.includes(token)) {
|
||||
candidateNotes.push(note);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return candidateNotes;
|
||||
}
|
||||
|
||||
searchDownThePath(note, tokens, path, resultNoteSet, searchContext) {
|
||||
if (tokens.length === 0) {
|
||||
const retPath = getSomePath(note, path);
|
||||
|
||||
if (retPath) {
|
||||
const noteId = retPath[retPath.length - 1];
|
||||
searchContext.noteIdToNotePath[noteId] = retPath;
|
||||
|
||||
resultNoteSet.add(notes[noteId]);
|
||||
}
|
||||
|
||||
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));
|
||||
|
||||
this.searchDownThePath(parentNote, remainingTokens, path.concat([note.noteId]), resultNoteSet, searchContext);
|
||||
}
|
||||
else {
|
||||
this.searchDownThePath(parentNote, tokens, path.concat([note.noteId]), resultNoteSet, searchContext);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
26
src/services/note_cache/expressions/note_content_fulltext.js
Normal file
26
src/services/note_cache/expressions/note_content_fulltext.js
Normal file
@ -0,0 +1,26 @@
|
||||
export default class NoteContentFulltextExp {
|
||||
constructor(tokens) {
|
||||
this.tokens = tokens;
|
||||
}
|
||||
|
||||
async execute(noteSet) {
|
||||
const resultNoteSet = new NoteSet();
|
||||
const wheres = this.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) {
|
||||
if (noteSet.hasNoteId(noteId) && noteId in notes) {
|
||||
resultNoteSet.add(notes[noteId]);
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
}
|
15
src/services/note_cache/expressions/or.js
Normal file
15
src/services/note_cache/expressions/or.js
Normal file
@ -0,0 +1,15 @@
|
||||
export default class OrExp {
|
||||
constructor(subExpressions) {
|
||||
this.subExpressions = subExpressions;
|
||||
}
|
||||
|
||||
execute(noteSet, searchContext) {
|
||||
const resultNoteSet = new NoteSet();
|
||||
|
||||
for (const subExpression of this.subExpressions) {
|
||||
resultNoteSet.mergeIn(subExpression.execute(noteSet, searchContext));
|
||||
}
|
||||
|
||||
return resultNoteSet;
|
||||
}
|
||||
}
|
224
src/services/note_cache/note_cache.js
Normal file
224
src/services/note_cache/note_cache.js
Normal file
@ -0,0 +1,224 @@
|
||||
import treeCache from "../../public/app/services/tree_cache.js";
|
||||
|
||||
const sql = require('../sql.js');
|
||||
const sqlInit = require('../sql_init.js');
|
||||
const eventService = require('../events.js');
|
||||
const protectedSessionService = require('../protected_session.js');
|
||||
const utils = require('../utils.js');
|
||||
const hoistedNoteService = require('../hoisted_note.js');
|
||||
const stringSimilarity = require('string-similarity');
|
||||
|
||||
class NoteCache {
|
||||
constructor() {
|
||||
/** @type {Object.<String, Note>} */
|
||||
this.notes = null;
|
||||
/** @type {Object.<String, Branch>} */
|
||||
this.branches = null;
|
||||
/** @type {Object.<String, Branch>} */
|
||||
this.childParentToBranch = {};
|
||||
/** @type {Object.<String, Attribute>} */
|
||||
this.attributes = null;
|
||||
/** @type {Object.<String, Attribute[]>} Points from attribute type-name to list of attributes them */
|
||||
this.attributeIndex = null;
|
||||
|
||||
this.loaded = false;
|
||||
this.loadedPromiseResolve;
|
||||
/** Is resolved after the initial load */
|
||||
this.loadedPromise = new Promise(res => this.loadedPromiseResolve = res);
|
||||
}
|
||||
|
||||
/** @return {Attribute[]} */
|
||||
findAttributes(type, name) {
|
||||
return this.attributeIndex[`${type}-${name}`] || [];
|
||||
}
|
||||
|
||||
async load() {
|
||||
this.notes = await this.getMappedRows(`SELECT noteId, title, isProtected FROM notes WHERE isDeleted = 0`,
|
||||
row => new Note(row));
|
||||
|
||||
this.branches = await this.getMappedRows(`SELECT branchId, noteId, parentNoteId, prefix FROM branches WHERE isDeleted = 0`,
|
||||
row => new Branch(row));
|
||||
|
||||
this.attributeIndex = [];
|
||||
|
||||
this.attributes = await this.getMappedRows(`SELECT attributeId, noteId, type, name, value, isInheritable FROM attributes WHERE isDeleted = 0`,
|
||||
row => new Attribute(row));
|
||||
|
||||
this.loaded = true;
|
||||
this.loadedPromiseResolve();
|
||||
}
|
||||
|
||||
async 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;
|
||||
}
|
||||
|
||||
decryptProtectedNote(note) {
|
||||
if (note.isProtected && !note.isDecrypted && protectedSessionService.isProtectedSessionAvailable()) {
|
||||
note.title = protectedSessionService.decryptString(note.title);
|
||||
|
||||
note.isDecrypted = true;
|
||||
}
|
||||
}
|
||||
|
||||
decryptProtectedNotes() {
|
||||
for (const note of Object.values(this.notes)) {
|
||||
decryptProtectedNote(note);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const noteCache = new NoteCache();
|
||||
|
||||
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 (!noteCache.loaded) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (entityName === 'notes') {
|
||||
const {noteId} = entity;
|
||||
|
||||
if (entity.isDeleted) {
|
||||
delete noteCache.notes[noteId];
|
||||
}
|
||||
else if (noteId in noteCache.notes) {
|
||||
const note = noteCache.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);
|
||||
noteCache.notes[noteId] = note;
|
||||
|
||||
decryptProtectedNote(note);
|
||||
}
|
||||
}
|
||||
else if (entityName === 'branches') {
|
||||
const {branchId, noteId, parentNoteId} = entity;
|
||||
const childNote = noteCache.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 = noteCache.notes[parentNoteId];
|
||||
|
||||
if (parentNote) {
|
||||
parentNote.children = parentNote.children.filter(child => child.noteId !== noteId);
|
||||
}
|
||||
|
||||
delete noteCache.childParentToBranch[`${noteId}-${parentNoteId}`];
|
||||
delete noteCache.branches[branchId];
|
||||
}
|
||||
else if (branchId in noteCache.branches) {
|
||||
// only relevant thing which can change in a branch is prefix
|
||||
noteCache.branches[branchId].prefix = entity.prefix;
|
||||
|
||||
if (childNote) {
|
||||
childNote.flatTextCache = null;
|
||||
}
|
||||
}
|
||||
else {
|
||||
noteCache.branches[branchId] = new Branch(entity);
|
||||
|
||||
if (childNote) {
|
||||
childNote.resortParents();
|
||||
}
|
||||
}
|
||||
}
|
||||
else if (entityName === 'attributes') {
|
||||
const {attributeId, noteId} = entity;
|
||||
const note = noteCache.notes[noteId];
|
||||
const attr = noteCache.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 noteCache.attributes[attributeId];
|
||||
delete noteCache.attributeIndex[`${attr.type}-${attr.name}`];
|
||||
}
|
||||
else if (attributeId in noteCache.attributes) {
|
||||
const attr = noteCache.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);
|
||||
noteCache.attributes[attributeId] = attr;
|
||||
|
||||
if (note) {
|
||||
if (attr.isAffectingSubtree || note.isTemplate) {
|
||||
note.invalidateSubtreeCaches();
|
||||
}
|
||||
else {
|
||||
this.invalidateThisCache();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
function getBranch(childNoteId, parentNoteId) {
|
||||
return noteCache.childParentToBranch[`${childNoteId}-${parentNoteId}`];
|
||||
}
|
||||
|
||||
eventService.subscribe(eventService.ENTER_PROTECTED_SESSION, () => {
|
||||
noteCache.loadedPromise.then(() => noteCache.decryptProtectedNotes());
|
||||
});
|
||||
|
||||
sqlInit.dbReady.then(() => utils.stopWatch("Note cache load", () => treeCache.load()));
|
||||
|
||||
module.exports = {
|
||||
loadedPromise,
|
||||
findNotesForAutocomplete,
|
||||
getNotePath,
|
||||
getNoteTitleForPath,
|
||||
isAvailable,
|
||||
isArchived,
|
||||
isInAncestor,
|
||||
load,
|
||||
findSimilarNotes
|
||||
};
|
233
src/services/note_cache/note_cache_service.js
Normal file
233
src/services/note_cache/note_cache_service.js
Normal file
@ -0,0 +1,233 @@
|
||||
function isNotePathArchived(notePath) {
|
||||
const noteId = notePath[notePath.length - 1];
|
||||
const note = noteCache.notes[noteId];
|
||||
|
||||
if (note.isArchived) {
|
||||
return true;
|
||||
}
|
||||
|
||||
for (let i = 0; i < notePath.length - 1; i++) {
|
||||
const note = noteCache.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 = noteCache.notes[noteId];
|
||||
|
||||
for (const parentNote of note.parents) {
|
||||
if (isInAncestor(parentNote.noteId, ancestorNoteId)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
function getNoteTitle(childNoteId, parentNoteId) {
|
||||
const childNote = noteCache.notes[childNoteId];
|
||||
const parentNote = noteCache.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(notePathArray) {
|
||||
const titles = [];
|
||||
|
||||
if (notePathArray[0] === hoistedNoteService.getHoistedNoteId() && notePathArray.length === 1) {
|
||||
return [ getNoteTitle(hoistedNoteService.getHoistedNoteId()) ];
|
||||
}
|
||||
|
||||
let parentNoteId = 'root';
|
||||
let hoistedNotePassed = false;
|
||||
|
||||
for (const noteId of notePathArray) {
|
||||
// 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(notePathArray) {
|
||||
const titles = getNoteTitleArrayForPath(notePathArray);
|
||||
|
||||
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 = noteCache.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 = noteCache.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;
|
||||
}
|
||||
|
||||
/**
|
||||
* @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;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
getNotePath,
|
||||
getNoteTitleForPath,
|
||||
isAvailable,
|
||||
isArchived,
|
||||
isInAncestor,
|
||||
findSimilarNotes
|
||||
};
|
22
src/services/note_cache/note_set.js
Normal file
22
src/services/note_cache/note_set.js
Normal file
@ -0,0 +1,22 @@
|
||||
export default class NoteSet {
|
||||
constructor(notes = []) {
|
||||
this.notes = notes;
|
||||
}
|
||||
|
||||
add(note) {
|
||||
this.notes.push(note);
|
||||
}
|
||||
|
||||
addAll(notes) {
|
||||
this.notes.push(...notes);
|
||||
}
|
||||
|
||||
hasNoteId(noteId) {
|
||||
// TODO: optimize
|
||||
return !!this.notes.find(note => note.noteId === noteId);
|
||||
}
|
||||
|
||||
mergeIn(anotherNoteSet) {
|
||||
this.notes = this.notes.concat(anotherNoteSet.arr);
|
||||
}
|
||||
}
|
113
src/services/note_cache/search.js
Normal file
113
src/services/note_cache/search.js
Normal file
@ -0,0 +1,113 @@
|
||||
async function findNotesWithExpression(expression) {
|
||||
|
||||
const hoistedNote = notes[hoistedNoteService.getHoistedNoteId()];
|
||||
const allNotes = (hoistedNote && hoistedNote.noteId !== 'root')
|
||||
? hoistedNote.subtreeNotes
|
||||
: Object.values(notes);
|
||||
|
||||
const allNoteSet = new NoteSet(allNotes);
|
||||
|
||||
const searchContext = {
|
||||
noteIdToNotePath: {}
|
||||
};
|
||||
|
||||
const noteSet = await expression.execute(allNoteSet, searchContext);
|
||||
|
||||
let searchResults = noteSet.notes
|
||||
.map(note => searchContext.noteIdToNotePath[note.noteId] || getSomePath(note))
|
||||
.filter(notePathArray => notePathArray.includes(hoistedNoteService.getHoistedNoteId()))
|
||||
.map(notePathArray => new SearchResult(notePathArray));
|
||||
|
||||
// sort results by depth of the note. This is based on the assumption that more important results
|
||||
// are closer to the note root.
|
||||
searchResults.sort((a, b) => {
|
||||
if (a.notePathArray.length === b.notePathArray.length) {
|
||||
return a.notePathTitle < b.notePathTitle ? -1 : 1;
|
||||
}
|
||||
|
||||
return a.notePathArray.length < b.notePathArray.length ? -1 : 1;
|
||||
});
|
||||
|
||||
return searchResults;
|
||||
}
|
||||
|
||||
async function findNotesForAutocomplete(query) {
|
||||
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 expression = new NoteCacheFulltextExp(tokens);
|
||||
|
||||
let searchResults = await findNotesWithExpression(expression);
|
||||
|
||||
searchResults = searchResults.slice(0, 200);
|
||||
|
||||
highlightSearchResults(searchResults, tokens);
|
||||
|
||||
return searchResults.map(result => {
|
||||
return {
|
||||
notePath: result.notePath,
|
||||
notePathTitle: result.notePathTitle,
|
||||
highlightedNotePathTitle: result.highlightedNotePathTitle
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function highlightSearchResults(searchResults, tokens) {
|
||||
// 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)
|
||||
tokens = tokens.map(token => token.replace('/[<\{\}]/g', ''));
|
||||
|
||||
// sort by the longest so we first highlight longest matches
|
||||
tokens.sort((a, b) => a.length > b.length ? -1 : 1);
|
||||
|
||||
for (const result of searchResults) {
|
||||
const note = notes[result.noteId];
|
||||
|
||||
result.highlightedNotePathTitle = result.notePathTitle;
|
||||
|
||||
for (const attr of note.attributes) {
|
||||
if (tokens.find(token => attr.name.includes(token) || attr.value.includes(token))) {
|
||||
result.highlightedNotePathTitle += ` <small>${formatAttribute(attr)}</small>`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const token of tokens) {
|
||||
const tokenRegex = new RegExp("(" + utils.escapeRegExp(token) + ")", "gi");
|
||||
|
||||
for (const result of searchResults) {
|
||||
result.highlightedNotePathTitle = result.highlightedNotePathTitle.replace(tokenRegex, "{$1}");
|
||||
}
|
||||
}
|
||||
|
||||
for (const result of searchResults) {
|
||||
result.highlightedNotePathTitle = result.highlightedNotePathTitle
|
||||
.replace(/{/g, "<b>")
|
||||
.replace(/}/g, "</b>");
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
14
src/services/note_cache/search_result.js
Normal file
14
src/services/note_cache/search_result.js
Normal file
@ -0,0 +1,14 @@
|
||||
export default class SearchResult {
|
||||
constructor(notePathArray) {
|
||||
this.notePathArray = notePathArray;
|
||||
this.notePathTitle = getNoteTitleForPath(notePathArray);
|
||||
}
|
||||
|
||||
get notePath() {
|
||||
return this.notePathArray.join('/');
|
||||
}
|
||||
|
||||
get noteId() {
|
||||
return this.notePathArray[this.notePathArray.length - 1];
|
||||
}
|
||||
}
|
@ -3,7 +3,7 @@ const sql = require('./sql');
|
||||
const log = require('./log');
|
||||
const parseFilters = require('./parse_filters');
|
||||
const buildSearchQuery = require('./build_search_query');
|
||||
const noteCacheService = require('./note_cache');
|
||||
const noteCacheService = require('./note_cache/note_cache.js');
|
||||
|
||||
async function searchForNotes(searchString) {
|
||||
const noteIds = await searchForNoteIds(searchString);
|
||||
@ -71,4 +71,4 @@ async function searchForNoteIds(searchString) {
|
||||
module.exports = {
|
||||
searchForNotes,
|
||||
searchForNoteIds
|
||||
};
|
||||
};
|
||||
|
@ -5,7 +5,7 @@ const repository = require('./repository');
|
||||
const Branch = require('../entities/branch');
|
||||
const syncTableService = require('./sync_table');
|
||||
const protectedSessionService = require('./protected_session');
|
||||
const noteCacheService = require('./note_cache');
|
||||
const noteCacheService = require('./note_cache/note_cache.js');
|
||||
|
||||
async function getNotes(noteIds) {
|
||||
// we return also deleted notes which have been specifically asked for
|
||||
@ -197,4 +197,4 @@ module.exports = {
|
||||
validateParentChild,
|
||||
sortNotesAlphabetically,
|
||||
setNoteToParent
|
||||
};
|
||||
};
|
||||
|
Loading…
x
Reference in New Issue
Block a user