Merge branch 'master' into stable

This commit is contained in:
azivner 2018-04-09 22:30:50 -04:00
commit d8924c536b
26 changed files with 939 additions and 863 deletions

View File

@ -24,9 +24,9 @@ jq '.version = "'$VERSION'"' package.json|sponge package.json
git add package.json git add package.json
echo 'module.exports = { buildDate:"'`date --iso-8601=seconds`'", buildRevision: "'`git log -1 --format="%H"`'" };' > services/build.js echo 'module.exports = { buildDate:"'`date --iso-8601=seconds`'", buildRevision: "'`git log -1 --format="%H"`'" };' > src/services/build.js
git add services/build.js git add src/services/build.js
TAG=v$VERSION TAG=v$VERSION

View File

@ -0,0 +1,5 @@
ALTER TABLE note_revisions ADD type TEXT DEFAULT '' NOT NULL;
ALTER TABLE note_revisions ADD mime TEXT DEFAULT '' NOT NULL;
UPDATE note_revisions SET type = (SELECT type FROM notes WHERE notes.noteId = note_revisions.noteId);
UPDATE note_revisions SET mime = (SELECT mime FROM notes WHERE notes.noteId = note_revisions.noteId);

View File

@ -12,7 +12,8 @@ class Note extends Entity {
constructor(row) { constructor(row) {
super(row); super(row);
if (this.isProtected) { // check if there's noteId, otherwise this is a new entity which wasn't encrypted yet
if (this.isProtected && this.noteId) {
protected_session.decryptNote(this); protected_session.decryptNote(this);
} }
@ -21,6 +22,14 @@ class Note extends Entity {
} }
} }
setContent(content) {
this.content = content;
if (this.isJson()) {
this.jsonContent = JSON.parse(this.content);
}
}
isJson() { isJson() {
return this.mime === "application/json"; return this.mime === "application/json";
} }

View File

