mirror of
https://github.com/zadam/trilium.git
synced 2025-03-01 14:22:32 +01:00
Merge branch 'm43'
This commit is contained in:
commit
ae934720bc
2
.idea/dataSources.xml
generated
2
.idea/dataSources.xml
generated
@ -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
675
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
28
package.json
28
package.json
@ -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
71
spec/lexer.spec.js
Normal 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"]);
|
||||||
|
});
|
||||||
|
});
|
70
spec/note_cache_mocking.js
Normal file
70
spec/note_cache_mocking.js
Normal 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
21
spec/parens.spec.js
Normal 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
136
spec/parser.spec.js
Normal 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
532
spec/search.spec.js
Normal 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
11
spec/support/jasmine.json
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
{
|
||||||
|
"spec_dir": "spec",
|
||||||
|
"spec_files": [
|
||||||
|
"**/*[sS]pec.js"
|
||||||
|
],
|
||||||
|
"helpers": [
|
||||||
|
"helpers/**/*.js"
|
||||||
|
],
|
||||||
|
"stopSpecOnExpectationFailure": false,
|
||||||
|
"random": true
|
||||||
|
}
|
86
spec/value_extractor.spec.js
Normal file
86
spec/value_extractor.spec.js
Normal 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());
|
||||||
|
});
|
@ -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
|
||||||
};
|
};
|
||||||
|
@ -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
|
||||||
|
@ -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;
|
||||||
|
@ -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;
|
||||||
});
|
});
|
||||||
|
@ -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 : '';
|
||||||
|
@ -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;
|
||||||
});
|
});
|
||||||
|
@ -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;
|
||||||
});
|
});
|
||||||
|
@ -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;
|
||||||
});
|
});
|
||||||
|
@ -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');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -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
|
||||||
}
|
}
|
||||||
|
@ -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
|
||||||
};
|
};
|
||||||
|
@ -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
|
||||||
};
|
};
|
||||||
|
@ -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) : "";
|
||||||
}
|
}
|
||||||
|
@ -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();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -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();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -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
|
||||||
};
|
};
|
||||||
|
@ -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
|
||||||
};
|
};
|
||||||
|
@ -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
|
||||||
};
|
};
|
||||||
|
@ -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');
|
||||||
|
@ -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
|
||||||
};
|
};
|
||||||
|
@ -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
|
||||||
};
|
};
|
||||||
|
@ -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
|
||||||
};
|
};
|
||||||
|
@ -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; }
|
||||||
|
};
|
||||||
|
14
src/services/hoisted_note_loader.js
Normal file
14
src/services/hoisted_note_loader.js
Normal 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'));
|
||||||
|
});
|
@ -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
|
|
||||||
};
|
|
50
src/services/note_cache/entities/attribute.js
Normal file
50
src/services/note_cache/entities/attribute.js
Normal 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;
|
49
src/services/note_cache/entities/branch.js
Normal file
49
src/services/note_cache/entities/branch.js
Normal 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;
|
327
src/services/note_cache/entities/note.js
Normal file
327
src/services/note_cache/entities/note.js
Normal 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;
|
61
src/services/note_cache/note_cache.js
Normal file
61
src/services/note_cache/note_cache.js
Normal 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;
|
155
src/services/note_cache/note_cache_loader.js
Normal file
155
src/services/note_cache/note_cache_loader.js
Normal 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();
|
241
src/services/note_cache/note_cache_service.js
Normal file
241
src/services/note_cache/note_cache_service.js
Normal 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
|
||||||
|
};
|
@ -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
|
||||||
};
|
};
|
||||||
|
77
src/services/search/comparator_builder.js
Normal file
77
src/services/search/comparator_builder.js
Normal 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;
|
30
src/services/search/expressions/and.js
Normal file
30
src/services/search/expressions/and.js
Normal 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;
|
43
src/services/search/expressions/attribute_exists.js
Normal file
43
src/services/search/expressions/attribute_exists.js
Normal 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;
|
36
src/services/search/expressions/child_of.js
Normal file
36
src/services/search/expressions/child_of.js
Normal 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;
|
28
src/services/search/expressions/descendant_of.js
Normal file
28
src/services/search/expressions/descendant_of.js
Normal 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;
|
12
src/services/search/expressions/expression.js
Normal file
12
src/services/search/expressions/expression.js
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
"use strict";
|
||||||
|
|
||||||
|
class Expression {
|
||||||
|
/**
|
||||||
|
* @param {NoteSet} inputNoteSet
|
||||||
|
* @param {object} searchContext
|
||||||
|
* @return {NoteSet}
|
||||||
|
*/
|
||||||
|
execute(inputNoteSet, searchContext) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = Expression;
|
40
src/services/search/expressions/label_comparison.js
Normal file
40
src/services/search/expressions/label_comparison.js
Normal 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;
|
19
src/services/search/expressions/not.js
Normal file
19
src/services/search/expressions/not.js
Normal 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;
|
137
src/services/search/expressions/note_cache_fulltext.js
Normal file
137
src/services/search/expressions/note_cache_fulltext.js
Normal 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;
|
40
src/services/search/expressions/note_content_fulltext.js
Normal file
40
src/services/search/expressions/note_content_fulltext.js
Normal 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;
|
35
src/services/search/expressions/or.js
Normal file
35
src/services/search/expressions/or.js
Normal 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;
|
58
src/services/search/expressions/order_by_and_limit.js
Normal file
58
src/services/search/expressions/order_by_and_limit.js
Normal 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;
|
36
src/services/search/expressions/parent_of.js
Normal file
36
src/services/search/expressions/parent_of.js
Normal 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;
|
63
src/services/search/expressions/property_comparison.js
Normal file
63
src/services/search/expressions/property_comparison.js
Normal 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;
|
41
src/services/search/expressions/relation_where.js
Normal file
41
src/services/search/expressions/relation_where.js
Normal 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;
|
115
src/services/search/lexer.js
Normal file
115
src/services/search/lexer.js
Normal 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;
|
61
src/services/search/note_set.js
Normal file
61
src/services/search/note_set.js
Normal 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;
|
43
src/services/search/parens.js
Normal file
43
src/services/search/parens.js
Normal 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;
|
@ -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;
|
305
src/services/search/parser.js
Normal file
305
src/services/search/parser.js
Normal 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;
|
20
src/services/search/parsing_context.js
Normal file
20
src/services/search/parsing_context.js
Normal 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;
|
179
src/services/search/search.js
Normal file
179
src/services/search/search.js
Normal 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
|
||||||
|
};
|
20
src/services/search/search_result.js
Normal file
20
src/services/search/search_result.js
Normal 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;
|
110
src/services/search/value_extractor.js
Normal file
110
src/services/search/value_extractor.js
Normal 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;
|
@ -197,4 +197,4 @@ module.exports = {
|
|||||||
createInitialDatabase,
|
createInitialDatabase,
|
||||||
createDatabaseForSync,
|
createDatabaseForSync,
|
||||||
dbInitialized
|
dbInitialized
|
||||||
};
|
};
|
||||||
|
@ -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
|
||||||
};
|
};
|
||||||
|
@ -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">
|
||||||
|
Loading…
x
Reference in New Issue
Block a user