Merge branch 'm43'

This commit is contained in:
zadam
2020-05-27 00:09:51 +02:00
70 changed files with 3852 additions and 1014 deletions

View File

@@ -10,8 +10,11 @@ const FileStore = require('session-file-store')(session);
const os = require('os');
const sessionSecret = require('./services/session_secret');
const cls = require('./services/cls');
const dataDir = require('./services/data_dir');
require('./entities/entity_constructor');
require('./services/handlers');
require('./services/hoisted_note_loader');
require('./services/note_cache/note_cache_loader');
const app = express();
@@ -56,7 +59,7 @@ const sessionParser = session({
},
store: new FileStore({
ttl: 30 * 24 * 3600,
path: os.tmpdir() + '/trilium-sessions'
path: dataDir.TRILIUM_DATA_DIR + '/sessions'
})
});
app.use(sessionParser);
@@ -120,4 +123,4 @@ require('./services/scheduler');
module.exports = {
app,
sessionParser
};
};

View File

@@ -8,13 +8,13 @@ const sql = require('../services/sql');
/**
* Attribute is key value pair owned by a note.
*
* @property {string} attributeId
* @property {string} noteId
* @property {string} type
* @property {string} name
* @property {string} attributeId - immutable
* @property {string} noteId - immutable
* @property {string} type - immutable
* @property {string} name - immutable
* @property {string} value
* @property {int} position
* @property {boolean} isInheritable
* @property {boolean} isInheritable - immutable
* @property {boolean} isDeleted
* @property {string|null} deleteId - ID identifying delete transaction
* @property {string} utcDateCreated
@@ -108,14 +108,14 @@ class Attribute extends Entity {
delete pojo.__note;
}
createClone(type, name, value) {
createClone(type, name, value, isInheritable) {
return new Attribute({
noteId: this.noteId,
type: type,
name: name,
value: value,
position: this.position,
isInheritable: this.isInheritable,
isInheritable: isInheritable,
isDeleted: false,
utcDateCreated: this.utcDateCreated,
utcDateModified: this.utcDateModified

View File

@@ -9,9 +9,9 @@ const sql = require('../services/sql');
* Branch represents note's placement in the tree - it's essentially pair of noteId and parentNoteId.
* Each note can have multiple (at least one) branches, meaning it can be placed into multiple places in the tree.
*
* @property {string} branchId - primary key
* @property {string} noteId
* @property {string} parentNoteId
* @property {string} branchId - primary key, immutable
* @property {string} noteId - immutable
* @property {string} parentNoteId - immutable
* @property {int} notePosition
* @property {string} prefix
* @property {boolean} isExpanded
@@ -77,4 +77,4 @@ class Branch extends Entity {
}
}
module.exports = Branch;
module.exports = Branch;

View File

@@ -75,7 +75,7 @@ function updateTitleFormGroupVisibility() {
}
$form.on('submit', () => {
const notePath = $autoComplete.getSelectedPath();
const notePath = $autoComplete.getSelectedNotePath();
if (notePath) {
$dialog.modal('hide');
@@ -89,4 +89,4 @@ $form.on('submit', () => {
}
return false;
});
});

View File

@@ -269,7 +269,7 @@ function initKoPlugins() {
init: function (element, valueAccessor, allBindings, viewModel, bindingContext) {
noteAutocompleteService.initNoteAutocomplete($(element));
$(element).setSelectedPath(bindingContext.$data.selectedPath);
$(element).setSelectedNotePath(bindingContext.$data.selectedPath);
$(element).on('autocomplete:selected', function (event, suggestion, dataset) {
bindingContext.$data.selectedPath = $(element).val().trim() ? suggestion.path : '';

View File

@@ -52,7 +52,7 @@ async function cloneNotesTo(notePath) {
}
$form.on('submit', () => {
const notePath = $noteAutoComplete.getSelectedPath();
const notePath = $noteAutoComplete.getSelectedNotePath();
if (notePath) {
$dialog.modal('hide');
@@ -64,4 +64,4 @@ $form.on('submit', () => {
}
return false;
});
});

View File

@@ -38,7 +38,7 @@ async function includeNote(notePath) {
}
$form.on('submit', () => {
const notePath = $autoComplete.getSelectedPath();
const notePath = $autoComplete.getSelectedNotePath();
if (notePath) {
$dialog.modal('hide');
@@ -50,4 +50,4 @@ $form.on('submit', () => {
}
return false;
});
});

View File

@@ -41,7 +41,7 @@ async function moveNotesTo(parentNoteId) {
}
$form.on('submit', () => {
const notePath = $noteAutoComplete.getSelectedPath();
const notePath = $noteAutoComplete.getSelectedNotePath();
if (notePath) {
$dialog.modal('hide');
@@ -55,4 +55,4 @@ $form.on('submit', () => {
}
return false;
});
});

View File

@@ -11,6 +11,7 @@ import Component from "../widgets/component.js";
import keyboardActionsService from "./keyboard_actions.js";
import MobileScreenSwitcherExecutor from "../widgets/mobile_widgets/mobile_screen_switcher.js";
import MainTreeExecutors from "./main_tree_executors.js";
import protectedSessionHolder from "./protected_session_holder.js";
class AppContext extends Component {
constructor(isMainWindow) {
@@ -111,6 +112,8 @@ const appContext = new AppContext(window.glob.isMainWindow);
// we should save all outstanding changes before the page/app is closed
$(window).on('beforeunload', () => {
protectedSessionHolder.resetSessionCookie();
appContext.triggerEvent('beforeUnload');
});

View File

@@ -3,7 +3,7 @@ import appContext from "./app_context.js";
import utils from './utils.js';
// this key needs to have this value so it's hit by the tooltip
const SELECTED_PATH_KEY = "data-note-path";
const SELECTED_NOTE_PATH_KEY = "data-note-path";
async function autocompleteSource(term, cb) {
const result = await server.get('autocomplete'
@@ -12,8 +12,8 @@ async function autocompleteSource(term, cb) {
if (result.length === 0) {
result.push({
pathTitle: "No results",
path: ""
notePathTitle: "No results",
notePath: ""
});
}
@@ -25,7 +25,7 @@ function clearText($el) {
return;
}
$el.setSelectedPath("");
$el.setSelectedNotePath("");
$el.autocomplete("val", "").trigger('change');
}
@@ -34,7 +34,7 @@ function showRecentNotes($el) {
return;
}
$el.setSelectedPath("");
$el.setSelectedNotePath("");
$el.autocomplete("val", "");
$el.trigger('focus');
}
@@ -91,10 +91,10 @@ function initNoteAutocomplete($el, options) {
}, [
{
source: autocompleteSource,
displayKey: 'pathTitle',
displayKey: 'notePathTitle',
templates: {
suggestion: function(suggestion) {
return suggestion.highlightedTitle;
return suggestion.highlightedNotePathTitle;
}
},
// we can't cache identical searches because notes can be created / renamed, new recent notes can be added
@@ -102,7 +102,7 @@ function initNoteAutocomplete($el, options) {
}
]);
$el.on('autocomplete:selected', (event, suggestion) => $el.setSelectedPath(suggestion.path));
$el.on('autocomplete:selected', (event, suggestion) => $el.setSelectedNotePath(suggestion.notePath));
$el.on('autocomplete:closed', () => {
if (!$el.val().trim()) {
clearText($el);
@@ -113,24 +113,24 @@ function initNoteAutocomplete($el, options) {
}
function init() {
$.fn.getSelectedPath = function () {
$.fn.getSelectedNotePath = function () {
if (!$(this).val().trim()) {
return "";
} else {
return $(this).attr(SELECTED_PATH_KEY);
return $(this).attr(SELECTED_NOTE_PATH_KEY);
}
};
$.fn.setSelectedPath = function (path) {
path = path || "";
$.fn.setSelectedNotePath = function (notePath) {
notePath = notePath || "";
$(this).attr(SELECTED_PATH_KEY, path);
$(this).attr(SELECTED_NOTE_PATH_KEY, notePath);
$(this)
.closest(".input-group")
.find(".go-to-selected-note-button")
.toggleClass("disabled", !path.trim())
.attr(SELECTED_PATH_KEY, path); // we also set attr here so tooltip can be displayed
.toggleClass("disabled", !notePath.trim())
.attr(SELECTED_NOTE_PATH_KEY, notePath); // we also set attr here so tooltip can be displayed
};
}
@@ -139,4 +139,4 @@ export default {
initNoteAutocomplete,
showRecentNotes,
init
}
}

View File

@@ -12,15 +12,19 @@ setInterval(() => {
resetProtectedSession();
}
}, 5000);
}, 10000);
function setProtectedSessionId(id) {
// using session cookie so that it disappears after browser/tab is closed
utils.setSessionCookie(PROTECTED_SESSION_ID_KEY, id);
}
function resetProtectedSession() {
function resetSessionCookie() {
utils.setSessionCookie(PROTECTED_SESSION_ID_KEY, null);
}
function resetProtectedSession() {
resetSessionCookie();
// most secure solution - guarantees nothing remained in memory
// since this expires because user doesn't use the app, it shouldn't be disruptive
@@ -47,8 +51,9 @@ function touchProtectedSessionIfNecessary(note) {
export default {
setProtectedSessionId,
resetSessionCookie,
resetProtectedSession,
isProtectedSessionAvailable,
touchProtectedSession,
touchProtectedSessionIfNecessary
};
};

View File

@@ -187,7 +187,7 @@ function setCookie(name, value) {
}
function setSessionCookie(name, value) {
document.cookie = name + "=" + (value || "") + ";";
document.cookie = name + "=" + (value || "") + "; SameSite=Strict";
}
function getCookie(name) {
@@ -356,4 +356,4 @@ export default {
copySelectionToClipboard,
isCKEditorInitialized,
dynamicRequire
};
};

View File

@@ -200,7 +200,7 @@ export default class PromotedAttributesWidget extends TabAwareWidget {
this.promotedAttributeChanged(event);
});
$input.setSelectedPath(valueAttr.value);
$input.setSelectedNotePath(valueAttr.value);
}
else {
ws.logError("Unknown attribute type=" + valueAttr.type);
@@ -250,7 +250,7 @@ export default class PromotedAttributesWidget extends TabAwareWidget {
value = $attr.is(':checked') ? "true" : "false";
}
else if ($attr.prop("attribute-type") === "relation") {
const selectedPath = $attr.getSelectedPath();
const selectedPath = $attr.getSelectedNotePath();
value = selectedPath ? treeService.getNoteIdFromNotePath(selectedPath) : "";
}

View File

@@ -48,8 +48,8 @@ export default class SearchResultsWidget extends BasicWidget {
for (const result of results) {
const link = $('<a>', {
href: 'javascript:',
text: result.title
}).attr('data-action', 'note').attr('data-note-path', result.path);
text: result.notePathTitle
}).attr('data-action', 'note').attr('data-note-path', result.notePath);
const $result = $('<li>').append(link);
@@ -60,4 +60,4 @@ export default class SearchResultsWidget extends BasicWidget {
searchFlowEndedEvent() {
this.$searchResults.hide();
}
}
}

View File

@@ -17,6 +17,10 @@ const TPL = `
padding-left: 10px;
padding-right: 10px;
}
.title-bar-buttons button:hover {
background-color: var(--accented-background-color) !important;
}
</style>
<button class="btn icon-action bx bx-minus minimize-btn"></button>
@@ -62,4 +66,4 @@ export default class TitleBarButtonsWidget extends BasicWidget {
return this.$widget;
}
}
}

View File

@@ -98,10 +98,11 @@ async function updateNoteAttributes(req) {
if (attribute.type !== attributeEntity.type
|| attribute.name !== attributeEntity.name
|| (attribute.type === 'relation' && attribute.value !== attributeEntity.value)) {
|| (attribute.type === 'relation' && attribute.value !== attributeEntity.value)
|| attribute.isInheritable !== attributeEntity.isInheritable) {
if (attribute.type !== 'relation' || !!attribute.value.trim()) {
const newAttribute = attributeEntity.createClone(attribute.type, attribute.name, attribute.value);
const newAttribute = attributeEntity.createClone(attribute.type, attribute.name, attribute.value, attribute.isInheritable);
await newAttribute.save();
}

View File

@@ -1,6 +1,7 @@
"use strict";
const noteCacheService = require('../../services/note_cache');
const noteCacheService = require('../../services/note_cache/note_cache_service');
const searchService = require('../../services/search/search');
const repository = require('../../services/repository');
const log = require('../../services/log');
const utils = require('../../services/utils');
@@ -18,7 +19,7 @@ async function getAutocomplete(req) {
results = await getRecentNotes(activeNoteId);
}
else {
results = await noteCacheService.findNotes(query);
results = await searchService.searchNotesForAutocomplete(query);
}
const msTaken = Date.now() - timestampStarted;
@@ -57,14 +58,13 @@ async function getRecentNotes(activeNoteId) {
const title = noteCacheService.getNoteTitleForPath(rn.notePath.split('/'));
return {
path: rn.notePath,
pathTitle: title,
highlightedTitle: title,
noteTitle: noteCacheService.getNoteTitleFromPath(rn.notePath)
notePath: rn.notePath,
notePathTitle: title,
highlightedNotePathTitle: utils.escapeHtml(title)
};
});
}
module.exports = {
getAutocomplete
};
};

View File

@@ -109,11 +109,17 @@ async function addImagesToNote(images, note, content) {
const {note: imageNote, url} = await imageService.saveImage(note.noteId, buffer, filename, true);
await new Attribute({
noteId: imageNote.noteId,
type: 'label',
name: 'hideInAutocomplete'
}).save();
await new Attribute({
noteId: note.noteId,
type: 'relation',
value: imageNote.noteId,
name: 'imageLink'
name: 'imageLink',
value: imageNote.noteId
}).save();
console.log(`Replacing ${imageId} with ${url}`);
@@ -155,4 +161,4 @@ module.exports = {
addClipping,
openNote,
handshake
};
};

View File

@@ -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
};
};

View File

@@ -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');

View File

@@ -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
};
};

View File

@@ -1,18 +1,18 @@
"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');
const searchService = require('../../services/search/search');
async function searchNotes(req) {
const noteIds = await searchService.searchForNoteIds(req.params.searchString);
const notePaths = await searchService.searchNotes(req.params.searchString);
try {
return {
success: true,
results: noteIds.map(noteCacheService.getNotePath).filter(res => !!res)
results: notePaths
}
}
catch {
@@ -110,4 +110,4 @@ async function searchFromRelation(note, relationName) {
module.exports = {
searchNotes,
searchFromNote
};
};

View File

@@ -1,6 +1,6 @@
"use strict";
const noteCacheService = require('../../services/note_cache');
const noteCacheService = require('../../services/note_cache/note_cache_service');
const repository = require('../../services/repository');
async function getSimilarNotes(req) {
@@ -12,7 +12,7 @@ async function getSimilarNotes(req) {
return [404, `Note ${noteId} not found.`];
}
const results = await noteCacheService.findSimilarNotes(note.title);
const results = await noteCacheService.findSimilarNotes(noteId);
return results
.filter(note => note.noteId !== noteId);
@@ -20,4 +20,4 @@ async function getSimilarNotes(req) {
module.exports = {
getSimilarNotes
};
};

View File

@@ -1,19 +1,6 @@
const optionService = require('./options');
const sqlInit = require('./sql_init');
const eventService = require('./events');
let hoistedNoteId = 'root';
eventService.subscribe(eventService.ENTITY_CHANGED, async ({entityName, entity}) => {
if (entityName === 'options' && entity.name === 'hoistedNoteId') {
hoistedNoteId = entity.value;
}
});
sqlInit.dbReady.then(async () => {
hoistedNoteId = await optionService.getOption('hoistedNoteId');
});
module.exports = {
getHoistedNoteId: () => hoistedNoteId
};
getHoistedNoteId: () => hoistedNoteId,
setHoistedNoteId(noteId) { hoistedNoteId = noteId; }
};

View File

@@ -0,0 +1,14 @@
const optionService = require('./options');
const sqlInit = require('./sql_init');
const eventService = require('./events');
const hoistedNote = require('./hoisted_note');
eventService.subscribe(eventService.ENTITY_CHANGED, async ({entityName, entity}) => {
if (entityName === 'options' && entity.name === 'hoistedNoteId') {
hoistedNote.setHoistedNoteId(entity.value);
}
});
sqlInit.dbReady.then(async () => {
hoistedNote.setHoistedNoteId(await optionService.getOption('hoistedNoteId'));
});

View File

@@ -1,559 +0,0 @@
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');
let loaded = false;
let loadedPromiseResolve;
/** Is resolved after the initial load */
let loadedPromise = new Promise(res => loadedPromiseResolve = res);
let noteTitles = {};
let protectedNoteTitles = {};
let noteIds;
let childParentToBranchId = {};
const childToParent = {};
let archived = {};
// key is 'childNoteId-parentNoteId' as a replacement for branchId which we don't use here
let prefixes = {};
async function load() {
noteTitles = await sql.getMap(`SELECT noteId, title FROM notes WHERE isDeleted = 0 AND isProtected = 0`);
noteIds = Object.keys(noteTitles);
prefixes = await sql.getMap(`
SELECT noteId || '-' || parentNoteId, prefix
FROM branches
WHERE isDeleted = 0 AND prefix IS NOT NULL AND prefix != ''`);
const branches = await sql.getRows(`SELECT branchId, noteId, parentNoteId FROM branches WHERE isDeleted = 0`);
for (const rel of branches) {
childToParent[rel.noteId] = childToParent[rel.noteId] || [];
childToParent[rel.noteId].push(rel.parentNoteId);
childParentToBranchId[`${rel.noteId}-${rel.parentNoteId}`] = rel.branchId;
}
archived = await sql.getMap(`SELECT noteId, isInheritable FROM attributes WHERE isDeleted = 0 AND type = 'label' AND name = 'archived'`);
if (protectedSessionService.isProtectedSessionAvailable()) {
await loadProtectedNotes();
}
for (const noteId in childToParent) {
resortChildToParent(noteId);
}
loaded = true;
loadedPromiseResolve();
}
async function loadProtectedNotes() {
protectedNoteTitles = await sql.getMap(`SELECT noteId, title FROM notes WHERE isDeleted = 0 AND isProtected = 1`);
for (const noteId in protectedNoteTitles) {
protectedNoteTitles[noteId] = protectedSessionService.decryptString(protectedNoteTitles[noteId]);
}
}
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) {
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>");
}
}
async function findNotes(query) {
if (!noteTitles || !query.length) {
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();
let results = [];
let noteIds = Object.keys(noteTitles);
if (protectedSessionService.isProtectedSessionAvailable()) {
noteIds = [...new Set(noteIds.concat(Object.keys(protectedNoteTitles)))];
}
for (const noteId of noteIds) {
// autocomplete should be able to find notes by their noteIds as well (only leafs)
if (noteId === query) {
search(noteId, [], [], results);
continue;
}
// for leaf note it doesn't matter if "archived" label is inheritable or not
if (noteId in archived) {
continue;
}
const parents = childToParent[noteId];
if (!parents) {
continue;
}
for (const parentNoteId of parents) {
// for parent note archived needs to be inheritable
if (archived[parentNoteId] === 1) {
continue;
}
const title = getNoteTitle(noteId, parentNoteId).toLowerCase();
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));
search(parentNoteId, remainingTokens, [noteId], results);
}
}
}
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;
}
function search(noteId, tokens, path, results) {
if (tokens.length === 0) {
const retPath = getSomePath(noteId, path);
if (retPath && !isNotePathArchived(retPath)) {
const thisNoteId = retPath[retPath.length - 1];
const thisParentNoteId = retPath[retPath.length - 2];
results.push({
noteId: thisNoteId,
branchId: childParentToBranchId[`${thisNoteId}-${thisParentNoteId}`],
pathArray: retPath,
titleArray: getNoteTitleArrayForPath(retPath)
});
}
return;
}
const parents = childToParent[noteId];
if (!parents || noteId === 'root') {
return;
}
for (const parentNoteId of parents) {
// archived must be inheritable
if (archived[parentNoteId] === 1) {
continue;
}
const title = getNoteTitle(noteId, parentNoteId).toLowerCase();
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));
search(parentNoteId, remainingTokens, path.concat([noteId]), results);
}
else {
search(parentNoteId, tokens, path.concat([noteId]), results);
}
}
}
function isNotePathArchived(notePath) {
// if the note is archived directly
if (archived[notePath[notePath.length - 1]] !== 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;
}
for (const parentNoteId of childToParent[noteId] || []) {
if (isInAncestor(parentNoteId, 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(noteId, parentNoteId) {
const prefix = prefixes[noteId + '-' + parentNoteId];
let title = noteTitles[noteId];
if (!title) {
if (protectedSessionService.isProtectedSessionAvailable()) {
title = protectedNoteTitles[noteId];
}
else {
title = '[protected]';
}
}
return (prefix ? (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(noteId, path = []) {
if (noteId === 'root') {
path.push(noteId);
path.reverse();
if (!path.includes(hoistedNoteService.getHoistedNoteId())) {
return false;
}
return path;
}
const parents = childToParent[noteId];
if (!parents || parents.length === 0) {
return false;
}
for (const parentNoteId of parents) {
const retPath = getSomePath(parentNoteId, path.concat([noteId]));
if (retPath) {
return retPath;
}
}
return false;
}
function getNotePath(noteId) {
const retPath = getSomePath(noteId);
if (retPath) {
const noteTitle = getNoteTitleForPath(retPath);
const parentNoteId = childToParent[noteId][0];
return {
noteId: noteId,
branchId: childParentToBranchId[`${noteId}-${parentNoteId}`],
title: noteTitle,
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
}
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}) => {
// 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];
}
}
}
else if (entityName === 'branches') {
const branch = entity;
if (branch.isDeleted) {
if (branch.noteId in childToParent) {
childToParent[branch.noteId] = childToParent[branch.noteId].filter(noteId => noteId !== branch.parentNoteId);
}
delete prefixes[branch.noteId + '-' + branch.parentNoteId];
delete childParentToBranchId[branch.noteId + '-' + branch.parentNoteId];
}
else {
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);
childParentToBranchId[branch.noteId + '-' + branch.parentNoteId] = branch.branchId;
}
}
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
const archivedLabel = await repository.getEntity(`SELECT * FROM attributes WHERE isDeleted = 0 AND type = 'label'
AND name = 'archived' AND noteId = ?`, [attribute.noteId]);
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);
}
/**
* @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(() => loadProtectedNotes());
});
sqlInit.dbReady.then(() => utils.stopWatch("Note cache load", load));
module.exports = {
loadedPromise,
findNotes,
getNotePath,
getNoteTitleForPath,
getNoteTitleFromPath,
isAvailable,
isArchived,
isInAncestor,
load,
findSimilarNotes
};

View File

@@ -0,0 +1,50 @@
"use strict";
class Attribute {
constructor(noteCache, row) {
/** @param {NoteCache} */
this.noteCache = noteCache;
/** @param {string} */
this.attributeId = row.attributeId;
/** @param {string} */
this.noteId = row.noteId;
/** @param {string} */
this.type = row.type;
/** @param {string} */
this.name = row.name.toLowerCase();
/** @param {string} */
this.value = row.type === 'label'? row.value.toLowerCase() : row.value;
/** @param {boolean} */
this.isInheritable = !!row.isInheritable;
this.noteCache.attributes[this.attributeId] = this;
this.noteCache.notes[this.noteId].ownedAttributes.push(this);
const key = `${this.type}-${this.name}`;
this.noteCache.attributeIndex[key] = this.noteCache.attributeIndex[key] || [];
this.noteCache.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 this.noteCache.notes[this.noteId];
}
get targetNote() {
if (this.type === 'relation') {
return this.noteCache.notes[this.value];
}
}
}
module.exports = Attribute;

