Compare commits

...

18 Commits

Author SHA1 Message Date
azivner
72df0d8861 release 0.6.0-beta 2018-02-11 22:06:57 -05:00
azivner
9910aebf45 fix schema.sql 2018-02-11 22:06:12 -05:00
azivner
f9f8ecb2b1 recent notes doesn't fail totally when we can't find title for some note 2018-02-11 15:33:10 -05:00
azivner
438f7c5b0b escape should close the recent notes dialog 2018-02-11 11:53:43 -05:00
azivner
4b1d1aba74 add sender API to send text notes 2018-02-11 10:54:56 -05:00
azivner
6dea73cfe2 sender API now accepts local time header so we don't have problems with UTC 2018-02-11 09:14:21 -05:00
azivner
58f5d0cf6e recent notes are not closed when I click on e.g. dialog title bar 2018-02-11 08:57:12 -05:00
azivner
7b77e40514 added support for trilium-sender 2018-02-11 00:18:59 -05:00
azivner
660908c54b fix sorting notes 2018-02-10 13:55:06 -05:00
azivner
e970564036 create months and days with associated english names, closes #37 2018-02-10 13:53:35 -05:00
azivner
b3038487f8 fix image support broken in recent refactorings 2018-02-10 10:00:40 -05:00
azivner
cac98392a6 code mirror in SQL console, closes #24 2018-02-10 09:14:18 -05:00
azivner
dbd28377e3 change in naming conventions for element variables from *El to $name 2018-02-10 08:44:34 -05:00
azivner
c76e4faf5d added attributes sorting 2018-02-10 08:37:14 -05:00
azivner
e011b9ae63 deleting attributes, closes #34 2018-02-06 23:09:19 -05:00
azivner
7c74c77a2c allow duplicated attribute per note (in effect attributes can be multi-valued). Closes #33 2018-02-06 21:18:09 -05:00
azivner
c2a2f195aa Merge branch 'stable' 2018-02-06 21:04:27 -05:00
azivner
4e70cebf70 recent notes now use autocomplete instead of select box, closes #36 2018-02-05 23:50:25 -05:00
44 changed files with 656 additions and 417 deletions

View File

