diff --git a/.idea/dataSources/a2c75661-f9e2-478f-a69f-6a9409e69997.xml b/.idea/dataSources/a2c75661-f9e2-478f-a69f-6a9409e69997.xml
index 87b69781b..2badc45a5 100644
--- a/.idea/dataSources/a2c75661-f9e2-478f-a69f-6a9409e69997.xml
+++ b/.idea/dataSources/a2c75661-f9e2-478f-a69f-6a9409e69997.xml
@@ -12,292 +12,289 @@
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
-
+
-
-
+
+
1
TEXT|0s
1
-
+
2
TEXT|0s
1
-
+
3
TEXT|0s
1
-
+
4
INT|0s
1
0
-
+
5
TEXT|0s
1
""
-
+
1
apiTokenId
1
-
+
apiTokenId
1
sqlite_autoindex_api_tokens_1
-
+
1
TEXT|0s
1
-
+
2
TEXT|0s
1
-
+
3
TEXT|0s
1
-
+
+ 4
+ TEXT|0s
+ 1
+
+
+ 5
+ TEXT|0s
+ 1
+ ''
+
+
+ 6
+ INT|0s
+ 1
+ 0
+
+
+ 7
+ TEXT|0s
+ 1
+
+
+ 8
+ TEXT|0s
+ 1
+
+
+ 9
+ INT|0s
+ 1
+
+
+ 10
+ TEXT|0s
+ 1
+ ""
+
+
+ 1
+ attributeId
+
+ 1
+
+
+ attributeId
+ 1
+ sqlite_autoindex_attributes_1
+
+
+ 1
+ TEXT|0s
+ 1
+
+
+ 2
+ TEXT|0s
+ 1
+
+
+ 3
+ TEXT|0s
+ 1
+
+
4
INTEGER|0s
1
-
+
5
TEXT|0s
-
+
6
BOOLEAN|0s
-
+
7
INTEGER|0s
1
0
-
+
8
TEXT|0s
1
-
+
9
TEXT|0s
1
""
-
+
10
TEXT|0s
1
'1970-01-01T00:00:00.000Z'
-
+
1
branchId
1
-
+
noteId
parentNoteId
-
+
noteId
-
+
parentNoteId
-
+
branchId
1
sqlite_autoindex_branches_1
-
+
1
TEXT|0s
1
-
+
2
TEXT|0s
-
+
3
TEXT|0s
-
+
4
TEXT|0s
1
-
+
1
eventId
1
-
+
eventId
1
sqlite_autoindex_event_log_1
-
+
1
TEXT|0s
1
-
+
2
TEXT|0s
1
-
+
3
TEXT|0s
1
-
+
4
TEXT|0s
1
-
+
5
BLOB|0s
-
+
6
INT|0s
1
0
-
+
7
TEXT|0s
1
-
+
8
TEXT|0s
1
-
+
9
TEXT|0s
1
""
-
+
1
imageId
1
-
+
imageId
1
sqlite_autoindex_images_1
-
- 1
- TEXT|0s
- 1
-
-
- 2
- TEXT|0s
- 1
-
-
- 3
- TEXT|0s
- 1
-
-
- 4
- TEXT|0s
- 1
- ''
-
-
- 5
- INT|0s
- 1
- 0
-
-
- 6
- TEXT|0s
- 1
-
-
- 7
- TEXT|0s
- 1
-
-
- 8
- INT|0s
- 1
-
-
- 9
- TEXT|0s
- 1
- ""
-
-
- 1
- labelId
-
- 1
-
-
- noteId
-
-
-
- name
-value
-
-
-
- labelId
- 1
- sqlite_autoindex_labels_1
-
-
+
1
TEXT|0s
1
@@ -307,437 +304,518 @@ value
TEXT|0s
1
-
+
3
TEXT|0s
1
-
+
4
+ TEXT|0s
+ 1
+ ''
+
+
+ 5
INT|0s
1
0
-
- 5
- TEXT|0s
- 1
-
6
TEXT|0s
1
-
+
+ 7
+ TEXT|0s
+ 1
+
+
+ 8
+ INT|0s
+ 1
+
+
+ 9
+ TEXT|0s
+ 1
+ ""
+
+
+ 1
+ labelId
+
+ 1
+
+
+ noteId
+
+
+
+ noteId
+
+
+
+ name
+value
+
+
+
+ name
+value
+
+
+
+ value
+
+
+
+ labelId
+ 1
+ sqlite_autoindex_labels_1
+
+
+ 1
+ TEXT|0s
+ 1
+
+
+ 2
+ TEXT|0s
+ 1
+
+
+ 3
+ TEXT|0s
+ 1
+
+
+ 4
+ INT|0s
+ 1
+ 0
+
+
+ 5
+ TEXT|0s
+ 1
+
+
+ 6
+ TEXT|0s
+ 1
+
+
7
TEXT|0s
1
""
-
+
1
noteImageId
1
-
+
noteId
imageId
-
+
noteId
-
+
imageId
-
+
noteImageId
1
sqlite_autoindex_note_images_1
-
+
1
TEXT|0s
1
-
+
2
TEXT|0s
1
-
+
3
TEXT|0s
-
+
4
TEXT|0s
-
+
5
INT|0s
1
0
-
+
6
TEXT|0s
1
-
+
7
TEXT|0s
1
-
+
8
TEXT|0s
1
''
-
+
9
TEXT|0s
1
''
-
+
10
TEXT|0s
1
""
-
+
1
noteRevisionId
1
-
+
noteId
-
+
dateModifiedFrom
-
+
dateModifiedTo
-
+
noteRevisionId
1
sqlite_autoindex_note_revisions_1
-
+
1
TEXT|0s
1
-
+
2
TEXT|0s
1
"unnamed"
-
+
3
TEXT|0s
1
""
-
+
4
INT|0s
1
0
-
+
5
INT|0s
1
0
-
+
6
TEXT|0s
1
-
+
7
TEXT|0s
1
-
+
8
TEXT|0s
1
'text'
-
+
9
TEXT|0s
1
'text/html'
-
+
10
TEXT|0s
1
""
-
+
1
noteId
1
-
+
type
-
+
noteId
1
sqlite_autoindex_notes_1
-
+
1
TEXT|0s
1
-
+
2
TEXT|0s
-
+
3
INT|0s
-
+
4
INTEGER|0s
1
0
-
+
5
TEXT|0s
1
""
-
+
6
TEXT|0s
1
'1970-01-01T00:00:00.000Z'
-
+
1
name
1
-
+
name
1
sqlite_autoindex_options_1
-
+
1
TEXT|0s
1
-
+
2
TEXT|0s
1
-
+
3
TEXT|0s
1
""
-
+
4
TEXT|0s
1
-
+
5
INT|0s
-
+
1
branchId
1
-
+
branchId
1
sqlite_autoindex_recent_notes_1
-
+
1
TEXT|0s
1
-
+
2
TEXT|0s
1
-
+
3
TEXT|0s
1
-
+
4
TEXT|0s
1
-
+
5
INT|0s
1
0
-
+
6
TEXT|0s
1
-
+
7
TEXT|0s
1
-
+
8
INT|0s
1
-
+
9
TEXT|0s
1
""
-
+
10
int|0s
0
-
+
1
relationId
1
-
+
sourceNoteId
-
+
targetNoteId
-
+
relationId
1
sqlite_autoindex_relations_1
-
+
1
TEXT|0s
1
-
+
2
TEXT|0s
1
-
+
1
sourceId
1
-
+
sourceId
1
sqlite_autoindex_source_ids_1
-
+
1
text|0s
-
+
2
text|0s
-
+
3
text|0s
-
+
4
integer|0s
-
+
5
text|0s
-
+
1
-
+
2
-
+
1
INTEGER|0s
1
1
-
+
2
TEXT|0s
1
-
+
3
TEXT|0s
1
-
+
4
TEXT|0s
1
-
+
5
TEXT|0s
1
-
+
entityName
entityId
1
-
+
syncDate
-
+
id
1
diff --git a/src/public/javascripts/dialogs/attributes.js b/src/public/javascripts/dialogs/attributes.js
new file mode 100644
index 000000000..263887c2a
--- /dev/null
+++ b/src/public/javascripts/dialogs/attributes.js
@@ -0,0 +1,239 @@
+import noteDetailService from '../services/note_detail.js';
+import server from '../services/server.js';
+import infoService from "../services/info.js";
+
+const $dialog = $("#attributes-dialog");
+const $saveAttributesButton = $("#save-attributes-button");
+const $attributesBody = $('#attributes-table tbody');
+
+const attributesModel = new AttributesModel();
+
+function AttributesModel() {
+ const self = this;
+
+ this.attributes = ko.observableArray();
+
+ this.availableTypes = [
+ { text: "Label", value: "label" },
+ { text: "Label definition", value: "definition" },
+ { text: "Relation", value: "relation" }
+ ];
+
+ this.updateAttributePositions = function() {
+ let position = 0;
+
+ // we need to update positions by searching in the DOM, because order of the
+ // attributes in the viewmodel (self.attributes()) stays the same
+ $attributesBody.find('input[name="position"]').each(function() {
+ const attribute = self.getTargetAttribute(this);
+
+ attribute().position = position++;
+ });
+ };
+
+ this.loadAttributes = async function() {
+ const noteId = noteDetailService.getCurrentNoteId();
+
+ const attributes = await server.get('notes/' + noteId + '/attributes');
+
+ self.attributes(attributes.map(ko.observable));
+
+ addLastEmptyRow();
+
+ // attribute might not be rendered immediatelly so could not focus
+ setTimeout(() => $(".attribute-name:last").focus(), 100);
+
+ $attributesBody.sortable({
+ handle: '.handle',
+ containment: $attributesBody,
+ update: this.updateAttributePositions
+ });
+ };
+
+ this.deleteAttribute = function(data, event) {
+ const attribute = self.getTargetAttribute(event.target);
+ const attributeData = attribute();
+
+ if (attributeData) {
+ attributeData.isDeleted = 1;
+
+ attribute(attributeData);
+
+ addLastEmptyRow();
+ }
+ };
+
+ function isValid() {
+ for (let attributes = self.attributes(), i = 0; i < attributes.length; i++) {
+ if (self.isEmptyName(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;
+ }
+
+ self.updateAttributePositions();
+
+ const noteId = noteDetailService.getCurrentNoteId();
+
+ const attributesToSave = self.attributes()
+ .map(attribute => attribute())
+ .filter(attribute => attribute.attributeId !== "" || attribute.name !== "");
+
+ const attributes = await server.put('notes/' + noteId + '/attributes', attributesToSave);
+
+ self.attributes(attributes.map(ko.observable));
+
+ addLastEmptyRow();
+
+ infoService.showMessage("Attributes have been saved.");
+
+ noteDetailService.loadAttributeList();
+ };
+
+ function addLastEmptyRow() {
+ const attributes = self.attributes().filter(attr => attr().isDeleted === 0);
+ const last = attributes.length === 0 ? null : attributes[attributes.length - 1]();
+
+ if (!last || last.name.trim() !== "" || last.value !== "") {
+ self.attributes.push(ko.observable({
+ attributeId: '',
+ type: 'label',
+ name: '',
+ value: '',
+ isDeleted: 0,
+ position: 0
+ }));
+ }
+ }
+
+ this.attributeChanged = function (data, event) {
+ addLastEmptyRow();
+
+ const attribute = self.getTargetAttribute(event.target);
+
+ attribute.valueHasMutated();
+ };
+
+ this.isNotUnique = function(index) {
+ const cur = self.attributes()[index]();
+
+ if (cur.name.trim() === "") {
+ return false;
+ }
+
+ for (let attributes = self.attributes(), i = 0; i < attributes.length; i++) {
+ const attribute = attributes[i]();
+
+ if (index !== i && cur.name === attribute.name) {
+ return true;
+ }
+ }
+
+ return false;
+ };
+
+ this.isEmptyName = function(index) {
+ const cur = self.attributes()[index]();
+
+ return cur.name.trim() === "" && (cur.attributeId !== "" || cur.value !== "");
+ };
+
+ this.getTargetAttribute = function(target) {
+ const context = ko.contextFor(target);
+ const index = context.$index();
+
+ return self.attributes()[index];
+ }
+}
+
+async function showDialog() {
+ glob.activeDialog = $dialog;
+
+ await attributesModel.loadAttributes();
+
+ $dialog.dialog({
+ modal: true,
+ width: 950,
+ height: 500
+ });
+}
+
+ko.applyBindings(attributesModel, $dialog[0]);
+
+$dialog.on('focus', '.attribute-name', function (e) {
+ if (!$(this).hasClass("ui-autocomplete-input")) {
+ $(this).autocomplete({
+ source: async (request, response) => {
+ const attribute = attributesModel.getTargetAttribute(this);
+ const type = attribute().type === 'relation' ? 'relation' : 'label';
+ const names = await server.get('attributes/names/?type=' + type + '&query=' + encodeURIComponent(request.term));
+ const result = names.map(name => {
+ return {
+ label: name,
+ value: name
+ }
+ });
+
+ if (result.length > 0) {
+ response(result);
+ }
+ else {
+ response([{
+ label: "No results",
+ value: "No results"
+ }]);
+ }
+ },
+ minLength: 0
+ });
+ }
+
+ $(this).autocomplete("search", $(this).val());
+});
+
+$dialog.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 autocomplete.js
+ source: attributeValues.map(attribute => {
+ return {
+ attribute: attribute,
+ value: attribute
+ }
+ }),
+ minLength: 0
+ });
+ }
+
+ $(this).autocomplete("search", $(this).val());
+});
+
+export default {
+ showDialog
+};
\ No newline at end of file
diff --git a/src/public/javascripts/services/bootstrap.js b/src/public/javascripts/services/bootstrap.js
index 84273e479..15236b222 100644
--- a/src/public/javascripts/services/bootstrap.js
+++ b/src/public/javascripts/services/bootstrap.js
@@ -1,6 +1,7 @@
import addLinkDialog from '../dialogs/add_link.js';
import jumpToNoteDialog from '../dialogs/jump_to_note.js';
import labelsDialog from '../dialogs/labels.js';
+import attributesDialog from '../dialogs/attributes.js';
import noteRevisionsDialog from '../dialogs/note_revisions.js';
import noteSourceDialog from '../dialogs/note_source.js';
import recentChangesDialog from '../dialogs/recent_changes.js';
diff --git a/src/public/javascripts/services/entrypoints.js b/src/public/javascripts/services/entrypoints.js
index d25f79785..0d11d92ae 100644
--- a/src/public/javascripts/services/entrypoints.js
+++ b/src/public/javascripts/services/entrypoints.js
@@ -11,6 +11,7 @@ import noteSourceDialog from "../dialogs/note_source.js";
import recentChangesDialog from "../dialogs/recent_changes.js";
import sqlConsoleDialog from "../dialogs/sql_console.js";
import searchNotesService from "./search_notes.js";
+import attributesDialog from "../dialogs/attributes.js";
import labelsDialog from "../dialogs/labels.js";
import relationsDialog from "../dialogs/relations.js";
import protectedSessionService from "./protected_session.js";
@@ -38,6 +39,9 @@ function registerEntrypoints() {
$("#toggle-search-button").click(searchNotesService.toggleSearch);
utils.bindShortcut('ctrl+s', searchNotesService.toggleSearch);
+ $(".show-attributes-button").click(attributesDialog.showDialog);
+ utils.bindShortcut('alt+a', attributesDialog.showDialog);
+
$(".show-labels-button").click(labelsDialog.showDialog);
utils.bindShortcut('alt+l', labelsDialog.showDialog);
diff --git a/src/routes/api/attributes.js b/src/routes/api/attributes.js
index 174eb05eb..f582070ec 100644
--- a/src/routes/api/attributes.js
+++ b/src/routes/api/attributes.js
@@ -42,18 +42,11 @@ async function updateNoteAttributes(req) {
return await repository.getEntities("SELECT * FROM attributes WHERE isDeleted = 0 AND noteId = ? ORDER BY position, dateCreated", [noteId]);
}
-async function getAllAttributeNames() {
- const names = await sql.getColumn("SELECT DISTINCT name FROM attributes WHERE isDeleted = 0");
+async function getAttributeNames(req) {
+ const type = req.query.type;
+ const query = req.query.query;
- for (const attribute of attributeService.BUILTIN_ATTRIBUTES) {
- if (!names.includes(attribute)) {
- names.push(attribute);
- }
- }
-
- names.sort();
-
- return names;
+ return attributeService.getAttributeNames(type, query);
}
async function getValuesForAttribute(req) {
@@ -65,6 +58,6 @@ async function getValuesForAttribute(req) {
module.exports = {
getNoteAttributes,
updateNoteAttributes,
- getAllAttributeNames,
+ getAttributeNames,
getValuesForAttribute
};
\ No newline at end of file
diff --git a/src/routes/routes.js b/src/routes/routes.js
index 1cc74a817..f492b12dc 100644
--- a/src/routes/routes.js
+++ b/src/routes/routes.js
@@ -136,7 +136,7 @@ function register(app) {
apiRoute(GET, '/api/notes/:noteId/attributes', attributesRoute.getNoteAttributes);
apiRoute(PUT, '/api/notes/:noteId/attributes', attributesRoute.updateNoteAttributes);
- apiRoute(GET, '/api/attributes/names', attributesRoute.getAllAttributeNames);
+ apiRoute(GET, '/api/attributes/names', attributesRoute.getAttributeNames);
apiRoute(GET, '/api/attributes/values/:attributeName', attributesRoute.getValuesForAttribute);
apiRoute(GET, '/api/notes/:noteId/labels', labelsRoute.getNoteLabels);
diff --git a/src/services/attributes.js b/src/services/attributes.js
index 069df17ba..0ece703c6 100644
--- a/src/services/attributes.js
+++ b/src/services/attributes.js
@@ -1,18 +1,25 @@
"use strict";
const repository = require('./repository');
+const sql = require('./sql');
+const utils = require('./utils');
const Attribute = require('../entities/attribute');
const BUILTIN_ATTRIBUTES = [
- 'disableVersioning',
- 'calendarRoot',
- 'archived',
- 'excludeFromExport',
- 'run',
- 'manualTransactionHandling',
- 'disableInclusion',
- 'appCss',
- 'hideChildrenOverview'
+ // label names
+ { type: 'label', name: 'disableVersioning' },
+ { type: 'label', name: 'calendarRoot' },
+ { type: 'label', name: 'archived' },
+ { type: 'label', name: 'excludeFromExport' },
+ { type: 'label', name: 'run' },
+ { type: 'label', name: 'manualTransactionHandling' },
+ { type: 'label', name: 'disableInclusion' },
+ { type: 'label', name: 'appCss' },
+ { type: 'label', name: 'hideChildrenOverview' },
+
+ // relation names
+ { type: 'relation', name: 'runOnNoteView' },
+ { type: 'relation', name: 'runOnNoteTitleChange' }
];
async function getNotesWithAttribute(name, value) {
@@ -44,9 +51,29 @@ async function createAttribute(noteId, name, value = "") {
}).save();
}
+async function getAttributeNames(type, nameLike) {
+ const names = await sql.getColumn(
+ `SELECT DISTINCT name
+ FROM attributes
+ WHERE isDeleted = 0
+ AND type = ?
+ AND name LIKE '%${utils.sanitizeSql(nameLike)}%'`, [ type ]);
+
+ for (const attribute of BUILTIN_ATTRIBUTES) {
+ if (attribute.type === type && !names.includes(attribute.name)) {
+ names.push(attribute.name);
+ }
+ }
+
+ names.sort();
+
+ return names;
+}
+
module.exports = {
getNotesWithAttribute,
getNoteWithAttribute,
createAttribute,
+ getAttributeNames,
BUILTIN_ATTRIBUTES
};
\ No newline at end of file
diff --git a/src/views/index.ejs b/src/views/index.ejs
index d7a6f6898..b62f50820 100644
--- a/src/views/index.ejs
+++ b/src/views/index.ejs
@@ -169,6 +169,7 @@