View File

@@ -0,0 +1,49 @@
"use strict";
class Branch {
constructor(noteCache, row) {
/** @param {NoteCache} */
this.noteCache = noteCache;
/** @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 = this.noteCache.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);
this.noteCache.branches[this.branchId] = this;
this.noteCache.childParentToBranch[`${this.noteId}-${this.parentNoteId}`] = this;
}
/** @return {Note} */
get parentNote() {
const note = this.noteCache.notes[this.parentNoteId];
if (!note) {
console.log(`Cannot find note ${this.parentNoteId}`);
}
return note;
}
}
module.exports = Branch;

View File

@@ -0,0 +1,327 @@
"use strict";
const protectedSessionService = require('../../protected_session');
class Note {
constructor(noteCache, row) {
/** @param {NoteCache} */
this.noteCache = noteCache;
/** @param {string} */
this.noteId = row.noteId;
/** @param {string} */
this.title = row.title;
/** @param {string} */
this.type = row.type;
/** @param {string} */
this.mime = row.mime;
/** @param {number} */
this.contentLength = row.contentLength;
/** @param {string} */
this.dateCreated = row.dateCreated;
/** @param {string} */
this.dateModified = row.dateModified;
/** @param {string} */
this.utcDateCreated = row.utcDateCreated;
/** @param {string} */
this.utcDateModified = row.utcDateModified;
/** @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;
this.noteCache.notes[this.noteId] = this;
if (protectedSessionService.isProtectedSessionAvailable()) {
this.decrypt();
}
/** @param {Note[]|null} */
this.ancestorCache = null;
}
/** @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 = this.noteCache.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);
}
getLabelValue(name) {
const label = this.attributes.find(attr => attr.type === 'label' && attr.name === name);
return label ? label.value : null;
}
getRelationTarget(name) {
const relation = this.attributes.find(attr => attr.type === 'relation' && attr.name === name);
return relation ? relation.targetNote : null;
}
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;
this.ancestorCache = 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();
}
get parentCount() {
return this.parents.length;
}
get childrenCount() {
return this.children.length;
}
get labelCount() {
return this.attributes.filter(attr => attr.type === 'label').length;
}
get relationCount() {
return this.attributes.filter(attr => attr.type === 'relation').length;
}
get attributeCount() {
return this.attributes.length;
}
get ancestors() {
if (!this.ancestorCache) {
const noteIds = new Set();
this.ancestorCache = [];
for (const parent of this.parents) {
if (!noteIds.has(parent.noteId)) {
this.ancestorCache.push(parent);
noteIds.add(parent.noteId);
}
for (const ancestorNote of parent.ancestors) {
if (!noteIds.has(ancestorNote.noteId)) {
this.ancestorCache.push(ancestorNote);
noteIds.add(ancestorNote.noteId);
}
}
}
}
return this.ancestorCache;
}
/** @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;
}
decrypt() {
if (this.isProtected && !this.isDecrypted && protectedSessionService.isProtectedSessionAvailable()) {
this.title = protectedSessionService.decryptString(note.title);
this.isDecrypted = true;
}
}
}
module.exports = Note;

View File

@@ -0,0 +1,61 @@
"use strict";
const Note = require('./entities/note');
const Branch = require('./entities/branch');
const Attribute = require('./entities/attribute');
class NoteCache {
constructor() {
this.reset();
}
reset() {
/** @type {Object.<String, Note>} */
this.notes = [];
/** @type {Object.<String, Branch>} */
this.branches = [];
/** @type {Object.<String, Branch>} */
this.childParentToBranch = {};
/** @type {Object.<String, Attribute>} */
this.attributes = [];
/** @type {Object.<String, Attribute[]>} Points from attribute type-name to list of attributes them */
this.attributeIndex = {};
this.loaded = false;
this.loadedResolve = null;
this.loadedPromise = new Promise(res => {this.loadedResolve = res;});
}
/** @return {Attribute[]} */
findAttributes(type, name) {
return this.attributeIndex[`${type}-${name}`] || [];
}
/** @return {Attribute[]} */
findAttributesWithPrefix(type, name) {
const resArr = [];
const key = `${type}-${name}`;
for (const idx in this.attributeIndex) {
if (idx.startsWith(key)) {
resArr.push(this.attributeIndex[idx]);
}
}
return resArr.flat();
}
decryptProtectedNotes() {
for (const note of Object.values(this.notes)) {
note.decrypt();
}
}
getBranch(childNoteId, parentNoteId) {
return this.childParentToBranch[`${childNoteId}-${parentNoteId}`];
}
}
const noteCache = new NoteCache();
module.exports = noteCache;

