Compare commits

...

11 Commits

Author SHA1 Message Date
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
16 changed files with 395 additions and 96 deletions

View File

@@ -1,7 +1,7 @@
{
"name": "trilium",
"description": "Trilium Notes",
"version": "0.5.3-beta",
"version": "0.5.5-beta",
"license": "AGPL-3.0-only",
"main": "electron.js",
"repository": {

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,111 @@ 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-name:last").focus();
};
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[attrs.length - 1]();
if (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 +131,56 @@ 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('blur', '.attribute-name', function (e) { console.log("blur!"); });
$(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

@@ -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 /\s/.test(val) ? '"' + val + '"' : val;
}
function formatAttribute(attr) {
let str = "@" + formatValueWithWhitespace(attr.name);
if (attr.value !== "") {
str += "=" + formatValueWithWhitespace(attr.value);
}
return str;
}

View File

@@ -6,7 +6,8 @@
grid-template-areas: "header header"
"tree-actions title"
"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
@@ -108,7 +109,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;
}
@@ -190,11 +191,6 @@ div.ui-tooltip {
float: right;
}
#note-id-display {
color: lightgrey;
margin-left: 10px;
}
#note-source {
height: 98%;
width: 100%;
@@ -243,8 +239,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 +250,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.replace(new RegExp(match[0], 'g'), '');
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">
@@ -53,12 +58,11 @@
</div>
<div id="search-box" style="display: none; padding: 10px; margin-top: 10px;">
<p>
<div style="display: flex; align-items: center;">
<label>Search:</label>
<input name="search-text" autocomplete="off">
<button id="reset-search-button">&times;</button>
<span id="matches"></span>
</p>
<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>
</div>
@@ -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-primary btn-large" id="save-attributes-button" type="submit">Save</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