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"?>
|
||||
<project version="4">
|
||||
<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>
|
||||
<synchronize>true</synchronize>
|
||||
<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-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",
|
||||
"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": {
|
||||
"async-mutex": "0.2.2",
|
||||
@ -28,16 +29,16 @@
|
||||
"commonmark": "0.29.1",
|
||||
"cookie-parser": "1.4.5",
|
||||
"csurf": "1.11.0",
|
||||
"dayjs": "1.8.26",
|
||||
"dayjs": "1.8.27",
|
||||
"debug": "4.1.1",
|
||||
"ejs": "3.1.2",
|
||||
"electron-debug": "3.0.1",
|
||||
"ejs": "3.1.3",
|
||||
"electron-debug": "3.1.0",
|
||||
"electron-dl": "3.0.0",
|
||||
"electron-find": "1.0.6",
|
||||
"electron-window-state": "5.0.3",
|
||||
"express": "4.17.1",
|
||||
"express-session": "1.17.1",
|
||||
"file-type": "14.3.0",
|
||||
"file-type": "14.5.0",
|
||||
"fs-extra": "9.0.0",
|
||||
"helmet": "3.22.0",
|
||||
"html": "1.0.0",
|
||||
@ -51,14 +52,14 @@
|
||||
"imagemin-pngquant": "8.0.0",
|
||||
"ini": "1.3.5",
|
||||
"is-svg": "4.2.1",
|
||||
"jimp": "0.10.3",
|
||||
"jimp": "0.12.1",
|
||||
"mime-types": "2.1.27",
|
||||
"multer": "1.4.2",
|
||||
"node-abi": "2.16.0",
|
||||
"open": "7.0.3",
|
||||
"node-abi": "2.17.0",
|
||||
"open": "7.0.4",
|
||||
"portscanner": "2.2.0",
|
||||
"rand-token": "1.0.1",
|
||||
"rcedit": "2.1.1",
|
||||
"rcedit": "2.2.0",
|
||||
"rimraf": "3.0.2",
|
||||
"sanitize-filename": "1.6.3",
|
||||
"sax": "1.2.4",
|
||||
@ -66,22 +67,23 @@
|
||||
"serve-favicon": "2.5.0",
|
||||
"session-file-store": "1.4.0",
|
||||
"simple-node-logger": "18.12.24",
|
||||
"sqlite": "4.0.7",
|
||||
"sqlite": "4.0.9",
|
||||
"sqlite3": "4.1.1",
|
||||
"string-similarity": "4.0.1",
|
||||
"tar-stream": "2.1.2",
|
||||
"turndown": "6.0.0",
|
||||
"turndown-plugin-gfm": "1.0.2",
|
||||
"unescape": "1.0.1",
|
||||
"ws": "7.2.5",
|
||||
"ws": "7.3.0",
|
||||
"yauzl": "^2.10.0",
|
||||
"yazl": "^2.5.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"electron": "9.0.0",
|
||||
"electron-builder": "22.6.0",
|
||||
"electron-builder": "22.6.1",
|
||||
"electron-packager": "14.2.1",
|
||||
"electron-rebuild": "1.10.1",
|
||||
"electron-rebuild": "1.11.0",
|
||||
"jasmine": "^3.5.0",
|
||||
"jsdoc": "3.6.4",
|
||||
"lorem-ipsum": "2.0.3",
|
||||
"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 sessionSecret = require('./services/session_secret');
|
||||
const cls = require('./services/cls');
|
||||
const dataDir = require('./services/data_dir');
|
||||
require('./entities/entity_constructor');
|
||||
require('./services/handlers');
|
||||
require('./services/hoisted_note_loader');
|
||||
require('./services/note_cache/note_cache_loader');
|
||||
|
||||
const app = express();
|
||||
|
||||
@ -56,7 +59,7 @@ const sessionParser = session({
|
||||
},
|
||||
store: new FileStore({
|
||||
ttl: 30 * 24 * 3600,
|
||||
path: os.tmpdir() + '/trilium-sessions'
|
||||
path: dataDir.TRILIUM_DATA_DIR + '/sessions'
|
||||
})
|
||||
});
|
||||
app.use(sessionParser);
|
||||
|
@ -8,13 +8,13 @@ const sql = require('../services/sql');
|
||||
/**
|
||||
* Attribute is key value pair owned by a note.
|
||||
*
|
||||
* @property {string} attributeId
|
||||
* @property {string} noteId
|
||||
* @property {string} type
|
||||
* @property {string} name
|
||||
* @property {string} attributeId - immutable
|
||||
* @property {string} noteId - immutable
|
||||
* @property {string} type - immutable
|
||||
* @property {string} name - immutable
|
||||
* @property {string} value
|
||||
* @property {int} position
|
||||
* @property {boolean} isInheritable
|
||||
* @property {boolean} isInheritable - immutable
|
||||
* @property {boolean} isDeleted
|
||||
* @property {string|null} deleteId - ID identifying delete transaction
|
||||
* @property {string} utcDateCreated
|
||||
@ -108,14 +108,14 @@ class Attribute extends Entity {
|
||||
delete pojo.__note;
|
||||
}
|
||||
|
||||
createClone(type, name, value) {
|
||||
createClone(type, name, value, isInheritable) {
|
||||
return new Attribute({
|
||||
noteId: this.noteId,
|
||||
type: type,
|
||||
name: name,
|
||||
value: value,
|
||||
position: this.position,
|
||||
isInheritable: this.isInheritable,
|
||||
isInheritable: isInheritable,
|
||||
isDeleted: false,
|
||||
utcDateCreated: this.utcDateCreated,
|
||||
utcDateModified: this.utcDateModified
|
||||
|
@ -9,9 +9,9 @@ const sql = require('../services/sql');
|
||||
* Branch represents note's placement in the tree - it's essentially pair of noteId and parentNoteId.
|
||||
* Each note can have multiple (at least one) branches, meaning it can be placed into multiple places in the tree.
|
||||
*
|
||||
* @property {string} branchId - primary key
|
||||
* @property {string} noteId
|
||||
* @property {string} parentNoteId
|
||||
* @property {string} branchId - primary key, immutable
|
||||
* @property {string} noteId - immutable
|
||||
* @property {string} parentNoteId - immutable
|
||||
* @property {int} notePosition
|
||||
* @property {string} prefix
|
||||
* @property {boolean} isExpanded
|
||||
|
@ -75,7 +75,7 @@ function updateTitleFormGroupVisibility() {
|
||||
}
|
||||
|
||||
$form.on('submit', () => {
|
||||
const notePath = $autoComplete.getSelectedPath();
|
||||
const notePath = $autoComplete.getSelectedNotePath();
|
||||
|
||||
if (notePath) {
|
||||
$dialog.modal('hide');
|
||||
|
@ -269,7 +269,7 @@ function initKoPlugins() {
|
||||
init: function (element, valueAccessor, allBindings, viewModel, bindingContext) {
|
||||
noteAutocompleteService.initNoteAutocomplete($(element));
|
||||
|
||||
$(element).setSelectedPath(bindingContext.$data.selectedPath);
|
||||
$(element).setSelectedNotePath(bindingContext.$data.selectedPath);
|
||||
|
||||
$(element).on('autocomplete:selected', function (event, suggestion, dataset) {
|
||||
bindingContext.$data.selectedPath = $(element).val().trim() ? suggestion.path : '';
|
||||
|
@ -52,7 +52,7 @@ async function cloneNotesTo(notePath) {
|
||||
}
|
||||
|
||||
$form.on('submit', () => {
|
||||
const notePath = $noteAutoComplete.getSelectedPath();
|
||||
const notePath = $noteAutoComplete.getSelectedNotePath();
|
||||
|
||||
if (notePath) {
|
||||
$dialog.modal('hide');
|
||||
|
@ -38,7 +38,7 @@ async function includeNote(notePath) {
|
||||
}
|
||||
|
||||
$form.on('submit', () => {
|
||||
const notePath = $autoComplete.getSelectedPath();
|
||||
const notePath = $autoComplete.getSelectedNotePath();
|
||||
|
||||
if (notePath) {
|
||||
$dialog.modal('hide');
|
||||
|
@ -41,7 +41,7 @@ async function moveNotesTo(parentNoteId) {
|
||||
}
|
||||
|
||||
$form.on('submit', () => {
|
||||
const notePath = $noteAutoComplete.getSelectedPath();
|
||||
const notePath = $noteAutoComplete.getSelectedNotePath();
|
||||
|
||||
if (notePath) {
|
||||
$dialog.modal('hide');
|
||||
|
@ -11,6 +11,7 @@ import Component from "../widgets/component.js";
|
||||
import keyboardActionsService from "./keyboard_actions.js";
|
||||
import MobileScreenSwitcherExecutor from "../widgets/mobile_widgets/mobile_screen_switcher.js";
|
||||
import MainTreeExecutors from "./main_tree_executors.js";
|
||||
import protectedSessionHolder from "./protected_session_holder.js";
|
||||
|
||||
class AppContext extends Component {
|
||||
constructor(isMainWindow) {
|
||||
@ -111,6 +112,8 @@ const appContext = new AppContext(window.glob.isMainWindow);
|
||||
|
||||
// we should save all outstanding changes before the page/app is closed
|
||||
$(window).on('beforeunload', () => {
|
||||
protectedSessionHolder.resetSessionCookie();
|
||||
|
||||
appContext.triggerEvent('beforeUnload');
|
||||
});
|
||||
|
||||
|
@ -3,7 +3,7 @@ import appContext from "./app_context.js";
|
||||
import utils from './utils.js';
|
||||
|
||||
// this key needs to have this value so it's hit by the tooltip
|
||||
const SELECTED_PATH_KEY = "data-note-path";
|
||||
const SELECTED_NOTE_PATH_KEY = "data-note-path";
|
||||
|
||||
async function autocompleteSource(term, cb) {
|
||||
const result = await server.get('autocomplete'
|
||||
@ -12,8 +12,8 @@ async function autocompleteSource(term, cb) {
|
||||
|
||||
if (result.length === 0) {
|
||||
result.push({
|
||||
pathTitle: "No results",
|
||||
path: ""
|
||||
notePathTitle: "No results",
|
||||
notePath: ""
|
||||
});
|
||||
}
|
||||
|
||||
@ -25,7 +25,7 @@ function clearText($el) {
|
||||
return;
|
||||
}
|
||||
|
||||
$el.setSelectedPath("");
|
||||
$el.setSelectedNotePath("");
|
||||
$el.autocomplete("val", "").trigger('change');
|
||||
}
|
||||
|
||||
@ -34,7 +34,7 @@ function showRecentNotes($el) {
|
||||
return;
|
||||
}
|
||||
|
||||
$el.setSelectedPath("");
|
||||
$el.setSelectedNotePath("");
|
||||
$el.autocomplete("val", "");
|
||||
$el.trigger('focus');
|
||||
}
|
||||
@ -91,10 +91,10 @@ function initNoteAutocomplete($el, options) {
|
||||
}, [
|
||||
{
|
||||
source: autocompleteSource,
|
||||
displayKey: 'pathTitle',
|
||||
displayKey: 'notePathTitle',
|
||||
templates: {
|
||||
suggestion: function(suggestion) {
|
||||
return suggestion.highlightedTitle;
|
||||
return suggestion.highlightedNotePathTitle;
|
||||
}
|
||||
},
|
||||
// we can't cache identical searches because notes can be created / renamed, new recent notes can be added
|
||||
@ -102,7 +102,7 @@ function initNoteAutocomplete($el, options) {
|
||||
}
|
||||
]);
|
||||
|
||||
$el.on('autocomplete:selected', (event, suggestion) => $el.setSelectedPath(suggestion.path));
|
||||
$el.on('autocomplete:selected', (event, suggestion) => $el.setSelectedNotePath(suggestion.notePath));
|
||||
$el.on('autocomplete:closed', () => {
|
||||
if (!$el.val().trim()) {
|
||||
clearText($el);
|
||||
@ -113,24 +113,24 @@ function initNoteAutocomplete($el, options) {
|
||||
}
|
||||
|
||||
function init() {
|
||||
$.fn.getSelectedPath = function () {
|
||||
$.fn.getSelectedNotePath = function () {
|
||||
if (!$(this).val().trim()) {
|
||||
return "";
|
||||
} else {
|
||||
return $(this).attr(SELECTED_PATH_KEY);
|
||||
return $(this).attr(SELECTED_NOTE_PATH_KEY);
|
||||
}
|
||||
};
|
||||
|
||||
$.fn.setSelectedPath = function (path) {
|
||||
path = path || "";
|
||||
$.fn.setSelectedNotePath = function (notePath) {
|
||||
notePath = notePath || "";
|
||||
|
||||
$(this).attr(SELECTED_PATH_KEY, path);
|
||||
$(this).attr(SELECTED_NOTE_PATH_KEY, notePath);
|
||||
|
||||
$(this)
|
||||
.closest(".input-group")
|
||||
.find(".go-to-selected-note-button")
|
||||
.toggleClass("disabled", !path.trim())
|
||||
.attr(SELECTED_PATH_KEY, path); // we also set attr here so tooltip can be displayed
|
||||
.toggleClass("disabled", !notePath.trim())
|
||||
.attr(SELECTED_NOTE_PATH_KEY, notePath); // we also set attr here so tooltip can be displayed
|
||||
};
|
||||
}
|
||||
|
||||
|
@ -12,15 +12,19 @@ setInterval(() => {
|
||||
|
||||
resetProtectedSession();
|
||||
}
|
||||
}, 5000);
|
||||
}, 10000);
|
||||
|
||||
function setProtectedSessionId(id) {
|
||||
// using session cookie so that it disappears after browser/tab is closed
|
||||
utils.setSessionCookie(PROTECTED_SESSION_ID_KEY, id);
|
||||
}
|
||||
|
||||
function resetProtectedSession() {
|
||||
function resetSessionCookie() {
|
||||
utils.setSessionCookie(PROTECTED_SESSION_ID_KEY, null);
|
||||
}
|
||||
|
||||
function resetProtectedSession() {
|
||||
resetSessionCookie();
|
||||
|
||||
// most secure solution - guarantees nothing remained in memory
|
||||
// since this expires because user doesn't use the app, it shouldn't be disruptive
|
||||
@ -47,6 +51,7 @@ function touchProtectedSessionIfNecessary(note) {
|
||||
|
||||
export default {
|
||||
setProtectedSessionId,
|
||||
resetSessionCookie,
|
||||
resetProtectedSession,
|
||||
isProtectedSessionAvailable,
|
||||
touchProtectedSession,
|
||||
|
@ -187,7 +187,7 @@ function setCookie(name, value) {
|
||||
}
|
||||
|
||||
function setSessionCookie(name, value) {
|
||||
document.cookie = name + "=" + (value || "") + ";";
|
||||
document.cookie = name + "=" + (value || "") + "; SameSite=Strict";
|
||||
}
|
||||
|
||||
function getCookie(name) {
|
||||
|
@ -200,7 +200,7 @@ export default class PromotedAttributesWidget extends TabAwareWidget {
|
||||
this.promotedAttributeChanged(event);
|
||||
});
|
||||
|
||||
$input.setSelectedPath(valueAttr.value);
|
||||
$input.setSelectedNotePath(valueAttr.value);
|
||||
}
|
||||
else {
|
||||
ws.logError("Unknown attribute type=" + valueAttr.type);
|
||||
@ -250,7 +250,7 @@ export default class PromotedAttributesWidget extends TabAwareWidget {
|
||||
value = $attr.is(':checked') ? "true" : "false";
|
||||
}
|
||||
else if ($attr.prop("attribute-type") === "relation") {
|
||||
const selectedPath = $attr.getSelectedPath();
|
||||
const selectedPath = $attr.getSelectedNotePath();
|
||||
|
||||
value = selectedPath ? treeService.getNoteIdFromNotePath(selectedPath) : "";
|
||||
}
|
||||
|
@ -48,8 +48,8 @@ export default class SearchResultsWidget extends BasicWidget {
|
||||
for (const result of results) {
|
||||
const link = $('<a>', {
|
||||
href: 'javascript:',
|
||||
text: result.title
|
||||
}).attr('data-action', 'note').attr('data-note-path', result.path);
|
||||
text: result.notePathTitle
|
||||
}).attr('data-action', 'note').attr('data-note-path', result.notePath);
|
||||
|
||||
const $result = $('<li>').append(link);
|
||||
|
||||
|
@ -17,6 +17,10 @@ const TPL = `
|
||||
padding-left: 10px;
|
||||
padding-right: 10px;
|
||||
}
|
||||
|
||||
.title-bar-buttons button:hover {
|
||||
background-color: var(--accented-background-color) !important;
|
||||
}
|
||||
</style>
|
||||
|
||||
<button class="btn icon-action bx bx-minus minimize-btn"></button>
|
||||
|
@ -98,10 +98,11 @@ async function updateNoteAttributes(req) {
|
||||
|
||||
if (attribute.type !== attributeEntity.type
|
||||
|| attribute.name !== attributeEntity.name
|
||||
|| (attribute.type === 'relation' && attribute.value !== attributeEntity.value)) {
|
||||
|| (attribute.type === 'relation' && attribute.value !== attributeEntity.value)
|
||||
|| attribute.isInheritable !== attributeEntity.isInheritable) {
|
||||
|
||||
if (attribute.type !== 'relation' || !!attribute.value.trim()) {
|
||||
const newAttribute = attributeEntity.createClone(attribute.type, attribute.name, attribute.value);
|
||||
const newAttribute = attributeEntity.createClone(attribute.type, attribute.name, attribute.value, attribute.isInheritable);
|
||||
await newAttribute.save();
|
||||
}
|
||||
|
||||
|
@ -1,6 +1,7 @@
|
||||
"use strict";
|
||||
|
||||
const noteCacheService = require('../../services/note_cache');
|
||||
const noteCacheService = require('../../services/note_cache/note_cache_service');
|
||||
const searchService = require('../../services/search/search');
|
||||
const repository = require('../../services/repository');
|
||||
const log = require('../../services/log');
|
||||
const utils = require('../../services/utils');
|
||||
@ -18,7 +19,7 @@ async function getAutocomplete(req) {
|
||||
results = await getRecentNotes(activeNoteId);
|
||||
}
|
||||
else {
|
||||
results = await noteCacheService.findNotes(query);
|
||||
results = await searchService.searchNotesForAutocomplete(query);
|
||||
}
|
||||
|
||||
const msTaken = Date.now() - timestampStarted;
|
||||
@ -57,10 +58,9 @@ async function getRecentNotes(activeNoteId) {
|
||||
const title = noteCacheService.getNoteTitleForPath(rn.notePath.split('/'));
|
||||
|
||||
return {
|
||||
path: rn.notePath,
|
||||
pathTitle: title,
|
||||
highlightedTitle: title,
|
||||
noteTitle: noteCacheService.getNoteTitleFromPath(rn.notePath)
|
||||
notePath: rn.notePath,
|
||||
notePathTitle: title,
|
||||
highlightedNotePathTitle: utils.escapeHtml(title)
|
||||
};
|
||||
});
|
||||
}
|
||||
|
@ -109,11 +109,17 @@ async function addImagesToNote(images, note, content) {
|
||||
|
||||
const {note: imageNote, url} = await imageService.saveImage(note.noteId, buffer, filename, true);
|
||||
|
||||
await new Attribute({
|
||||
noteId: imageNote.noteId,
|
||||
type: 'label',
|
||||
name: 'hideInAutocomplete'
|
||||
}).save();
|
||||
|
||||
await new Attribute({
|
||||
noteId: note.noteId,
|
||||
type: 'relation',
|
||||
value: imageNote.noteId,
|
||||
name: 'imageLink'
|
||||
name: 'imageLink',
|
||||
value: imageNote.noteId
|
||||
}).save();
|
||||
|
||||
console.log(`Replacing ${imageId} with ${url}`);
|
||||
|
@ -8,7 +8,7 @@ const zipImportService = require('../../services/import/zip');
|
||||
const singleImportService = require('../../services/import/single');
|
||||
const cls = require('../../services/cls');
|
||||
const path = require('path');
|
||||
const noteCacheService = require('../../services/note_cache');
|
||||
const noteCacheService = require('../../services/note_cache/note_cache.js');
|
||||
const log = require('../../services/log');
|
||||
const TaskContext = require('../../services/task_context.js');
|
||||
|
||||
|
@ -1,7 +1,7 @@
|
||||
"use strict";
|
||||
|
||||
const repository = require('../../services/repository');
|
||||
const noteCacheService = require('../../services/note_cache');
|
||||
const noteCacheService = require('../../services/note_cache/note_cache.js');
|
||||
const protectedSessionService = require('../../services/protected_session');
|
||||
const noteRevisionService = require('../../services/note_revisions');
|
||||
const utils = require('../../services/utils');
|
||||
|
@ -3,7 +3,7 @@
|
||||
const sql = require('../../services/sql');
|
||||
const protectedSessionService = require('../../services/protected_session');
|
||||
const noteService = require('../../services/notes');
|
||||
const noteCacheService = require('../../services/note_cache');
|
||||
const noteCacheService = require('../../services/note_cache/note_cache.js');
|
||||
|
||||
async function getRecentChanges(req) {
|
||||
const {ancestorNoteId} = req.params;
|
||||
|
@ -1,18 +1,18 @@
|
||||
"use strict";
|
||||
|
||||
const repository = require('../../services/repository');
|
||||
const noteCacheService = require('../../services/note_cache');
|
||||
const noteCacheService = require('../../services/note_cache/note_cache.js');
|
||||
const log = require('../../services/log');
|
||||
const scriptService = require('../../services/script');
|
||||
const searchService = require('../../services/search');
|
||||
const searchService = require('../../services/search/search');
|
||||
|
||||
async function searchNotes(req) {
|
||||
const noteIds = await searchService.searchForNoteIds(req.params.searchString);
|
||||
const notePaths = await searchService.searchNotes(req.params.searchString);
|
||||
|
||||
try {
|
||||
return {
|
||||
success: true,
|
||||
results: noteIds.map(noteCacheService.getNotePath).filter(res => !!res)
|
||||
results: notePaths
|
||||
}
|
||||
}
|
||||
catch {
|
||||
|
@ -1,6 +1,6 @@
|
||||
"use strict";
|
||||
|
||||
const noteCacheService = require('../../services/note_cache');
|
||||
const noteCacheService = require('../../services/note_cache/note_cache_service');
|
||||
const repository = require('../../services/repository');
|
||||
|
||||
async function getSimilarNotes(req) {
|
||||
@ -12,7 +12,7 @@ async function getSimilarNotes(req) {
|
||||
return [404, `Note ${noteId} not found.`];
|
||||
}
|
||||
|
||||
const results = await noteCacheService.findSimilarNotes(note.title);
|
||||
const results = await noteCacheService.findSimilarNotes(noteId);
|
||||
|
||||
return results
|
||||
.filter(note => note.noteId !== noteId);
|
||||
|
@ -1,19 +1,6 @@
|
||||
const optionService = require('./options');
|
||||
const sqlInit = require('./sql_init');
|
||||
const eventService = require('./events');
|
||||
|
||||
let hoistedNoteId = 'root';
|
||||
|
||||
eventService.subscribe(eventService.ENTITY_CHANGED, async ({entityName, entity}) => {
|
||||
if (entityName === 'options' && entity.name === 'hoistedNoteId') {
|
||||
hoistedNoteId = entity.value;
|
||||
}
|
||||
});
|
||||
|
||||
sqlInit.dbReady.then(async () => {
|
||||
hoistedNoteId = await optionService.getOption('hoistedNoteId');
|
||||
});
|
||||
|
||||
module.exports = {
|
||||
getHoistedNoteId: () => hoistedNoteId
|
||||
getHoistedNoteId: () => hoistedNoteId,
|
||||
setHoistedNoteId(noteId) { hoistedNoteId = noteId; }
|
||||
};
|
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 sql = require('./sql');
|
||||
const log = require('./log');
|
||||
const parseFilters = require('./parse_filters');
|
||||
const parseFilters = require('./search/parse_filters.js');
|
||||
const buildSearchQuery = require('./build_search_query');
|
||||
const noteCacheService = require('./note_cache');
|
||||
const noteCacheService = require('./note_cache/note_cache.js');
|
||||
|
||||
async function searchForNotes(searchString) {
|
||||
const noteIds = await searchForNoteIds(searchString);
|
||||
|
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 AndExp = require('./expressions/and');
|
||||
const OrExp = require('./expressions/or');
|
||||
const NotExp = require('./expressions/not');
|
||||
const NoteCacheFulltextExp = require('./expressions/note_cache_fulltext');
|
||||
const NoteContentFulltextExp = require('./expressions/note_content_fulltext');
|
||||
|
||||
const filterRegex = /(\b(AND|OR)\s+)?@(!?)([\p{L}\p{Number}_]+|"[^"]+")\s*((=|!=|<|<=|>|>=|!?\*=|!?=\*|!?\*=\*)\s*([^\s=*"]+|"[^"]+"))?/igu;
|
||||
const smartValueRegex = /^(NOW|TODAY|WEEK|MONTH|YEAR) *([+\-] *\d+)?$/i;
|
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;
|
@ -5,7 +5,7 @@ const repository = require('./repository');
|
||||
const Branch = require('../entities/branch');
|
||||
const syncTableService = require('./sync_table');
|
||||
const protectedSessionService = require('./protected_session');
|
||||
const noteCacheService = require('./note_cache');
|
||||
const noteCacheService = require('./note_cache/note_cache.js');
|
||||
|
||||
async function getNotes(noteIds) {
|
||||
// we return also deleted notes which have been specifically asked for
|
||||
|
@ -8,6 +8,11 @@
|
||||
<body class="desktop theme-<%= theme %>" style="--main-font-size: <%= mainFontSize %>%; --tree-font-size: <%= treeFontSize %>%; --detail-font-size: <%= detailFontSize %>%;">
|
||||
<noscript>Trilium requires JavaScript to be enabled.</noscript>
|
||||
|
||||
<script>
|
||||
// hide body to reduce flickering on the startup. This is done through JS and not CSS to not hide <noscript>
|
||||
document.getElementsByTagName("body")[0].style.display = "none";
|
||||
</script>
|
||||
|
||||
<div id="toast-container" class="d-flex flex-column justify-content-center align-items-center"></div>
|
||||
|
||||
<div class="dropdown-menu dropdown-menu-sm" id="context-menu-container"></div>
|
||||
@ -78,6 +83,10 @@
|
||||
<link href="stylesheets/style.css" rel="stylesheet">
|
||||
<link href="stylesheets/detail.css" rel="stylesheet">
|
||||
|
||||
<script>
|
||||
$("body").show();
|
||||
</script>
|
||||
|
||||
<script src="app/desktop.js" crossorigin type="module"></script>
|
||||
|
||||
<link rel="stylesheet" type="text/css" href="libraries/boxicons/css/boxicons.min.css">
|
||||
|
Loading…
x
Reference in New Issue
Block a user