Compare commits

...

14 Commits

12 changed files with 353 additions and 60 deletions

View File

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

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: 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

@@ -118,4 +118,18 @@ async function stopWatch(what, func) {
function executeScript(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;
}
@@ -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 {
@@ -238,7 +245,7 @@ div.ui-tooltip {
#note-id-display {
position: absolute;
right: 10px;
bottom: 5px;
bottom: 8px;
z-index: 1000;
color: lightgrey;
}
@@ -249,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

@@ -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);

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

@@ -56,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>
@@ -73,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;">
@@ -143,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;">
@@ -383,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">
@@ -402,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>