View File

@@ -0,0 +1,155 @@
"use strict";
const sql = require('../sql.js');
const sqlInit = require('../sql_init.js');
const eventService = require('../events.js');
const noteCache = require('./note_cache');
const Note = require('./entities/note');
const Branch = require('./entities/branch');
const Attribute = require('./entities/attribute');
async function load() {
await sqlInit.dbReady;
noteCache.reset();
(await sql.getRows(`SELECT noteId, title, type, mime, isProtected, dateCreated, dateModified, utcDateCreated, utcDateModified, contentLength FROM notes WHERE isDeleted = 0`, []))
.map(row => new Note(noteCache, row));
(await sql.getRows(`SELECT branchId, noteId, parentNoteId, prefix FROM branches WHERE isDeleted = 0`, []))
.map(row => new Branch(noteCache, row));
(await sql.getRows(`SELECT attributeId, noteId, type, name, value, isInheritable FROM attributes WHERE isDeleted = 0`, [])).map(row => new Attribute(noteCache, row));
noteCache.loaded = true;
noteCache.loadedResolve();
}
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;
note.decrypt();
}
else {
const note = new Note(entity);
noteCache.notes[noteId] = note;
note.decrypt();
}
}
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 {
note.invalidateThisCache();
}
}
}
}
});
eventService.subscribe(eventService.ENTER_PROTECTED_SESSION, () => {
noteCache.loadedPromise.then(() => noteCache.decryptProtectedNotes());
});
load();

