Merge branch 'm43'

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

2
.idea/dataSources.xml generated
View File

@ -1,7 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?> <?xml version="1.0" encoding="UTF-8"?>
<project version="4"> <project version="4">
<component name="DataSourceManagerImpl" format="xml" multifile-model="true"> <component name="DataSourceManagerImpl" format="xml" multifile-model="true">
<data-source source="LOCAL" name="document.db" uuid="b0b03187-36c8-4ec1-bdab-fd4273cd692e"> <data-source source="LOCAL" name="document.db" uuid="4e69c96a-8a2b-43f5-9b40-d1608f75f7a4">
<driver-ref>sqlite.xerial</driver-ref> <driver-ref>sqlite.xerial</driver-ref>
<synchronize>true</synchronize> <synchronize>true</synchronize>
<jdbc-driver>org.sqlite.JDBC</jdbc-driver> <jdbc-driver>org.sqlite.JDBC</jdbc-driver>

675
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -18,7 +18,8 @@
"build-backend-docs": "./node_modules/.bin/jsdoc -c jsdoc-conf.json -d ./docs/backend_api src/entities/*.js src/services/backend_script_api.js", "build-backend-docs": "./node_modules/.bin/jsdoc -c jsdoc-conf.json -d ./docs/backend_api src/entities/*.js src/services/backend_script_api.js",
"build-frontend-docs": "./node_modules/.bin/jsdoc -c jsdoc-conf.json -d ./docs/frontend_api src/public/app/entities/*.js src/public/app/services/frontend_script_api.js src/public/app/widgets/collapsible_widget.js", "build-frontend-docs": "./node_modules/.bin/jsdoc -c jsdoc-conf.json -d ./docs/frontend_api src/public/app/entities/*.js src/public/app/services/frontend_script_api.js src/public/app/widgets/collapsible_widget.js",
"build-docs": "npm run build-backend-docs && npm run build-frontend-docs", "build-docs": "npm run build-backend-docs && npm run build-frontend-docs",
"webpack": "npx webpack -c webpack-desktop.config.js && npx webpack -c webpack-mobile.config.js && npx webpack -c webpack-setup.config.js" "webpack": "npx webpack -c webpack-desktop.config.js && npx webpack -c webpack-mobile.config.js && npx webpack -c webpack-setup.config.js",
"test": "jasmine"
}, },
"dependencies": { "dependencies": {
"async-mutex": "0.2.2", "async-mutex": "0.2.2",
@ -28,16 +29,16 @@
"commonmark": "0.29.1", "commonmark": "0.29.1",
"cookie-parser": "1.4.5", "cookie-parser": "1.4.5",
"csurf": "1.11.0", "csurf": "1.11.0",
"dayjs": "1.8.26", "dayjs": "1.8.27",
"debug": "4.1.1", "debug": "4.1.1",
"ejs": "3.1.2", "ejs": "3.1.3",
"electron-debug": "3.0.1", "electron-debug": "3.1.0",
"electron-dl": "3.0.0", "electron-dl": "3.0.0",
"electron-find": "1.0.6", "electron-find": "1.0.6",
"electron-window-state": "5.0.3", "electron-window-state": "5.0.3",
"express": "4.17.1", "express": "4.17.1",
"express-session": "1.17.1", "express-session": "1.17.1",
"file-type": "14.3.0", "file-type": "14.5.0",
"fs-extra": "9.0.0", "fs-extra": "9.0.0",
"helmet": "3.22.0", "helmet": "3.22.0",
"html": "1.0.0", "html": "1.0.0",
@ -51,14 +52,14 @@
"imagemin-pngquant": "8.0.0", "imagemin-pngquant": "8.0.0",
"ini": "1.3.5", "ini": "1.3.5",
"is-svg": "4.2.1", "is-svg": "4.2.1",
"jimp": "0.10.3", "jimp": "0.12.1",
"mime-types": "2.1.27", "mime-types": "2.1.27",
"multer": "1.4.2", "multer": "1.4.2",
"node-abi": "2.16.0", "node-abi": "2.17.0",
"open": "7.0.3", "open": "7.0.4",
"portscanner": "2.2.0", "portscanner": "2.2.0",
"rand-token": "1.0.1", "rand-token": "1.0.1",
"rcedit": "2.1.1", "rcedit": "2.2.0",
"rimraf": "3.0.2", "rimraf": "3.0.2",
"sanitize-filename": "1.6.3", "sanitize-filename": "1.6.3",
"sax": "1.2.4", "sax": "1.2.4",
@ -66,22 +67,23 @@
"serve-favicon": "2.5.0", "serve-favicon": "2.5.0",
"session-file-store": "1.4.0", "session-file-store": "1.4.0",
"simple-node-logger": "18.12.24", "simple-node-logger": "18.12.24",
"sqlite": "4.0.7", "sqlite": "4.0.9",
"sqlite3": "4.1.1", "sqlite3": "4.1.1",
"string-similarity": "4.0.1", "string-similarity": "4.0.1",
"tar-stream": "2.1.2", "tar-stream": "2.1.2",
"turndown": "6.0.0", "turndown": "6.0.0",
"turndown-plugin-gfm": "1.0.2", "turndown-plugin-gfm": "1.0.2",
"unescape": "1.0.1", "unescape": "1.0.1",
"ws": "7.2.5", "ws": "7.3.0",
"yauzl": "^2.10.0", "yauzl": "^2.10.0",
"yazl": "^2.5.1" "yazl": "^2.5.1"
}, },
"devDependencies": { "devDependencies": {
"electron": "9.0.0", "electron": "9.0.0",
"electron-builder": "22.6.0", "electron-builder": "22.6.1",
"electron-packager": "14.2.1", "electron-packager": "14.2.1",
"electron-rebuild": "1.10.1", "electron-rebuild": "1.11.0",
"jasmine": "^3.5.0",
"jsdoc": "3.6.4", "jsdoc": "3.6.4",
"lorem-ipsum": "2.0.3", "lorem-ipsum": "2.0.3",
"webpack": "5.0.0-beta.16", "webpack": "5.0.0-beta.16",

71
spec/lexer.spec.js Normal file
View File

@ -0,0 +1,71 @@
const lexer = require('../src/services/search/lexer');
describe("Lexer fulltext", () => {
it("simple lexing", () => {
expect(lexer("hello world").fulltextTokens)
.toEqual(["hello", "world"]);
});
it("use quotes to keep words together", () => {
expect(lexer("'hello world' my friend").fulltextTokens)
.toEqual(["hello world", "my", "friend"]);
expect(lexer('"hello world" my friend').fulltextTokens)
.toEqual(["hello world", "my", "friend"]);
expect(lexer('`hello world` my friend').fulltextTokens)
.toEqual(["hello world", "my", "friend"]);
});
it("you can use different quotes and other special characters inside quotes", () => {
expect(lexer("'i can use \" or ` or #~=*' without problem").fulltextTokens)
.toEqual(["i can use \" or ` or #~=*", "without", "problem"]);
});
it("if quote is not ended then it's just one long token", () => {
expect(lexer("'unfinished quote").fulltextTokens)
.toEqual(["unfinished quote"]);
});
it("parenthesis and symbols in fulltext section are just normal characters", () => {
expect(lexer("what's u=p <b(r*t)h>").fulltextTokens)
.toEqual(["what's", "u=p", "<b(r*t)h>"]);
});
it("escaping special characters", () => {
expect(lexer("hello \\#\\~\\'").fulltextTokens)
.toEqual(["hello", "#~'"]);
});
});
describe("Lexer expression", () => {
it("simple attribute existence", () => {
expect(lexer("#label ~relation").expressionTokens)
.toEqual(["#label", "~relation"]);
});
it("simple label operators", () => {
expect(lexer("#label*=*text").expressionTokens)
.toEqual(["#label", "*=*", "text"]);
});
it("spaces in attribute names and values", () => {
expect(lexer(`#'long label'="hello o' world" ~'long relation'`).expressionTokens)
.toEqual(["#long label", "=", "hello o' world", "~long relation"]);
});
it("complex expressions with and, or and parenthesis", () => {
expect(lexer(`# (#label=text OR #second=text) AND ~relation`).expressionTokens)
.toEqual(["#", "(", "#label", "=", "text", "or", "#second", "=", "text", ")", "and", "~relation"]);
});
it("dot separated properties", () => {
expect(lexer(`# ~author.title = 'Hugh Howey' AND note.'book title' = 'Silo'`).expressionTokens)
.toEqual(["#", "~author", ".", "title", "=", "hugh howey", "and", "note", ".", "book title", "=", "silo"]);
});
it("negation of sub-expression", () => {
expect(lexer(`# not(#capital) and note.noteId != "root"`).expressionTokens)
.toEqual(["#", "not", "(", "#capital", ")", "and", "note", ".", "noteid", "!=", "root"]);
});
});

View File

@ -0,0 +1,70 @@
const Note = require('../src/services/note_cache/entities/note');
const Branch = require('../src/services/note_cache/entities/branch');
const Attribute = require('../src/services/note_cache/entities/attribute');
const noteCache = require('../src/services/note_cache/note_cache');
const randtoken = require('rand-token').generator({source: 'crypto'});
/** @return {Note} */
function findNoteByTitle(searchResults, title) {
return searchResults
.map(sr => noteCache.notes[sr.noteId])
.find(note => note.title === title);
}
class NoteBuilder {
constructor(note) {
this.note = note;
}
label(name, value = '', isInheritable = false) {
new Attribute(noteCache, {
attributeId: id(),
noteId: this.note.noteId,
type: 'label',
isInheritable,
name,
value
});
return this;
}
relation(name, targetNote) {
new Attribute(noteCache, {
attributeId: id(),
noteId: this.note.noteId,
type: 'relation',
name,
value: targetNote.noteId
});
return this;
}
child(childNoteBuilder, prefix = "") {
new Branch(noteCache, {
branchId: id(),
noteId: childNoteBuilder.note.noteId,
parentNoteId: this.note.noteId,
prefix
});
return this;
}
}
function id() {
return randtoken.generate(10);
}
function note(title) {
const note = new Note(noteCache, {noteId: id(), title});
return new NoteBuilder(note);
}
module.exports = {
NoteBuilder,
findNoteByTitle,
note
};

21
spec/parens.spec.js Normal file
View File

@ -0,0 +1,21 @@
const parens = require('../src/services/search/parens');
describe("Parens handler", () => {
it("handles parens", () => {
expect(parens(["(", "hello", ")", "and", "(", "(", "pick", "one", ")", "and", "another", ")"]))
.toEqual([
[
"hello"
],
"and",
[
[
"pick",
"one"
],
"and",
"another"
]
]);
});
});

136
spec/parser.spec.js Normal file
View File

@ -0,0 +1,136 @@
const ParsingContext = require("../src/services/search/parsing_context");
const parser = require('../src/services/search/parser');
describe("Parser", () => {
it("fulltext parser without content", () => {
const rootExp = parser({
fulltextTokens: ["hello", "hi"],
expressionTokens: [],
parsingContext: new ParsingContext({includeNoteContent: false})
});
expect(rootExp.constructor.name).toEqual("NoteCacheFulltextExp");
expect(rootExp.tokens).toEqual(["hello", "hi"]);
});
it("fulltext parser with content", () => {
const rootExp = parser({
fulltextTokens: ["hello", "hi"],
expressionTokens: [],
parsingContext: new ParsingContext({includeNoteContent: true})
});
expect(rootExp.constructor.name).toEqual("OrExp");
const [firstSub, secondSub] = rootExp.subExpressions;
expect(firstSub.constructor.name).toEqual("NoteCacheFulltextExp");
expect(firstSub.tokens).toEqual(["hello", "hi"]);
expect(secondSub.constructor.name).toEqual("NoteContentFulltextExp");
expect(secondSub.tokens).toEqual(["hello", "hi"]);
});
it("simple label comparison", () => {
const rootExp = parser({
fulltextTokens: [],
expressionTokens: ["#mylabel", "=", "text"],
parsingContext: new ParsingContext()
});
expect(rootExp.constructor.name).toEqual("LabelComparisonExp");
expect(rootExp.attributeType).toEqual("label");
expect(rootExp.attributeName).toEqual("mylabel");
expect(rootExp.comparator).toBeTruthy();
});
it("simple label AND", () => {
const rootExp = parser({
fulltextTokens: [],
expressionTokens: ["#first", "=", "text", "and", "#second", "=", "text"],
parsingContext: new ParsingContext(true)
});
expect(rootExp.constructor.name).toEqual("AndExp");
const [firstSub, secondSub] = rootExp.subExpressions;
expect(firstSub.constructor.name).toEqual("LabelComparisonExp");
expect(firstSub.attributeName).toEqual("first");
expect(secondSub.constructor.name).toEqual("LabelComparisonExp");
expect(secondSub.attributeName).toEqual("second");
});
it("simple label AND without explicit AND", () => {
const rootExp = parser({
fulltextTokens: [],
expressionTokens: ["#first", "=", "text", "#second", "=", "text"],
parsingContext: new ParsingContext()
});
expect(rootExp.constructor.name).toEqual("AndExp");
const [firstSub, secondSub] = rootExp.subExpressions;
expect(firstSub.constructor.name).toEqual("LabelComparisonExp");
expect(firstSub.attributeName).toEqual("first");
expect(secondSub.constructor.name).toEqual("LabelComparisonExp");
expect(secondSub.attributeName).toEqual("second");
});
it("simple label OR", () => {
const rootExp = parser({
fulltextTokens: [],
expressionTokens: ["#first", "=", "text", "or", "#second", "=", "text"],
parsingContext: new ParsingContext()
});
expect(rootExp.constructor.name).toEqual("OrExp");
const [firstSub, secondSub] = rootExp.subExpressions;
expect(firstSub.constructor.name).toEqual("LabelComparisonExp");
expect(firstSub.attributeName).toEqual("first");
expect(secondSub.constructor.name).toEqual("LabelComparisonExp");
expect(secondSub.attributeName).toEqual("second");
});
it("fulltext and simple label", () => {
const rootExp = parser({
fulltextTokens: ["hello"],
expressionTokens: ["#mylabel", "=", "text"],
parsingContext: new ParsingContext()
});
expect(rootExp.constructor.name).toEqual("AndExp");
const [firstSub, secondSub] = rootExp.subExpressions;
expect(firstSub.constructor.name).toEqual("NoteCacheFulltextExp");
expect(firstSub.tokens).toEqual(["hello"]);
expect(secondSub.constructor.name).toEqual("LabelComparisonExp");
expect(secondSub.attributeName).toEqual("mylabel");
});
it("label sub-expression", () => {
const rootExp = parser({
fulltextTokens: [],
expressionTokens: ["#first", "=", "text", "or", ["#second", "=", "text", "and", "#third", "=", "text"]],
parsingContext: new ParsingContext()
});
expect(rootExp.constructor.name).toEqual("OrExp");
const [firstSub, secondSub] = rootExp.subExpressions;
expect(firstSub.constructor.name).toEqual("LabelComparisonExp");
expect(firstSub.attributeName).toEqual("first");
expect(secondSub.constructor.name).toEqual("AndExp");
const [firstSubSub, secondSubSub] = secondSub.subExpressions;
expect(firstSubSub.constructor.name).toEqual("LabelComparisonExp");
expect(firstSubSub.attributeName).toEqual("second");
expect(secondSubSub.constructor.name).toEqual("LabelComparisonExp");
expect(secondSubSub.attributeName).toEqual("third");
});
});

532
spec/search.spec.js Normal file
View File

@ -0,0 +1,532 @@
const searchService = require('../src/services/search/search');
const Note = require('../src/services/note_cache/entities/note');
const Branch = require('../src/services/note_cache/entities/branch');
const Attribute = require('../src/services/note_cache/entities/attribute');
const ParsingContext = require('../src/services/search/parsing_context');
const dateUtils = require('../src/services/date_utils');
const noteCache = require('../src/services/note_cache/note_cache');
const {NoteBuilder, findNoteByTitle, note} = require('./note_cache_mocking');
describe("Search", () => {
let rootNote;
beforeEach(() => {
noteCache.reset();
rootNote = new NoteBuilder(new Note(noteCache, {noteId: 'root', title: 'root'}));
new Branch(noteCache, {branchId: 'root', noteId: 'root', parentNoteId: 'none'});
});
it("simple path match", async () => {
rootNote
.child(note("Europe")
.child(note("Austria"))
);
const parsingContext = new ParsingContext();
const searchResults = await searchService.findNotesWithQuery('europe austria', parsingContext);
expect(searchResults.length).toEqual(1);
expect(findNoteByTitle(searchResults, "Austria")).toBeTruthy();
});
it("only end leafs are results", async () => {
rootNote
.child(note("Europe")
.child(note("Austria"))
);
const parsingContext = new ParsingContext();
const searchResults = await searchService.findNotesWithQuery('europe', parsingContext);
expect(searchResults.length).toEqual(1);
expect(findNoteByTitle(searchResults, "Europe")).toBeTruthy();
});
it("only end leafs are results", async () => {
rootNote
.child(note("Europe")
.child(note("Austria")
.label('capital', 'Vienna'))
);
const parsingContext = new ParsingContext();
const searchResults = await searchService.findNotesWithQuery('Vienna', parsingContext);
expect(searchResults.length).toEqual(1);
expect(findNoteByTitle(searchResults, "Austria")).toBeTruthy();
});
it("label comparison with short syntax", async () => {
rootNote
.child(note("Europe")
.child(note("Austria")
.label('capital', 'Vienna'))
.child(note("Czech Republic")
.label('capital', 'Prague'))
);
const parsingContext = new ParsingContext();
let searchResults = await searchService.findNotesWithQuery('#capital=Vienna', parsingContext);
expect(searchResults.length).toEqual(1);
expect(findNoteByTitle(searchResults, "Austria")).toBeTruthy();
});
it("label comparison with full syntax", async () => {
rootNote
.child(note("Europe")
.child(note("Austria")
.label('capital', 'Vienna'))
.child(note("Czech Republic")
.label('capital', 'Prague'))
);
const parsingContext = new ParsingContext();
let searchResults = await searchService.findNotesWithQuery('# note.labels.capital=Prague', parsingContext);
expect(searchResults.length).toEqual(1);
expect(findNoteByTitle(searchResults, "Czech Republic")).toBeTruthy();
});
it("numeric label comparison", async () => {
rootNote
.child(note("Europe")
.label('country', '', true)
.child(note("Austria")
.label('population', '8859000'))
.child(note("Czech Republic")
.label('population', '10650000'))
);
const parsingContext = new ParsingContext();
const searchResults = await searchService.findNotesWithQuery('#country #population >= 10000000', parsingContext);
expect(searchResults.length).toEqual(1);
expect(findNoteByTitle(searchResults, "Czech Republic")).toBeTruthy();
});
it("numeric label comparison fallback to string comparison", async () => {
// dates should not be coerced into numbers which would then give wrong numbers
rootNote
.child(note("Europe")
.label('country', '', true)
.child(note("Austria")
.label('established', '1955-07-27'))
.child(note("Czech Republic")
.label('established', '1993-01-01'))
.child(note("Hungary")
.label('established', '1920-06-04'))
);
const parsingContext = new ParsingContext();
let searchResults = await searchService.findNotesWithQuery('#established <= 1955-01-01', parsingContext);
expect(searchResults.length).toEqual(1);
expect(findNoteByTitle(searchResults, "Hungary")).toBeTruthy();
searchResults = await searchService.findNotesWithQuery('#established > 1955-01-01', parsingContext);
expect(searchResults.length).toEqual(2);
expect(findNoteByTitle(searchResults, "Austria")).toBeTruthy();
expect(findNoteByTitle(searchResults, "Czech Republic")).toBeTruthy();
});
it("smart date comparisons", async () => {
// dates should not be coerced into numbers which would then give wrong numbers
rootNote
.child(note("My note")
.label('year', new Date().getFullYear().toString())
.label('month', dateUtils.localNowDate().substr(0, 7))
.label('date', dateUtils.localNowDate())
.label('dateTime', dateUtils.localNowDateTime())
);
const parsingContext = new ParsingContext();
async function test(query, expectedResultCount) {
const searchResults = await searchService.findNotesWithQuery(query, parsingContext);
expect(searchResults.length).toEqual(expectedResultCount);
if (expectedResultCount === 1) {
expect(findNoteByTitle(searchResults, "My note")).toBeTruthy();
}
}
await test("#year = YEAR", 1);
await test("#year >= YEAR", 1);
await test("#year <= YEAR", 1);
await test("#year < YEAR+1", 1);
await test("#year > YEAR+1", 0);
await test("#month = MONTH", 1);
await test("#date = TODAY", 1);
await test("#date > TODAY", 0);
await test("#date > TODAY-1", 1);
await test("#date < TODAY+1", 1);
await test("#date < 'TODAY + 1'", 1);
await test("#dateTime <= NOW+10", 1);
await test("#dateTime < NOW-10", 0);
await test("#dateTime >= NOW-10", 1);
await test("#dateTime < NOW-10", 0);
});
it("logical or", async () => {
rootNote
.child(note("Europe")
.label('country', '', true)
.child(note("Austria")
.label('languageFamily', 'germanic'))
.child(note("Czech Republic")
.label('languageFamily', 'slavic'))
.child(note("Hungary")
.label('languageFamily', 'finnougric'))
);
const parsingContext = new ParsingContext();
const searchResults = await searchService.findNotesWithQuery('#languageFamily = slavic OR #languageFamily = germanic', parsingContext);
expect(searchResults.length).toEqual(2);
expect(findNoteByTitle(searchResults, "Czech Republic")).toBeTruthy();
expect(findNoteByTitle(searchResults, "Austria")).toBeTruthy();
});
it("fuzzy attribute search", async () => {
rootNote
.child(note("Europe")
.label('country', '', true)
.child(note("Austria")
.label('languageFamily', 'germanic'))
.child(note("Czech Republic")
.label('languageFamily', 'slavic')));
let parsingContext = new ParsingContext({fuzzyAttributeSearch: false});
let searchResults = await searchService.findNotesWithQuery('#language', parsingContext);
expect(searchResults.length).toEqual(0);
searchResults = await searchService.findNotesWithQuery('#languageFamily=ger', parsingContext);
expect(searchResults.length).toEqual(0);
parsingContext = new ParsingContext({fuzzyAttributeSearch: true});
searchResults = await searchService.findNotesWithQuery('#language', parsingContext);
expect(searchResults.length).toEqual(2);
searchResults = await searchService.findNotesWithQuery('#languageFamily=ger', parsingContext);
expect(searchResults.length).toEqual(1);
expect(findNoteByTitle(searchResults, "Austria")).toBeTruthy();
});
it("filter by note property", async () => {
rootNote
.child(note("Europe")
.child(note("Austria"))
.child(note("Czech Republic")));
const parsingContext = new ParsingContext();
const searchResults = await searchService.findNotesWithQuery('# note.title =* czech', parsingContext);
expect(searchResults.length).toEqual(1);
expect(findNoteByTitle(searchResults, "Czech Republic")).toBeTruthy();
});
it("filter by note's parent", async () => {
rootNote
.child(note("Europe")
.child(note("Austria"))
.child(note("Czech Republic")
.child(note("Prague")))
)
.child(note("Asia")
.child(note('Taiwan')));
const parsingContext = new ParsingContext();
let searchResults = await searchService.findNotesWithQuery('# note.parents.title = Europe', parsingContext);
expect(searchResults.length).toEqual(2);
expect(findNoteByTitle(searchResults, "Austria")).toBeTruthy();
expect(findNoteByTitle(searchResults, "Czech Republic")).toBeTruthy();
searchResults = await searchService.findNotesWithQuery('# note.parents.title = Asia', parsingContext);
expect(searchResults.length).toEqual(1);
expect(findNoteByTitle(searchResults, "Taiwan")).toBeTruthy();
searchResults = await searchService.findNotesWithQuery('# note.parents.parents.title = Europe', parsingContext);
expect(searchResults.length).toEqual(1);
expect(findNoteByTitle(searchResults, "Prague")).toBeTruthy();
});
it("filter by note's ancestor", async () => {
rootNote
.child(note("Europe")
.child(note("Austria"))
.child(note("Czech Republic")
.child(note("Prague").label('city')))
)
.child(note("Asia")
.child(note('Taiwan')
.child(note('Taipei').label('city')))
);
const parsingContext = new ParsingContext();
let searchResults = await searchService.findNotesWithQuery('#city AND note.ancestors.title = Europe', parsingContext);
expect(searchResults.length).toEqual(1);
expect(findNoteByTitle(searchResults, "Prague")).toBeTruthy();
searchResults = await searchService.findNotesWithQuery('#city AND note.ancestors.title = Asia', parsingContext);
expect(searchResults.length).toEqual(1);
expect(findNoteByTitle(searchResults, "Taipei")).toBeTruthy();
});
it("filter by note's child", async () => {
rootNote
.child(note("Europe")
.child(note("Austria")
.child(note("Vienna")))
.child(note("Czech Republic")
.child(note("Prague"))))
.child(note("Oceania")
.child(note('Australia')));
const parsingContext = new ParsingContext();
let searchResults = await searchService.findNotesWithQuery('# note.children.title =* Aust', parsingContext);
expect(searchResults.length).toEqual(2);
expect(findNoteByTitle(searchResults, "Europe")).toBeTruthy();
expect(findNoteByTitle(searchResults, "Oceania")).toBeTruthy();
searchResults = await searchService.findNotesWithQuery('# note.children.title =* Aust AND note.children.title *= republic', parsingContext);
expect(searchResults.length).toEqual(1);
expect(findNoteByTitle(searchResults, "Europe")).toBeTruthy();
searchResults = await searchService.findNotesWithQuery('# note.children.children.title = Prague', parsingContext);
expect(searchResults.length).toEqual(1);
expect(findNoteByTitle(searchResults, "Europe")).toBeTruthy();
});
it("filter by relation's note properties using short syntax", async () => {
const austria = note("Austria");
const portugal = note("Portugal");
rootNote
.child(note("Europe")
.child(austria)
.child(note("Czech Republic")
.relation('neighbor', austria.note))
.child(portugal)
.child(note("Spain")
.relation('neighbor', portugal.note))
);
const parsingContext = new ParsingContext();
let searchResults = await searchService.findNotesWithQuery('# ~neighbor.title = Austria', parsingContext);
expect(searchResults.length).toEqual(1);
expect(findNoteByTitle(searchResults, "Czech Republic")).toBeTruthy();
searchResults = await searchService.findNotesWithQuery('# ~neighbor.title = Portugal', parsingContext);
expect(searchResults.length).toEqual(1);
expect(findNoteByTitle(searchResults, "Spain")).toBeTruthy();
});
it("filter by relation's note properties using long syntax", async () => {
const austria = note("Austria");
const portugal = note("Portugal");
rootNote
.child(note("Europe")
.child(austria)
.child(note("Czech Republic")
.relation('neighbor', austria.note))
.child(portugal)
.child(note("Spain")
.relation('neighbor', portugal.note))
);
const parsingContext = new ParsingContext();
const searchResults = await searchService.findNotesWithQuery('# note.relations.neighbor.title = Austria', parsingContext);
expect(searchResults.length).toEqual(1);
expect(findNoteByTitle(searchResults, "Czech Republic")).toBeTruthy();
});
it("filter by multiple level relation", async () => {
const austria = note("Austria");
const slovakia = note("Slovakia");
const italy = note("Italy");
const ukraine = note("Ukraine");
rootNote
.child(note("Europe")
.child(austria
.relation('neighbor', italy.note)
.relation('neighbor', slovakia.note))
.child(note("Czech Republic")
.relation('neighbor', austria.note)
.relation('neighbor', slovakia.note))
.child(slovakia
.relation('neighbor', ukraine.note))
.child(ukraine)
);
const parsingContext = new ParsingContext();
let searchResults = await searchService.findNotesWithQuery('# note.relations.neighbor.relations.neighbor.title = Italy', parsingContext);
expect(searchResults.length).toEqual(1);
expect(findNoteByTitle(searchResults, "Czech Republic")).toBeTruthy();
searchResults = await searchService.findNotesWithQuery('# note.relations.neighbor.relations.neighbor.title = Ukraine', parsingContext);
expect(searchResults.length).toEqual(2);
expect(findNoteByTitle(searchResults, "Czech Republic")).toBeTruthy();
expect(findNoteByTitle(searchResults, "Austria")).toBeTruthy();
});
it("test note properties", async () => {
const austria = note("Austria");
austria.relation('myself', austria.note);
austria.label('capital', 'Vienna');
austria.label('population', '8859000');
rootNote
.child(note("Asia"))
.child(note("Europe")
.child(austria
.child(note("Vienna"))
.child(note("Sebastian Kurz"))
)
)
.child(note("Mozart")
.child(austria));
austria.note.type = 'text';
austria.note.mime = 'text/html';
austria.note.isProtected = false;
austria.note.dateCreated = '2020-05-14 12:11:42.001+0200';
austria.note.dateModified = '2020-05-14 13:11:42.001+0200';
austria.note.utcDateCreated = '2020-05-14 10:11:42.001Z';
austria.note.utcDateModified = '2020-05-14 11:11:42.001Z';
austria.note.contentLength = 1001;
const parsingContext = new ParsingContext();
async function test(propertyName, value, expectedResultCount) {
const searchResults = await searchService.findNotesWithQuery(`# note.${propertyName} = ${value}`, parsingContext);
expect(searchResults.length).toEqual(expectedResultCount);
if (expectedResultCount === 1) {
expect(findNoteByTitle(searchResults, "Austria")).toBeTruthy();
}
}
await test("type", "text", 1);
await test("type", "code", 0);
await test("mime", "text/html", 1);
await test("mime", "application/json", 0);
await test("isProtected", "false", 7);
await test("isProtected", "true", 0);
await test("dateCreated", "'2020-05-14 12:11:42.001+0200'", 1);
await test("dateCreated", "wrong", 0);
await test("dateModified", "'2020-05-14 13:11:42.001+0200'", 1);
await test("dateModified", "wrong", 0);
await test("utcDateCreated", "'2020-05-14 10:11:42.001Z'", 1);
await test("utcDateCreated", "wrong", 0);
await test("utcDateModified", "'2020-05-14 11:11:42.001Z'", 1);
await test("utcDateModified", "wrong", 0);
await test("contentLength", "1001", 1);
await test("contentLength", "10010", 0);
await test("parentCount", "2", 1);
await test("parentCount", "3", 0);
await test("childrenCount", "2", 1);
await test("childrenCount", "10", 0);
await test("attributeCount", "3", 1);
await test("attributeCount", "4", 0);
await test("labelCount", "2", 1);
await test("labelCount", "3", 0);
await test("relationCount", "1", 1);
await test("relationCount", "2", 0);
});
it("test order by", async () => {
const italy = note("Italy").label("capital", "Rome");
const slovakia = note("Slovakia").label("capital", "Bratislava");
const austria = note("Austria").label("capital", "Vienna");
const ukraine = note("Ukraine").label("capital", "Kiev");
rootNote
.child(note("Europe")
.child(ukraine)
.child(slovakia)
.child(austria)
.child(italy));
const parsingContext = new ParsingContext();
let searchResults = await searchService.findNotesWithQuery('# note.parents.title = Europe orderBy note.title', parsingContext);
expect(searchResults.length).toEqual(4);
expect(noteCache.notes[searchResults[0].noteId].title).toEqual("Austria");
expect(noteCache.notes[searchResults[1].noteId].title).toEqual("Italy");
expect(noteCache.notes[searchResults[2].noteId].title).toEqual("Slovakia");
expect(noteCache.notes[searchResults[3].noteId].title).toEqual("Ukraine");
searchResults = await searchService.findNotesWithQuery('# note.parents.title = Europe orderBy note.labels.capital', parsingContext);
expect(searchResults.length).toEqual(4);
expect(noteCache.notes[searchResults[0].noteId].title).toEqual("Slovakia");
expect(noteCache.notes[searchResults[1].noteId].title).toEqual("Ukraine");
expect(noteCache.notes[searchResults[2].noteId].title).toEqual("Italy");
expect(noteCache.notes[searchResults[3].noteId].title).toEqual("Austria");
searchResults = await searchService.findNotesWithQuery('# note.parents.title = Europe orderBy note.labels.capital DESC', parsingContext);
expect(searchResults.length).toEqual(4);
expect(noteCache.notes[searchResults[0].noteId].title).toEqual("Austria");
expect(noteCache.notes[searchResults[1].noteId].title).toEqual("Italy");
expect(noteCache.notes[searchResults[2].noteId].title).toEqual("Ukraine");
expect(noteCache.notes[searchResults[3].noteId].title).toEqual("Slovakia");
searchResults = await searchService.findNotesWithQuery('# note.parents.title = Europe orderBy note.labels.capital DESC limit 2', parsingContext);
expect(searchResults.length).toEqual(2);
expect(noteCache.notes[searchResults[0].noteId].title).toEqual("Austria");
expect(noteCache.notes[searchResults[1].noteId].title).toEqual("Italy");
searchResults = await searchService.findNotesWithQuery('# note.parents.title = Europe orderBy #capital DESC limit 0', parsingContext);
expect(searchResults.length).toEqual(0);
searchResults = await searchService.findNotesWithQuery('# note.parents.title = Europe orderBy #capital DESC limit 1000', parsingContext);
expect(searchResults.length).toEqual(4);
});
it("test not(...)", async () => {
const italy = note("Italy").label("capital", "Rome");
const slovakia = note("Slovakia").label("capital", "Bratislava");
rootNote
.child(note("Europe")
.child(slovakia)
.child(italy));
const parsingContext = new ParsingContext();
let searchResults = await searchService.findNotesWithQuery('# not(#capital) and note.noteId != root', parsingContext);
expect(searchResults.length).toEqual(1);
expect(noteCache.notes[searchResults[0].noteId].title).toEqual("Europe");
});
// FIXME: test what happens when we order without any filter criteria
});

11
spec/support/jasmine.json Normal file
View File

@ -0,0 +1,11 @@
{
"spec_dir": "spec",
"spec_files": [
"**/*[sS]pec.js"
],
"helpers": [
"helpers/**/*.js"
],
"stopSpecOnExpectationFailure": false,
"random": true
}

View File

@ -0,0 +1,86 @@
const {NoteBuilder, findNoteByTitle, note} = require('./note_cache_mocking');
const ValueExtractor = require('../src/services/search/value_extractor');
const noteCache = require('../src/services/note_cache/note_cache');
describe("Value extractor", () => {
beforeEach(() => {
noteCache.reset();
});
it("simple title extraction", async () => {
const europe = note("Europe").note;
const valueExtractor = new ValueExtractor(["note", "title"]);
expect(valueExtractor.validate()).toBeFalsy();
expect(valueExtractor.extract(europe)).toEqual("Europe");
});
it("label extraction", async () => {
const austria = note("Austria")
.label("Capital", "Vienna")
.note;
let valueExtractor = new ValueExtractor(["note", "labels", "capital"]);
expect(valueExtractor.validate()).toBeFalsy();
expect(valueExtractor.extract(austria)).toEqual("vienna");
valueExtractor = new ValueExtractor(["#capital"]);
expect(valueExtractor.validate()).toBeFalsy();
expect(valueExtractor.extract(austria)).toEqual("vienna");
});
it("parent/child property extraction", async () => {
const vienna = note("Vienna");
const europe = note("Europe")
.child(note("Austria")
.child(vienna));
let valueExtractor = new ValueExtractor(["note", "children", "children", "title"]);
expect(valueExtractor.validate()).toBeFalsy();
expect(valueExtractor.extract(europe.note)).toEqual("Vienna");
valueExtractor = new ValueExtractor(["note", "parents", "parents", "title"]);
expect(valueExtractor.validate()).toBeFalsy();
expect(valueExtractor.extract(vienna.note)).toEqual("Europe");
});
it("extract through relation", async () => {
const czechRepublic = note("Czech Republic").label("capital", "Prague");
const slovakia = note("Slovakia").label("capital", "Bratislava");
const austria = note("Austria")
.relation('neighbor', czechRepublic.note)
.relation('neighbor', slovakia.note);
let valueExtractor = new ValueExtractor(["note", "relations", "neighbor", "labels", "capital"]);
expect(valueExtractor.validate()).toBeFalsy();
expect(valueExtractor.extract(austria.note)).toEqual("prague");
valueExtractor = new ValueExtractor(["~neighbor", "labels", "capital"]);
expect(valueExtractor.validate()).toBeFalsy();
expect(valueExtractor.extract(austria.note)).toEqual("prague");
});
});
describe("Invalid value extractor property path", () => {
it('each path must start with "note" (or label/relation)',
() => expect(new ValueExtractor(["neighbor"]).validate()).toBeTruthy());
it("extra path element after terminal label",
() => expect(new ValueExtractor(["~neighbor", "labels", "capital", "noteId"]).validate()).toBeTruthy());
it("extra path element after terminal title",
() => expect(new ValueExtractor(["note", "title", "isProtected"]).validate()).toBeTruthy());
it("relation name and note property is missing",
() => expect(new ValueExtractor(["note", "relations"]).validate()).toBeTruthy());
it("relation is specified but target note property is not specified",
() => expect(new ValueExtractor(["note", "relations", "myrel"]).validate()).toBeTruthy());
});

View File

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

View File

@ -8,13 +8,13 @@ const sql = require('../services/sql');
/** /**
* Attribute is key value pair owned by a note. * Attribute is key value pair owned by a note.
* *
* @property {string} attributeId * @property {string} attributeId - immutable
* @property {string} noteId * @property {string} noteId - immutable
* @property {string} type * @property {string} type - immutable
* @property {string} name * @property {string} name - immutable
* @property {string} value * @property {string} value
* @property {int} position * @property {int} position
* @property {boolean} isInheritable * @property {boolean} isInheritable - immutable
* @property {boolean} isDeleted * @property {boolean} isDeleted
* @property {string|null} deleteId - ID identifying delete transaction * @property {string|null} deleteId - ID identifying delete transaction
* @property {string} utcDateCreated * @property {string} utcDateCreated
@ -108,14 +108,14 @@ class Attribute extends Entity {
delete pojo.__note; delete pojo.__note;
} }
createClone(type, name, value) { createClone(type, name, value, isInheritable) {
return new Attribute({ return new Attribute({
noteId: this.noteId, noteId: this.noteId,
type: type, type: type,
name: name, name: name,
value: value, value: value,
position: this.position, position: this.position,
isInheritable: this.isInheritable, isInheritable: isInheritable,
isDeleted: false, isDeleted: false,
utcDateCreated: this.utcDateCreated, utcDateCreated: this.utcDateCreated,
utcDateModified: this.utcDateModified 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. * 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. * 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} branchId - primary key, immutable
* @property {string} noteId * @property {string} noteId - immutable
* @property {string} parentNoteId * @property {string} parentNoteId - immutable
* @property {int} notePosition * @property {int} notePosition
* @property {string} prefix * @property {string} prefix
* @property {boolean} isExpanded * @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', () => { $form.on('submit', () => {
const notePath = $autoComplete.getSelectedPath(); const notePath = $autoComplete.getSelectedNotePath();
if (notePath) { if (notePath) {
$dialog.modal('hide'); $dialog.modal('hide');
@ -89,4 +89,4 @@ $form.on('submit', () => {
} }
return false; return false;
}); });

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -98,10 +98,11 @@ async function updateNoteAttributes(req) {
if (attribute.type !== attributeEntity.type if (attribute.type !== attributeEntity.type
|| attribute.name !== attributeEntity.name || 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()) { 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(); await newAttribute.save();
} }

View File

@ -1,6 +1,7 @@
"use strict"; "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 repository = require('../../services/repository');
const log = require('../../services/log'); const log = require('../../services/log');
const utils = require('../../services/utils'); const utils = require('../../services/utils');
@ -18,7 +19,7 @@ async function getAutocomplete(req) {
results = await getRecentNotes(activeNoteId); results = await getRecentNotes(activeNoteId);
} }
else { else {
results = await noteCacheService.findNotes(query); results = await searchService.searchNotesForAutocomplete(query);
} }
const msTaken = Date.now() - timestampStarted; const msTaken = Date.now() - timestampStarted;
@ -57,14 +58,13 @@ async function getRecentNotes(activeNoteId) {
const title = noteCacheService.getNoteTitleForPath(rn.notePath.split('/')); const title = noteCacheService.getNoteTitleForPath(rn.notePath.split('/'));
return { return {
path: rn.notePath, notePath: rn.notePath,
pathTitle: title, notePathTitle: title,
highlightedTitle: title, highlightedNotePathTitle: utils.escapeHtml(title)
noteTitle: noteCacheService.getNoteTitleFromPath(rn.notePath)
}; };
}); });
} }
module.exports = { module.exports = {
getAutocomplete 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); 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({ await new Attribute({
noteId: note.noteId, noteId: note.noteId,
type: 'relation', type: 'relation',
value: imageNote.noteId, name: 'imageLink',
name: 'imageLink' value: imageNote.noteId
}).save(); }).save();
console.log(`Replacing ${imageId} with ${url}`); console.log(`Replacing ${imageId} with ${url}`);
@ -155,4 +161,4 @@ module.exports = {
addClipping, addClipping,
openNote, openNote,
handshake handshake
}; };

View File

@ -8,7 +8,7 @@ const zipImportService = require('../../services/import/zip');
const singleImportService = require('../../services/import/single'); const singleImportService = require('../../services/import/single');
const cls = require('../../services/cls'); const cls = require('../../services/cls');
const path = require('path'); 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 log = require('../../services/log');
const TaskContext = require('../../services/task_context.js'); const TaskContext = require('../../services/task_context.js');
@ -85,4 +85,4 @@ async function importToBranch(req) {
module.exports = { module.exports = {
importToBranch importToBranch
}; };

View File

@ -1,7 +1,7 @@
"use strict"; "use strict";
const repository = require('../../services/repository'); 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 protectedSessionService = require('../../services/protected_session');
const noteRevisionService = require('../../services/note_revisions'); const noteRevisionService = require('../../services/note_revisions');
const utils = require('../../services/utils'); const utils = require('../../services/utils');

View File

@ -3,7 +3,7 @@
const sql = require('../../services/sql'); const sql = require('../../services/sql');
const protectedSessionService = require('../../services/protected_session'); const protectedSessionService = require('../../services/protected_session');
const noteService = require('../../services/notes'); const noteService = require('../../services/notes');
const noteCacheService = require('../../services/note_cache'); const noteCacheService = require('../../services/note_cache/note_cache.js');
async function getRecentChanges(req) { async function getRecentChanges(req) {
const {ancestorNoteId} = req.params; const {ancestorNoteId} = req.params;
@ -102,4 +102,4 @@ async function getRecentChanges(req) {
module.exports = { module.exports = {
getRecentChanges getRecentChanges
}; };

View File

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

View File

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

View File

@ -1,19 +1,6 @@
const optionService = require('./options');
const sqlInit = require('./sql_init');
const eventService = require('./events');
let hoistedNoteId = 'root'; 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 = { 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 repository = require('./repository');
const sql = require('./sql'); const sql = require('./sql');
const log = require('./log'); const log = require('./log');
const parseFilters = require('./parse_filters'); const parseFilters = require('./search/parse_filters.js');
const buildSearchQuery = require('./build_search_query'); const buildSearchQuery = require('./build_search_query');
const noteCacheService = require('./note_cache'); const noteCacheService = require('./note_cache/note_cache.js');
async function searchForNotes(searchString) { async function searchForNotes(searchString) {
const noteIds = await searchForNoteIds(searchString); const noteIds = await searchForNoteIds(searchString);
@ -71,4 +86,4 @@ async function searchForNoteIds(searchString) {
module.exports = { module.exports = {
searchForNotes, searchForNotes,
searchForNoteIds 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 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 filterRegex = /(\b(AND|OR)\s+)?@(!?)([\p{L}\p{Number}_]+|"[^"]+")\s*((=|!=|<|<=|>|>=|!?\*=|!?=\*|!?\*=\*)\s*([^\s=*"]+|"[^"]+"))?/igu;
const smartValueRegex = /^(NOW|TODAY|WEEK|MONTH|YEAR) *([+\-] *\d+)?$/i; 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, createInitialDatabase,
createDatabaseForSync, createDatabaseForSync,
dbInitialized dbInitialized
}; };

View File

@ -5,7 +5,7 @@ const repository = require('./repository');
const Branch = require('../entities/branch'); const Branch = require('../entities/branch');
const syncTableService = require('./sync_table'); const syncTableService = require('./sync_table');
const protectedSessionService = require('./protected_session'); const protectedSessionService = require('./protected_session');
const noteCacheService = require('./note_cache'); const noteCacheService = require('./note_cache/note_cache.js');
async function getNotes(noteIds) { async function getNotes(noteIds) {
// we return also deleted notes which have been specifically asked for // we return also deleted notes which have been specifically asked for
@ -197,4 +197,4 @@ module.exports = {
validateParentChild, validateParentChild,
sortNotesAlphabetically, sortNotesAlphabetically,
setNoteToParent 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 %>%;"> <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> <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 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> <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/style.css" rel="stylesheet">
<link href="stylesheets/detail.css" rel="stylesheet"> <link href="stylesheets/detail.css" rel="stylesheet">
<script>
$("body").show();
</script>
<script src="app/desktop.js" crossorigin type="module"></script> <script src="app/desktop.js" crossorigin type="module"></script>
<link rel="stylesheet" type="text/css" href="libraries/boxicons/css/boxicons.min.css"> <link rel="stylesheet" type="text/css" href="libraries/boxicons/css/boxicons.min.css">