@@ -10,6 +10,7 @@ Trilium Notes is a hierarchical note taking application. Picture tells a thousan
* WYSIWYG (What You See Is What You Get) editing
* Fast and easy [navigation between notes](https://github.com/zadam/trilium/wiki/Note-navigation)
* Seamless note versioning
* Note attributes can be used to tag/label notes as an alternative note organization and querying
* Can be deployed as web application and / or desktop application with offline access (electron based)
* [Synchronization with](https://github.com/zadam/trilium/wiki/Synchronization) self-hosted sync server
* Strong [note encryption](https://github.com/zadam/trilium/wiki/Protected-notes)
@@ -34,6 +35,7 @@ List of documentation pages:
* [Installation as webapp](https://github.com/zadam/trilium/wiki/Installation-as-webapp)
* [Note navigation](https://github.com/zadam/trilium/wiki/Note-navigation)
* [Tree manipulation](https://github.com/zadam/trilium/wiki/Tree-manipulation)
* [Attributes](https://github.com/zadam/trilium/wiki/Attributes)
* [Links](https://github.com/zadam/trilium/wiki/Links)
* [Cloning notes](https://github.com/zadam/trilium/wiki/Cloning-notes)
* [Protected notes](https://github.com/zadam/trilium/wiki/Protected-notes)

View File

@@ -0,0 +1 @@
DROP INDEX IDX_attributes_noteId_name;

View File

@@ -0,0 +1 @@
ALTER TABLE attributes ADD COLUMN isDeleted INT NOT NULL DEFAULT 0;

View File

@@ -0,0 +1 @@
ALTER TABLE attributes ADD COLUMN position INT NOT NULL DEFAULT 0;

View File

@@ -0,0 +1,7 @@
CREATE TABLE IF NOT EXISTS "api_tokens"
(
apiTokenId TEXT PRIMARY KEY NOT NULL,
token TEXT NOT NULL,
dateCreated TEXT NOT NULL,
isDeleted INT NOT NULL DEFAULT 0
);

View File

@@ -85,8 +85,10 @@ CREATE TABLE IF NOT EXISTS "attributes"
noteId TEXT NOT NULL,
name TEXT NOT NULL,
value TEXT,
position INT NOT NULL DEFAULT 0,
dateCreated TEXT NOT NULL,
dateModified TEXT NOT NULL
dateModified TEXT NOT NULL,
isDeleted INT NOT NULL
);
CREATE UNIQUE INDEX `IDX_sync_entityName_entityId` ON `sync` (
`entityName`,
@@ -118,4 +120,11 @@ 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);
CREATE TABLE IF NOT EXISTS "api_tokens"
(
apiTokenId TEXT PRIMARY KEY NOT NULL,
token TEXT NOT NULL,
dateCreated TEXT NOT NULL,
isDeleted INT NOT NULL DEFAULT 0
);

View File

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

View File

@@ -24,7 +24,7 @@ class Note extends Entity {
}
async getAttributes() {
return this.repository.getEntities("SELECT * FROM attributes WHERE noteId = ?", [this.noteId]);
return this.repository.getEntities("SELECT * FROM attributes WHERE noteId = ? AND isDeleted = 0", [this.noteId]);
}
async getAttribute(name) {

View File

@@ -1,18 +1,18 @@
"use strict";
const addLink = (function() {
const dialogEl = $("#add-link-dialog");
const formEl = $("#add-link-form");
const autoCompleteEl = $("#note-autocomplete");
const linkTitleEl = $("#link-title");
const clonePrefixEl = $("#clone-prefix");
const linkTitleFormGroup = $("#add-link-title-form-group");
const prefixFormGroup = $("#add-link-prefix-form-group");
const linkTypeEls = $("input[name='add-link-type']");
const linkTypeHtmlEl = linkTypeEls.filter('input[value="html"]');
const $dialog = $("#add-link-dialog");
const $form = $("#add-link-form");
const $autoComplete = $("#note-autocomplete");
const $linkTitle = $("#link-title");
const $clonePrefix = $("#clone-prefix");
const $linkTitleFormGroup = $("#add-link-title-form-group");
const $prefixFormGroup = $("#add-link-prefix-form-group");
const $linkTypes = $("input[name='add-link-type']");
const $linkTypeHtml = $linkTypes.filter('input[value="html"]');
function setLinkType(linkType) {
linkTypeEls.each(function () {
$linkTypes.each(function () {
$(this).prop('checked', $(this).val() === linkType);
});
@@ -20,39 +20,39 @@ const addLink = (function() {
}
function showDialog() {
glob.activeDialog = dialogEl;
glob.activeDialog = $dialog;
if (noteEditor.getCurrentNoteType() === 'text') {
linkTypeHtmlEl.prop('disabled', false);
$linkTypeHtml.prop('disabled', false);
setLinkType('html');
}
else {
linkTypeHtmlEl.prop('disabled', true);
$linkTypeHtml.prop('disabled', true);
setLinkType('selected-to-current');
}
dialogEl.dialog({
$dialog.dialog({
modal: true,
width: 700
});
autoCompleteEl.val('').focus();
clonePrefixEl.val('');
linkTitleEl.val('');
$autoComplete.val('').focus();
$clonePrefix.val('');
$linkTitle.val('');
function setDefaultLinkTitle(noteId) {
const noteTitle = noteTree.getNoteTitle(noteId);
linkTitleEl.val(noteTitle);
$linkTitle.val(noteTitle);
}
autoCompleteEl.autocomplete({
$autoComplete.autocomplete({
source: noteTree.getAutocompleteItems(),
minLength: 0,
change: () => {
const val = autoCompleteEl.val();
const val = $autoComplete.val();
const notePath = link.getNodePathFromLabel(val);
if (!notePath) {
return;
@@ -75,8 +75,8 @@ const addLink = (function() {
});
}
formEl.submit(() => {
const value = autoCompleteEl.val();
$form.submit(() => {
const value = $autoComplete.val();
const notePath = link.getNodePathFromLabel(value);
const noteId = treeUtils.getNoteIdFromNotePath(notePath);
@@ -85,25 +85,25 @@ const addLink = (function() {
const linkType = $("input[name='add-link-type']:checked").val();
if (linkType === 'html') {
const linkTitle = linkTitleEl.val();
const linkTitle = $linkTitle.val();
dialogEl.dialog("close");
$dialog.dialog("close");
link.addLinkToEditor(linkTitle, '#' + notePath);
}
else if (linkType === 'selected-to-current') {
const prefix = clonePrefixEl.val();
const prefix = $clonePrefix.val();
cloning.cloneNoteTo(noteId, noteEditor.getCurrentNoteId(), prefix);
dialogEl.dialog("close");
$dialog.dialog("close");
}
else if (linkType === 'current-to-selected') {
const prefix = clonePrefixEl.val();
const prefix = $clonePrefix.val();
cloning.cloneNoteTo(noteEditor.getCurrentNoteId(), noteId, prefix);
dialogEl.dialog("close");
$dialog.dialog("close");
}
}
@@ -111,19 +111,19 @@ const addLink = (function() {
});
function linkTypeChanged() {
const value = linkTypeEls.filter(":checked").val();
const value = $linkTypes.filter(":checked").val();
if (value === 'html') {
linkTitleFormGroup.show();
prefixFormGroup.hide();
$linkTitleFormGroup.show();
$prefixFormGroup.hide();
}
else {
linkTitleFormGroup.hide();
prefixFormGroup.show();
$linkTitleFormGroup.hide();
$prefixFormGroup.show();
}
}
linkTypeEls.change(linkTypeChanged);
$linkTypes.change(linkTypeChanged);
$(document).bind('keydown', 'ctrl+l', e => {
showDialog();

View File

@@ -1,8 +1,10 @@
"use strict";
const attributesDialog = (function() {
const dialogEl = $("#attributes-dialog");
const saveAttributesButton = $("#save-attributes-button");
const $dialog = $("#attributes-dialog");
const $saveAttributesButton = $("#save-attributes-button");
const $attributesBody = $('#attributes-table tbody');
const attributesModel = new AttributesModel();
let attributeNames = [];
@@ -24,11 +26,40 @@ const attributesDialog = (function() {
// attribute might not be rendered immediatelly so could not focus
setTimeout(() => $(".attribute-name:last").focus(), 100);
$attributesBody.sortable({
handle: '.handle',
containment: $attributesBody,
update: 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 attr = self.getTargetAttribute(this);
attr().position = position++;
});
}
});
};
this.deleteAttribute = function(data, event) {
const attr = self.getTargetAttribute(event.target);
const attrData = attr();
if (attrData) {
attrData.isDeleted = 1;
attr(attrData);
addLastEmptyRow();
}
};
function isValid() {
for (let attrs = self.attributes(), i = 0; i < attrs.length; i++) {
if (self.isEmptyName(i) || self.isNotUnique(i)) {
if (self.isEmptyName(i)) {
return false;
}
}
@@ -40,7 +71,7 @@ const attributesDialog = (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();
$saveAttributesButton.focus();
if (!isValid()) {
alert("Please fix all validation errors and try saving again.");
@@ -65,26 +96,26 @@ const attributesDialog = (function() {
};
function addLastEmptyRow() {
const attrs = self.attributes();
const attrs = self.attributes().filter(attr => attr().isDeleted === 0);
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: ''
value: '',
isDeleted: 0,
position: 0
}));
}
}
this.attributeChanged = function (row) {
this.attributeChanged = function (data, event) {
addLastEmptyRow();
for (const attr of self.attributes()) {
if (row.attributeId === attr().attributeId) {
attr.valueHasMutated();
}
}
const attr = self.getTargetAttribute(event.target);
attr.valueHasMutated();
};
this.isNotUnique = function(index) {
@@ -109,15 +140,22 @@ const attributesDialog = (function() {
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 = dialogEl;
glob.activeDialog = $dialog;
await attributesModel.loadAttributes();
dialogEl.dialog({
$dialog.dialog({
modal: true,
width: 800,
height: 500

View File

@@ -1,17 +1,17 @@
"use strict";
const editTreePrefix = (function() {
const dialogEl = $("#edit-tree-prefix-dialog");
const formEl = $("#edit-tree-prefix-form");
const treePrefixInputEl = $("#tree-prefix-input");
const noteTitleEl = $('#tree-prefix-note-title');
const $dialog = $("#edit-tree-prefix-dialog");
const $form = $("#edit-tree-prefix-form");
const $treePrefixInput = $("#tree-prefix-input");
const $noteTitle = $('#tree-prefix-note-title');
let noteTreeId;
async function showDialog() {
glob.activeDialog = dialogEl;
glob.activeDialog = $dialog;
await dialogEl.dialog({
await $dialog.dialog({
modal: true,
width: 500
});
@@ -20,21 +20,21 @@ const editTreePrefix = (function() {
noteTreeId = currentNode.data.noteTreeId;
treePrefixInputEl.val(currentNode.data.prefix).focus();
$treePrefixInput.val(currentNode.data.prefix).focus();
const noteTitle = noteTree.getNoteTitle(currentNode.data.noteId);
noteTitleEl.html(noteTitle);
$noteTitle.html(noteTitle);
}
formEl.submit(() => {
const prefix = treePrefixInputEl.val();
$form.submit(() => {
const prefix = $treePrefixInput.val();
server.put('tree/' + noteTreeId + '/set-prefix', {
prefix: prefix
}).then(() => noteTree.setPrefix(noteTreeId, prefix));
dialogEl.dialog("close");
$dialog.dialog("close");
return false;
});

View File

@@ -1,13 +1,13 @@
"use strict";
const eventLog = (function() {
const dialogEl = $("#event-log-dialog");
const listEl = $("#event-log-list");
const $dialog = $("#event-log-dialog");
const $list = $("#event-log-list");
async function showDialog() {
glob.activeDialog = dialogEl;
glob.activeDialog = $dialog;
dialogEl.dialog({
$dialog.dialog({
modal: true,
width: 800,
height: 700
@@ -15,7 +15,7 @@ const eventLog = (function() {
const result = await server.get('event-log');
listEl.html('');
$list.html('');
for (const event of result) {
const dateTime = formatDateTime(parseDate(event.dateAdded));
@@ -28,7 +28,7 @@ const eventLog = (function() {
const eventEl = $('<li>').html(dateTime + " - " + event.comment);
listEl.append(eventEl);
$list.append(eventEl);
}
}

View File

@@ -1,28 +1,28 @@
"use strict";
const jumpToNote = (function() {
const dialogEl = $("#jump-to-note-dialog");
const autoCompleteEl = $("#jump-to-note-autocomplete");
const formEl = $("#jump-to-note-form");
const $dialog = $("#jump-to-note-dialog");
const $autoComplete = $("#jump-to-note-autocomplete");
const $form = $("#jump-to-note-form");
async function showDialog() {
glob.activeDialog = dialogEl;
glob.activeDialog = $dialog;
autoCompleteEl.val('');
$autoComplete.val('');
dialogEl.dialog({
$dialog.dialog({
modal: true,
width: 800
});
await autoCompleteEl.autocomplete({
await $autoComplete.autocomplete({
source: await stopWatch("building autocomplete", noteTree.getAutocompleteItems),
minLength: 0
});
}
function getSelectedNotePath() {
const val = autoCompleteEl.val();
const val = $autoComplete.val();
return link.getNodePathFromLabel(val);
}
@@ -32,7 +32,7 @@ const jumpToNote = (function() {
if (notePath) {
noteTree.activateNode(notePath);
dialogEl.dialog('close');
$dialog.dialog('close');
}
}
@@ -42,8 +42,8 @@ const jumpToNote = (function() {
e.preventDefault();
});
formEl.submit(() => {
const action = dialogEl.find("button:focus").val();
$form.submit(() => {
const action = $dialog.find("button:focus").val();
goToNote();

View File

@@ -1,10 +1,10 @@
"use strict";
const noteHistory = (function() {
const dialogEl = $("#note-history-dialog");
const listEl = $("#note-history-list");
const contentEl = $("#note-history-content");
const titleEl = $("#note-history-title");
const $dialog = $("#note-history-dialog");
const $list = $("#note-history-list");
const $content = $("#note-history-content");
const $title = $("#note-history-title");
let historyItems = [];
@@ -13,23 +13,23 @@ const noteHistory = (function() {
}
async function showNoteHistoryDialog(noteId, noteRevisionId) {
glob.activeDialog = dialogEl;
glob.activeDialog = $dialog;
dialogEl.dialog({
$dialog.dialog({
modal: true,
width: 800,
height: 700
});
listEl.empty();
contentEl.empty();
$list.empty();
$content.empty();
historyItems = await server.get('notes-history/' + noteId);
for (const item of historyItems) {
const dateModified = parseDate(item.dateModifiedFrom);
listEl.append($('<option>', {
$list.append($('<option>', {
value: item.noteRevisionId,
text: formatDateTime(dateModified)
}));
@@ -37,13 +37,13 @@ const noteHistory = (function() {
if (historyItems.length > 0) {
if (!noteRevisionId) {
noteRevisionId = listEl.find("option:first").val();
noteRevisionId = $list.find("option:first").val();
}
listEl.val(noteRevisionId).trigger('change');
$list.val(noteRevisionId).trigger('change');
}
else {
titleEl.text("No history for this note yet...");
$title.text("No history for this note yet...");
}
}
@@ -53,13 +53,13 @@ const noteHistory = (function() {
e.preventDefault();
});
listEl.on('change', () => {
const optVal = listEl.find(":selected").val();
$list.on('change', () => {
const optVal = $list.find(":selected").val();
const historyItem = historyItems.find(r => r.noteRevisionId === optVal);
titleEl.html(historyItem.title);
contentEl.html(historyItem.content);
$title.html(historyItem.title);
$content.html(historyItem.content);
});
$(document).on('click', "a[action='note-history']", event => {

View File

@@ -1,13 +1,13 @@
"use strict";
const noteSource = (function() {
const dialogEl = $("#note-source-dialog");
const noteSourceEl = $("#note-source");
const $dialog = $("#note-source-dialog");
const $noteSource = $("#note-source");
function showDialog() {
glob.activeDialog = dialogEl;
glob.activeDialog = $dialog;
dialogEl.dialog({
$dialog.dialog({
modal: true,
width: 800,
height: 500
@@ -15,7 +15,7 @@ const noteSource = (function() {
const noteText = noteEditor.getCurrentNote().detail.content;
noteSourceEl.text(formatHtml(noteText));
$noteSource.text(formatHtml(noteText));
}
function formatHtml(str) {

View File

@@ -1,12 +1,12 @@
"use strict";
const recentChanges = (function() {
const dialogEl = $("#recent-changes-dialog");
const $dialog = $("#recent-changes-dialog");
async function showDialog() {
glob.activeDialog = dialogEl;
glob.activeDialog = $dialog;
dialogEl.dialog({
$dialog.dialog({
modal: true,
width: 800,
height: 700
@@ -14,7 +14,7 @@ const recentChanges = (function() {
const result = await server.get('recent-changes/');
dialogEl.html('');
$dialog.html('');
const groupedByDate = groupByDate(result);
@@ -48,7 +48,7 @@ const recentChanges = (function() {
.append(' (').append(revLink).append(')'));
}
dialogEl.append(dayEl);
$dialog.append(dayEl);
}
}

View File

@@ -1,13 +1,9 @@
"use strict";
const recentNotes = (function() {
const dialogEl = $("#recent-notes-dialog");
const selectBoxEl = $('#recent-notes-select-box');
const jumpToButtonEl = $('#recent-notes-jump-to');
const addLinkButtonEl = $('#recent-notes-add-link');
const addCurrentAsChildEl = $("#recent-notes-add-current-as-child");
const addRecentAsChildEl = $("#recent-notes-add-recent-as-child");
const noteDetailEl = $('#note-detail');
const $dialog = $("#recent-notes-dialog");
const $searchInput = $('#recent-notes-search-input');
// list of recent note paths
let list = [];
@@ -29,98 +25,66 @@ const recentNotes = (function() {
}
function showDialog() {
glob.activeDialog = dialogEl;
glob.activeDialog = $dialog;
dialogEl.dialog({
$dialog.dialog({
modal: true,
width: 800
width: 800,
height: 400
});
selectBoxEl.find('option').remove();
$searchInput.val('');
// remove the current note
const recNotes = list.filter(note => note !== noteTree.getCurrentNotePath());
$.each(recNotes, (key, valueNotePath) => {
const noteTitle = noteTree.getNotePathTitle(valueNotePath);
$searchInput.autocomplete({
source: recNotes.map(notePath => {
let noteTitle;
const option = $("<option></option>")
.attr("value", valueNotePath)
.text(noteTitle);
try {
noteTitle = noteTree.getNotePathTitle(notePath);
}
catch (e) {
noteTitle = "[error - can't find note title]";
// select the first one (most recent one) by default
if (key === 0) {
option.attr("selected", "selected");
messaging.logError("Could not find title for notePath=" + notePath + ", stack=" + e.stack);
}
return {
label: noteTitle,
value: notePath
}
}),
minLength: 0,
autoFocus: true,
select: function (event, ui) {
noteTree.activateNode(ui.item.value);
$searchInput.autocomplete('destroy');
$dialog.dialog('close');
},
focus: function (event, ui) {
event.preventDefault();
},
close: function (event, ui) {
if (event.keyCode === 27) { // escape closes dialog
$searchInput.autocomplete('destroy');
$dialog.dialog('close');
}
else {
// keep autocomplete open
// we're kind of abusing autocomplete to work in a way which it's not designed for
$searchInput.autocomplete("search", "");
}
},
create: () => $searchInput.autocomplete("search", ""),
classes: {
"ui-autocomplete": "recent-notes-autocomplete"
}
selectBoxEl.append(option);
});
}
function getSelectedNotePath() {
return selectBoxEl.find("option:selected").val();
}
function getSelectedNoteId() {
const notePath = getSelectedNotePath();
return treeUtils.getNoteIdFromNotePath(notePath);
}
function setActiveNoteBasedOnRecentNotes() {
const notePath = getSelectedNotePath();
noteTree.activateNode(notePath);
dialogEl.dialog('close');
}
function addLinkBasedOnRecentNotes() {
const notePath = getSelectedNotePath();
const noteId = treeUtils.getNoteIdFromNotePath(notePath);
const linkTitle = noteTree.getNoteTitle(noteId);
dialogEl.dialog("close");
link.addLinkToEditor(linkTitle, '#' + notePath);
}
async function addCurrentAsChild() {
await cloning.cloneNoteTo(noteEditor.getCurrentNoteId(), getSelectedNoteId());
dialogEl.dialog("close");
}
async function addRecentAsChild() {
await cloning.cloneNoteTo(getSelectedNoteId(), noteEditor.getCurrentNoteId());
dialogEl.dialog("close");
}
selectBoxEl.keydown(e => {
const key = e.which;
// to get keycodes use http://keycode.info/
if (key === 13)// the enter key code
{
setActiveNoteBasedOnRecentNotes();
}
else if (key === 76 /* l */) {
addLinkBasedOnRecentNotes();
}
else if (key === 67 /* c */) {
addCurrentAsChild();
}
else if (key === 82 /* r */) {
addRecentAsChild()
}
else {
return; // avoid prevent default
}
e.preventDefault();
});
reload();
$(document).bind('keydown', 'ctrl+e', e => {
@@ -129,15 +93,6 @@ const recentNotes = (function() {
e.preventDefault();
});
selectBoxEl.dblclick(e => {
setActiveNoteBasedOnRecentNotes();
});
jumpToButtonEl.click(setActiveNoteBasedOnRecentNotes);
addLinkButtonEl.click(addLinkBasedOnRecentNotes);
addCurrentAsChildEl.click(addCurrentAsChild);
addRecentAsChildEl.click(addRecentAsChild);
return {
showDialog,
addRecentNote,

View File

@@ -1,8 +1,8 @@
"use strict";
const settings = (function() {
const dialogEl = $("#settings-dialog");
const tabsEl = $("#settings-tabs");
const $dialog = $("#settings-dialog");
const $tabs = $("#settings-tabs");
const settingModules = [];
@@ -11,16 +11,16 @@ const settings = (function() {
}
async function showDialog() {
glob.activeDialog = dialogEl;
glob.activeDialog = $dialog;
const settings = await server.get('settings');
dialogEl.dialog({
$dialog.dialog({
modal: true,
width: 900
});
tabsEl.tabs();
$tabs.tabs();
for (const module of settingModules) {
if (module.settingsLoaded) {
@@ -46,22 +46,22 @@ const settings = (function() {
})();
settings.addModule((function() {
const formEl = $("#change-password-form");
const oldPasswordEl = $("#old-password");
const newPassword1El = $("#new-password1");
const newPassword2El = $("#new-password2");
const $form = $("#change-password-form");
const $oldPassword = $("#old-password");
const $newPassword1 = $("#new-password1");
const $newPassword2 = $("#new-password2");
function settingsLoaded(settings) {
}
formEl.submit(() => {
const oldPassword = oldPasswordEl.val();
const newPassword1 = newPassword1El.val();
const newPassword2 = newPassword2El.val();
$form.submit(() => {
const oldPassword = $oldPassword.val();
const newPassword1 = $newPassword1.val();
const newPassword2 = $newPassword2.val();
oldPasswordEl.val('');
newPassword1El.val('');
newPassword2El.val('');
$oldPassword.val('');
$newPassword1.val('');
$newPassword2.val('');
if (newPassword1 !== newPassword2) {
alert("New passwords are not the same.");
@@ -92,16 +92,16 @@ settings.addModule((function() {
})());
settings.addModule((function() {
const formEl = $("#protected-session-timeout-form");
const protectedSessionTimeoutEl = $("#protected-session-timeout-in-seconds");
const $form = $("#protected-session-timeout-form");
const $protectedSessionTimeout = $("#protected-session-timeout-in-seconds");
const settingName = 'protected_session_timeout';
function settingsLoaded(settings) {
protectedSessionTimeoutEl.val(settings[settingName]);
$protectedSessionTimeout.val(settings[settingName]);
}
formEl.submit(() => {
const protectedSessionTimeout = protectedSessionTimeoutEl.val();
$form.submit(() => {
const protectedSessionTimeout = $protectedSessionTimeout.val();
settings.saveSettings(settingName, protectedSessionTimeout).then(() => {
protected_session.setProtectedSessionTimeout(protectedSessionTimeout);
@@ -116,16 +116,16 @@ settings.addModule((function() {
})());
settings.addModule((function () {
const formEl = $("#history-snapshot-time-interval-form");
const timeIntervalEl = $("#history-snapshot-time-interval-in-seconds");
const $form = $("#history-snapshot-time-interval-form");
const $timeInterval = $("#history-snapshot-time-interval-in-seconds");
const settingName = 'history_snapshot_time_interval';
function settingsLoaded(settings) {
timeIntervalEl.val(settings[settingName]);
$timeInterval.val(settings[settingName]);
}
formEl.submit(() => {
settings.saveSettings(settingName, timeIntervalEl.val());
$form.submit(() => {
settings.saveSettings(settingName, $timeInterval.val());
return false;
});
@@ -136,50 +136,50 @@ settings.addModule((function () {
})());
settings.addModule((async function () {
const appVersionEl = $("#app-version");
const dbVersionEl = $("#db-version");
const buildDateEl = $("#build-date");
const buildRevisionEl = $("#build-revision");
const $appVersion = $("#app-version");
const $dbVersion = $("#db-version");
const $buildDate = $("#build-date");
const $buildRevision = $("#build-revision");
const appInfo = await server.get('app-info');
appVersionEl.html(appInfo.app_version);
dbVersionEl.html(appInfo.db_version);
buildDateEl.html(appInfo.build_date);
buildRevisionEl.html(appInfo.build_revision);
buildRevisionEl.attr('href', 'https://github.com/zadam/trilium/commit/' + appInfo.build_revision);
$appVersion.html(appInfo.app_version);
$dbVersion.html(appInfo.db_version);
$buildDate.html(appInfo.build_date);
$buildRevision.html(appInfo.build_revision);
$buildRevision.attr('href', 'https://github.com/zadam/trilium/commit/' + appInfo.build_revision);
return {};
})());
settings.addModule((async function () {
const forceFullSyncButton = $("#force-full-sync-button");
const fillSyncRowsButton = $("#fill-sync-rows-button");
const anonymizeButton = $("#anonymize-button");
const cleanupSoftDeletedButton = $("#cleanup-soft-deleted-items-button");
const cleanupUnusedImagesButton = $("#cleanup-unused-images-button");
const vacuumDatabaseButton = $("#vacuum-database-button");
const $forceFullSyncButton = $("#force-full-sync-button");
const $fillSyncRowsButton = $("#fill-sync-rows-button");
const $anonymizeButton = $("#anonymize-button");
const $cleanupSoftDeletedButton = $("#cleanup-soft-deleted-items-button");
const $cleanupUnusedImagesButton = $("#cleanup-unused-images-button");
const $vacuumDatabaseButton = $("#vacuum-database-button");
forceFullSyncButton.click(async () => {
$forceFullSyncButton.click(async () => {
await server.post('sync/force-full-sync');
showMessage("Full sync triggered");
});
fillSyncRowsButton.click(async () => {
$fillSyncRowsButton.click(async () => {
await server.post('sync/fill-sync-rows');
showMessage("Sync rows filled successfully");
});
anonymizeButton.click(async () => {
$anonymizeButton.click(async () => {
await server.post('anonymization/anonymize');
showMessage("Created anonymized database");
});
cleanupSoftDeletedButton.click(async () => {
$cleanupSoftDeletedButton.click(async () => {
if (confirm("Do you really want to clean up soft-deleted items?")) {
await server.post('cleanup/cleanup-soft-deleted-items');
@@ -187,7 +187,7 @@ settings.addModule((async function () {
}
});
cleanupUnusedImagesButton.click(async () => {
$cleanupUnusedImagesButton.click(async () => {
if (confirm("Do you really want to clean up unused images?")) {
await server.post('cleanup/cleanup-unused-images');
@@ -195,7 +195,7 @@ settings.addModule((async function () {
}
});
vacuumDatabaseButton.click(async () => {
$vacuumDatabaseButton.click(async () => {
await server.post('cleanup/vacuum-database');
showMessage("Database has been vacuumed");

View File

@@ -1,24 +1,44 @@
"use strict";
const sqlConsole = (function() {
const dialogEl = $("#sql-console-dialog");
const queryEl = $('#sql-console-query');
const executeButton = $('#sql-console-execute');
const resultHeadEl = $('#sql-console-results thead');
const resultBodyEl = $('#sql-console-results tbody');
const $dialog = $("#sql-console-dialog");
const $query = $('#sql-console-query');
const $executeButton = $('#sql-console-execute');
const $resultHead = $('#sql-console-results thead');
const $resultBody = $('#sql-console-results tbody');
let codeEditor;
function showDialog() {
glob.activeDialog = dialogEl;
glob.activeDialog = $dialog;
dialogEl.dialog({
$dialog.dialog({
modal: true,
width: $(window).width(),
height: $(window).height()
height: $(window).height(),
open: function() {
CodeMirror.keyMap.default["Shift-Tab"] = "indentLess";
CodeMirror.keyMap.default["Tab"] = "indentMore";
CodeMirror.modeURL = 'libraries/codemirror/mode/%N/%N.js';
codeEditor = CodeMirror($query[0], {
value: "",
viewportMargin: Infinity,
indentUnit: 4,
highlightSelectionMatches: { showToken: /\w/, annotateScrollbar: false }
});
codeEditor.setOption("mode", "text/x-sqlite");
CodeMirror.autoLoadMode(codeEditor, "sql");
codeEditor.focus();
}
});
}
async function execute() {
const sqlQuery = queryEl.val();
const sqlQuery = codeEditor.getValue();
const result = await server.post("sql/execute", {
query: sqlQuery
@@ -34,8 +54,8 @@ const sqlConsole = (function() {
const rows = result.rows;
resultHeadEl.empty();
resultBodyEl.empty();
$resultHead.empty();
$resultBody.empty();
if (rows.length > 0) {
const result = rows[0];
@@ -45,7 +65,7 @@ const sqlConsole = (function() {
rowEl.append($("<th>").html(key));
}
resultHeadEl.append(rowEl);
$resultHead.append(rowEl);
}
for (const result of rows) {
@@ -55,15 +75,15 @@ const sqlConsole = (function() {
rowEl.append($("<td>").html(result[key]));
}
resultBodyEl.append(rowEl);
$resultBody.append(rowEl);
}
}
$(document).bind('keydown', 'alt+o', showDialog);
queryEl.bind('keydown', 'ctrl+return', execute);
$query.bind('keydown', 'ctrl+return', execute);
executeButton.click(execute);
$executeButton.click(execute);
return {
showDialog

View File

@@ -104,6 +104,8 @@ const server = (function() {
post,
put,
remove,
exec
exec,
// don't remove, used from CKEditor image upload!
getHeaders
}
})();

File diff suppressed because one or more lines are too long

View File

@@ -268,4 +268,8 @@ div.ui-tooltip {
#attribute-list button {
padding: 2px;
margin-right: 5px;
}
.recent-notes-autocomplete {
border: 0 !important;
}

View File

@@ -12,7 +12,7 @@ const attributes = require('../../services/attributes');
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]));
res.send(await sql.getRows("SELECT * FROM attributes WHERE isDeleted = 0 AND noteId = ? ORDER BY position, dateCreated", [noteId]));
}));
router.put('/notes/:noteId/attributes', auth.checkApiAuth, wrap(async (req, res, next) => {
@@ -23,19 +23,26 @@ router.put('/notes/:noteId/attributes', auth.checkApiAuth, wrap(async (req, res,
await sql.doInTransaction(async () => {
for (const attr of attributes) {
if (attr.attributeId) {
await sql.execute("UPDATE attributes SET name = ?, value = ?, dateModified = ? WHERE attributeId = ?",
[attr.name, attr.value, now, attr.attributeId]);
await sql.execute("UPDATE attributes SET name = ?, value = ?, dateModified = ?, isDeleted = ?, position = ? WHERE attributeId = ?",
[attr.name, attr.value, now, attr.isDeleted, attr.position, attr.attributeId]);
}
else {
// if it was "created" and then immediatelly deleted, we just don't create it at all
if (attr.isDeleted) {
continue;
}
attr.attributeId = utils.newAttributeId();
await sql.insert("attributes", {
attributeId: attr.attributeId,
noteId: noteId,
name: attr.name,
value: attr.value,
dateCreated: now,
dateModified: now
attributeId: attr.attributeId,
noteId: noteId,
name: attr.name,
value: attr.value,
position: attr.position,
dateCreated: now,
dateModified: now,
isDeleted: false
});
}
@@ -43,11 +50,11 @@ router.put('/notes/:noteId/attributes', auth.checkApiAuth, wrap(async (req, res,
}
});
res.send(await sql.getRows("SELECT * FROM attributes WHERE noteId = ? ORDER BY dateCreated", [noteId]));
res.send(await sql.getRows("SELECT * FROM attributes WHERE isDeleted = 0 AND noteId = ? ORDER BY position, dateCreated", [noteId]));
}));
router.get('/attributes/names', auth.checkApiAuth, wrap(async (req, res, next) => {
const names = await sql.getColumn("SELECT DISTINCT name FROM attributes");
const names = await sql.getColumn("SELECT DISTINCT name FROM attributes WHERE isDeleted = 0");
for (const attr of attributes.BUILTIN_ATTRIBUTES) {
if (!names.includes(attr)) {
@@ -63,7 +70,7 @@ router.get('/attributes/names', auth.checkApiAuth, wrap(async (req, res, next) =
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]);
const values = await sql.getColumn("SELECT DISTINCT value FROM attributes WHERE isDeleted = 0 AND name = ? AND value != '' ORDER BY value", [attributeName]);
res.send(values);
}));

View File

@@ -4,16 +4,8 @@ const express = require('express');
const router = express.Router();
const sql = require('../../services/sql');
const auth = require('../../services/auth');
const utils = require('../../services/utils');
const sync_table = require('../../services/sync_table');
const image = require('../../services/image');
const multer = require('multer')();
const imagemin = require('imagemin');
const imageminMozJpeg = require('imagemin-mozjpeg');
const imageminPngQuant = require('imagemin-pngquant');
const imageminGifLossy = require('imagemin-giflossy');
const jimp = require('jimp');
const imageType = require('image-type');
const sanitizeFilename = require('sanitize-filename');
const wrap = require('express-promise-wrap').wrap;
const RESOURCE_DIR = require('../../services/resource_dir').RESOURCE_DIR;
const fs = require('fs');
@@ -49,45 +41,7 @@ router.post('', auth.checkApiAuthOrElectron, multer.single('upload'), wrap(async
return res.status(400).send("Unknown image type: " + file.mimetype);
}
const now = utils.nowDate();
const resizedImage = await resize(file.buffer);
const optimizedImage = await optimize(resizedImage);
const imageFormat = imageType(optimizedImage);
const fileNameWithouExtension = file.originalname.replace(/\.[^/.]+$/, "");
const fileName = sanitizeFilename(fileNameWithouExtension + "." + imageFormat.ext);
const imageId = utils.newImageId();
await sql.doInTransaction(async () => {
await sql.insert("images", {
imageId: imageId,
format: imageFormat.ext,
name: fileName,
checksum: utils.hash(optimizedImage),
data: optimizedImage,
isDeleted: 0,
dateModified: now,
dateCreated: now
});
await sync_table.addImageSync(imageId, sourceId);
const noteImageId = utils.newNoteImageId();
await sql.insert("note_images", {
noteImageId: noteImageId,
noteId: noteId,
imageId: imageId,
isDeleted: 0,
dateModified: now,
dateCreated: now
});
await sync_table.addNoteImageSync(noteImageId, sourceId);
});
const {fileName, imageId} = await image.saveImage(file, sourceId, noteId);
res.send({
uploaded: true,
@@ -95,54 +49,4 @@ router.post('', auth.checkApiAuthOrElectron, multer.single('upload'), wrap(async
});
}));
const MAX_SIZE = 1000;
const MAX_BYTE_SIZE = 200000; // images should have under 100 KBs
async function resize(buffer) {
const image = await jimp.read(buffer);
if (image.bitmap.width > image.bitmap.height && image.bitmap.width > MAX_SIZE) {
image.resize(MAX_SIZE, jimp.AUTO);
}
else if (image.bitmap.height > MAX_SIZE) {
image.resize(jimp.AUTO, MAX_SIZE);
}
else if (buffer.byteLength <= MAX_BYTE_SIZE) {
return buffer;
}
// we do resizing with max quality which will be trimmed during optimization step next
image.quality(100);
// when converting PNG to JPG we lose alpha channel, this is replaced by white to match Trilium white background
image.background(0xFFFFFFFF);
// getBuffer doesn't support promises so this workaround
return await new Promise((resolve, reject) => image.getBuffer(jimp.MIME_JPEG, (err, data) => {
if (err) {
reject(err);
}
else {
resolve(data);
}
}));
}
async function optimize(buffer) {
return await imagemin.buffer(buffer, {
plugins: [
imageminMozJpeg({
quality: 50
}),
imageminPngQuant({
quality: "0-70"
}),
imageminGifLossy({
lossy: 80,
optimize: '3' // needs to be string
})
]
});
}
module.exports = router;

View File

@@ -66,7 +66,7 @@ async function importNotes(dir, parentNoteId) {
const noteText = fs.readFileSync(path, "utf8");
const noteId = utils.newNoteId();
const noteTreeId = utils.newnoteRevisionId();
const noteTreeId = utils.newNoteRevisionId();
const now = utils.nowDate();

View File

@@ -14,7 +14,7 @@ const wrap = require('express-promise-wrap').wrap;
router.post('/sync', wrap(async (req, res, next) => {
const timestampStr = req.body.timestamp;
const timestamp = utils.parseDate(timestampStr);
const timestamp = utils.parseDateTime(timestampStr);
const now = new Date();

View File

@@ -62,6 +62,8 @@ router.get('/', auth.checkApiAuth, wrap(async (req, res, next) => {
const {query, params} = getSearchQuery(attrFilters, searchText);
console.log(query, params);
const noteIds = await sql.getColumn(query, params);
res.send(noteIds);
@@ -152,7 +154,7 @@ function getSearchQuery(attrFilters, searchText) {
searchParams.push(searchText); // two occurences in searchCondition
}
const query = `SELECT notes.noteId FROM notes
const query = `SELECT DISTINCT notes.noteId FROM notes
${joins.join('\r\n')}
WHERE
notes.isDeleted = 0

View File

@@ -45,7 +45,8 @@ async function getRecentNotes() {
recent_notes.isDeleted = 0
AND note_tree.isDeleted = 0
ORDER BY
dateAccessed DESC`);
dateAccessed DESC
LIMIT 200`);
}
module.exports = router;

106
src/routes/api/sender.js Normal file
View File

@@ -0,0 +1,106 @@
"use strict";
const express = require('express');
const router = express.Router();
const image = require('../../services/image');
const utils = require('../../services/utils');
const date_notes = require('../../services/date_notes');
const sql = require('../../services/sql');
const wrap = require('express-promise-wrap').wrap;
const notes = require('../../services/notes');
const multer = require('multer')();
const password_encryption = require('../../services/password_encryption');
const options = require('../../services/options');
const sync_table = require('../../services/sync_table');
router.post('/login', wrap(async (req, res, next) => {
const username = req.body.username;
const password = req.body.password;
const isUsernameValid = username === await options.getOption('username');
const isPasswordValid = await password_encryption.verifyPassword(password);
if (!isUsernameValid || !isPasswordValid) {
res.status(401).send("Incorrect username/password");
}
else {
const token = utils.randomSecureToken();
await sql.doInTransaction(async () => {
const apiTokenId = utils.newApiTokenId();
await sql.insert("api_tokens", {
apiTokenId: apiTokenId,
token: token,
dateCreated: utils.nowDate(),
isDeleted: false
});
await sync_table.addApiTokenSync(apiTokenId);
});
res.send({
token: token
});
}
}));
async function checkSenderToken(req, res, next) {
const token = req.headers.authorization;
if (await sql.getValue("SELECT COUNT(*) FROM api_tokens WHERE isDeleted = 0 AND token = ?", [token]) === 0) {
res.status(401).send("Not authorized");
}
else if (await sql.isDbUpToDate()) {
next();
}
else {
res.status(409).send("Mismatched app versions"); // need better response than that
}
}
router.post('/image', checkSenderToken, multer.single('upload'), wrap(async (req, res, next) => {
const file = req.file;
if (!["image/png", "image/jpeg", "image/gif"].includes(file.mimetype)) {
return res.status(400).send("Unknown image type: " + file.mimetype);
}
const parentNoteId = await date_notes.getDateNoteId(req.headers['x-local-date']);
const noteId = (await notes.createNewNote(parentNoteId, {
title: "Sender image",
content: "",
target: 'into',
isProtected: false,
type: 'text',
mime: 'text/html'
})).noteId;
const {fileName, imageId} = await image.saveImage(file, null, noteId);
const url = `/api/images/${imageId}/${fileName}`;
const content = `<img src="${url}"/>`;
await sql.execute("UPDATE notes SET content = ? WHERE noteId = ?", [content, noteId]);
res.send({});
}));
router.post('/note', checkSenderToken, wrap(async (req, res, next) => {
const parentNoteId = await date_notes.getDateNoteId(req.headers['x-local-date']);
await notes.createNewNote(parentNoteId, {
title: req.body.title,
content: req.body.content,
target: 'into',
isProtected: false,
type: 'text',
mime: 'text/html'
});
res.send({});
}));
module.exports = router;

View File

@@ -147,6 +147,12 @@ router.get('/attributes/:attributeId', auth.checkApiAuth, wrap(async (req, res,
res.send(await sql.getRow("SELECT * FROM attributes WHERE attributeId = ?", [attributeId]));
}));
router.get('/api_tokens/:apiTokenId', auth.checkApiAuth, wrap(async (req, res, next) => {
const apiTokenId = req.params.apiTokenId;
res.send(await sql.getRow("SELECT * FROM api_tokens WHERE apiTokenId = ?", [apiTokenId]));
}));
router.put('/notes', auth.checkApiAuth, wrap(async (req, res, next) => {
await syncUpdate.updateNote(req.body.entity, req.body.sourceId);
@@ -201,4 +207,10 @@ router.put('/attributes', auth.checkApiAuth, wrap(async (req, res, next) => {
res.send({});
}));
router.put('/api_tokens', auth.checkApiAuth, wrap(async (req, res, next) => {
await syncUpdate.updateApiToken(req.body.entity, req.body.sourceId);
res.send({});
}));
module.exports = router;

View File

@@ -28,6 +28,7 @@ const cleanupRoute = require('./api/cleanup');
const imageRoute = require('./api/image');
const attributesRoute = require('./api/attributes');
const scriptRoute = require('./api/script');
const senderRoute = require('./api/sender');
function register(app) {
app.use('/', indexRoute);
@@ -59,6 +60,7 @@ function register(app) {
app.use('/api/cleanup', cleanupRoute);
app.use('/api/images', imageRoute);
app.use('/api/script', scriptRoute);
app.use('/api/sender', senderRoute);
}
module.exports = {

View File

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

View File

@@ -5,7 +5,7 @@ const utils = require('./utils');
const sync_table = require('./sync_table');
const Repository = require('./repository');
const BUILTIN_ATTRIBUTES = [ 'run_on_startup', 'disable_versioning' ];
const BUILTIN_ATTRIBUTES = [ 'run_on_startup', 'disable_versioning', 'calendar_root' ];
async function getNoteAttributeMap(noteId) {
return await sql.getMap(`SELECT name, value FROM attributes WHERE noteId = ?`, [noteId]);
@@ -13,7 +13,10 @@ async function getNoteAttributeMap(noteId) {
async function getNoteIdWithAttribute(name, value) {
return await sql.getValue(`SELECT notes.noteId FROM notes JOIN attributes USING(noteId)
WHERE notes.isDeleted = 0 AND attributes.name = ? AND attributes.value = ?`, [name, value]);
WHERE notes.isDeleted = 0
AND attributes.isDeleted = 0
AND attributes.name = ?
AND attributes.value = ?`, [name, value]);
}
async function getNotesWithAttribute(dataKey, name, value) {
@@ -23,11 +26,11 @@ async function getNotesWithAttribute(dataKey, name, value) {
if (value !== undefined) {
notes = await repository.getEntities(`SELECT notes.* FROM notes JOIN attributes USING(noteId)
WHERE notes.isDeleted = 0 AND attributes.name = ? AND attributes.value = ?`, [name, value]);
WHERE notes.isDeleted = 0 AND attributes.isDeleted = 0 AND attributes.name = ? AND attributes.value = ?`, [name, value]);
}
else {
notes = await repository.getEntities(`SELECT notes.* FROM notes JOIN attributes USING(noteId)
WHERE notes.isDeleted = 0 AND attributes.name = ?`, [name]);
WHERE notes.isDeleted = 0 AND attributes.isDeleted = 0 AND attributes.name = ?`, [name]);
}
return notes;
@@ -41,7 +44,7 @@ async function getNoteWithAttribute(dataKey, name, value) {
async function getNoteIdsWithAttribute(name) {
return await sql.getColumn(`SELECT DISTINCT notes.noteId FROM notes JOIN attributes USING(noteId)
WHERE notes.isDeleted = 0 AND attributes.name = ?`, [name]);
WHERE notes.isDeleted = 0 AND attributes.isDeleted = 0 AND attributes.name = ? AND attributes.isDeleted = 0`, [name]);
}
async function createAttribute(noteId, name, value = null, sourceId = null) {
@@ -54,7 +57,8 @@ async function createAttribute(noteId, name, value = null, sourceId = null) {
name: name,
value: value,
dateModified: now,
dateCreated: now
dateCreated: now,
isDeleted: false
});
await sync_table.addAttributeSync(attributeId, sourceId);

View File

@@ -10,7 +10,7 @@ const sync_mutex = require('./sync_mutex');
async function regularBackup() {
const now = new Date();
const lastBackupDate = utils.parseDate(await options.getOption('last_backup_date'));
const lastBackupDate = utils.parseDateTime(await options.getOption('last_backup_date'));
console.log(lastBackupDate);

View File

@@ -223,6 +223,8 @@ async function runAllChecks() {
await runSyncRowChecks("recent_notes", "noteTreeId", errorList);
await runSyncRowChecks("images", "imageId", errorList);
await runSyncRowChecks("note_images", "noteImageId", errorList);
await runSyncRowChecks("attributes", "attributeId", errorList);
await runSyncRowChecks("api_tokens", "apiTokenId", errorList);
if (errorList.length === 0) {
// we run this only if basic checks passed since this assumes basic data consistency

View File

@@ -3,12 +3,16 @@
const sql = require('./sql');
const notes = require('./notes');
const attributes = require('./attributes');
const utils = require('./utils');
const CALENDAR_ROOT_ATTRIBUTE = 'calendar_root';
const YEAR_ATTRIBUTE = 'year_note';
const MONTH_ATTRIBUTE = 'month_note';
const DATE_ATTRIBUTE = 'date_note';
const DAYS = ['Sunday','Monday','Tuesday','Wednesday','Thursday','Friday','Saturday'];
const MONTHS = ['January','February','March','April','May','June','July','August','September','October','November','December'];
async function createNote(parentNoteId, noteTitle, noteText) {
return (await notes.createNewNote(parentNoteId, {
title: noteTitle,
@@ -72,7 +76,11 @@ async function getMonthNoteId(dateTimeStr, rootNoteId) {
monthNoteId = await getNoteStartingWith(yearNoteId, monthNumber);
if (!monthNoteId) {
monthNoteId = await createNote(yearNoteId, monthNumber);
const dateObj = utils.parseDate(dateTimeStr);
const noteTitle = monthNumber + " - " + MONTHS[dateObj.getMonth()];
monthNoteId = await createNote(yearNoteId, noteTitle);
}
await attributes.createAttribute(monthNoteId, MONTH_ATTRIBUTE, monthStr);
@@ -97,7 +105,11 @@ async function getDateNoteId(dateTimeStr, rootNoteId = null) {
dateNoteId = await getNoteStartingWith(monthNoteId, dayNumber);
if (!dateNoteId) {
dateNoteId = await createNote(monthNoteId, dayNumber);
const dateObj = utils.parseDate(dateTimeStr);
const noteTitle = dayNumber + " - " + DAYS[dateObj.getDay()];
dateNoteId = await createNote(monthNoteId, noteTitle);
}
await attributes.createAttribute(dateNoteId, DATE_ATTRIBUTE, dateStr);

108
src/services/image.js Normal file
View File

@@ -0,0 +1,108 @@
"use strict";
const utils = require('./utils');
const sql = require('./sql');
const sync_table = require('./sync_table');
const imagemin = require('imagemin');
const imageminMozJpeg = require('imagemin-mozjpeg');
const imageminPngQuant = require('imagemin-pngquant');
const imageminGifLossy = require('imagemin-giflossy');
const jimp = require('jimp');
const imageType = require('image-type');
const sanitizeFilename = require('sanitize-filename');
async function saveImage(file, sourceId, noteId) {
const resizedImage = await resize(file.buffer);
const optimizedImage = await optimize(resizedImage);
const imageFormat = imageType(optimizedImage);
const fileNameWithouExtension = file.originalname.replace(/\.[^/.]+$/, "");
const fileName = sanitizeFilename(fileNameWithouExtension + "." + imageFormat.ext);
const imageId = utils.newImageId();
const now = utils.nowDate();
await sql.doInTransaction(async () => {
await sql.insert("images", {
imageId: imageId,
format: imageFormat.ext,
name: fileName,
checksum: utils.hash(optimizedImage),
data: optimizedImage,
isDeleted: 0,
dateModified: now,
dateCreated: now
});
await sync_table.addImageSync(imageId, sourceId);
const noteImageId = utils.newNoteImageId();
await sql.insert("note_images", {
noteImageId: noteImageId,
noteId: noteId,
imageId: imageId,
isDeleted: 0,
dateModified: now,
dateCreated: now
});
await sync_table.addNoteImageSync(noteImageId, sourceId);
});
return {fileName, imageId};
}
const MAX_SIZE = 1000;
const MAX_BYTE_SIZE = 200000; // images should have under 100 KBs
async function resize(buffer) {
const image = await jimp.read(buffer);
if (image.bitmap.width > image.bitmap.height && image.bitmap.width > MAX_SIZE) {
image.resize(MAX_SIZE, jimp.AUTO);
}
else if (image.bitmap.height > MAX_SIZE) {
image.resize(jimp.AUTO, MAX_SIZE);
}
else if (buffer.byteLength <= MAX_BYTE_SIZE) {
return buffer;
}
// we do resizing with max quality which will be trimmed during optimization step next
image.quality(100);
// when converting PNG to JPG we lose alpha channel, this is replaced by white to match Trilium white background
image.background(0xFFFFFFFF);
// getBuffer doesn't support promises so this workaround
return await new Promise((resolve, reject) => image.getBuffer(jimp.MIME_JPEG, (err, data) => {
if (err) {
reject(err);
}
else {
resolve(data);
}
}));
}
async function optimize(buffer) {
return await imagemin.buffer(buffer, {
plugins: [
imageminMozJpeg({
quality: 50
}),
imageminPngQuant({
quality: "0-70"
}),
imageminGifLossy({
lossy: 80,
optimize: '3' // needs to be string
})
]
});
}
module.exports = {
saveImage
};

View File

@@ -154,10 +154,10 @@ async function saveNoteHistory(noteId, dataKey, sourceId, nowStr) {
note.isProtected = false;
}
const newnoteRevisionId = utils.newnoteRevisionId();
const newNoteRevisionId = utils.newNoteRevisionId();
await sql.insert('note_revisions', {
noteRevisionId: newnoteRevisionId,
noteRevisionId: newNoteRevisionId,
noteId: noteId,
// title and text should be decrypted now
title: oldNote.title,
@@ -167,7 +167,7 @@ async function saveNoteHistory(noteId, dataKey, sourceId, nowStr) {
dateModifiedTo: nowStr
});
await sync_table.addNoteHistorySync(newnoteRevisionId, sourceId);
await sync_table.addNoteHistorySync(newNoteRevisionId, sourceId);
}
async function saveNoteImages(noteId, noteText, sourceId) {
@@ -235,7 +235,7 @@ async function updateNote(noteId, newNote, dataKey, sourceId) {
"SELECT noteRevisionId FROM note_revisions WHERE noteId = ? AND dateModifiedTo >= ?", [noteId, historyCutoff]);
await sql.doInTransaction(async () => {
const msSinceDateCreated = now.getTime() - utils.parseDate(newNote.detail.dateCreated).getTime();
const msSinceDateCreated = now.getTime() - utils.parseDateTime(newNote.detail.dateCreated).getTime();
if (attributesMap.disable_versioning !== 'true'
&& !existingnoteRevisionId

View File

@@ -149,6 +149,9 @@ async function pullSync(syncContext) {
else if (sync.entityName === 'attributes') {
await syncUpdate.updateAttribute(resp, syncContext.sourceId);
}
else if (sync.entityName === 'api_tokens') {
await syncUpdate.updateApiToken(resp, syncContext.sourceId);
}
else {
throw new Error(`Unrecognized entity type ${sync.entityName} in sync #${sync.id}`);
}
@@ -233,6 +236,9 @@ async function pushEntity(sync, syncContext) {
else if (sync.entityName === 'attributes') {
entity = await sql.getRow('SELECT * FROM attributes WHERE attributeId = ?', [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}`);
}

View File

@@ -40,6 +40,10 @@ async function addAttributeSync(attributeId, sourceId) {
await addEntitySync("attributes", attributeId, sourceId);
}
async function addApiTokenSync(apiTokenId, sourceId) {
await addEntitySync("api_tokens", apiTokenId, sourceId);
}
async function addEntitySync(entityName, entityId, sourceId) {
await sql.replace("sync", {
entityName: entityName,
@@ -93,6 +97,7 @@ async function fillAllSyncRows() {
await fillSyncRows("images", "imageId");
await fillSyncRows("note_images", "noteImageId");
await fillSyncRows("attributes", "attributeId");
await fillSyncRows("api_tokens", "apiTokenId");
}
module.exports = {
@@ -105,6 +110,7 @@ module.exports = {
addImageSync,
addNoteImageSync,
addAttributeSync,
addApiTokenSync,
addEntitySync,
cleanupSyncRowsForMissingEntities,
fillAllSyncRows

View File

@@ -137,6 +137,20 @@ async function updateAttribute(entity, sourceId) {
}
}
async function updateApiToken(entity, sourceId) {
const apiTokenId = await sql.getRow("SELECT * FROM api_tokens WHERE apiTokenId = ?", [entity.apiTokenId]);
if (!apiTokenId) {
await sql.doInTransaction(async () => {
await sql.replace("api_tokens", entity);
await sync_table.addApiTokenSync(entity.apiTokenId, sourceId);
});
log.info("Update/sync API token " + entity.apiTokenId);
}
}
module.exports = {
updateNote,
updateNoteTree,
@@ -146,5 +160,6 @@ module.exports = {
updateRecentNotes,
updateImage,
updateNoteImage,
updateAttribute
updateAttribute,
updateApiToken
};

View File

@@ -2,6 +2,7 @@
const sql = require('./sql');
const sync_table = require('./sync_table');
const protected_session = require('./protected_session');
async function validateParentChild(res, parentNoteId, childNoteId, noteTreeId = null) {
const existing = await getExistingNoteTree(parentNoteId, childNoteId);

View File

@@ -11,7 +11,7 @@ function newNoteTreeId() {
return randomString(12);
}
function newnoteRevisionId() {
function newNoteRevisionId() {
return randomString(12);
}
@@ -27,6 +27,10 @@ function newAttributeId() {
return randomString(12);
}
function newApiTokenId() {
return randomString(12);
}
function randomString(length) {
return randtoken.generate(length);
}
@@ -47,7 +51,7 @@ function dateStr(date) {
* @param str - needs to be in the ISO 8601 format "YYYY-MM-DDTHH:MM:SS.sssZ" format as outputted by dateStr().
* also is assumed to be GMT time (as indicated by the "Z" at the end), *not* local time
*/
function parseDate(str) {
function parseDateTime(str) {
try {
return new Date(Date.parse(str));
}
@@ -56,6 +60,12 @@ function parseDate(str) {
}
}
function parseDate(str) {
const datePart = str.substr(0, 10);
return parseDateTime(datePart + "T12:00:00.000Z");
}
function toBase64(plainText) {
return Buffer.from(plainText).toString('base64');
}
@@ -117,12 +127,14 @@ module.exports = {
nowDate,
dateStr,
parseDate,
parseDateTime,
newNoteId,
newNoteTreeId,
newnoteRevisionId,
newNoteRevisionId,
newImageId,
newNoteImageId,
newAttributeId,
newApiTokenId,
toBase64,
fromBase64,
hmac,

View File

@@ -151,20 +151,7 @@
</div>
<div id="recent-notes-dialog" title="Recent notes" style="display: none;">
<select id="recent-notes-select-box" size="20" style="width: 100%">
</select>
<br/><br/>
<p>
<button class="btn btn-sm" id="recent-notes-jump-to">Jump to <kbd>enter</kbd></button>
&nbsp;
<button class="btn btn-sm" id="recent-notes-add-link">Add link <kbd>l</kbd></button>
<button class="btn btn-sm" id="recent-notes-add-current-as-child">Add current as child <kbd>c</kbd></button>
<button class="btn btn-sm" id="recent-notes-add-recent-as-child">Add recent as child <kbd>r</kbd></button>
</p>
<input id="recent-notes-search-input" class="form-control"/>
</div>
<div id="add-link-dialog" title="Add link" style="display: none;">
@@ -373,8 +360,11 @@
</div>
<div id="sql-console-dialog" title="SQL console" style="display: none; padding: 20px;">
<textarea style="width: 100%; height: 100px" id="sql-console-query"></textarea>
<button class="btn btn-danger" id="sql-console-execute">Execute <kbd>CTRL+ENTER</kbd></button>
<div style="height: 150px; width: 100%; border: 1px solid #ccc; margin-bottom: 10px;" id="sql-console-query"></div>
<div style="text-align: center">
<button class="btn btn-danger" id="sql-console-execute">Execute <kbd>CTRL+ENTER</kbd></button>
</div>
<table id="sql-console-results" class="table table-striped" style="overflow: scroll; width: 100%;">
<thead></thead>
@@ -389,31 +379,40 @@
<div id="attributes-dialog" title="Note attributes" style="display: none; padding: 20px;">
<form data-bind="submit: save">
<div style="text-align: center">
<button class="btn btn-large" style="width: 200px;" id="save-attributes-button" type="submit">Save <kbd>enter</kbd></button>
<button class="btn btn-large" style="width: 200px;" id="save-attributes-button" type="submit">Save changes <kbd>enter</kbd></button>
</div>
<div style="height: 97%; overflow: auto">
<table id="attributes-table" class="table">
<thead>
<tr>
<th></th>
<th>ID</th>
<th>Name</th>
<th>Value</th>
<th></th>
</tr>
</thead>
<tbody data-bind="foreach: attributes">
<tr>
<td data-bind="text: attributeId"></td>
<tr data-bind="if: isDeleted == 0">
<td class="handle">
<span class="glyphicon glyphicon-resize-vertical"></span>
<input type="hidden" name="position" data-bind="value: position"/>
</td>
<!-- ID column has specific width because if it's empty its size can be deformed when dragging -->
<td data-bind="text: attributeId" style="width: 150px;"></td>
<td>
<!-- 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: yellowgreen" data-bind="if: $parent.isNotUnique($index())"><span class="glyphicon glyphicon-info-sign"></span> Duplicate attribute.</div>
<div style="color: red" data-bind="if: $parent.isEmptyName($index())">Attribute name can't be empty.</div>
</td>
<td>
<input type="text" class="attribute-value" data-bind="value: value, valueUpdate: 'blur', event: { blur: $parent.attributeChanged }" style="width: 300px"/>
</td>
<td title="Delete" style="padding: 13px;">
<span class="glyphicon glyphicon-trash" data-bind="click: $parent.deleteAttribute"></span>
</td>
</tr>
</tbody>
</table>