View File

@@ -0,0 +1,241 @@
"use strict";
const noteCache = require('./note_cache');
const hoistedNoteService = require('../hoisted_note');
const stringSimilarity = require('string-similarity');
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 ? noteCache.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(noteCache.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 = {
getSomePath,
getNotePath,
getNoteTitle,
getNoteTitleForPath,
isAvailable,
isArchived,
isInAncestor,
findSimilarNotes
};

View File

@@ -1,9 +1,24 @@
"use strict";
/**
* Missing things from the OLD search:
* - orderBy
* - limit
* - in - replaced with note.ancestors
* - content in attribute search
* - not - pherhaps not necessary
*
* other potential additions:
* - targetRelations - either named or not
* - any relation without name
*/
const repository = require('./repository');
const sql = require('./sql');
const log = require('./log');
const parseFilters = require('./parse_filters');
const parseFilters = require('./search/parse_filters.js');
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 +86,4 @@ async function searchForNoteIds(searchString) {
module.exports = {
searchForNotes,
searchForNoteIds
};
};

View File

@@ -0,0 +1,77 @@
const dayjs = require("dayjs");
const stringComparators = {
"=": comparedValue => (val => val === comparedValue),
"!=": comparedValue => (val => val !== comparedValue),
">": comparedValue => (val => val > comparedValue),
">=": comparedValue => (val => val >= comparedValue),
"<": comparedValue => (val => val < comparedValue),
"<=": comparedValue => (val => val <= comparedValue),
"*=": comparedValue => (val => val.endsWith(comparedValue)),
"=*": comparedValue => (val => val.startsWith(comparedValue)),
"*=*": comparedValue => (val => val.includes(comparedValue)),
};
const numericComparators = {
">": comparedValue => (val => parseFloat(val) > comparedValue),
">=": comparedValue => (val => parseFloat(val) >= comparedValue),
"<": comparedValue => (val => parseFloat(val) < comparedValue),
"<=": comparedValue => (val => parseFloat(val) <= comparedValue)
};
const smartValueRegex = /^(NOW|TODAY|WEEK|MONTH|YEAR) *([+\-] *\d+)?$/i;
function calculateSmartValue(v) {
const match = smartValueRegex.exec(v);
if (match === null) {
return v;
}
const keyword = match[1].toUpperCase();
const num = match[2] ? parseInt(match[2].replace(/ /g, "")) : 0; // can contain spaces between sign and digits
let format, date;
if (keyword === 'NOW') {
date = dayjs().add(num, 'second');
format = "YYYY-MM-DD HH:mm:ss";
}
else if (keyword === 'TODAY') {
date = dayjs().add(num, 'day');
format = "YYYY-MM-DD";
}
else if (keyword === 'WEEK') {
// FIXME: this will always use sunday as start of the week
date = dayjs().startOf('week').add(7 * num, 'day');
format = "YYYY-MM-DD";
}
else if (keyword === 'MONTH') {
date = dayjs().add(num, 'month');
format = "YYYY-MM";
}
else if (keyword === 'YEAR') {
date = dayjs().add(num, 'year');
format = "YYYY";
}
else {
throw new Error("Unrecognized keyword: " + keyword);
}
return date.format(format);
}
function buildComparator(operator, comparedValue) {
comparedValue = comparedValue.toLowerCase();
comparedValue = calculateSmartValue(comparedValue);
if (operator in numericComparators && !isNaN(comparedValue)) {
return numericComparators[operator](parseFloat(comparedValue));
}
if (operator in stringComparators) {
return stringComparators[operator](comparedValue);
}
}
module.exports = buildComparator;

View File

@@ -0,0 +1,30 @@
"use strict";
const Expression = require('./expression');
class AndExp extends Expression {
static of(subExpressions) {
subExpressions = subExpressions.filter(exp => !!exp);
if (subExpressions.length === 1) {
return subExpressions[0];
} else if (subExpressions.length > 0) {
return new AndExp(subExpressions);
}
}
constructor(subExpressions) {
super();
this.subExpressions = subExpressions;
}
async execute(inputNoteSet, searchContext) {
for (const subExpression of this.subExpressions) {
inputNoteSet = await subExpression.execute(inputNoteSet, searchContext);
}
return inputNoteSet;
}
}
module.exports = AndExp;

View File

@@ -0,0 +1,43 @@
"use strict";
const NoteSet = require('../note_set');
const noteCache = require('../../note_cache/note_cache');
const Expression = require('./expression');
class AttributeExistsExp extends Expression {
constructor(attributeType, attributeName, prefixMatch) {
super();
this.attributeType = attributeType;
this.attributeName = attributeName;
this.prefixMatch = prefixMatch;
}
execute(inputNoteSet) {
const attrs = this.prefixMatch
? noteCache.findAttributesWithPrefix(this.attributeType, this.attributeName)
: noteCache.findAttributes(this.attributeType, this.attributeName);
const resultNoteSet = new NoteSet();
for (const attr of attrs) {
const note = attr.note;
if (inputNoteSet.hasNoteId(note.noteId)) {
if (attr.isInheritable) {
resultNoteSet.addAll(note.subtreeNotesIncludingTemplated);
}
else if (note.isTemplate) {
resultNoteSet.addAll(note.templatedNotes);
}
else {
resultNoteSet.add(note);
}
}
}
return resultNoteSet;
}
}
module.exports = AttributeExistsExp;

View File

@@ -0,0 +1,36 @@
"use strict";
const Expression = require('./expression');
const NoteSet = require('../note_set');
class ChildOfExp extends Expression {
constructor(subExpression) {
super();
this.subExpression = subExpression;
}
execute(inputNoteSet, searchContext) {
const subInputNoteSet = new NoteSet();
for (const note of inputNoteSet.notes) {
subInputNoteSet.addAll(note.parents);
}
const subResNoteSet = this.subExpression.execute(subInputNoteSet, searchContext);
const resNoteSet = new NoteSet();
for (const parentNote of subResNoteSet.notes) {
for (const childNote of parentNote.children) {
if (inputNoteSet.hasNote(childNote)) {
resNoteSet.add(childNote);
}
}
}
return resNoteSet;
}
}
module.exports = ChildOfExp;

View File

@@ -0,0 +1,28 @@
"use strict";
const Expression = require('./expression');
const NoteSet = require('../note_set');
const noteCache = require('../../note_cache/note_cache');
class DescendantOfExp extends Expression {
constructor(subExpression) {
super();
this.subExpression = subExpression;
}
execute(inputNoteSet, searchContext) {
const subInputNoteSet = new NoteSet(Object.values(noteCache.notes));
const subResNoteSet = this.subExpression.execute(subInputNoteSet, searchContext);
const subTreeNoteSet = new NoteSet();
for (const note of subResNoteSet.notes) {
subTreeNoteSet.addAll(note.subtreeNotes);
}
return inputNoteSet.intersection(subTreeNoteSet);
}
}
module.exports = DescendantOfExp;

View File

@@ -0,0 +1,12 @@
"use strict";
class Expression {
/**
* @param {NoteSet} inputNoteSet
* @param {object} searchContext
* @return {NoteSet}
*/
execute(inputNoteSet, searchContext) {}
}
module.exports = Expression;

View File

@@ -0,0 +1,40 @@
"use strict";
const Expression = require('./expression');
const NoteSet = require('../note_set');
const noteCache = require('../../note_cache/note_cache');
class LabelComparisonExp extends Expression {
constructor(attributeType, attributeName, comparator) {
super();
this.attributeType = attributeType;
this.attributeName = attributeName;
this.comparator = comparator;
}
execute(inputNoteSet) {
const attrs = noteCache.findAttributes(this.attributeType, this.attributeName);
const resultNoteSet = new NoteSet();
for (const attr of attrs) {
const note = attr.note;
if (inputNoteSet.hasNoteId(note.noteId) && this.comparator(attr.value)) {
if (attr.isInheritable) {
resultNoteSet.addAll(note.subtreeNotesIncludingTemplated);
}
else if (note.isTemplate) {
resultNoteSet.addAll(note.templatedNotes);
}
else {
resultNoteSet.add(note);
}
}
}
return resultNoteSet;
}
}
module.exports = LabelComparisonExp;

View File

@@ -0,0 +1,19 @@
"use strict";
const Expression = require('./expression');
class NotExp extends Expression {
constructor(subExpression) {
super();
this.subExpression = subExpression;
}
execute(inputNoteSet, searchContext) {
const subNoteSet = this.subExpression.execute(inputNoteSet, searchContext);
return inputNoteSet.minus(subNoteSet);
}
}
module.exports = NotExp;

View File

@@ -0,0 +1,137 @@
"use strict";
const Expression = require('./expression');
const NoteSet = require('../note_set');
const noteCache = require('../../note_cache/note_cache');
class NoteCacheFulltextExp extends Expression {
constructor(tokens) {
super();
this.tokens = tokens;
}
execute(inputNoteSet, searchContext) {
// has deps on SQL which breaks unit test so needs to be dynamically required
const noteCacheService = require('../../note_cache/note_cache_service');
const resultNoteSet = new NoteSet();
function searchDownThePath(note, tokens, path) {
if (tokens.length === 0) {
const retPath = noteCacheService.getSomePath(note, path);
if (retPath) {
const noteId = retPath[retPath.length - 1];
searchContext.noteIdToNotePath[noteId] = retPath;
resultNoteSet.add(noteCache.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 = noteCacheService.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));
searchDownThePath(parentNote, remainingTokens, path.concat([note.noteId]));
}
else {
searchDownThePath(parentNote, tokens, path.concat([note.noteId]));
}
}
}
const candidateNotes = this.getCandidateNotes(inputNoteSet);
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]) {
searchDownThePath(note, [], []);
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 = noteCacheService.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));
searchDownThePath(parentNote, remainingTokens, [note.noteId]);
}
}
}
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;
}
}
module.exports = NoteCacheFulltextExp;