@ -54,7 +54,13 @@ $list.on('change', () => {
const revisionItem = revisionItems.find(r => r.noteRevisionId === optVal); const revisionItem = revisionItems.find(r => r.noteRevisionId === optVal);
$title.html(revisionItem.title); $title.html(revisionItem.title);
$content.html(revisionItem.content);
if (revisionItem.type === 'text') {
$content.html(revisionItem.content);
}
else if (revisionItem.type === 'code') {
$content.html($("<pre>").text(revisionItem.content));
}
}); });
$(document).on('click', "a[action='note-revision']", event => { $(document).on('click', "a[action='note-revision']", event => {

View File

@ -14,6 +14,10 @@ class Branch {
return await this.treeCache.getNote(this.noteId); return await this.treeCache.getNote(this.noteId);
} }
isTopLevel() {
return this.parentNoteId === 'root';
}
get toString() { get toString() {
return `Branch(branchId=${this.branchId})`; return `Branch(branchId=${this.branchId})`;
} }

View File

@ -44,6 +44,14 @@ class NoteShort {
get toString() { get toString() {
return `Note(noteId=${this.noteId}, title=${this.title})`; return `Note(noteId=${this.noteId}, title=${this.title})`;
} }
get dto() {
const dto = Object.assign({}, this);
delete dto.treeCache;
delete dto.hideInAutocomplete;
return dto;
}
} }
export default NoteShort; export default NoteShort;

View File

@ -1,5 +1,6 @@
import treeCache from "./tree_cache.js"; import treeCache from "./tree_cache.js";
import treeUtils from "./tree_utils.js"; import treeUtils from "./tree_utils.js";
import protectedSessionHolder from './protected_session_holder.js';
async function getAutocompleteItems(parentNoteId, notePath, titlePath) { async function getAutocompleteItems(parentNoteId, notePath, titlePath) {
if (!parentNoteId) { if (!parentNoteId) {
@ -21,9 +22,6 @@ async function getAutocompleteItems(parentNoteId, notePath, titlePath) {
titlePath = ''; titlePath = '';
} }
// https://github.com/zadam/trilium/issues/46
// unfortunately not easy to implement because we don't have an easy access to note's isProtected property
const autocompleteItems = []; const autocompleteItems = [];
for (const childNote of childNotes) { for (const childNote of childNotes) {
@ -34,10 +32,12 @@ async function getAutocompleteItems(parentNoteId, notePath, titlePath) {
const childNotePath = (notePath ? (notePath + '/') : '') + childNote.noteId; const childNotePath = (notePath ? (notePath + '/') : '') + childNote.noteId;
const childTitlePath = (titlePath ? (titlePath + ' / ') : '') + await treeUtils.getNoteTitle(childNote.noteId, parentNoteId); const childTitlePath = (titlePath ? (titlePath + ' / ') : '') + await treeUtils.getNoteTitle(childNote.noteId, parentNoteId);
autocompleteItems.push({ if (!childNote.isProtected || protectedSessionHolder.isProtectedSessionAvailable()) {
value: childTitlePath + ' (' + childNotePath + ')', autocompleteItems.push({
label: childTitlePath value: childTitlePath + ' (' + childNotePath + ')',
}); label: childTitlePath
});
}
const childItems = await getAutocompleteItems(childNote.noteId, childNotePath, childTitlePath); const childItems = await getAutocompleteItems(childNote.noteId, childNotePath, childTitlePath);

View File

@ -1,4 +1,5 @@
import treeService from './tree.js'; import treeService from './tree.js';
import treeUtils from './tree_utils.js';
import noteTypeService from './note_type.js'; import noteTypeService from './note_type.js';
import protectedSessionService from './protected_session.js'; import protectedSessionService from './protected_session.js';
import protectedSessionHolder from './protected_session_holder.js'; import protectedSessionHolder from './protected_session_holder.js';
@ -24,6 +25,7 @@ const $noteDetailWrapper = $("#note-detail-wrapper");
const $noteIdDisplay = $("#note-id-display"); const $noteIdDisplay = $("#note-id-display");
const $labelList = $("#label-list"); const $labelList = $("#label-list");
const $labelListInner = $("#label-list-inner"); const $labelListInner = $("#label-list-inner");
const $childrenOverview = $("#children-overview");
let currentNote = null; let currentNote = null;
@ -73,50 +75,42 @@ function noteChanged() {
async function reload() { async function reload() {
// no saving here // no saving here
await loadNoteToEditor(getCurrentNoteId()); await loadNoteDetail(getCurrentNoteId());
} }
async function switchToNote(noteId) { async function switchToNote(noteId) {
if (getCurrentNoteId() !== noteId) { if (getCurrentNoteId() !== noteId) {
await saveNoteIfChanged(); await saveNoteIfChanged();
await loadNoteToEditor(noteId); await loadNoteDetail(noteId);
} }
} }
async function saveNote() {
const note = getCurrentNote();
note.title = $noteTitle.val();
note.content = getComponent(note.type).getContent();
treeService.setNoteTitle(note.noteId, note.title);
await server.put('notes/' + note.noteId, note.dto);
isNoteChanged = false;
if (note.isProtected) {
protectedSessionHolder.touchProtectedSession();
}
infoService.showMessage("Saved!");
}
async function saveNoteIfChanged() { async function saveNoteIfChanged() {
if (!isNoteChanged) { if (!isNoteChanged) {
return; return;
} }
const note = getCurrentNote(); await saveNote();
updateNoteFromInputs(note);
await saveNoteToServer(note);
if (note.isProtected) {
protectedSessionHolder.touchProtectedSession();
}
}
function updateNoteFromInputs(note) {
note.title = $noteTitle.val();
note.content = getComponent(note.type).getContent();
treeService.setNoteTitle(note.noteId, note.title);
}
async function saveNoteToServer(note) {
const dto = Object.assign({}, note);
delete dto.treeCache;
delete dto.hideInAutocomplete;
await server.put('notes/' + dto.noteId, dto);
isNoteChanged = false;
infoService.showMessage("Saved!");
} }
function setNoteBackgroundIfProtected(note) { function setNoteBackgroundIfProtected(note) {
@ -145,7 +139,7 @@ async function handleProtectedSession() {
protectedSessionService.ensureDialogIsClosed(); protectedSessionService.ensureDialogIsClosed();
} }
async function loadNoteToEditor(noteId) { async function loadNoteDetail(noteId) {
currentNote = await loadNote(noteId); currentNote = await loadNote(noteId);
if (isNewNoteCreated) { if (isNewNoteCreated) {
@ -183,6 +177,26 @@ async function loadNoteToEditor(noteId) {
$noteDetailWrapper.scrollTop(0); $noteDetailWrapper.scrollTop(0);
await loadLabelList(); await loadLabelList();
await showChildrenOverview();
}
async function showChildrenOverview() {
const note = getCurrentNote();
$childrenOverview.empty();
const notePath = treeService.getCurrentNotePath();
for (const childBranch of await note.getChildBranches()) {
const link = $('<a>', {
href: 'javascript:',
text: await treeUtils.getNoteTitle(childBranch.noteId, childBranch.parentNoteId)
}).attr('action', 'note').attr('note-path', notePath + '/' + childBranch.noteId);
const childEl = $('<div class="child-overview">').html(link);
$childrenOverview.append(childEl);
}
} }
async function loadLabelList() { async function loadLabelList() {
@ -245,8 +259,6 @@ setInterval(saveNoteIfChanged, 5000);
export default { export default {
reload, reload,
switchToNote, switchToNote,
updateNoteFromInputs,
saveNoteToServer,
setNoteBackgroundIfProtected, setNoteBackgroundIfProtected,
loadNote, loadNote,
getCurrentNote, getCurrentNote,
@ -255,6 +267,7 @@ export default {
newNoteCreated, newNoteCreated,
focus, focus,
loadLabelList, loadLabelList,
saveNote,
saveNoteIfChanged, saveNoteIfChanged,
noteChanged noteChanged
}; };

View File

@ -16,6 +16,10 @@ async function show() {
CodeMirror.keyMap.default["Shift-Tab"] = "indentLess"; CodeMirror.keyMap.default["Shift-Tab"] = "indentLess";
CodeMirror.keyMap.default["Tab"] = "indentMore"; CodeMirror.keyMap.default["Tab"] = "indentMore";
// these conflict with backward/forward navigation shortcuts
delete CodeMirror.keyMap.default["Alt-Left"];
delete CodeMirror.keyMap.default["Alt-Right"];
CodeMirror.modeURL = 'libraries/codemirror/mode/%N/%N.js'; CodeMirror.modeURL = 'libraries/codemirror/mode/%N/%N.js';
codeEditor = CodeMirror($noteDetailCode[0], { codeEditor = CodeMirror($noteDetailCode[0], {

View File

@ -1,5 +1,5 @@
import treeService from './tree.js'; import treeService from './tree.js';
import noteDetail from './note_detail.js'; import noteDetailService from './note_detail.js';
import server from './server.js'; import server from './server.js';
import infoService from "./info.js"; import infoService from "./info.js";
@ -84,13 +84,13 @@ function NoteTypeModel() {
}; };
async function save() { async function save() {
const note = noteDetail.getCurrentNote(); const note = noteDetailService.getCurrentNote();
await server.put('notes/' + note.noteId await server.put('notes/' + note.noteId
+ '/type/' + encodeURIComponent(self.type()) + '/type/' + encodeURIComponent(self.type())
+ '/mime/' + encodeURIComponent(self.mime())); + '/mime/' + encodeURIComponent(self.mime()));
await noteDetail.reload(); await noteDetailService.reload();
// for the note icon to be updated in the tree // for the note icon to be updated in the tree
await treeService.reload(); await treeService.reload();

View File

@ -1,5 +1,5 @@
import treeService from './tree.js'; import treeService from './tree.js';
import noteDetail from './note_detail.js'; import noteDetailService from './note_detail.js';
import utils from './utils.js'; import utils from './utils.js';
import server from './server.js'; import server from './server.js';
import protectedSessionHolder from './protected_session_holder.js'; import protectedSessionHolder from './protected_session_holder.js';
@ -57,7 +57,7 @@ async function setupProtectedSession() {
$dialog.dialog("close"); $dialog.dialog("close");
noteDetail.reload(); noteDetailService.reload();
treeService.reload(); treeService.reload();
if (protectedSessionDeferred !== null) { if (protectedSessionDeferred !== null) {
@ -90,33 +90,27 @@ async function enterProtectedSession(password) {
async function protectNoteAndSendToServer() { async function protectNoteAndSendToServer() {
await ensureProtectedSession(true, true); await ensureProtectedSession(true, true);
const note = noteDetail.getCurrentNote(); const note = noteDetailService.getCurrentNote();
noteDetail.updateNoteFromInputs(note);
note.isProtected = true; note.isProtected = true;
await noteDetail.saveNoteToServer(note); await noteDetailService.saveNote(note);
treeService.setProtected(note.noteId, note.isProtected); treeService.setProtected(note.noteId, note.isProtected);
noteDetail.setNoteBackgroundIfProtected(note); noteDetailService.setNoteBackgroundIfProtected(note);
} }
async function unprotectNoteAndSendToServer() { async function unprotectNoteAndSendToServer() {
await ensureProtectedSession(true, true); await ensureProtectedSession(true, true);
const note = noteDetail.getCurrentNote(); const note = noteDetailService.getCurrentNote();
noteDetail.updateNoteFromInputs(note);
note.isProtected = false; note.isProtected = false;
await noteDetail.saveNoteToServer(note); await noteDetailService.saveNote(note);
treeService.setProtected(note.noteId, note.isProtected); treeService.setProtected(note.noteId, note.isProtected);
noteDetail.setNoteBackgroundIfProtected(note); noteDetailService.setNoteBackgroundIfProtected(note);
} }
async function protectBranch(noteId, protect) { async function protectBranch(noteId, protect) {
@ -127,7 +121,7 @@ async function protectBranch(noteId, protect) {
infoService.showMessage("Request to un/protect sub tree has finished successfully"); infoService.showMessage("Request to un/protect sub tree has finished successfully");
treeService.reload(); treeService.reload();
noteDetail.reload(); noteDetailService.reload();
} }
$passwordForm.submit(() => { $passwordForm.submit(() => {

View File

@ -293,7 +293,7 @@ function initFancyTree(branch) {
keyboard: false, // we takover keyboard handling in the hotkeys plugin keyboard: false, // we takover keyboard handling in the hotkeys plugin
extensions: ["hotkeys", "filter", "dnd", "clones"], extensions: ["hotkeys", "filter", "dnd", "clones"],
source: branch, source: branch,
scrollParent: $("#tree"), scrollParent: $tree,
click: (event, data) => { click: (event, data) => {
const targetType = data.targetType; const targetType = data.targetType;
const node = data.node; const node = data.node;

File diff suppressed because it is too large Load Diff

View File

@ -5,9 +5,9 @@
display: grid; display: grid;
grid-template-areas: "header header" grid-template-areas: "header header"
"tree-actions title" "tree-actions title"
"search note-content" "search note-detail"
"tree note-content" "tree note-detail"
"parent-list note-content" "parent-list note-detail"
"parent-list label-list"; "parent-list label-list";
grid-template-columns: 2fr 5fr; grid-template-columns: 2fr 5fr;
grid-template-rows: auto grid-template-rows: auto
@ -288,4 +288,21 @@ div.ui-tooltip {
#file-table th, #file-table td { #file-table th, #file-table td {
padding: 10px; padding: 10px;
font-size: large; font-size: large;
}
#children-overview {
padding-top: 20px;
}
.child-overview {
font-weight: bold;
font-size: large;
padding: 10px;
border: 1px solid black;
width: 150px;
height: 95px;
margin-right: 20px;
margin-bottom: 20px;
border-radius: 15px;
overflow: hidden;
} }

View File

@ -13,7 +13,68 @@ async function exportNote(req, res) {
const pack = tar.pack(); const pack = tar.pack();
const name = await exportNoteInner(branchId, '', pack); const exportedNoteIds = [];
const name = await exportNoteInner(branchId, '');
async function exportNoteInner(branchId, directory) {
const branch = await repository.getBranch(branchId);
const note = await branch.getNote();
const childFileName = directory + sanitize(note.title);
if (exportedNoteIds.includes(note.noteId)) {
saveMetadataFile(childFileName, {
version: 1,
clone: true,
noteId: note.noteId,
prefix: branch.prefix
});
return;
}
const metadata = {
version: 1,
clone: false,
noteId: note.noteId,
title: note.title,
prefix: branch.prefix,
type: note.type,
mime: note.mime,
labels: (await note.getLabels()).map(label => {
return {
name: label.name,
value: label.value
};
})
};
if (metadata.labels.find(label => label.name === 'excludeFromExport')) {
return;
}
saveMetadataFile(childFileName, metadata);
saveDataFile(childFileName, note);
exportedNoteIds.push(note.noteId);
for (const child of await note.getChildBranches()) {
await exportNoteInner(child.branchId, childFileName + "/");
}
return childFileName;
}
function saveDataFile(childFileName, note) {
const content = note.type === 'text' ? html.prettyPrint(note.content, {indent_size: 2}) : note.content;
pack.entry({name: childFileName + ".dat", size: content.length}, content);
}
function saveMetadataFile(childFileName, metadata) {
const metadataJson = JSON.stringify(metadata, null, '\t');
pack.entry({name: childFileName + ".meta", size: metadataJson.length}, metadataJson);
}
pack.finalize(); pack.finalize();
@ -23,51 +84,6 @@ async function exportNote(req, res) {
pack.pipe(res); pack.pipe(res);
} }
async function exportNoteInner(branchId, directory, pack) {
const branch = await repository.getBranch(branchId);
const note = await branch.getNote();
if (note.isProtected) {
return;
}
const metadata = await getMetadata(note);
if (metadata.labels.find(label => label.name === 'excludeFromExport')) {
return;
}
const metadataJson = JSON.stringify(metadata, null, '\t');
const childFileName = directory + sanitize(note.title);
pack.entry({ name: childFileName + ".meta", size: metadataJson.length }, metadataJson);
const content = note.type === 'text' ? html.prettyPrint(note.content, {indent_size: 2}) : note.content;
pack.entry({ name: childFileName + ".dat", size: content.length }, content);
for (const child of await note.getChildBranches()) {
await exportNoteInner(child.branchId, childFileName + "/", pack);
}
return childFileName;
}
async function getMetadata(note) {
return {
version: 1,
title: note.title,
type: note.type,
mime: note.mime,
labels: (await note.getLabels()).map(label => {
return {
name: label.name,
value: label.value
};
})
};
}
module.exports = { module.exports = {
exportNote exportNote
}; };

View File

@ -3,6 +3,7 @@
const repository = require('../../services/repository'); const repository = require('../../services/repository');
const labelService = require('../../services/labels'); const labelService = require('../../services/labels');
const noteService = require('../../services/notes'); const noteService = require('../../services/notes');
const Branch = require('../../entities/branch');
const tar = require('tar-stream'); const tar = require('tar-stream');
const stream = require('stream'); const stream = require('stream');
const path = require('path'); const path = require('path');
@ -31,7 +32,7 @@ async function parseImportFile(file) {
const extract = tar.extract(); const extract = tar.extract();
extract.on('entry', function(header, stream, next) { extract.on('entry', function(header, stream, next) {
let {name, key} = getFileName(header.name); const {name, key} = getFileName(header.name);
let file = fileMap[name]; let file = fileMap[name];
@ -97,30 +98,46 @@ async function importTar(req) {
const files = await parseImportFile(file); const files = await parseImportFile(file);
await importNotes(files, parentNoteId); // maps from original noteId (in tar file) to newly generated noteId
const noteIdMap = {};
await importNotes(files, parentNoteId, noteIdMap);
} }
async function importNotes(files, parentNoteId) { async function importNotes(files, parentNoteId, noteIdMap) {
for (const file of files) { for (const file of files) {
if (file.meta.version !== 1) { if (file.meta.version !== 1) {
throw new Error("Can't read meta data version " + file.meta.version); throw new Error("Can't read meta data version " + file.meta.version);
} }
if (file.meta.clone) {
await new Branch({
parentNoteId: parentNoteId,
noteId: noteIdMap[file.meta.noteId],
prefix: file.meta.prefix
}).save();
return;
}
if (file.meta.type !== 'file') { if (file.meta.type !== 'file') {
file.data = file.data.toString("UTF-8"); file.data = file.data.toString("UTF-8");
} }
const {note} = await noteService.createNote(parentNoteId, file.meta.title, file.data, { const {note} = await noteService.createNote(parentNoteId, file.meta.title, file.data, {
type: file.meta.type, type: file.meta.type,
mime: file.meta.mime mime: file.meta.mime,
prefix: file.meta.prefix
}); });
noteIdMap[file.meta.noteId] = note.noteId;
for (const label of file.meta.labels) { for (const label of file.meta.labels) {
await labelService.createLabel(note.noteId, label.name, label.value); await labelService.createLabel(note.noteId, label.name, label.value);
} }
if (file.children.length > 0) { if (file.children.length > 0) {
await importNotes(file.children, note.noteId); await importNotes(file.children, note.noteId, noteIdMap);
} }
} }
} }

View File

@ -10,8 +10,8 @@ const log = require('../../services/log');
async function checkSync() { async function checkSync() {
return { return {
'hashes': await contentHashService.getHashes(), hashes: await contentHashService.getHashes(),
'max_sync_id': await sql.getValue('SELECT MAX(id) FROM sync') maxSyncId: await sql.getValue('SELECT MAX(id) FROM sync')
}; };
} }
@ -58,126 +58,18 @@ async function forceNoteSync(req) {
async function getChanged(req) { async function getChanged(req) {
const lastSyncId = parseInt(req.query.lastSyncId); const lastSyncId = parseInt(req.query.lastSyncId);
return await sql.getRows("SELECT * FROM sync WHERE id > ?", [lastSyncId]); const syncs = await sql.getRows("SELECT * FROM sync WHERE id > ? LIMIT 1000", [lastSyncId]);
return await syncService.getSyncRecords(syncs);
} }
async function getNote(req) { async function update(req) {
const noteId = req.params.noteId; const sourceId = req.body.sourceId;
const entity = await sql.getRow("SELECT * FROM notes WHERE noteId = ?", [noteId]); const entities = req.body.entities;
syncService.serializeNoteContentBuffer(entity); for (const {sync, entity} of entities) {
await syncUpdateService.updateEntity(sync.entityName, entity, sourceId);
return {
entity: entity
};
}
async function getBranch(req) {
const branchId = req.params.branchId;
return await sql.getRow("SELECT * FROM branches WHERE branchId = ?", [branchId]);
}
async function getNoteRevision(req) {
const noteRevisionId = req.params.noteRevisionId;
return await sql.getRow("SELECT * FROM note_revisions WHERE noteRevisionId = ?", [noteRevisionId]);
}
async function getOption(req) {
const name = req.params.name;
const opt = await sql.getRow("SELECT * FROM options WHERE name = ?", [name]);
if (!opt.isSynced) {
return [400, "This option can't be synced."];
} }
else {
return opt;
}
}
async function getNoteReordering(req) {
const parentNoteId = req.params.parentNoteId;
return {
parentNoteId: parentNoteId,
ordering: await sql.getMap("SELECT branchId, notePosition FROM branches WHERE parentNoteId = ? AND isDeleted = 0", [parentNoteId])
};
}
async function getRecentNote(req) {
const branchId = req.params.branchId;
return await sql.getRow("SELECT * FROM recent_notes WHERE branchId = ?", [branchId]);
}
async function getImage(req) {
const imageId = req.params.imageId;
const entity = await sql.getRow("SELECT * FROM images WHERE imageId = ?", [imageId]);
if (entity && entity.data !== null) {
entity.data = entity.data.toString('base64');
}
return entity;
}
async function getNoteImage(req) {
const noteImageId = req.params.noteImageId;
return await sql.getRow("SELECT * FROM note_images WHERE noteImageId = ?", [noteImageId]);
}
async function getLabel(req) {
const labelId = req.params.labelId;
return await sql.getRow("SELECT * FROM labels WHERE labelId = ?", [labelId]);
}
async function getApiToken(req) {
const apiTokenId = req.params.apiTokenId;
return await sql.getRow("SELECT * FROM api_tokens WHERE apiTokenId = ?", [apiTokenId]);
}
async function updateNote(req) {
await syncUpdateService.updateNote(req.body.entity, req.body.sourceId);
}
async function updateBranch(req) {
await syncUpdateService.updateBranch(req.body.entity, req.body.sourceId);
}
async function updateNoteRevision(req) {
await syncUpdateService.updateNoteRevision(req.body.entity, req.body.sourceId);
}
async function updateNoteReordering(req) {
await syncUpdateService.updateNoteReordering(req.body.entity, req.body.sourceId);
}
async function updateOption(req) {
await syncUpdateService.updateOptions(req.body.entity, req.body.sourceId);
}
async function updateRecentNote(req) {
await syncUpdateService.updateRecentNotes(req.body.entity, req.body.sourceId);
}
async function updateImage(req) {
await syncUpdateService.updateImage(req.body.entity, req.body.sourceId);
}
async function updateNoteImage(req) {
await syncUpdateService.updateNoteImage(req.body.entity, req.body.sourceId);
}
async function updateLabel(req) {
await syncUpdateService.updateLabel(req.body.entity, req.body.sourceId);
}
async function updateApiToken(req) {
await syncUpdateService.updateApiToken(req.body.entity, req.body.sourceId);
} }
module.exports = { module.exports = {
@ -187,24 +79,5 @@ module.exports = {
forceFullSync, forceFullSync,
forceNoteSync, forceNoteSync,
getChanged, getChanged,
getNote, update
getBranch,
getImage,
getNoteImage,
getNoteReordering,
getNoteRevision,
getRecentNote,
getOption,
getLabel,
getApiToken,
updateNote,
updateBranch,
updateImage,
updateNoteImage,
updateNoteReordering,
updateNoteRevision,
updateRecentNote,
updateOption,
updateLabel,
updateApiToken
}; };

View File

@ -147,26 +147,7 @@ function register(app) {
apiRoute(POST, '/api/sync/force-full-sync', syncApiRoute.forceFullSync); apiRoute(POST, '/api/sync/force-full-sync', syncApiRoute.forceFullSync);
apiRoute(POST, '/api/sync/force-note-sync/:noteId', syncApiRoute.forceNoteSync); apiRoute(POST, '/api/sync/force-note-sync/:noteId', syncApiRoute.forceNoteSync);
apiRoute(GET, '/api/sync/changed', syncApiRoute.getChanged); apiRoute(GET, '/api/sync/changed', syncApiRoute.getChanged);
apiRoute(GET, '/api/sync/notes/:noteId', syncApiRoute.getNote); apiRoute(PUT, '/api/sync/update', syncApiRoute.update);
apiRoute(GET, '/api/sync/branches/:branchId', syncApiRoute.getBranch);
apiRoute(GET, '/api/sync/note_revisions/:noteRevisionId', syncApiRoute.getNoteRevision);
apiRoute(GET, '/api/sync/options/:name', syncApiRoute.getOption);
apiRoute(GET, '/api/sync/note_reordering/:parentNoteId', syncApiRoute.getNoteReordering);
apiRoute(GET, '/api/sync/recent_notes/:branchId', syncApiRoute.getRecentNote);
apiRoute(GET, '/api/sync/images/:imageId', syncApiRoute.getImage);
apiRoute(GET, '/api/sync/note_images/:noteImageId', syncApiRoute.getNoteImage);
apiRoute(GET, '/api/sync/labels/:labelId', syncApiRoute.getLabel);
apiRoute(GET, '/api/sync/api_tokens/:apiTokenId', syncApiRoute.getApiToken);
apiRoute(PUT, '/api/sync/notes', syncApiRoute.updateNote);
apiRoute(PUT, '/api/sync/branches', syncApiRoute.updateBranch);
apiRoute(PUT, '/api/sync/note_revisions', syncApiRoute.updateNoteRevision);
apiRoute(PUT, '/api/sync/note_reordering', syncApiRoute.updateNoteReordering);
apiRoute(PUT, '/api/sync/options', syncApiRoute.updateOption);
apiRoute(PUT, '/api/sync/recent_notes', syncApiRoute.updateRecentNote);
apiRoute(PUT, '/api/sync/images', syncApiRoute.updateImage);
apiRoute(PUT, '/api/sync/note_images', syncApiRoute.updateNoteImage);
apiRoute(PUT, '/api/sync/labels', syncApiRoute.updateLabel);
apiRoute(PUT, '/api/sync/api_tokens', syncApiRoute.updateApiToken);
apiRoute(GET, '/api/event-log', eventLogRoute.getEventLog); apiRoute(GET, '/api/event-log', eventLogRoute.getEventLog);

View File

@ -3,7 +3,7 @@
const build = require('./build'); const build = require('./build');
const packageJson = require('../../package'); const packageJson = require('../../package');
const APP_DB_VERSION = 86; const APP_DB_VERSION = 87;
module.exports = { module.exports = {
appVersion: packageJson.version, appVersion: packageJson.version,

View File

@ -1,6 +1,10 @@
"use strict";
const sql = require('./sql'); const sql = require('./sql');
const utils = require('./utils'); const utils = require('./utils');
const log = require('./log'); const log = require('./log');
const eventLogService = require('./event_log');
const messagingService = require('./messaging');
function getHash(rows) { function getHash(rows) {
let hash = ''; let hash = '';
@ -121,6 +125,29 @@ async function getHashes() {
return hashes; return hashes;
} }
async function checkContentHashes(otherHashes) {
const hashes = await getHashes();
let allChecksPassed = true;
for (const key in hashes) {
if (hashes[key] !== otherHashes[key]) {
allChecksPassed = false;
await eventLogService.addEvent(`Content hash check for ${key} FAILED. Local is ${hashes[key]}, remote is ${resp.hashes[key]}`);
if (key !== 'recent_notes') {
// let's not get alarmed about recent notes which get updated often and can cause failures in race conditions
await messagingService.sendMessageToAllClients({type: 'sync-hash-check-failed'});
}
}
}
if (allChecksPassed) {
log.info("Content hash checks PASSED");
}
}
module.exports = { module.exports = {
getHashes getHashes,
checkContentHashes
}; };

View File

@ -15,14 +15,22 @@ const logger = require('simple-node-logger').createRollingFileLogger({
}); });
function info(message) { function info(message) {
logger.info(message); // info messages are logged asynchronously
setTimeout(() => {
console.log(message);
console.log(message); logger.info(message);
}, 0);
} }
function error(message) { function error(message) {
message = "ERROR: " + message;
// we're using .info() instead of .error() because simple-node-logger emits weird error for showError() // we're using .info() instead of .error() because simple-node-logger emits weird error for showError()
info("ERROR: " + message); // errors are logged synchronously to make sure it doesn't get lost in case of crash
logger.info(message);
console.trace(message);
} }
const requestBlacklist = [ "/libraries", "/javascripts", "/images", "/stylesheets" ]; const requestBlacklist = [ "/libraries", "/javascripts", "/images", "/stylesheets" ];

View File

@ -56,6 +56,7 @@ async function createNewNote(parentNoteId, noteData) {
noteId: note.noteId, noteId: note.noteId,
parentNoteId: parentNoteId, parentNoteId: parentNoteId,
notePosition: newNotePos, notePosition: newNotePos,
prefix: noteData.prefix,
isExpanded: 0 isExpanded: 0
}).save(); }).save();
@ -180,6 +181,8 @@ async function saveNoteRevision(note) {
// title and text should be decrypted now // title and text should be decrypted now
title: note.title, title: note.title,
content: note.content, content: note.content,
type: note.type,
mime: note.mime,
isProtected: 0, // will be fixed in the protectNoteRevisions() call isProtected: 0, // will be fixed in the protectNoteRevisions() call
dateModifiedFrom: note.dateModified, dateModifiedFrom: note.dateModified,
dateModifiedTo: dateUtils.nowDate() dateModifiedTo: dateUtils.nowDate()
@ -198,7 +201,7 @@ async function updateNote(noteId, noteUpdates) {
await saveNoteRevision(note); await saveNoteRevision(note);
note.title = noteUpdates.title; note.title = noteUpdates.title;
note.content = noteUpdates.content; note.setContent(noteUpdates.content);
note.isProtected = noteUpdates.isProtected; note.isProtected = noteUpdates.isProtected;
await note.save(); await note.save();

View File

@ -10,10 +10,8 @@ const sourceIdService = require('./source_id');
const dateUtils = require('./date_utils'); const dateUtils = require('./date_utils');
const syncUpdateService = require('./sync_update'); const syncUpdateService = require('./sync_update');
const contentHashService = require('./content_hash'); const contentHashService = require('./content_hash');
const eventLogService = require('./event_log');
const fs = require('fs'); const fs = require('fs');
const appInfo = require('./app_info'); const appInfo = require('./app_info');
const messagingService = require('./messaging');
const syncSetup = require('./sync_setup'); const syncSetup = require('./sync_setup');
const syncMutexService = require('./sync_mutex'); const syncMutexService = require('./sync_mutex');
const cls = require('./cls'); const cls = require('./cls');
@ -91,69 +89,19 @@ async function login() {
return syncContext; return syncContext;
} }
async function getLastSyncedPull() {
return parseInt(await optionService.getOption('lastSyncedPull'));
}
async function setLastSyncedPull(syncId) {
await optionService.setOption('lastSyncedPull', syncId);
}
async function pullSync(syncContext) { async function pullSync(syncContext) {
const lastSyncedPull = await getLastSyncedPull(); const changesUri = '/api/sync/changed?lastSyncId=' + await getLastSyncedPull();
const changesUri = '/api/sync/changed?lastSyncId=' + lastSyncedPull; const rows = await syncRequest(syncContext, 'GET', changesUri);
const syncRows = await syncRequest(syncContext, 'GET', changesUri); log.info("Pulled " + rows.length + " changes from " + changesUri);
log.info("Pulled " + syncRows.length + " changes from " + changesUri); for (const {sync, entity} of rows) {
for (const sync of syncRows) {
if (sourceIdService.isLocalSourceId(sync.sourceId)) { if (sourceIdService.isLocalSourceId(sync.sourceId)) {
log.info(`Skipping pull #${sync.id} ${sync.entityName} ${sync.entityId} because ${sync.sourceId} is a local source id.`); log.info(`Skipping pull #${sync.id} ${sync.entityName} ${sync.entityId} because ${sync.sourceId} is a local source id.`);
await setLastSyncedPull(sync.id);
continue;
}
const resp = await syncRequest(syncContext, 'GET', "/api/sync/" + sync.entityName + "/" + encodeURIComponent(sync.entityId));
if (!resp || (sync.entityName === 'notes' && !resp.entity)) {
log.error(`Empty response to pull for sync #${sync.id} ${sync.entityName}, id=${sync.entityId}`);
}
else if (sync.entityName === 'notes') {
await syncUpdateService.updateNote(resp.entity, syncContext.sourceId);
}
else if (sync.entityName === 'branches') {
await syncUpdateService.updateBranch(resp, syncContext.sourceId);
}
else if (sync.entityName === 'note_revisions') {
await syncUpdateService.updateNoteRevision(resp, syncContext.sourceId);
}
else if (sync.entityName === 'note_reordering') {
await syncUpdateService.updateNoteReordering(resp, syncContext.sourceId);
}
else if (sync.entityName === 'options') {
await syncUpdateService.updateOptions(resp, syncContext.sourceId);
}
else if (sync.entityName === 'recent_notes') {
await syncUpdateService.updateRecentNotes(resp, syncContext.sourceId);
}
else if (sync.entityName === 'images') {
await syncUpdateService.updateImage(resp, syncContext.sourceId);
}
else if (sync.entityName === 'note_images') {
await syncUpdateService.updateNoteImage(resp, syncContext.sourceId);
}
else if (sync.entityName === 'labels') {
await syncUpdateService.updateLabel(resp, syncContext.sourceId);
}
else if (sync.entityName === 'api_tokens') {
await syncUpdateService.updateApiToken(resp, syncContext.sourceId);
} }
else { else {
throw new Error(`Unrecognized entity type ${sync.entityName} in sync #${sync.id}`); await syncUpdateService.updateEntity(sync.entityName, entity, syncContext.sourceId);
} }
await setLastSyncedPull(sync.id); await setLastSyncedPull(sync.id);
@ -162,145 +110,69 @@ async function pullSync(syncContext) {
log.info("Finished pull"); log.info("Finished pull");
} }
async function getLastSyncedPush() {
return parseInt(await optionService.getOption('lastSyncedPush'));
}
async function setLastSyncedPush(lastSyncedPush) {
await optionService.setOption('lastSyncedPush', lastSyncedPush);
}
async function pushSync(syncContext) { async function pushSync(syncContext) {
let lastSyncedPush = await getLastSyncedPush(); let lastSyncedPush = await getLastSyncedPush();
while (true) { while (true) {
const sync = await sql.getRowOrNull('SELECT * FROM sync WHERE id > ? LIMIT 1', [lastSyncedPush]); const syncs = await sql.getRows('SELECT * FROM sync WHERE id > ? LIMIT 1000', [lastSyncedPush]);
if (sync === null) { const filteredSyncs = syncs.filter(sync => {
// nothing to sync if (sync.sourceId === syncContext.sourceId) {
log.info(`Skipping push #${sync.id} ${sync.entityName} ${sync.entityId} because it originates from sync target`);
// this may set lastSyncedPush beyond what's actually sent (because of size limit)
// so this is applied to the database only if there's no actual update
// TODO: it would be better to simplify this somehow
lastSyncedPush = sync.id;
return false;
}
else {
return true;
}
});
if (filteredSyncs.length === 0) {
log.info("Nothing to push"); log.info("Nothing to push");
await setLastSyncedPush(lastSyncedPush);
break; break;
} }
if (sync.sourceId === syncContext.sourceId) { const syncRecords = await getSyncRecords(filteredSyncs);
log.info(`Skipping push #${sync.id} ${sync.entityName} ${sync.entityId} because it originates from sync target`);
}
else {
await pushEntity(sync, syncContext);
}
lastSyncedPush = sync.id; log.info(`Pushing ${syncRecords.length} syncs.`);
await syncRequest(syncContext, 'PUT', '/api/sync/update', {
sourceId: sourceIdService.getCurrentSourceId(),
entities: syncRecords
});
lastSyncedPush = syncRecords[syncRecords.length - 1].sync.id;
await setLastSyncedPush(lastSyncedPush); await setLastSyncedPush(lastSyncedPush);
} }
} }
async function pushEntity(sync, syncContext) {
let entity;
if (sync.entityName === 'notes') {
entity = await sql.getRow('SELECT * FROM notes WHERE noteId = ?', [sync.entityId]);
serializeNoteContentBuffer(entity);
}
else if (sync.entityName === 'branches') {
entity = await sql.getRow('SELECT * FROM branches WHERE branchId = ?', [sync.entityId]);
}
else if (sync.entityName === 'note_revisions') {
entity = await sql.getRow('SELECT * FROM note_revisions WHERE noteRevisionId = ?', [sync.entityId]);
}
else if (sync.entityName === 'note_reordering') {
entity = {
parentNoteId: sync.entityId,
ordering: await sql.getMap('SELECT branchId, notePosition FROM branches WHERE parentNoteId = ? AND isDeleted = 0', [sync.entityId])
};
}
else if (sync.entityName === 'options') {
entity = await sql.getRow('SELECT * FROM options WHERE name = ?', [sync.entityId]);
}
else if (sync.entityName === 'recent_notes') {
entity = await sql.getRow('SELECT * FROM recent_notes WHERE branchId = ?', [sync.entityId]);
}
else if (sync.entityName === 'images') {
entity = await sql.getRow('SELECT * FROM images WHERE imageId = ?', [sync.entityId]);
if (entity.data !== null) {
entity.data = entity.data.toString('base64');
}
}
else if (sync.entityName === 'note_images') {
entity = await sql.getRow('SELECT * FROM note_images WHERE noteImageId = ?', [sync.entityId]);
}
else if (sync.entityName === 'labels') {
entity = await sql.getRow('SELECT * FROM labels WHERE labelId = ?', [sync.entityId]);
}
else if (sync.entityName === 'api_tokens') {
entity = await sql.getRow('SELECT * FROM api_tokens WHERE apiTokenId = ?', [sync.entityId]);
}
else {
throw new Error(`Unrecognized entity type ${sync.entityName} in sync #${sync.id}`);
}
if (!entity) {
log.info(`Sync #${sync.id} entity for ${sync.entityName} ${sync.entityId} doesn't exist. Skipping.`);
return;
}
log.info(`Pushing changes in sync #${sync.id} ${sync.entityName} ${sync.entityId}`);
const payload = {
sourceId: sourceIdService.getCurrentSourceId(),
entity: entity
};
await syncRequest(syncContext, 'PUT', '/api/sync/' + sync.entityName, payload);
}
function serializeNoteContentBuffer(note) {
if (note.type === 'file') {
note.content = note.content.toString("binary");
}
}
async function checkContentHash(syncContext) { async function checkContentHash(syncContext) {
const resp = await syncRequest(syncContext, 'GET', '/api/sync/check'); const resp = await syncRequest(syncContext, 'GET', '/api/sync/check');
if (await getLastSyncedPull() < resp.max_sync_id) { if (await getLastSyncedPull() < resp.maxSyncId) {
log.info("There are some outstanding pulls, skipping content check."); log.info("There are some outstanding pulls, skipping content check.");
return; return;
} }
const lastSyncedPush = await getLastSyncedPush(); const notPushedSyncs = await sql.getValue("SELECT COUNT(*) FROM sync WHERE id > ?", [await getLastSyncedPush()]);
const notPushedSyncs = await sql.getValue("SELECT COUNT(*) FROM sync WHERE id > ?", [lastSyncedPush]);
if (notPushedSyncs > 0) { if (notPushedSyncs > 0) {
log.info("There's " + notPushedSyncs + " outstanding pushes, skipping content check."); log.info(`There's ${notPushedSyncs} outstanding pushes, skipping content check.`);
return; return;
} }
const hashes = await contentHashService.getHashes(); await contentHashService.checkContentHashes(resp.hashes);
let allChecksPassed = true;
for (const key in hashes) {
if (hashes[key] !== resp.hashes[key]) {
allChecksPassed = false;
await eventLogService.addEvent(`Content hash check for ${key} FAILED. Local is ${hashes[key]}, remote is ${resp.hashes[key]}`);
if (key !== 'recent_notes') {
// let's not get alarmed about recent notes which get updated often and can cause failures in race conditions
await messagingService.sendMessageToAllClients({type: 'sync-hash-check-failed'});
}
}
}
if (allChecksPassed) {
log.info("Content hash checks PASSED");
}
} }
async function syncRequest(syncContext, method, uri, body) { async function syncRequest(syncContext, method, uri, body) {
@ -331,6 +203,80 @@ async function syncRequest(syncContext, method, uri, body) {
} }
} }
const primaryKeys = {
"notes": "noteId",
"branches": "branchId",
"note_revisions": "noteRevisionId",
"option": "name",
"recent_notes": "branchId",
"images": "imageId",
"note_images": "noteImageId",
"labels": "labelId",
"api_tokens": "apiTokenId"
};
async function getEntityRow(entityName, entityId) {
if (entityName === 'note_reordering') {
return await sql.getMap("SELECT branchId, notePosition FROM branches WHERE parentNoteId = ? AND isDeleted = 0", [entityId]);
}
else {
const primaryKey = primaryKeys[entityName];
if (!primaryKey) {
throw new Error("Unknown entity " + entityName);
}
const entity = await sql.getRow(`SELECT * FROM ${entityName} WHERE ${primaryKey} = ?`, [entityId]);
if (entityName === 'notes' && entity.type === 'file') {
entity.content = entity.content.toString("binary");
}
else if (entityName === 'images') {
entity.data = entity.data.toString('base64');
}
return entity;
}
}
async function getSyncRecords(syncs) {
const records = [];
let length = 0;
for (const sync of syncs) {
const record = {
sync: sync,
entity: await getEntityRow(sync.entityName, sync.entityId)
};
records.push(record);
length += JSON.stringify(record).length;
if (length > 1000000) {
break;
}
}
return records;
}
async function getLastSyncedPull() {
return parseInt(await optionService.getOption('lastSyncedPull'));
}
async function setLastSyncedPull(syncId) {
await optionService.setOption('lastSyncedPull', syncId);
}
async function getLastSyncedPush() {
return parseInt(await optionService.getOption('lastSyncedPush'));
}
async function setLastSyncedPush(lastSyncedPush) {
await optionService.setOption('lastSyncedPush', lastSyncedPush);
}
sqlInit.dbReady.then(() => { sqlInit.dbReady.then(() => {
if (syncSetup.isSyncSetup) { if (syncSetup.isSyncSetup) {
log.info("Setting up sync to " + syncSetup.SYNC_SERVER + " with timeout " + syncSetup.SYNC_TIMEOUT); log.info("Setting up sync to " + syncSetup.SYNC_SERVER + " with timeout " + syncSetup.SYNC_TIMEOUT);
@ -357,5 +303,5 @@ sqlInit.dbReady.then(() => {
module.exports = { module.exports = {
sync, sync,
serializeNoteContentBuffer getSyncRecords
}; };

View File

@ -91,6 +91,8 @@ async function fillSyncRows(entityName, entityKey) {
} }
async function fillAllSyncRows() { async function fillAllSyncRows() {
await sql.execute("DELETE FROM sync");
await fillSyncRows("notes", "noteId"); await fillSyncRows("notes", "noteId");
await fillSyncRows("branches", "branchId"); await fillSyncRows("branches", "branchId");
await fillSyncRows("note_revisions", "noteRevisionId"); await fillSyncRows("note_revisions", "noteRevisionId");

View File

@ -3,6 +3,42 @@ const log = require('./log');
const eventLogService = require('./event_log'); const eventLogService = require('./event_log');
const syncTableService = require('./sync_table'); const syncTableService = require('./sync_table');
async function updateEntity(entityName, entity, sourceId) {
if (entityName === 'notes') {
await updateNote(entity, sourceId);
}
else if (entityName === 'branches') {
await updateBranch(entity, sourceId);
}
else if (entityName === 'note_revisions') {
await updateNoteRevision(entity, sourceId);
}
else if (entityName === 'note_reordering') {
await updateNoteReordering(entity, sourceId);
}
else if (entityName === 'options') {
await updateOptions(entity, sourceId);
}
else if (entityName === 'recent_notes') {
await updateRecentNotes(entity, sourceId);
}
else if (entityName === 'images') {
await updateImage(entity, sourceId);
}
else if (entityName === 'note_images') {
await updateNoteImage(entity, sourceId);
}
else if (entityName === 'labels') {
await updateLabel(entity, sourceId);
}
else if (entityName === 'api_tokens') {
await updateApiToken(entity, sourceId);
}
else {
throw new Error(`Unrecognized entity type ${entityName}`);
}
}
function deserializeNoteContentBuffer(note) { function deserializeNoteContentBuffer(note) {
if (note.type === 'file') { if (note.type === 'file') {
note.content = new Buffer(note.content, 'binary'); note.content = new Buffer(note.content, 'binary');
@ -159,14 +195,5 @@ async function updateApiToken(entity, sourceId) {
} }
module.exports = { module.exports = {
updateNote, updateEntity
updateBranch,
updateNoteRevision,
updateNoteReordering,
updateOptions,
updateRecentNotes,
updateImage,
updateNoteImage,
updateLabel,
updateApiToken
}; };

View File

@ -132,76 +132,81 @@
</div> </div>
</div> </div>
<div style="position: relative; overflow: auto; grid-area: note-content; padding-left: 10px; padding-top: 10px;" id="note-detail-wrapper"> <div style="position: relative; overflow: hidden; grid-area: note-detail; padding-left: 10px; padding-top: 10px; display: flex; flex-direction: column;" id="note-detail-wrapper">
<div id="note-detail-text" class="note-detail-component"></div> <div style="flex-grow: 1; position: relative; overflow: auto; flex-basis: content;">
<div id="note-detail-text" style="height: 100%;" class="note-detail-component"></div>
<div id="note-detail-search" class="note-detail-component"> <div id="note-detail-search" class="note-detail-component">
<div style="display: flex; align-items: center;"> <div style="display: flex; align-items: center;">
<strong>Search string: &nbsp; &nbsp;</strong> <strong>Search string: &nbsp; &nbsp;</strong>
<textarea rows="4" cols="50" id="search-string"></textarea> <textarea rows="4" cols="50" id="search-string"></textarea>
</div>
<br />
<h4>Help</h4>
<p>
<ul>
<li>
<code>@abc</code> - matches notes with label abc</li>
<li>
<code>@!abc</code> - matches notes without abc label (maybe not the best syntax)</li>
<li>
<code>@abc=true</code> - matches notes with label abc having value true</li>
<li><code>@abc!=true</code></li>
<li>
<code>@"weird label"="weird value"</code> - works also with whitespace inside names values</li>
<li>
<code>@abc and @def</code> - matches notes with both abc and def</li>
<li>
<code>@abc @def</code> - AND relation is implicit when specifying multiple labels</li>
<li>
<code>@abc or @def</code> - OR relation</li>
<li>
<code>@abc&lt;=5</code> - numerical comparison (also &gt;, &gt;=, &lt;).</li>
<li>
<code>some search string @abc @def</code> - combination of fulltext and label search - both of them need to match (OR not supported)</li>
<li>
<code>@abc @def some search string</code> - same combination</li>
</ul>
<a href="https://github.com/zadam/trilium/wiki/Labels">Complete help on search syntax</a>
</p>
</div> </div>
<br /> <div id="note-detail-code" class="note-detail-component"></div>
<h4>Help</h4> <div id="note-detail-render" class="note-detail-component"></div>
<p>
<ul>
<li>
<code>@abc</code> - matches notes with label abc</li>
<li>
<code>@!abc</code> - matches notes without abc label (maybe not the best syntax)</li>
<li>
<code>@abc=true</code> - matches notes with label abc having value true</li>
<li><code>@abc!=true</code></li>
<li>
<code>@"weird label"="weird value"</code> - works also with whitespace inside names values</li>
<li>
<code>@abc and @def</code> - matches notes with both abc and def</li>
<li>
<code>@abc @def</code> - AND relation is implicit when specifying multiple labels</li>
<li>
<code>@abc or @def</code> - OR relation</li>
<li>
<code>@abc&lt;=5</code> - numerical comparison (also &gt;, &gt;=, &lt;).</li>
<li>
<code>some search string @abc @def</code> - combination of fulltext and label search - both of them need to match (OR not supported)</li>
<li>
<code>@abc @def some search string</code> - same combination</li>
</ul>
<a href="https://github.com/zadam/trilium/wiki/Labels">Complete help on search syntax</a> <div id="note-detail-file" class="note-detail-component">
</p> <table id="file-table">
<tr>
<th>File name:</th>
<td id="file-filename"></td>
</tr>
<tr>
<th>File type:</th>
<td id="file-filetype"></td>
</tr>
<tr>
<th>File size:</th>
<td id="file-filesize"></td>
</tr>
<tr>
<td>
<button id="file-download" class="btn btn-primary" type="button">Download</button>
&nbsp;
<button id="file-open" class="btn btn-primary" type="button">Open</button>
</td>
</tr>
</table>
</div>
<input type="file" id="file-upload" style="display: none" />
</div> </div>
<div id="note-detail-code" class="note-detail-component"></div> <div id="children-overview" style="flex-grow: 1000; flex-shrink: 1000; flex-basis: 1px; height: 100px; overflow: hidden; display: flex; flex-wrap: wrap">
<div id="note-detail-render" class="note-detail-component"></div>
<div id="note-detail-file" class="note-detail-component">
<table id="file-table">
<tr>
<th>File name:</th>
<td id="file-filename"></td>
</tr>
<tr>
<th>File type:</th>
<td id="file-filetype"></td>
</tr>
<tr>
<th>File size:</th>
<td id="file-filesize"></td>
</tr>
<tr>
<td>
<button id="file-download" class="btn btn-primary" type="button">Download</button>
&nbsp;
<button id="file-open" class="btn btn-primary" type="button">Open</button>
</td>
</tr>
</table>
</div> </div>
<input type="file" id="file-upload" style="display: none" />
</div> </div>
<div id="label-list"> <div id="label-list">