Notes/src/services/note_cache.js

778 lines
22 KiB
JavaScript
Raw Normal View History

2018-04-18 00:26:42 -04:00
const sql = require('./sql');
const sqlInit = require('./sql_init');
const eventService = require('./events');
const repository = require('./repository');
const protectedSessionService = require('./protected_session');
const utils = require('./utils');
const hoistedNoteService = require('./hoisted_note');
const stringSimilarity = require('string-similarity');
2018-04-18 00:26:42 -04:00
2020-05-09 23:42:26 +02:00
/** @type {Object.<String, Note>} */
2020-05-03 09:18:57 +02:00
let notes;
2020-05-09 23:42:26 +02:00
/** @type {Object.<String, Branch>} */
2020-05-03 09:18:57 +02:00
let branches
2020-05-09 23:42:26 +02:00
/** @type {Object.<String, Attribute>} */
2020-05-03 09:18:57 +02:00
let attributes;
2020-05-09 23:42:26 +02:00
/** @type {Object.<String, Attribute[]>} */
2020-05-03 09:18:57 +02:00
let noteAttributeCache = {};
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 {Note[]} */
this.parents = [];
/** @param {Attribute[]} */
this.ownedAttributes = [];
}
/** @return {Attribute[]} */
get attributes() {
2020-05-10 23:27:53 +02:00
if (!(this.noteId in noteAttributeCache)) {
2020-05-03 09:18:57 +02:00
const attrArrs = [
this.ownedAttributes
];
for (const templateAttr of this.ownedAttributes.filter(oa => oa.type === 'relation' && oa.name === 'template')) {
const templateNote = notes[templateAttr.value];
if (templateNote) {
attrArrs.push(templateNote.attributes);
}
}
if (this.noteId !== 'root') {
for (const parentNote of this.parents) {
attrArrs.push(parentNote.inheritableAttributes);
}
}
noteAttributeCache[this.noteId] = attrArrs.flat();
}
return noteAttributeCache[this.noteId];
}
/** @return {Attribute[]} */
get inheritableAttributes() {
return this.attributes.filter(attr => attr.isInheritable);
}
hasAttribute(type, name) {
return this.attributes.find(attr => attr.type === type && attr.name === name);
}
get isArchived() {
return this.hasAttribute('label', 'archived');
}
}
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;
}
/** @return {Note} */
get parentNote() {
2020-05-10 23:27:53 +02:00
const note = notes[this.parentNoteId];
if (!note) {
console.log(`Cannot find note ${this.parentNoteId}`);
}
return note;
2020-05-03 09:18:57 +02:00
}
}
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;
}
}
2020-05-09 23:42:26 +02:00
class FulltextReference {
/**
* @param type - attributeName, attributeValue, title
2020-05-10 23:27:53 +02:00
* @param noteId
2020-05-09 23:42:26 +02:00
*/
2020-05-10 23:27:53 +02:00
constructor(type, noteId) {
2020-05-09 23:42:26 +02:00
this.type = type;
2020-05-10 23:27:53 +02:00
this.noteId = noteId;
2020-05-09 23:42:26 +02:00
}
}
/** @type {Object.<String, FulltextReference>} */
let fulltext = {};
/** @type {Object.<String, AttributeMeta>} */
let attributeMetas = {};
class AttributeMeta {
constructor(attribute) {
this.type = attribute.type;
this.name = attribute.name;
this.isInheritable = attribute.isInheritable;
this.attributeIds = new Set(attribute.attributeId);
}
addAttribute(attribute) {
this.attributeIds.add(attribute.attributeId);
this.isInheritable = this.isInheritable || attribute.isInheritable;
}
updateAttribute(attribute) {
if (attribute.isDeleted) {
this.attributeIds.delete(attribute.attributeId);
}
else {
this.attributeIds.add(attribute.attributeId);
}
this.isInheritable = !!this.attributeIds.find(attributeId => attributes[attributeId].isInheritable);
}
}
function addToAttributeMeta(attribute) {
const key = `${attribute.type}-${attribute.name}`;
if (!(key in attributeMetas)) {
attributeMetas[key] = new AttributeMeta(attribute);
}
else {
attributeMetas[key].addAttribute(attribute);
}
}
let loaded = false;
2019-10-25 21:57:08 +02:00
let loadedPromiseResolve;
/** Is resolved after the initial load */
let loadedPromise = new Promise(res => loadedPromiseResolve = res);
2019-10-25 21:57:08 +02:00
let noteTitles = {};
let protectedNoteTitles = {};
2018-04-18 00:26:42 -04:00
let noteIds;
const childToParent = {};
let archived = {};
2018-04-18 00:26:42 -04:00
2018-04-19 20:59:44 -04:00
// key is 'childNoteId-parentNoteId' as a replacement for branchId which we don't use here
let prefixes = {};
2020-05-03 09:18:57 +02:00
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;
}
2018-04-18 00:26:42 -04:00
async function load() {
2020-05-03 09:18:57 +02:00
notes = await getMappedRows(`SELECT noteId, title, isProtected FROM notes WHERE isDeleted = 0`,
row => new Note(row));
2018-04-18 00:26:42 -04:00
2020-05-10 23:27:53 +02:00
for (const note of Object.values(notes)) {
const title = note.title.toLowerCase();
fulltext[title] = fulltext[title] || [];
fulltext[title].push(new FulltextReference('note', note.noteId));
2020-05-09 23:42:26 +02:00
}
2020-05-03 09:18:57 +02:00
branches = await getMappedRows(`SELECT branchId, noteId, parentNoteId, prefix FROM branches WHERE isDeleted = 0`,
row => new Branch(row));
2018-04-19 20:59:44 -04:00
2020-05-03 09:18:57 +02:00
attributes = await getMappedRows(`SELECT attributeId, noteId, type, name, value, isInheritable FROM attributes WHERE isDeleted = 0`,
row => new Attribute(row));
2018-04-18 00:26:42 -04:00
2020-05-10 23:27:53 +02:00
for (const attr of Object.values(attributes)) {
notes[attr.noteId].attributes.push(attr);
2020-05-09 23:42:26 +02:00
addToAttributeMeta(attributes);
2020-05-10 23:27:53 +02:00
const attrName = attr.name.toLowerCase();
fulltext[attrName] = fulltext[attrName] || [];
fulltext[attrName].push(new FulltextReference('aName', attr.noteId));
2020-05-09 23:42:26 +02:00
2020-05-10 23:27:53 +02:00
const attrValue = attr.value.toLowerCase();
fulltext[attrValue] = fulltext[attrValue] || [];
fulltext[attrValue].push(new FulltextReference('aVal', attr.noteId));
2020-05-09 23:42:26 +02:00
}
2020-05-10 23:27:53 +02:00
for (const branch of Object.values(branches)) {
if (branch.branchId === 'root') {
continue;
}
2020-05-03 09:18:57 +02:00
const childNote = notes[branch.noteId];
2020-05-03 09:18:57 +02:00
if (!childNote) {
console.log(`Cannot find child note ${branch.noteId} of a branch ${branch.branchId}`);
continue;
}
2020-05-03 09:18:57 +02:00
childNote.parents.push(branch.parentNote);
childParentToBranch[`${branch.noteId}-${branch.parentNoteId}`] = branch;
}
2020-05-03 09:18:57 +02:00
if (protectedSessionService.isProtectedSessionAvailable()) {
await decryptProtectedNotes();
}
loaded = true;
2019-10-25 21:57:08 +02:00
loadedPromiseResolve();
2018-04-18 00:26:42 -04:00
}
2020-05-03 09:18:57 +02:00
async function decryptProtectedNotes() {
for (const note of notes) {
if (note.isProtected) {
note.title = protectedSessionService.decryptString(note.title);
}
}
}
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);
2018-11-07 17:16:33 +01:00
for (const result of results) {
2020-05-10 23:27:53 +02:00
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>@${attr.name}=${attr.value}</small>`;
}
}
result.highlightedTitle = result.pathTitle;
2018-11-07 17:16:33 +01:00
}
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>");
}
}
async function findNotes(query) {
if (!noteTitles || !query.length) {
2018-04-18 00:26:42 -04:00
return [];
}
const allTokens = query
.trim() // necessary because even with .split() trailing spaces are tokens which causes havoc
.toLowerCase()
.split(/[ -]/)
.filter(token => token !== '/'); // '/' is used as separator
const tokens = allTokens.slice();
2020-05-10 23:27:53 +02:00
const matchedNoteIds = new Set();
for (const token of tokens) {
for (const chunk in fulltext) {
if (chunk.includes(token)) {
for (const fulltextReference of fulltext[chunk]) {
matchedNoteIds.add(fulltextReference.noteId);
}
}
}
}
// now we have set of noteIds which match at least one token
let results = [];
2018-04-18 00:26:42 -04:00
2020-05-10 23:27:53 +02:00
for (const noteId of matchedNoteIds) {
2020-05-03 09:18:57 +02:00
const note = notes[noteId];
// autocomplete should be able to find notes by their noteIds as well (only leafs)
if (noteId === query) {
2020-05-10 23:27:53 +02:00
search(note, [], [], results);
continue;
}
// for leaf note it doesn't matter if "archived" label is inheritable or not
2020-05-03 09:18:57 +02:00
if (note.isArchived) {
2018-04-19 20:59:44 -04:00
continue;
}
2018-04-18 00:26:42 -04:00
2020-05-10 23:27:53 +02:00
const foundAttrTokens = [];
for (const attribute of note.attributes) {
for (const token of tokens) {
if (attribute.name.includes(token) || attribute.value.includes(token)) {
foundAttrTokens.push(token);
}
}
}
2020-05-03 09:18:57 +02:00
for (const parentNote of note.parents) {
2020-05-10 23:27:53 +02:00
const title = getNoteTitle(note.noteId, parentNote.noteId).toLowerCase();
const foundTokens = foundAttrTokens.slice();
2018-04-19 20:59:44 -04:00
for (const token of tokens) {
if (title.includes(token)) {
foundTokens.push(token);
}
2018-04-18 00:26:42 -04:00
}
2018-04-19 20:59:44 -04:00
if (foundTokens.length > 0) {
const remainingTokens = tokens.filter(token => !foundTokens.includes(token));
2018-04-18 00:26:42 -04:00
2020-05-03 09:18:57 +02:00
search(parentNote, remainingTokens, [noteId], results);
2018-04-19 20:59:44 -04:00
}
2018-04-18 00:26:42 -04:00
}
}
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, allTokens);
return apiResults;
2018-04-18 00:26:42 -04:00
}
2020-05-03 09:18:57 +02:00
function getBranch(childNoteId, parentNoteId) {
return childParentToBranch[`${childNoteId}-${parentNoteId}`];
}
function search(note, tokens, path, results) {
if (tokens.length === 0) {
2020-05-03 09:18:57 +02:00
const retPath = getSomePath(note, path);
2018-04-18 00:26:42 -04:00
2020-05-03 09:18:57 +02:00
if (retPath) {
2018-06-04 23:21:45 -04:00
const thisNoteId = retPath[retPath.length - 1];
const thisParentNoteId = retPath[retPath.length - 2];
2018-04-18 00:26:42 -04:00
results.push({
2018-06-04 23:21:45 -04:00
noteId: thisNoteId,
2020-05-03 09:18:57 +02:00
branchId: getBranch(thisNoteId, thisParentNoteId),
pathArray: retPath,
titleArray: getNoteTitleArrayForPath(retPath)
});
}
return;
}
2020-05-10 23:27:53 +02:00
if (!note.parents.length === 0 || note.noteId === 'root') {
2018-04-19 20:59:44 -04:00
return;
}
2020-05-03 09:18:57 +02:00
for (const parentNote of note.parents) {
2020-05-10 23:27:53 +02:00
const title = getNoteTitle(note.noteId, parentNote.noteId).toLowerCase();
2018-04-18 00:26:42 -04:00
const foundTokens = [];
for (const token of tokens) {
if (title.includes(token)) {
foundTokens.push(token);
}
}
if (foundTokens.length > 0) {
const remainingTokens = tokens.filter(token => !foundTokens.includes(token));
2020-05-10 23:27:53 +02:00
search(parentNote, remainingTokens, path.concat([note.noteId]), results);
2018-04-18 00:26:42 -04:00
}
else {
2020-05-10 23:27:53 +02:00
search(parentNote, tokens, path.concat([note.noteId]), results);
2018-04-18 00:26:42 -04:00
}
}
}
function isNotePathArchived(notePath) {
2020-05-03 09:18:57 +02:00
const noteId = notePath[notePath.length - 1];
if (archived[noteId] !== undefined) {
return true;
}
for (let i = 0; i < notePath.length - 1; i++) {
// this is going through parents so archived must be inheritable
if (archived[notePath[i]] === 1) {
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;
}
2020-05-03 09:18:57 +02:00
const note = notes[noteId];
2020-05-10 23:27:53 +02:00
for (const parentNote of note.parents) {
2020-05-03 09:18:57 +02:00
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]);
}
}
2020-05-10 23:27:53 +02:00
function getNoteTitle(childNoteId, parentNoteId) {
const childNote = notes[childNoteId];
const parentNote = notes[parentNoteId];
2020-05-03 09:18:57 +02:00
let title;
2020-05-03 09:18:57 +02:00
if (childNote.isProtected) {
title = protectedSessionService.isProtectedSessionAvailable() ? childNote.title : '[protected]';
}
2020-05-03 09:18:57 +02:00
else {
title = childNote.title;
}
2020-05-10 23:27:53 +02:00
const branch = parentNote ? getBranch(childNote.noteId, parentNote.noteId) : null;
2020-05-10 23:27:53 +02:00
return ((branch && branch.prefix) ? (branch.prefix + ' - ') : '') + title;
}
function getNoteTitleArrayForPath(path) {
2018-04-19 20:59:44 -04:00
const titles = [];
2018-04-18 00:26:42 -04:00
2018-12-13 21:43:13 +01:00
if (path[0] === hoistedNoteService.getHoistedNoteId() && path.length === 1) {
return [ getNoteTitle(hoistedNoteService.getHoistedNoteId()) ];
2018-06-06 22:38:36 -04:00
}
2018-04-19 20:59:44 -04:00
let parentNoteId = 'root';
let hoistedNotePassed = false;
2018-04-18 00:26:42 -04:00
2018-04-19 20:59:44 -04:00
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;
}
2018-04-19 20:59:44 -04:00
parentNoteId = noteId;
}
return titles;
}
function getNoteTitleForPath(path) {
const titles = getNoteTitleArrayForPath(path);
2018-04-19 20:59:44 -04:00
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()
*/
2020-05-10 23:27:53 +02:00
function getSomePath(note, path = []) {
if (note.noteId === 'root') {
path.push(note.noteId);
2018-12-13 21:43:13 +01:00
path.reverse();
if (!path.includes(hoistedNoteService.getHoistedNoteId())) {
return false;
}
2018-04-19 20:59:44 -04:00
return path;
}
2020-05-10 23:27:53 +02:00
const parents = note.parents;
if (parents.length === 0) {
2018-04-19 20:59:44 -04:00
return false;
}
2020-05-10 23:27:53 +02:00
for (const parentNote of parents) {
const retPath = getSomePath(parentNote, path.concat([note.noteId]));
if (retPath) {
return retPath;
}
}
return false;
}
function getNotePath(noteId) {
const retPath = getSomePath(noteId);
if (retPath) {
const noteTitle = getNoteTitleForPath(retPath);
2018-06-05 22:47:47 -04:00
const parentNoteId = childToParent[noteId][0];
return {
noteId: noteId,
2018-06-05 22:47:47 -04:00
branchId: childParentToBranchId[`${noteId}-${parentNoteId}`],
title: noteTitle,
2019-09-07 10:11:59 +02:00
notePath: retPath,
path: retPath.join('/')
};
}
}
function evaluateSimilarity(text1, text2, noteId, results) {
let coeff = stringSimilarity.compareTwoStrings(text1, text2);
if (coeff > 0.4) {
const notePath = getSomePath(noteId);
// this takes care of note hoisting
if (!notePath) {
return;
}
if (isNotePathArchived(notePath)) {
coeff -= 0.2; // archived penalization
}
2019-09-02 21:36:24 +02:00
results.push({coeff, notePath, 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 evaluateSimilarityDict(title, dict, results) {
let i = 0;
for (const noteId in dict) {
evaluateSimilarity(title, dict[noteId], noteId, results);
i++;
if (i % 200 === 0) {
await setImmediatePromise();
}
}
}
async function findSimilarNotes(title) {
const results = [];
await evaluateSimilarityDict(title, noteTitles, results);
if (protectedSessionService.isProtectedSessionAvailable()) {
await evaluateSimilarityDict(title, protectedNoteTitles, results);
}
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}) => {
2019-01-03 23:27:10 +01:00
// note that entity can also be just POJO without methods if coming from sync
if (!loaded) {
return;
}
if (entityName === 'notes') {
const note = entity;
if (note.isDeleted) {
delete noteTitles[note.noteId];
delete childToParent[note.noteId];
}
else {
if (note.isProtected) {
// we can assume we have protected session since we managed to update
// removing from the maps is important when switching between protected & unprotected
protectedNoteTitles[note.noteId] = note.title;
delete noteTitles[note.noteId];
}
else {
noteTitles[note.noteId] = note.title;
delete protectedNoteTitles[note.noteId];
}
}
}
2018-04-19 20:59:44 -04:00
else if (entityName === 'branches') {
const branch = entity;
2018-04-19 20:59:44 -04:00
if (branch.isDeleted) {
if (branch.noteId in childToParent) {
childToParent[branch.noteId] = childToParent[branch.noteId].filter(noteId => noteId !== branch.parentNoteId);
}
2018-04-19 20:59:44 -04:00
delete prefixes[branch.noteId + '-' + branch.parentNoteId];
delete childParentToBranchId[branch.noteId + '-' + branch.parentNoteId];
}
else {
2018-04-19 20:59:44 -04:00
if (branch.prefix) {
prefixes[branch.noteId + '-' + branch.parentNoteId] = branch.prefix;
}
childToParent[branch.noteId] = childToParent[branch.noteId] || [];
if (!childToParent[branch.noteId].includes(branch.parentNoteId)) {
childToParent[branch.noteId].push(branch.parentNoteId);
}
resortChildToParent(branch.noteId);
2018-06-04 23:21:45 -04:00
childParentToBranchId[branch.noteId + '-' + branch.parentNoteId] = branch.branchId;
2018-04-19 20:59:44 -04:00
}
}
else if (entityName === 'attributes') {
const attribute = entity;
if (attribute.type === 'label' && attribute.name === 'archived') {
// we're not using label object directly, since there might be other non-deleted archived label
2018-11-26 22:22:16 +01:00
const archivedLabel = await repository.getEntity(`SELECT * FROM attributes WHERE isDeleted = 0 AND type = 'label'
AND name = 'archived' AND noteId = ?`, [attribute.noteId]);
2018-11-26 22:22:16 +01:00
if (archivedLabel) {
archived[attribute.noteId] = archivedLabel.isInheritable ? 1 : 0;
}
else {
delete archived[attribute.noteId];
}
}
}
});
// will sort the childs 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
function resortChildToParent(noteId) {
if (!(noteId in childToParent)) {
return;
}
childToParent[noteId].sort((a, b) => archived[a] === 1 ? 1 : -1);
}
2019-04-22 18:08:33 +02:00
/**
* @param noteId
* @returns {boolean} - true if note exists (is not deleted) and is available in current note hoisting
2019-04-22 18:08:33 +02:00
*/
function isAvailable(noteId) {
const notePath = getNotePath(noteId);
return !!notePath;
}
eventService.subscribe(eventService.ENTER_PROTECTED_SESSION, () => {
2020-05-03 09:18:57 +02:00
loadedPromise.then(() => decryptProtectedNotes());
});
2019-10-25 21:57:08 +02:00
sqlInit.dbReady.then(() => utils.stopWatch("Note cache load", load));
2018-04-18 00:26:42 -04:00
module.exports = {
2019-10-25 21:57:08 +02:00
loadedPromise,
findNotes,
2018-06-06 22:38:36 -04:00
getNotePath,
getNoteTitleForPath,
getNoteTitleFromPath,
2019-04-22 18:08:33 +02:00
isAvailable,
isArchived,
isInAncestor,
load,
findSimilarNotes
2020-05-09 23:42:26 +02:00
};