View File

@@ -0,0 +1,40 @@
"use strict";
const Expression = require('./expression');
const NoteSet = require('../note_set');
const noteCache = require('../../note_cache/note_cache');
const utils = require('../../utils');
class NoteContentFulltextExp extends Expression {
constructor(operator, tokens) {
super();
this.likePrefix = ["*=*", "*="].includes(operator) ? "%" : "";
this.likeSuffix = ["*=*", "=*"].includes(operator) ? "%" : "";
this.tokens = tokens;
}
async execute(inputNoteSet) {
const resultNoteSet = new NoteSet();
const wheres = this.tokens.map(token => "note_contents.content LIKE " + utils.prepareSqlForLike(this.likePrefix, token, this.likeSuffix));
const sql = require('../../sql');
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 ')}`);
for (const noteId of noteIds) {
if (inputNoteSet.hasNoteId(noteId) && noteId in noteCache.notes) {
resultNoteSet.add(noteCache.notes[noteId]);
}
}
return resultNoteSet;
}
}
module.exports = NoteContentFulltextExp;

View File

@@ -0,0 +1,35 @@
"use strict";
const Expression = require('./expression');
const NoteSet = require('../note_set');
class OrExp extends Expression {
static of(subExpressions) {
subExpressions = subExpressions.filter(exp => !!exp);
if (subExpressions.length === 1) {
return subExpressions[0];
}
else if (subExpressions.length > 0) {
return new OrExp(subExpressions);
}
}
constructor(subExpressions) {
super();
this.subExpressions = subExpressions;
}
async execute(inputNoteSet, searchContext) {
const resultNoteSet = new NoteSet();
for (const subExpression of this.subExpressions) {
resultNoteSet.mergeIn(await subExpression.execute(inputNoteSet, searchContext));
}
return resultNoteSet;
}
}
module.exports = OrExp;

View File

