Compare commits

..

20 Commits

Author SHA1 Message Date
azivner
85d32c66f2 release 0.5.6 2018-02-06 00:06:04 -05:00
azivner
214d2e7659 correct quoting rules for attribute/status bar 2018-02-05 22:28:12 -05:00
azivner
f380bb7f65 removal of extra console logs 2018-02-05 22:26:50 -05:00
azivner
0a9a032daa fix incorrect removal of attribute filter from string, fixes #35 2018-02-05 22:25:25 -05:00
azivner
23a2b58b24 fix #32, could not open attribute dialog if it didn't have any attributes yet 2018-02-05 21:07:18 -05:00
azivner
aee64b2522 fix visual glitch in search - showing search now doesn't move note content 2018-02-05 20:53:04 -05:00
azivner
02e07ec03a release 0.5.5-beta 2018-02-04 23:19:20 -05:00
azivner
3d2dc8e699 fixes for change propagation (conflict between knockout and jquery UI autocomplete) 2018-02-04 23:16:45 -05:00
azivner
c84e15c9be implemented query language for attributes, closes #26 2018-02-04 22:44:15 -05:00
azivner
e18d0b9fd4 tag list in "status bar", closes #28 2018-02-04 20:23:30 -05:00
azivner
52817504d1 autocomplete for attribute values, closes #31 2018-02-04 19:43:11 -05:00
azivner
a3b31fab54 autocomplete for attribute names, issue #31 2018-02-04 19:27:27 -05:00
azivner
bc4aa3e40a removed ctrl+shift+left, ctrl+shift+right because of conflict with standard keyboard mapping, close #25 2018-02-04 18:12:17 -05:00
azivner
873ea67e9c nice UI for attributes with validation 2018-02-04 17:22:21 -05:00
azivner
2c5115003b release 0.5.4-beta 2018-02-03 13:25:29 -05:00
azivner
e8ed913374 small changes in the toolbar 2018-02-03 12:44:22 -05:00
azivner
5bffba4e2f add API to add plugin buttons, fixes 2018-02-03 10:37:57 -05:00
azivner
05575913db release 0.5.3-beta 2018-01-31 23:57:20 -05:00
azivner
31c32ff42c fixes when generating new DB 2018-01-31 23:36:39 -05:00
azivner
6a671a5c02 fix electron app icon 2018-01-31 22:39:30 -05:00
21 changed files with 506 additions and 200 deletions

View File