@@ -0,0 +1,58 @@
"use strict";
const Expression = require('./expression');
const NoteSet = require('../note_set');
class OrderByAndLimitExp extends Expression {
constructor(orderDefinitions, limit) {
super();
this.orderDefinitions = orderDefinitions;
for (const od of this.orderDefinitions) {
od.smaller = od.direction === "asc" ? -1 : 1;
od.larger = od.direction === "asc" ? 1 : -1;
}
this.limit = limit;
/** @type {Expression} */
this.subExpression = null; // it's expected to be set after construction
}
execute(inputNoteSet, searchContext) {
let {notes} = this.subExpression.execute(inputNoteSet, searchContext);
notes.sort((a, b) => {
for (const {valueExtractor, smaller, larger} of this.orderDefinitions) {
let valA = valueExtractor.extract(a);
let valB = valueExtractor.extract(b);
if (!isNaN(valA) && !isNaN(valB)) {
valA = parseFloat(valA);
valB = parseFloat(valB);
}
if (valA < valB) {
return smaller;
} else if (valA > valB) {
return larger;
}
// else go to next order definition
}
return 0;
});
if (this.limit >= 0) {
notes = notes.slice(0, this.limit);
}
const noteSet = new NoteSet(notes);
noteSet.sorted = true;
return noteSet;
}
}
module.exports = OrderByAndLimitExp;

View File

@@ -0,0 +1,36 @@
"use strict";
const Expression = require('./expression');
const NoteSet = require('../note_set');
class ParentOfExp extends Expression {
constructor(subExpression) {
super();
this.subExpression = subExpression;
}
execute(inputNoteSet, searchContext) {
const subInputNoteSet = new NoteSet();
for (const note of inputNoteSet.notes) {
subInputNoteSet.addAll(note.children);
}
const subResNoteSet = this.subExpression.execute(subInputNoteSet, searchContext);
const resNoteSet = new NoteSet();
for (const childNote of subResNoteSet.notes) {
for (const parentNote of childNote.parents) {
if (inputNoteSet.hasNote(parentNote)) {
resNoteSet.add(parentNote);
}
}
}
return resNoteSet;
}
}
module.exports = ParentOfExp;

View File

@@ -0,0 +1,63 @@
"use strict";
const Expression = require('./expression');
const NoteSet = require('../note_set');
/**
* Search string is lower cased for case insensitive comparison. But when retrieving properties
* we need case sensitive form so we have this translation object.
*/
const PROP_MAPPING = {
"noteid": "noteId",
"title": "title",
"type": "type",
"mime": "mime",
"isprotected": "isProtected",
"isarhived": "isArchived",
"datecreated": "dateCreated",
"datemodified": "dateModified",
"utcdatecreated": "utcDateCreated",
"utcdatemodified": "utcDateModified",
"contentlength": "contentLength",
"parentcount": "parentCount",
"childrencount": "childrenCount",
"attributecount": "attributeCount",
"labelcount": "labelCount",
"relationcount": "relationCount"
};
class PropertyComparisonExp extends Expression {
static isProperty(name) {
return name in PROP_MAPPING;
}
constructor(propertyName, comparator) {
super();
this.propertyName = PROP_MAPPING[propertyName];
this.comparator = comparator;
}
execute(inputNoteSet, searchContext) {
const resNoteSet = new NoteSet();
for (const note of inputNoteSet.notes) {
let value = note[this.propertyName];
if (value !== undefined && value !== null && typeof value !== 'string') {
value = value.toString();
}
if (value) {
value = value.toLowerCase();
}
if (this.comparator(value)) {
resNoteSet.add(note);
}
}
return resNoteSet;
}
}
module.exports = PropertyComparisonExp;

View File

@@ -0,0 +1,41 @@
"use strict";
const Expression = require('./expression');
const NoteSet = require('../note_set');
const noteCache = require('../../note_cache/note_cache');
class RelationWhereExp extends Expression {
constructor(relationName, subExpression) {
super();
this.relationName = relationName;
this.subExpression = subExpression;
}
execute(inputNoteSet, searchContext) {
const candidateNoteSet = new NoteSet();
for (const attr of noteCache.findAttributes('relation', this.relationName)) {
const note = attr.note;
if (inputNoteSet.hasNoteId(note.noteId)) {
const subInputNoteSet = new NoteSet([attr.targetNote]);
const subResNoteSet = this.subExpression.execute(subInputNoteSet, searchContext);
if (subResNoteSet.hasNote(attr.targetNote)) {
if (attr.isInheritable) {
candidateNoteSet.addAll(note.subtreeNotesIncludingTemplated);
} else if (note.isTemplate) {
candidateNoteSet.addAll(note.templatedNotes);
} else {
candidateNoteSet.add(note);
}
}
}
}
return candidateNoteSet.intersection(inputNoteSet);
}
}
module.exports = RelationWhereExp;

View File

@@ -0,0 +1,115 @@
function lexer(str) {
str = str.toLowerCase();
const fulltextTokens = [];
const expressionTokens = [];
let quotes = false;
let fulltextEnded = false;
let currentWord = '';
function isOperatorSymbol(chr) {
return ['=', '*', '>', '<', '!'].includes(chr);
}
function previusOperatorSymbol() {
if (currentWord.length === 0) {
return false;
}
else {
return isOperatorSymbol(currentWord[currentWord.length - 1]);
}
}
function finishWord() {
if (currentWord === '') {
return;
}
if (fulltextEnded) {
expressionTokens.push(currentWord);
} else {
fulltextTokens.push(currentWord);
}
currentWord = '';
}
for (let i = 0; i < str.length; i++) {
const chr = str[i];
if (chr === '\\') {
if ((i + 1) < str.length) {
i++;
currentWord += str[i];
}
else {
currentWord += chr;
}
continue;
}
else if (['"', "'", '`'].includes(chr)) {
if (!quotes) {
if (currentWord.length === 0 || fulltextEnded) {
if (previusOperatorSymbol()) {
finishWord();
}
quotes = chr;
}
else {
// quote inside a word does not have special meening and does not break word
// e.g. d'Artagnan is kept as a single token
currentWord += chr;
}
}
else if (quotes === chr) {
quotes = false;
finishWord();
}
else {
// it's a quote but within other kind of quotes so it's valid as a literal character
currentWord += chr;
}
continue;
}
else if (!quotes) {
if (currentWord.length === 0 && (chr === '#' || chr === '~')) {
fulltextEnded = true;
currentWord = chr;
continue;
}
else if (chr === ' ') {
finishWord();
continue;
}
else if (fulltextEnded && ['(', ')', '.'].includes(chr)) {
finishWord();
currentWord += chr;
finishWord();
continue;
}
else if (fulltextEnded && previusOperatorSymbol() !== isOperatorSymbol(chr)) {
finishWord();
currentWord += chr;
continue;
}
}
currentWord += chr;
}
finishWord();
return {
fulltextTokens,
expressionTokens
}
}
module.exports = lexer;

View File

@@ -0,0 +1,61 @@
"use strict";
class NoteSet {
constructor(notes = []) {
/** @type {Note[]} */
this.notes = notes;
/** @type {boolean} */
this.sorted = false;
}
add(note) {
if (!this.hasNote(note)) {
this.notes.push(note);
}
}
addAll(notes) {
for (const note of notes) {
this.add(note);
}
}
hasNote(note) {
return this.hasNoteId(note.noteId);
}
hasNoteId(noteId) {
// TODO: optimize
return !!this.notes.find(note => note.noteId === noteId);
}
mergeIn(anotherNoteSet) {
this.notes = this.notes.concat(anotherNoteSet.notes);
}
minus(anotherNoteSet) {
const newNoteSet = new NoteSet();
for (const note of this.notes) {
if (!anotherNoteSet.hasNoteId(note.noteId)) {
newNoteSet.add(note);
}
}
return newNoteSet;
}
intersection(anotherNoteSet) {
const newNoteSet = new NoteSet();
for (const note of this.notes) {
if (anotherNoteSet.hasNote(note)) {
newNoteSet.add(note);
}
}
return newNoteSet;
}
}
module.exports = NoteSet;

View File

@@ -0,0 +1,43 @@
/**
* This will create a recursive object from list of tokens - tokens between parenthesis are grouped in a single array
*/
function parens(tokens) {
if (tokens.length === 0) {
return [];
}
while (true) {
const leftIdx = tokens.findIndex(token => token === '(');
if (leftIdx === -1) {
return tokens;
}
let rightIdx;
let parensLevel = 0
for (rightIdx = leftIdx; rightIdx < tokens.length; rightIdx++) {
if (tokens[rightIdx] === ')') {
parensLevel--;
if (parensLevel === 0) {
break;
}
} else if (tokens[rightIdx] === '(') {
parensLevel++;
}
}
if (rightIdx >= tokens.length) {
throw new Error("Did not find matching right parenthesis.");
}
tokens = [
...tokens.slice(0, leftIdx),
parens(tokens.slice(leftIdx + 1, rightIdx)),
...tokens.slice(rightIdx + 1)
];
}
}
module.exports = parens;

View File

@@ -1,4 +1,9 @@
const dayjs = require("dayjs");
const AndExp = require('./expressions/and');
const OrExp = require('./expressions/or');
const NotExp = require('./expressions/not');
const NoteCacheFulltextExp = require('./expressions/note_cache_fulltext');
const NoteContentFulltextExp = require('./expressions/note_content_fulltext');
const filterRegex = /(\b(AND|OR)\s+)?@(!?)([\p{L}\p{Number}_]+|"[^"]+")\s*((=|!=|<|<=|>|>=|!?\*=|!?=\*|!?\*=\*)\s*([^\s=*"]+|"[^"]+"))?/igu;
const smartValueRegex = /^(NOW|TODAY|WEEK|MONTH|YEAR) *([+\-] *\d+)?$/i;

View File

@@ -0,0 +1,305 @@
"use strict";
const AndExp = require('./expressions/and');
const OrExp = require('./expressions/or');
const NotExp = require('./expressions/not');
const ChildOfExp = require('./expressions/child_of');
const DescendantOfExp = require('./expressions/descendant_of');
const ParentOfExp = require('./expressions/parent_of');
const RelationWhereExp = require('./expressions/relation_where');
const PropertyComparisonExp = require('./expressions/property_comparison');
const AttributeExistsExp = require('./expressions/attribute_exists');
const LabelComparisonExp = require('./expressions/label_comparison');
const NoteCacheFulltextExp = require('./expressions/note_cache_fulltext');
const NoteContentFulltextExp = require('./expressions/note_content_fulltext');
const OrderByAndLimitExp = require('./expressions/order_by_and_limit');
const comparatorBuilder = require('./comparator_builder');
const ValueExtractor = require('./value_extractor');
function getFulltext(tokens, parsingContext) {
parsingContext.highlightedTokens.push(...tokens);
if (tokens.length === 0) {
return null;
}
else if (parsingContext.includeNoteContent) {
return new OrExp([
new NoteCacheFulltextExp(tokens),
new NoteContentFulltextExp('*=*', tokens)
]);
}
else {
return new NoteCacheFulltextExp(tokens);
}
}
function isOperator(str) {
return str.match(/^[=<>*]+$/);
}
function getExpression(tokens, parsingContext, level = 0) {
if (tokens.length === 0) {
return null;
}
const expressions = [];
let op = null;
let i;
function parseNoteProperty() {
if (tokens[i] !== '.') {
parsingContext.addError('Expected "." to separate field path');
return;
}
i++;
if (tokens[i] === 'content') {
i += 1;
const operator = tokens[i];
if (!isOperator(operator)) {
parsingContext.addError(`After content expected operator, but got "${tokens[i]}"`);
return;
}
i++;
return new NoteContentFulltextExp(operator, [tokens[i]]);
}
if (tokens[i] === 'parents') {
i += 1;
return new ChildOfExp(parseNoteProperty());
}
if (tokens[i] === 'children') {
i += 1;
return new ParentOfExp(parseNoteProperty());
}
if (tokens[i] === 'ancestors') {
i += 1;
return new DescendantOfExp(parseNoteProperty());
}
if (tokens[i] === 'labels') {
if (tokens[i + 1] !== '.') {
parsingContext.addError(`Expected "." to separate field path, god "${tokens[i + 1]}"`);
return;
}
i += 2;
return parseLabel(tokens[i]);
}
if (tokens[i] === 'relations') {
if (tokens[i + 1] !== '.') {
parsingContext.addError(`Expected "." to separate field path, god "${tokens[i + 1]}"`);
return;
}
i += 2;
return parseRelation(tokens[i]);
}
if (PropertyComparisonExp.isProperty(tokens[i])) {
const propertyName = tokens[i];
const operator = tokens[i + 1];
const comparedValue = tokens[i + 2];
const comparator = comparatorBuilder(operator, comparedValue);
if (!comparator) {
parsingContext.addError(`Can't find operator '${operator}'`);
return;
}
i += 2;
return new PropertyComparisonExp(propertyName, comparator);
}
parsingContext.addError(`Unrecognized note property "${tokens[i]}"`);
}
function parseLabel(labelName) {
parsingContext.highlightedTokens.push(labelName);
if (i < tokens.length - 2 && isOperator(tokens[i + 1])) {
let operator = tokens[i + 1];
const comparedValue = tokens[i + 2];
parsingContext.highlightedTokens.push(comparedValue);
if (parsingContext.fuzzyAttributeSearch && operator === '=') {
operator = '*=*';
}
const comparator = comparatorBuilder(operator, comparedValue);
if (!comparator) {
parsingContext.addError(`Can't find operator '${operator}'`);
} else {
i += 2;
return new LabelComparisonExp('label', labelName, comparator);
}
} else {
return new AttributeExistsExp('label', labelName, parsingContext.fuzzyAttributeSearch);
}
}
function parseRelation(relationName) {
parsingContext.highlightedTokens.push(relationName);
if (i < tokens.length - 2 && tokens[i + 1] === '.') {
i += 1;
return new RelationWhereExp(relationName, parseNoteProperty());
} else {
return new AttributeExistsExp('relation', relationName, parsingContext.fuzzyAttributeSearch);
}
}
function parseOrderByAndLimit() {
const orderDefinitions = [];
let limit;
if (tokens[i] === 'orderby') {
do {
const propertyPath = [];
let direction = "asc";
do {
i++;
propertyPath.push(tokens[i]);
i++;
} while (tokens[i] === '.');
if (["asc", "desc"].includes(tokens[i])) {
direction = tokens[i];
i++;
}
const valueExtractor = new ValueExtractor(propertyPath);
if (valueExtractor.validate()) {
parsingContext.addError(valueExtractor.validate());
}
orderDefinitions.push({
valueExtractor,
direction
});
} while (tokens[i] === ',');
}
if (tokens[i] === 'limit') {
limit = parseInt(tokens[i + 1]);
}
return new OrderByAndLimitExp(orderDefinitions, limit);
}
function getAggregateExpression() {
if (op === null || op === 'and') {
return AndExp.of(expressions);
}
else if (op === 'or') {
return OrExp.of(expressions);
}
}
for (i = 0; i < tokens.length; i++) {
const token = tokens[i];
if (token === '#' || token === '~') {
continue;
}
if (Array.isArray(token)) {
expressions.push(getExpression(token, parsingContext, level++));
}
else if (token.startsWith('#')) {
const labelName = token.substr(1);
expressions.push(parseLabel(labelName));
}
else if (token.startsWith('~')) {
const relationName = token.substr(1);
expressions.push(parseRelation(relationName));
}
else if (['orderby', 'limit'].includes(token)) {
if (level !== 0) {
parsingContext.addError('orderBy can appear only on the top expression level');
continue;
}
const exp = parseOrderByAndLimit();
if (!exp) {
continue;
}
exp.subExpression = getAggregateExpression();
return exp;
}
else if (token === 'not') {
i += 1;
if (!Array.isArray(tokens[i])) {
parsingContext.addError(`not keyword should be followed by sub-expression in parenthesis, got ${tokens[i]} instead`);
continue;
}
expressions.push(new NotExp(getExpression(tokens[i], parsingContext, level++)));
}
else if (token === 'note') {
i++;
expressions.push(parseNoteProperty(tokens));
continue;
}
else if (['and', 'or'].includes(token)) {
if (!op) {
op = token;
}
else if (op !== token) {
parsingContext.addError('Mixed usage of AND/OR - always use parenthesis to group AND/OR expressions.');
}
}
else if (isOperator(token)) {
parsingContext.addError(`Misplaced or incomplete expression "${token}"`);
}
else {
parsingContext.addError(`Unrecognized expression "${token}"`);
}
if (!op && expressions.length > 1) {
op = 'and';
}
}
return getAggregateExpression();
}
function parse({fulltextTokens, expressionTokens, parsingContext}) {
return AndExp.of([
getFulltext(fulltextTokens, parsingContext),
getExpression(expressionTokens, parsingContext)
]);
}
module.exports = parse;