@@ -1,119 +1,121 @@
CREATE TABLE IF NOT EXISTS "options" (
`opt_name` TEXT NOT NULL PRIMARY KEY,
`opt_value` TEXT,
`date_modified` INT
, is_synced INTEGER NOT NULL DEFAULT 0);
`name` TEXT NOT NULL PRIMARY KEY,
`value` TEXT,
`dateModified` INT,
isSynced INTEGER NOT NULL DEFAULT 0);
CREATE TABLE IF NOT EXISTS "sync" (
`id` INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
`entity_name` TEXT NOT NULL,
`entity_id` TEXT NOT NULL,
`source_id` TEXT NOT NULL,
`sync_date` TEXT NOT NULL);
CREATE UNIQUE INDEX `IDX_sync_entity_name_id` ON `sync` (
`entity_name`,
`entity_id`
);
CREATE INDEX `IDX_sync_sync_date` ON `sync` (
`sync_date`
);
CREATE TABLE `source_ids` (
`source_id` TEXT NOT NULL,
`date_created` TEXT NOT NULL,
PRIMARY KEY(`source_id`)
`id` INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
`entityName` TEXT NOT NULL,
`entityId` TEXT NOT NULL,
`sourceId` TEXT NOT NULL,
`syncDate` TEXT NOT NULL);
CREATE TABLE IF NOT EXISTS "source_ids" (
`sourceId` TEXT NOT NULL,
`dateCreated` TEXT NOT NULL,
PRIMARY KEY(`sourceId`)
);
CREATE TABLE IF NOT EXISTS "notes" (
`note_id` TEXT NOT NULL,
`note_title` TEXT,
`note_text` TEXT,
`is_protected` INT NOT NULL DEFAULT 0,
`is_deleted` INT NOT NULL DEFAULT 0,
`date_created` TEXT NOT NULL,
`date_modified` TEXT NOT NULL, type TEXT NOT NULL DEFAULT 'text', mime TEXT NOT NULL DEFAULT 'text/html',
PRIMARY KEY(`note_id`)
`noteId` TEXT NOT NULL,
`title` TEXT,
`content` TEXT,
`isProtected` INT NOT NULL DEFAULT 0,
`isDeleted` INT NOT NULL DEFAULT 0,
`dateCreated` TEXT NOT NULL,
`dateModified` TEXT NOT NULL,
type TEXT NOT NULL DEFAULT 'text',
mime TEXT NOT NULL DEFAULT 'text/html',
PRIMARY KEY(`noteId`)
);
CREATE INDEX `IDX_notes_is_deleted` ON `notes` (
`is_deleted`
CREATE TABLE IF NOT EXISTS "event_log" (
`id` INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
`noteId` TEXT,
`comment` TEXT,
`dateAdded` TEXT NOT NULL,
FOREIGN KEY(noteId) REFERENCES notes(noteId)
);
CREATE TABLE `event_log` (
`id` INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
`note_id` TEXT,
`comment` TEXT,
`date_added` TEXT NOT NULL,
FOREIGN KEY(note_id) REFERENCES notes(note_id)
);
CREATE TABLE IF NOT EXISTS "notes_tree" (
`note_tree_id` TEXT NOT NULL,
`note_id` TEXT NOT NULL,
`parent_note_id` TEXT NOT NULL,
`note_position` INTEGER NOT NULL,
CREATE TABLE IF NOT EXISTS "note_tree" (
`noteTreeId` TEXT NOT NULL,
`noteId` TEXT NOT NULL,
`parentNoteId` TEXT NOT NULL,
`notePosition` INTEGER NOT NULL,
`prefix` TEXT,
`is_expanded` BOOLEAN,
`is_deleted` INTEGER NOT NULL DEFAULT 0,
`date_modified` TEXT NOT NULL,
PRIMARY KEY(`note_tree_id`)
`isExpanded` BOOLEAN,
`isDeleted` INTEGER NOT NULL DEFAULT 0,
`dateModified` TEXT NOT NULL,
PRIMARY KEY(`noteTreeId`)
);
CREATE INDEX `IDX_notes_tree_note_id` ON `notes_tree` (
`note_id`
CREATE TABLE IF NOT EXISTS "note_revisions" (
`noteRevisionId` TEXT NOT NULL PRIMARY KEY,
`noteId` TEXT NOT NULL,
`title` TEXT,
`content` TEXT,
`isProtected` INT NOT NULL DEFAULT 0,
`dateModifiedFrom` TEXT NOT NULL,
`dateModifiedTo` TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS "notes_history" (
`note_history_id` TEXT NOT NULL PRIMARY KEY,
`note_id` TEXT NOT NULL,
`note_title` TEXT,
`note_text` TEXT,
`is_protected` INT NOT NULL DEFAULT 0,
`date_modified_from` TEXT NOT NULL,
`date_modified_to` TEXT NOT NULL
CREATE TABLE IF NOT EXISTS "recent_notes" (
`noteTreeId` TEXT NOT NULL PRIMARY KEY,
`notePath` TEXT NOT NULL,
`dateAccessed` TEXT NOT NULL,
isDeleted INT
);
CREATE INDEX `IDX_notes_history_note_id` ON `notes_history` (
`note_id`
);
CREATE INDEX `IDX_notes_history_note_date_modified_from` ON `notes_history` (
`date_modified_from`
);
CREATE INDEX `IDX_notes_history_note_date_modified_to` ON `notes_history` (
`date_modified_to`
);
CREATE TABLE `recent_notes` (
`note_tree_id` TEXT NOT NULL PRIMARY KEY,
`note_path` TEXT NOT NULL,
`date_accessed` TEXT NOT NULL,
is_deleted INT
);
CREATE INDEX `IDX_notes_tree_note_id_parent_note_id` ON `notes_tree` (
`note_id`,
`parent_note_id`
);
CREATE TABLE images
CREATE TABLE IF NOT EXISTS "images"
(
image_id TEXT PRIMARY KEY NOT NULL,
imageId TEXT PRIMARY KEY NOT NULL,
format TEXT NOT NULL,
checksum TEXT NOT NULL,
name TEXT NOT NULL,
data BLOB,
is_deleted INT NOT NULL DEFAULT 0,
date_modified TEXT NOT NULL,
date_created TEXT NOT NULL
isDeleted INT NOT NULL DEFAULT 0,
dateModified TEXT NOT NULL,
dateCreated TEXT NOT NULL
);
CREATE TABLE notes_image
CREATE TABLE note_images
(
note_image_id TEXT PRIMARY KEY NOT NULL,
note_id TEXT NOT NULL,
image_id TEXT NOT NULL,
is_deleted INT NOT NULL DEFAULT 0,
date_modified TEXT NOT NULL,
date_created TEXT NOT NULL
noteImageId TEXT PRIMARY KEY NOT NULL,
noteId TEXT NOT NULL,
imageId TEXT NOT NULL,
isDeleted INT NOT NULL DEFAULT 0,
dateModified TEXT NOT NULL,
dateCreated TEXT NOT NULL
);
CREATE INDEX notes_image_note_id_index ON notes_image (note_id);
CREATE INDEX notes_image_image_id_index ON notes_image (image_id);
CREATE INDEX notes_image_note_id_image_id_index ON notes_image (note_id, image_id);
CREATE TABLE attributes
CREATE TABLE IF NOT EXISTS "attributes"
(
attribute_id TEXT PRIMARY KEY NOT NULL,
note_id TEXT NOT NULL,
attributeId TEXT PRIMARY KEY NOT NULL,
noteId TEXT NOT NULL,
name TEXT NOT NULL,
value TEXT,
date_created TEXT NOT NULL,
date_modified TEXT NOT NULL
dateCreated TEXT NOT NULL,
dateModified TEXT NOT NULL
);
CREATE INDEX attributes_note_id_index ON attributes (note_id);
CREATE UNIQUE INDEX attributes_note_id_name_index ON attributes (note_id, name);
CREATE UNIQUE INDEX `IDX_sync_entityName_entityId` ON `sync` (
`entityName`,
`entityId`
);
CREATE INDEX `IDX_sync_syncDate` ON `sync` (
`syncDate`
);
CREATE INDEX `IDX_notes_isDeleted` ON `notes` (
`isDeleted`
);
CREATE INDEX `IDX_note_tree_noteId` ON `note_tree` (
`noteId`
);
CREATE INDEX `IDX_note_tree_noteId_parentNoteId` ON `note_tree` (
`noteId`,
`parentNoteId`
);
CREATE INDEX `IDX_note_revisions_noteId` ON `note_revisions` (
`noteId`
);
CREATE INDEX `IDX_note_revisions_dateModifiedFrom` ON `note_revisions` (
`dateModifiedFrom`
);
CREATE INDEX `IDX_note_revisions_dateModifiedTo` ON `note_revisions` (
`dateModifiedTo`
);
CREATE INDEX IDX_note_images_noteId ON note_images (noteId);
CREATE INDEX IDX_note_images_imageId ON note_images (imageId);
CREATE INDEX IDX_note_images_noteId_imageId ON note_images (noteId, imageId);
CREATE INDEX IDX_attributes_noteId ON attributes (noteId);
CREATE UNIQUE INDEX IDX_attributes_noteId_name ON attributes (noteId, name);

View File

@@ -24,7 +24,7 @@ function createMainWindow() {
width: 1200,
height: 900,
title: 'Trilium Notes',
icon: path.join(__dirname, 'public/images/app-icons/png/256x256.png')
icon: path.join(__dirname, 'src/public/images/app-icons/png/256x256.png')
});
const port = config['Network']['port'] || '3000';

View File

@@ -1,7 +1,7 @@
{
"name": "trilium",
"description": "Trilium Notes",
"version": "0.5.2-beta",
"version": "0.5.6",
"license": "AGPL-3.0-only",
"main": "electron.js",
"repository": {
@@ -12,7 +12,7 @@
"start": "node ./bin/www",
"test-electron": "xo",
"rebuild-electron": "electron-rebuild",
"start-electron": "electron src/electron",
"start-electron": "electron .",
"build-electron": "electron-packager . --out=dist --asar --overwrite --all",
"start-forge": "electron-forge start",
"package-forge": "electron-forge package",

View File

@@ -0,0 +1,21 @@
const api = (function() {
const pluginButtonsEl = $("#plugin-buttons");
async function activateNote(notePath) {
await noteTree.activateNode(notePath);
}
function addButtonToToolbar(buttonId, button) {
$("#" + buttonId).remove();
button.attr('id', buttonId);
pluginButtonsEl.append(button);
}
return {
addButtonToToolbar,
activateNote
}
})();

View File

@@ -2,7 +2,9 @@
const attributesDialog = (function() {
const dialogEl = $("#attributes-dialog");
const saveAttributesButton = $("#save-attributes-button");
const attributesModel = new AttributesModel();
let attributeNames = [];
function AttributesModel() {
const self = this;
@@ -14,38 +16,112 @@ const attributesDialog = (function() {
const attributes = await server.get('notes/' + noteId + '/attributes');
this.attributes(attributes);
self.attributes(attributes.map(ko.observable));
addLastEmptyRow();
attributeNames = await server.get('attributes/names');
// attribute might not be rendered immediatelly so could not focus
setTimeout(() => $(".attribute-name:last").focus(), 100);
};
this.addNewRow = function() {
self.attributes.push({
attributeId: '',
name: '',
value: ''
});
};
function isValid() {
for (let attrs = self.attributes(), i = 0; i < attrs.length; i++) {
if (self.isEmptyName(i) || self.isNotUnique(i)) {
return false;
}
}
return true;
}
this.save = async function() {
// we need to defocus from input (in case of enter-triggered save) because value is updated
// on blur event (because of conflict with jQuery UI Autocomplete). Without this, input would
// stay in focus, blur wouldn't be triggered and change wouldn't be updated in the viewmodel.
saveAttributesButton.focus();
if (!isValid()) {
alert("Please fix all validation errors and try saving again.");
return;
}
const noteId = noteEditor.getCurrentNoteId();
const attributes = await server.put('notes/' + noteId + '/attributes', this.attributes());
const attributesToSave = self.attributes()
.map(attr => attr())
.filter(attr => attr.attributeId !== "" || attr.name !== "");
self.attributes(attributes);
const attributes = await server.put('notes/' + noteId + '/attributes', attributesToSave);
self.attributes(attributes.map(ko.observable));
addLastEmptyRow();
showMessage("Attributes have been saved.");
noteEditor.loadAttributeList();
};
function addLastEmptyRow() {
const attrs = self.attributes();
const last = attrs.length === 0 ? null : attrs[attrs.length - 1]();
if (!last || last.name.trim() !== "" || last.value !== "") {
self.attributes.push(ko.observable({
attributeId: '',
name: '',
value: ''
}));
}
}
this.attributeChanged = function (row) {
addLastEmptyRow();
for (const attr of self.attributes()) {
if (row.attributeId === attr().attributeId) {
attr.valueHasMutated();
}
}
};
this.isNotUnique = function(index) {
const cur = self.attributes()[index]();
if (cur.name.trim() === "") {
return false;
}
for (let attrs = self.attributes(), i = 0; i < attrs.length; i++) {
const attr = attrs[i]();
if (index !== i && cur.name === attr.name) {
return true;
}
}
return false;
};
this.isEmptyName = function(index) {
const cur = self.attributes()[index]();
return cur.name.trim() === "" && (cur.attributeId !== "" || cur.value !== "");
}
}
async function showDialog() {
glob.activeDialog = dialogEl;
await attributesModel.loadAttributes();
dialogEl.dialog({
modal: true,
width: 800,
height: 700
height: 500
});
attributesModel.loadAttributes();
}
$(document).bind('keydown', 'alt+a', e => {
@@ -56,6 +132,54 @@ const attributesDialog = (function() {
ko.applyBindings(attributesModel, document.getElementById('attributes-dialog'));
$(document).on('focus', '.attribute-name', function (e) {
if (!$(this).hasClass("ui-autocomplete-input")) {
$(this).autocomplete({
// shouldn't be required and autocomplete should just accept array of strings, but that fails
// because we have overriden filter() function in init.js
source: attributeNames.map(attr => {
return {
label: attr,
value: attr
}
}),
minLength: 0
});
}
$(this).autocomplete("search", $(this).val());
});
$(document).on('focus', '.attribute-value', async function (e) {
if (!$(this).hasClass("ui-autocomplete-input")) {
const attributeName = $(this).parent().parent().find('.attribute-name').val();
if (attributeName.trim() === "") {
return;
}
const attributeValues = await server.get('attributes/values/' + encodeURIComponent(attributeName));
if (attributeValues.length === 0) {
return;
}
$(this).autocomplete({
// shouldn't be required and autocomplete should just accept array of strings, but that fails
// because we have overriden filter() function in init.js
source: attributeValues.map(attr => {
return {
label: attr,
value: attr
}
}),
minLength: 0
});
}
$(this).autocomplete("search", $(this).val());
});
return {
showDialog
};

View File

@@ -54,24 +54,6 @@ $(document).bind('keydown', 'ctrl+f', () => {
}
});
$(document).bind('keydown', "ctrl+shift+left", () => {
const node = noteTree.getCurrentNode();
node.navigate($.ui.keyCode.LEFT, true);
$("#note-detail").focus();
return false;
});
$(document).bind('keydown', "ctrl+shift+right", () => {
const node = noteTree.getCurrentNode();
node.navigate($.ui.keyCode.RIGHT, true);
$("#note-detail").focus();
return false;
});
$(document).bind('keydown', "ctrl+shift+up", () => {
const node = noteTree.getCurrentNode();
node.navigate($.ui.keyCode.UP, true);
@@ -123,7 +105,7 @@ $(window).on('beforeunload', () => {
// Overrides the default autocomplete filter function to search for matched on atleast 1 word in each of the input term's words
$.ui.autocomplete.filter = (array, terms) => {
if (!terms) {
return [];
return array;
}
const startDate = new Date();

View File

@@ -9,6 +9,8 @@ const noteEditor = (function() {
const unprotectButton = $("#unprotect-button");
const noteDetailWrapperEl = $("#note-detail-wrapper");
const noteIdDisplayEl = $("#note-id-display");
const attributeListEl = $("#attribute-list");
const attributeListInnerEl = $("#attribute-list-inner");
let editor = null;
let codeEditor = null;
@@ -187,6 +189,27 @@ const noteEditor = (function() {
// after loading new note make sure editor is scrolled to the top
noteDetailWrapperEl.scrollTop(0);
loadAttributeList();
}
async function loadAttributeList() {
const noteId = getCurrentNoteId();
const attributes = await server.get('notes/' + noteId + '/attributes');
attributeListInnerEl.html('');
if (attributes.length > 0) {
for (const attr of attributes) {
attributeListInnerEl.append(formatAttribute(attr) + " ");
}
attributeListEl.show();
}
else {
attributeListEl.hide();
}
}
async function loadNote(noteId) {
@@ -290,6 +313,7 @@ const noteEditor = (function() {
newNoteCreated,
getEditor,
focus,
executeCurrentNote
executeCurrentNote,
loadAttributeList
};
})();

View File

@@ -3,7 +3,7 @@
const noteTree = (function() {
const treeEl = $("#tree");
const parentListEl = $("#parent-list");
const parentListListEl = $("#parent-list-list");
const parentListListEl = $("#parent-list-inner");
let startNotePath = null;
let notesTreeMap = {};

View File

@@ -116,5 +116,20 @@ async function stopWatch(what, func) {
}
function executeScript(script) {
eval("(async function() {" + script + "})()");
// last \r\n is necessary if script contains line comment on its last line
eval("(async function() {" + script + "\r\n})()");
}
function formatValueWithWhitespace(val) {
return /[^\w_-]/.test(val) ? '"' + val + '"' : val;
}
function formatAttribute(attr) {
let str = "@" + formatValueWithWhitespace(attr.name);
if (attr.value !== "") {
str += "=" + formatValueWithWhitespace(attr.value);
}
return str;
}

View File

@@ -5,12 +5,18 @@
display: grid;
grid-template-areas: "header header"
"tree-actions title"
"search note-content"
"tree note-content"
"parent-list note-content";
"parent-list note-content"
"parent-list attribute-list";
grid-template-columns: 2fr 5fr;
grid-template-rows: auto
auto
1fr;
auto
1fr
auto
auto;
justify-content: center;
grid-gap: 10px;
}
@@ -108,7 +114,7 @@ span.fancytree-active:not(.fancytree-focused) .fancytree-title {
}
#header-title {
padding: 5px 50px 5px 10px;
padding: 5px 20px 5px 10px;
font-size: large;
font-weight: bold;
}
@@ -134,6 +140,7 @@ div.ui-tooltip {
margin-left: 20px;
border-top: 2px solid #eee;
padding-top: 10px;
grid-area: parent-list;
}
#parent-list ul {
@@ -190,11 +197,6 @@ div.ui-tooltip {
float: right;
}
#note-id-display {
color: lightgrey;
margin-left: 10px;
}
#note-source {
height: 98%;
width: 100%;
@@ -243,8 +245,9 @@ div.ui-tooltip {
#note-id-display {
position: absolute;
right: 10px;
bottom: 5px;
bottom: 8px;
z-index: 1000;
color: lightgrey;
}
#note-type-dropdown {
@@ -253,4 +256,16 @@ div.ui-tooltip {
overflow-x: hidden;
}
.cm-matchhighlight {background-color: #eeeeee}
.cm-matchhighlight {background-color: #eeeeee}
#attribute-list {
grid-area: attribute-list;
color: #777777;
border-top: 1px solid #eee;
padding: 5px; display: none;
}
#attribute-list button {
padding: 2px;
margin-right: 5px;
}

View File

@@ -7,14 +7,15 @@ const auth = require('../../services/auth');
const sync_table = require('../../services/sync_table');
const utils = require('../../services/utils');
const wrap = require('express-promise-wrap').wrap;
const attributes = require('../../services/attributes');
router.get('/:noteId/attributes', auth.checkApiAuth, wrap(async (req, res, next) => {
router.get('/notes/:noteId/attributes', auth.checkApiAuth, wrap(async (req, res, next) => {
const noteId = req.params.noteId;
res.send(await sql.getRows("SELECT * FROM attributes WHERE noteId = ? ORDER BY dateCreated", [noteId]));
}));
router.put('/:noteId/attributes', auth.checkApiAuth, wrap(async (req, res, next) => {
router.put('/notes/:noteId/attributes', auth.checkApiAuth, wrap(async (req, res, next) => {
const noteId = req.params.noteId;
const attributes = req.body;
const now = utils.nowDate();
@@ -45,4 +46,26 @@ router.put('/:noteId/attributes', auth.checkApiAuth, wrap(async (req, res, next)
res.send(await sql.getRows("SELECT * FROM attributes WHERE noteId = ? ORDER BY dateCreated", [noteId]));
}));
router.get('/attributes/names', auth.checkApiAuth, wrap(async (req, res, next) => {
const names = await sql.getColumn("SELECT DISTINCT name FROM attributes");
for (const attr of attributes.BUILTIN_ATTRIBUTES) {
if (!names.includes(attr)) {
names.push(attr);
}
}
names.sort();
res.send(names);
}));
router.get('/attributes/values/:attributeName', auth.checkApiAuth, wrap(async (req, res, next) => {
const attributeName = req.params.attributeName;
const values = await sql.getColumn("SELECT DISTINCT value FROM attributes WHERE name = ? AND value != '' ORDER BY value", [attributeName]);
res.send(values);
}));
module.exports = router;

View File

@@ -58,15 +58,112 @@ router.put('/:noteId', auth.checkApiAuth, wrap(async (req, res, next) => {
}));
router.get('/', auth.checkApiAuth, wrap(async (req, res, next) => {
const search = '%' + utils.sanitizeSql(req.query.search) + '%';
let {attrFilters, searchText} = parseFilters(req.query.search);
// searching in protected notes is pointless because of encryption
const noteIds = await sql.getColumn(`SELECT noteId FROM notes
WHERE isDeleted = 0 AND isProtected = 0 AND (title LIKE ? OR content LIKE ?)`, [search, search]);
const {query, params} = getSearchQuery(attrFilters, searchText);
const noteIds = await sql.getColumn(query, params);
res.send(noteIds);
}));
function parseFilters(searchText) {
const attrFilters = [];
const attrRegex = /(\b(and|or)\s+)?@(!?)([\w_-]+|"[^"]+")((=|!=|<|<=|>|>=)([\w_-]+|"[^"]+"))?/i;
let match = attrRegex.exec(searchText);
function trimQuotes(str) { return str.startsWith('"') ? str.substr(1, str.length - 2) : str; }
while (match != null) {
const relation = match[2] !== undefined ? match[2].toLowerCase() : 'and';
const operator = match[3] === '!' ? 'not-exists' : 'exists';
attrFilters.push({
relation: relation,
name: trimQuotes(match[4]),
operator: match[6] !== undefined ? match[6] : operator,
value: match[7] !== undefined ? trimQuotes(match[7]) : null
});
// remove attributes from further fulltext search
searchText = searchText.split(match[0]).join('');
match = attrRegex.exec(searchText);
}
return {attrFilters, searchText};
}
function getSearchQuery(attrFilters, searchText) {
const joins = [];
const joinParams = [];
let where = '1';
const whereParams = [];
let i = 1;
for (const filter of attrFilters) {
joins.push(`LEFT JOIN attributes AS attr${i} ON attr${i}.noteId = notes.noteId AND attr${i}.name = ?`);
joinParams.push(filter.name);
where += " " + filter.relation + " ";
if (filter.operator === 'exists') {
where += `attr${i}.attributeId IS NOT NULL`;
}
else if (filter.operator === 'not-exists') {
where += `attr${i}.attributeId IS NULL`;
}
else if (filter.operator === '=' || filter.operator === '!=') {
where += `attr${i}.value ${filter.operator} ?`;
whereParams.push(filter.value);
}
else if ([">", ">=", "<", "<="].includes(filter.operator)) {
const floatParam = parseFloat(filter.value);
if (isNaN(floatParam)) {
where += `attr${i}.value ${filter.operator} ?`;
whereParams.push(filter.value);
}
else {
where += `CAST(attr${i}.value AS DECIMAL) ${filter.operator} ?`;
whereParams.push(floatParam);
}
}
else {
throw new Error("Unknown operator " + filter.operator);
}
i++;
}
let searchCondition = '';
const searchParams = [];
if (searchText.trim() !== '') {
// searching in protected notes is pointless because of encryption
searchCondition = ' AND (notes.isProtected = 0 AND (notes.title LIKE ? OR notes.content LIKE ?))';
searchText = '%' + searchText.trim() + '%';
searchParams.push(searchText);
searchParams.push(searchText); // two occurences in searchCondition
}
const query = `SELECT notes.noteId FROM notes
${joins.join('\r\n')}
WHERE
notes.isDeleted = 0
AND (${where})
${searchCondition}`;
const params = joinParams.concat(whereParams).concat(searchParams);
return { query, params };
}
router.put('/:noteId/sort', auth.checkApiAuth, wrap(async (req, res, next) => {
const noteId = req.params.noteId;
const sourceId = req.headers.source_id;

View File

@@ -19,11 +19,12 @@ router.post('/exec', auth.checkApiAuth, wrap(async (req, res, next) => {
router.get('/startup', auth.checkApiAuth, wrap(async (req, res, next) => {
const noteIds = await attributes.getNoteIdsWithAttribute("run_on_startup");
const repository = new Repository(req);
const scripts = [];
for (const noteId of noteIds) {
scripts.push(await getNoteWithSubtreeScript(noteId, req));
scripts.push(await getNoteWithSubtreeScript(noteId, repository));
}
res.send(scripts);
@@ -41,10 +42,10 @@ router.get('/subtree/:noteId', auth.checkApiAuth, wrap(async (req, res, next) =>
res.send(subTreeScripts + noteScript);
}));
async function getNoteWithSubtreeScript(noteId, req) {
const noteScript = (await notes.getNoteById(noteId, req)).content;
async function getNoteWithSubtreeScript(noteId, repository) {
const noteScript = (await repository.getNote(noteId)).content;
const subTreeScripts = await getSubTreeScripts(noteId, [noteId], req);
const subTreeScripts = await getSubTreeScripts(noteId, [noteId], repository);
return subTreeScripts + noteScript;
}

View File

@@ -40,7 +40,7 @@ function register(app) {
app.use('/api/notes', notesApiRoute);
app.use('/api/tree', treeChangesApiRoute);
app.use('/api/notes', cloningApiRoute);
app.use('/api/notes', attributesRoute);
app.use('/api', attributesRoute);
app.use('/api/notes-history', noteHistoryApiRoute);
app.use('/api/recent-changes', recentChangesApiRoute);
app.use('/api/settings', settingsApiRoute);

11
src/scripts/today.js Normal file
View File

@@ -0,0 +1,11 @@
api.addButtonToToolbar('go-today', $('<button class="btn btn-xs" onclick="goToday();"><span class="ui-icon ui-icon-calendar"></span> Today</button>'));
window.goToday = async function() {
const todayDateStr = formatDateISO(new Date());
const todayNoteId = await server.exec([todayDateStr], async todayDateStr => {
return await this.getDateNoteId(todayDateStr);
});
api.activateNote(todayNoteId);
};

View File

@@ -65,6 +65,8 @@
});
}
data.sort((a, b) => a.date < b.date ? -1 : +1);
return data;
});

View File

@@ -5,6 +5,8 @@ const utils = require('./utils');
const sync_table = require('./sync_table');
const Repository = require('./repository');
const BUILTIN_ATTRIBUTES = [ 'run_on_startup', 'disable_versioning' ];
async function getNoteAttributeMap(noteId) {
return await sql.getMap(`SELECT name, value FROM attributes WHERE noteId = ?`, [noteId]);
}
@@ -64,5 +66,6 @@ module.exports = {
getNotesWithAttribute,
getNoteWithAttribute,
getNoteIdsWithAttribute,
createAttribute
createAttribute,
BUILTIN_ATTRIBUTES
};

View File

@@ -5,32 +5,6 @@ const sync_table = require('./sync_table');
const attributes = require('./attributes');
const protected_session = require('./protected_session');
async function updateJsonNote(noteId, data) {
const ret = await createNewNote(noteId, {
title: name,
content: JSON.stringify(data),
target: 'into',
isProtected: false,
type: 'code',
mime: 'application/json'
});
return ret.noteId;
}
async function createNewJsonNote(parentNoteId, name, payload) {
const ret = await createNewNote(parentNoteId, {
title: name,
content: JSON.stringify(payload),
target: 'into',
isProtected: false,
type: 'code',
mime: 'application/json'
});
return ret.noteId;
}
async function createNewNote(parentNoteId, noteOpts, dataKey, sourceId) {
const noteId = utils.newNoteId();
const noteTreeId = utils.newNoteTreeId();

View File

@@ -17,15 +17,20 @@
<button class="btn btn-xs" onclick="jumpToNote.showDialog();" title="CTRL+J">Jump to note</button>
<button class="btn btn-xs" onclick="recentNotes.showDialog();" title="CTRL+E">Recent notes</button>
<button class="btn btn-xs" onclick="recentChanges.showDialog();">Recent changes</button>
<button class="btn btn-xs" onclick="eventLog.showDialog();">Event log</button>
</div>
<div id="plugin-buttons">
</div>
<div>
<button class="btn btn-xs" onclick="syncNow();" title="Number of outstanding changes to be pushed to server">
<span class="ui-icon ui-icon-refresh"></span>
Sync now (<span id="changes-to-push-count">0</span>)
</button>
<button class="btn btn-xs" onclick="settings.showDialog();">Settings</button>
<button class="btn btn-xs" onclick="settings.showDialog();">
<span class="ui-icon ui-icon-gear"></span> Settings</button>
<form action="logout" id="logout-button" method="POST" style="display: inline;">
<input type="submit" class="btn btn-xs" value="Logout">
@@ -51,14 +56,13 @@
<img src="images/icons/search.png" alt="Search in notes"/>
</a>
</div>
</div>
<div id="search-box" style="display: none; padding: 10px; margin-top: 10px;">
<p>
<label>Search:</label>
<input name="search-text" autocomplete="off">
<button id="reset-search-button">&times;</button>
<span id="matches"></span>
</p>
<div id="search-box" class="hide-toggle" style="grid-area: search; display: none; padding: 10px; margin-top: 10px;">
<div style="display: flex; align-items: center;">
<label>Search:</label>
<input name="search-text" style="flex-grow: 100; margin-left: 5px; margin-right: 5px;" autocomplete="off">
<button id="reset-search-button" class="btn btn-sm" title="Reset search">&times;</button>
</div>
</div>
@@ -68,7 +72,7 @@
<div id="parent-list" class="hide-toggle">
<p><strong>Note locations:</strong></p>
<ul id="parent-list-list"></ul>
<ul id="parent-list-inner"></ul>
</div>
<div class="hide-toggle" style="grid-area: title;">
@@ -138,6 +142,12 @@
<div id="note-detail-render"></div>
</div>
<div id="attribute-list">
<button class="btn btn-sm" onclick="attributesDialog.showDialog();">Attributes:</button>
<span id="attribute-list-inner"></span>
</div>
</div>
<div id="recent-notes-dialog" title="Recent notes" style="display: none;">
@@ -378,10 +388,8 @@
<div id="attributes-dialog" title="Note attributes" style="display: none; padding: 20px;">
<form data-bind="submit: save">
<div style="display: flex; justify-content: space-between; padding: 15px; padding-top: 0;">
<button class="btn-default" type="button" data-bind="click: addNewRow">Add new attribute</button>
<button class="btn-primary" type="submit">Save</button>
<div style="text-align: center">
<button class="btn btn-large" style="width: 200px;" id="save-attributes-button" type="submit">Save <kbd>enter</kbd></button>
</div>
<div style="height: 97%; overflow: auto">
@@ -397,10 +405,14 @@
<tr>
<td data-bind="text: attributeId"></td>
<td>
<input type="text" data-bind="value: name"/>
<!-- Change to valueUpdate: blur is necessary because jQuery UI autocomplete hijacks change event -->
<input type="text" class="attribute-name" data-bind="value: name, valueUpdate: 'blur', event: { blur: $parent.attributeChanged }"/>
<div style="color: red" data-bind="if: $parent.isNotUnique($index())">Attribute name must be unique per note.</div>
<div style="color: red" data-bind="if: $parent.isEmptyName($index())">Attribute name can't be empty.</div>
</td>
<td>
<input type="text" data-bind="value: value" style="width: 300px"/>
<input type="text" class="attribute-value" data-bind="value: value, valueUpdate: 'blur', event: { blur: $parent.attributeChanged }" style="width: 300px"/>
</td>
</tr>
</tbody>
@@ -492,7 +504,7 @@
<script src="javascripts/link.js"></script>
<script src="javascripts/sync.js"></script>
<script src="javascripts/messaging.js"></script>
<script src="javascripts/api.js"></script>
<script type="text/javascript">
// we hide container initally because otherwise it is rendered first without CSS and then flickers into