View File

@@ -0,0 +1,20 @@
"use strict";
class ParsingContext {
constructor(params = {}) {
this.includeNoteContent = !!params.includeNoteContent;
this.fuzzyAttributeSearch = !!params.fuzzyAttributeSearch;
this.highlightedTokens = [];
this.error = null;
}
addError(error) {
// we record only the first error, subsequent ones are usually consequence of the first
if (!this.error) {
this.error = error;
console.log(this.error);
}
}
}
module.exports = ParsingContext;

View File

@@ -0,0 +1,179 @@
"use strict";
const lexer = require('./lexer');
const parens = require('./parens');
const parser = require('./parser');
const NoteSet = require("./note_set");
const SearchResult = require("./search_result");
const ParsingContext = require("./parsing_context");
const noteCache = require('../note_cache/note_cache');
const noteCacheService = require('../note_cache/note_cache_service');
const hoistedNoteService = require('../hoisted_note');
const utils = require('../utils');
/**
* @param {Expression} expression
* @return {Promise<SearchResult[]>}
*/
async function findNotesWithExpression(expression) {
const hoistedNote = noteCache.notes[hoistedNoteService.getHoistedNoteId()];
const allNotes = (hoistedNote && hoistedNote.noteId !== 'root')
? hoistedNote.subtreeNotes
: Object.values(noteCache.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] || noteCacheService.getSomePath(note))
.filter(notePathArray => notePathArray.includes(hoistedNoteService.getHoistedNoteId()))
.map(notePathArray => new SearchResult(notePathArray));
if (!noteSet.sorted) {
// 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;
}
function parseQueryToExpression(query, parsingContext) {
const {fulltextTokens, expressionTokens} = lexer(query);
const structuredExpressionTokens = parens(expressionTokens);
const expression = parser({
fulltextTokens,
expressionTokens: structuredExpressionTokens,
parsingContext
});
return expression;
}
/**
* @param {string} query
* @param {ParsingContext} parsingContext
* @return {Promise<SearchResult[]>}
*/
async function findNotesWithQuery(query, parsingContext) {
const expression = parseQueryToExpression(query, parsingContext);
if (!expression) {
return [];
}
return await findNotesWithExpression(expression);
}
async function searchNotes(query) {
if (!query.trim().length) {
return [];
}
const parsingContext = new ParsingContext({
includeNoteContent: true,
fuzzyAttributeSearch: false
});
let searchResults = await findNotesWithQuery(query, parsingContext);
searchResults = searchResults.slice(0, 200);
return searchResults;
}
async function searchNotesForAutocomplete(query) {
if (!query.trim().length) {
return [];
}
const parsingContext = new ParsingContext({
includeNoteContent: false,
fuzzyAttributeSearch: true
});
let searchResults = await findNotesWithQuery(query, parsingContext);
searchResults = searchResults.slice(0, 200);
highlightSearchResults(searchResults, parsingContext.highlightedTokens);
return searchResults.map(result => {
return {
notePath: result.notePath,
notePathTitle: result.notePathTitle,
highlightedNotePathTitle: result.highlightedNotePathTitle
}
});
}
function highlightSearchResults(searchResults, highlightedTokens) {
// 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)
highlightedTokens = highlightedTokens.map(token => token.replace('/[<\{\}]/g', ''));
// sort by the longest so we first highlight longest matches
highlightedTokens.sort((a, b) => a.length > b.length ? -1 : 1);
for (const result of searchResults) {
const note = noteCache.notes[result.noteId];
result.highlightedNotePathTitle = result.notePathTitle;
for (const attr of note.attributes) {
if (highlightedTokens.find(token => attr.name.includes(token) || attr.value.includes(token))) {
result.highlightedNotePathTitle += ` <small>${formatAttribute(attr)}</small>`;
}
}
}
for (const token of highlightedTokens) {
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;
}
}
module.exports = {
searchNotes,
searchNotesForAutocomplete,
findNotesWithQuery
};

View File

@@ -0,0 +1,20 @@
"use strict";
const noteCacheService = require('../note_cache/note_cache_service');
class SearchResult {
constructor(notePathArray) {
this.notePathArray = notePathArray;
this.notePathTitle = noteCacheService.getNoteTitleForPath(notePathArray);
}
get notePath() {
return this.notePathArray.join('/');
}
get noteId() {
return this.notePathArray[this.notePathArray.length - 1];
}
}
module.exports = SearchResult;

View File

@@ -0,0 +1,110 @@
"use strict";
/**
* Search string is lower cased for case insensitive comparison. But when retrieving properties
* we need case sensitive form so we have this translation object.
*/
const PROP_MAPPING = {
"noteid": "noteId",
"title": "title",
"type": "type",
"mime": "mime",
"isprotected": "isProtected",
"isarhived": "isArchived",
"datecreated": "dateCreated",
"datemodified": "dateModified",
"utcdatecreated": "utcDateCreated",
"utcdatemodified": "utcDateModified",
"contentlength": "contentLength",
"parentcount": "parentCount",
"childrencount": "childrenCount",
"attributecount": "attributeCount",
"labelcount": "labelCount",
"relationcount": "relationCount"
};
class ValueExtractor {
constructor(propertyPath) {
this.propertyPath = propertyPath.map(pathEl => pathEl.toLowerCase());
if (this.propertyPath[0].startsWith('#')) {
this.propertyPath = ['note', 'labels', this.propertyPath[0].substr(1), ...this.propertyPath.slice( 1, this.propertyPath.length)];
}
else if (this.propertyPath[0].startsWith('~')) {
this.propertyPath = ['note', 'relations', this.propertyPath[0].substr(1), ...this.propertyPath.slice( 1, this.propertyPath.length)];
}
}
validate() {
if (this.propertyPath[0] !== 'note') {
return `property specifier must start with 'note', but starts with '${this.propertyPath[0]}'`;
}
for (let i = 1; i < this.propertyPath.length; i++) {
const pathEl = this.propertyPath[i];
if (pathEl === 'labels') {
if (i !== this.propertyPath.length - 2) {
return `label is a terminal property specifier and must be at the end`;
}
i++;
}
else if (pathEl === 'relations') {
if (i >= this.propertyPath.length - 2) {
return `relation name or property name is missing`;
}
i++;
}
else if (pathEl in PROP_MAPPING) {
if (i !== this.propertyPath.length - 1) {
return `${pathEl} is a terminal property specifier and must be at the end`;
}
}
else if (!["parents", "children"].includes(pathEl)) {
return `Unrecognized property specifier ${pathEl}`;
}
}
}
extract(note) {
let cursor = note;
let i;
const cur = () => this.propertyPath[i];
for (i = 0; i < this.propertyPath.length; i++) {
if (!cursor) {
return cursor;
}
if (cur() === 'labels') {
i++;
return cursor.getLabelValue(cur());
}
if (cur() === 'relations') {
i++;
cursor = cursor.getRelationTarget(cur());
}
else if (cur() === 'parents') {
cursor = cursor.parents[0];
}
else if (cur() === 'children') {
cursor = cursor.children[0];
}
else if (cur() in PROP_MAPPING) {
return cursor[PROP_MAPPING[cur()]];
}
else {
// FIXME
}
}
}
}
module.exports = ValueExtractor;

View File

@@ -197,4 +197,4 @@ module.exports = {
createInitialDatabase,
createDatabaseForSync,
dbInitialized
};
};

View File

@@ -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
};
};

View File

@@ -8,6 +8,11 @@
<body class="desktop theme-<%= theme %>" style="--main-font-size: <%= mainFontSize %>%; --tree-font-size: <%= treeFontSize %>%; --detail-font-size: <%= detailFontSize %>%;">
<noscript>Trilium requires JavaScript to be enabled.</noscript>
<script>
// hide body to reduce flickering on the startup. This is done through JS and not CSS to not hide <noscript>
document.getElementsByTagName("body")[0].style.display = "none";
</script>
<div id="toast-container" class="d-flex flex-column justify-content-center align-items-center"></div>
<div class="dropdown-menu dropdown-menu-sm" id="context-menu-container"></div>
@@ -78,6 +83,10 @@
<link href="stylesheets/style.css" rel="stylesheet">
<link href="stylesheets/detail.css" rel="stylesheet">
<script>
$("body").show();
</script>
<script src="app/desktop.js" crossorigin type="module"></script>
<link rel="stylesheet" type="text/css" href="libraries/boxicons/css/boxicons.min.css">