Compare commits

...

50 Commits

Author SHA1 Message Date
azivner
8028b09351 release 0.6.1 2018-02-13 23:27:34 -05:00
azivner
ebe66eaed9 after creating new note, unselect previous active, fixes #45 2018-02-13 23:25:28 -05:00
azivner
5bce9a5f94 added hide_in_autocomplete attribute to weight script 2018-02-13 22:50:12 -05:00
azivner
dfd9927310 added createAttribute method to script API 2018-02-13 22:46:45 -05:00
azivner
9bf1735bde reddit notes will be created with "hide_in_autocomplete" attribute 2018-02-13 22:34:33 -05:00
azivner
2e8eeda5ab new attribute "hide_in_autocomplete", fixes #16 2018-02-13 22:30:33 -05:00
azivner
1cef0ce5f9 removed CTRL-ALT-C global shortcut with pasting from clipboard, keeping only CTRL-ALT-P without clipboard 2018-02-13 19:55:04 -05:00
azivner
1efac99828 limit number of autocomplete results to 100, closes #44 2018-02-13 19:37:07 -05:00
azivner
0e9473119e global keyboard shortcuts for quick creating sub-notes under day note 2018-02-12 23:53:00 -05:00
azivner
7bbfef7af3 better positioning of the recent notes dialog 2018-02-12 21:20:30 -05:00
azivner
5cb93509c1 stop trying to wrap autocomplete with underlying dialog - seems to be impossible to get it right on all platforms 2018-02-12 21:09:50 -05:00
azivner
89e89e04d8 alt+t is now shortcut for today script 2018-02-12 00:30:02 -05:00
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
85d32c66f2 release 0.5.6 2018-02-06 00:06:04 -05:00
azivner
4e70cebf70 recent notes now use autocomplete instead of select box, closes #36 2018-02-05 23:50:25 -05:00
azivner
214d2e7659 correct quoting rules for attribute/status bar 2018-02-05 22:28:12 -05:00
azivner
f380bb7f65 removal of extra console logs 2018-02-05 22:26:50 -05:00
azivner
0a9a032daa fix incorrect removal of attribute filter from string, fixes #35 2018-02-05 22:25:25 -05:00
azivner
23a2b58b24 fix #32, could not open attribute dialog if it didn't have any attributes yet 2018-02-05 21:07:18 -05:00
azivner
aee64b2522 fix visual glitch in search - showing search now doesn't move note content 2018-02-05 20:53:04 -05:00
azivner
02e07ec03a release 0.5.5-beta 2018-02-04 23:19:20 -05:00
azivner
3d2dc8e699 fixes for change propagation (conflict between knockout and jquery UI autocomplete) 2018-02-04 23:16:45 -05:00
azivner
c84e15c9be implemented query language for attributes, closes #26 2018-02-04 22:44:15 -05:00
azivner
e18d0b9fd4 tag list in "status bar", closes #28 2018-02-04 20:23:30 -05:00
azivner
52817504d1 autocomplete for attribute values, closes #31 2018-02-04 19:43:11 -05:00
azivner
a3b31fab54 autocomplete for attribute names, issue #31 2018-02-04 19:27:27 -05:00
azivner
bc4aa3e40a removed ctrl+shift+left, ctrl+shift+right because of conflict with standard keyboard mapping, close #25 2018-02-04 18:12:17 -05:00
azivner
873ea67e9c nice UI for attributes with validation 2018-02-04 17:22:21 -05:00
azivner
2c5115003b release 0.5.4-beta 2018-02-03 13:25:29 -05:00
azivner
e8ed913374 small changes in the toolbar 2018-02-03 12:44:22 -05:00
azivner
5bffba4e2f add API to add plugin buttons, fixes 2018-02-03 10:37:57 -05:00
azivner
05575913db release 0.5.3-beta 2018-01-31 23:57:20 -05:00
azivner
31c32ff42c fixes when generating new DB 2018-01-31 23:36:39 -05:00
azivner
6a671a5c02 fix electron app icon 2018-01-31 22:39:30 -05:00
60 changed files with 1273 additions and 641 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 * WYSIWYG (What You See Is What You Get) editing
* Fast and easy [navigation between notes](https://github.com/zadam/trilium/wiki/Note-navigation) * Fast and easy [navigation between notes](https://github.com/zadam/trilium/wiki/Note-navigation)
* Seamless note versioning * 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) * 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 * [Synchronization with](https://github.com/zadam/trilium/wiki/Synchronization) self-hosted sync server
* Strong [note encryption](https://github.com/zadam/trilium/wiki/Protected-notes) * 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) * [Installation as webapp](https://github.com/zadam/trilium/wiki/Installation-as-webapp)
* [Note navigation](https://github.com/zadam/trilium/wiki/Note-navigation) * [Note navigation](https://github.com/zadam/trilium/wiki/Note-navigation)
* [Tree manipulation](https://github.com/zadam/trilium/wiki/Tree-manipulation) * [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) * [Links](https://github.com/zadam/trilium/wiki/Links)
* [Cloning notes](https://github.com/zadam/trilium/wiki/Cloning-notes) * [Cloning notes](https://github.com/zadam/trilium/wiki/Cloning-notes)
* [Protected notes](https://github.com/zadam/trilium/wiki/Protected-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

@@ -0,0 +1 @@
CREATE INDEX IDX_attributes_name_value ON attributes (name, value);

View File

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

@@ -6,6 +6,8 @@ const config = require('./src/services/config');
const url = require("url"); const url = require("url");
const app = electron.app; const app = electron.app;
const globalShortcut = electron.globalShortcut;
const clipboard = electron.clipboard;
// Adds debug features like hotkeys for triggering dev tools and reload // Adds debug features like hotkeys for triggering dev tools and reload
require('electron-debug')(); require('electron-debug')();
@@ -24,7 +26,7 @@ function createMainWindow() {
width: 1200, width: 1200,
height: 900, height: 900,
title: 'Trilium Notes', title: 'Trilium Notes',
icon: path.join(__dirname, 'public/images/app-icons/png/256x256.png') icon: path.join(__dirname, 'src/public/images/app-icons/png/256x256.png')
}); });
const port = config['Network']['port'] || '3000'; const port = config['Network']['port'] || '3000';
@@ -67,6 +69,22 @@ app.on('activate', () => {
app.on('ready', () => { app.on('ready', () => {
mainWindow = createMainWindow(); mainWindow = createMainWindow();
globalShortcut.register('CommandOrControl+Alt+P', async () => {
const date_notes = require('./src/services/date_notes');
const utils = require('./src/services/utils');
const parentNoteId = await date_notes.getDateNoteId(utils.nowDate());
// window may be hidden / not in focus
mainWindow.focus();
mainWindow.webContents.send('create-day-sub-note', parentNoteId);
});
});
app.on('will-quit', () => {
globalShortcut.unregisterAll();
}); });
require('./src/www'); require('./src/www');

View File

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

View File

@@ -24,7 +24,7 @@ class Note extends Entity {
} }
async getAttributes() { 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) { async getAttribute(name) {

View File

@@ -38,6 +38,7 @@ async function getDateNoteIdForReddit(dateTimeStr, rootNoteId) {
redditDateNoteId = await createNote(dateNoteId, "Reddit"); redditDateNoteId = await createNote(dateNoteId, "Reddit");
await attributes.createAttribute(redditDateNoteId, REDDIT_DATE_ATTRIBUTE, dateStr); await attributes.createAttribute(redditDateNoteId, REDDIT_DATE_ATTRIBUTE, dateStr);
await attributes.createAttribute(redditDateNoteId, "hide_in_autocomplete");
} }
return redditDateNoteId; return redditDateNoteId;

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

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

View File

@@ -1,8 +1,12 @@
"use strict"; "use strict";
const attributesDialog = (function() { const attributesDialog = (function() {
const dialogEl = $("#attributes-dialog"); const $dialog = $("#attributes-dialog");
const $saveAttributesButton = $("#save-attributes-button");
const $attributesBody = $('#attributes-table tbody');
const attributesModel = new AttributesModel(); const attributesModel = new AttributesModel();
let attributeNames = [];
function AttributesModel() { function AttributesModel() {
const self = this; const self = this;
@@ -14,38 +18,148 @@ const attributesDialog = (function() {
const attributes = await server.get('notes/' + noteId + '/attributes'); const attributes = await server.get('notes/' + noteId + '/attributes');
this.attributes(attributes); self.attributes(attributes.map(ko.observable));
};
this.addNewRow = function() { addLastEmptyRow();
self.attributes.push({
attributeId: '', attributeNames = await server.get('attributes/names');
name: '',
value: '' // 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)) {
return false;
}
}
return true;
}
this.save = async function() { 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 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."); showMessage("Attributes have been saved.");
noteEditor.loadAttributeList();
}; };
function addLastEmptyRow() {
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: '',
isDeleted: 0,
position: 0
}));
}
}
this.attributeChanged = function (data, event) {
addLastEmptyRow();
const attr = self.getTargetAttribute(event.target);
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 !== "");
};
this.getTargetAttribute = function(target) {
const context = ko.contextFor(target);
const index = context.$index();
return self.attributes()[index];
}
} }
async function showDialog() { async function showDialog() {
glob.activeDialog = dialogEl; glob.activeDialog = $dialog;
dialogEl.dialog({ await attributesModel.loadAttributes();
$dialog.dialog({
modal: true, modal: true,
width: 800, width: 800,
height: 700 height: 500
}); });
attributesModel.loadAttributes();
} }
$(document).bind('keydown', 'alt+a', e => { $(document).bind('keydown', 'alt+a', e => {
@@ -56,6 +170,54 @@ const attributesDialog = (function() {
ko.applyBindings(attributesModel, document.getElementById('attributes-dialog')); 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 { return {
showDialog showDialog
}; };

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,13 +1,9 @@
"use strict"; "use strict";
const recentNotes = (function() { const recentNotes = (function() {
const dialogEl = $("#recent-notes-dialog"); const $dialog = $("#recent-notes-dialog");
const selectBoxEl = $('#recent-notes-select-box'); const $searchInput = $('#recent-notes-search-input');
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');
// list of recent note paths // list of recent note paths
let list = []; let list = [];
@@ -29,98 +25,67 @@ const recentNotes = (function() {
} }
function showDialog() { function showDialog() {
glob.activeDialog = dialogEl; glob.activeDialog = $dialog;
dialogEl.dialog({ $dialog.dialog({
modal: true, modal: true,
width: 800 width: 800,
height: 100,
position: { my: "center top+100", at: "top", of: window }
}); });
selectBoxEl.find('option').remove(); $searchInput.val('');
// remove the current note // remove the current note
const recNotes = list.filter(note => note !== noteTree.getCurrentNotePath()); const recNotes = list.filter(note => note !== noteTree.getCurrentNotePath());
$.each(recNotes, (key, valueNotePath) => { $searchInput.autocomplete({
const noteTitle = noteTree.getNotePathTitle(valueNotePath); source: recNotes.map(notePath => {
let noteTitle;
const option = $("<option></option>") try {
.attr("value", valueNotePath) noteTitle = noteTree.getNotePathTitle(notePath);
.text(noteTitle); }
catch (e) {
noteTitle = "[error - can't find note title]";
// select the first one (most recent one) by default messaging.logError("Could not find title for notePath=" + notePath + ", stack=" + e.stack);
if (key === 0) { }
option.attr("selected", "selected");
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(); reload();
$(document).bind('keydown', 'ctrl+e', e => { $(document).bind('keydown', 'ctrl+e', e => {
@@ -129,15 +94,6 @@ const recentNotes = (function() {
e.preventDefault(); e.preventDefault();
}); });
selectBoxEl.dblclick(e => {
setActiveNoteBasedOnRecentNotes();
});
jumpToButtonEl.click(setActiveNoteBasedOnRecentNotes);
addLinkButtonEl.click(addLinkBasedOnRecentNotes);
addCurrentAsChildEl.click(addCurrentAsChild);
addRecentAsChildEl.click(addRecentAsChild);
return { return {
showDialog, showDialog,
addRecentNote, addRecentNote,

View File

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

View File

@@ -1,24 +1,44 @@
"use strict"; "use strict";
const sqlConsole = (function() { const sqlConsole = (function() {
const dialogEl = $("#sql-console-dialog"); const $dialog = $("#sql-console-dialog");
const queryEl = $('#sql-console-query'); const $query = $('#sql-console-query');
const executeButton = $('#sql-console-execute'); const $executeButton = $('#sql-console-execute');
const resultHeadEl = $('#sql-console-results thead'); const $resultHead = $('#sql-console-results thead');
const resultBodyEl = $('#sql-console-results tbody'); const $resultBody = $('#sql-console-results tbody');
let codeEditor;
function showDialog() { function showDialog() {
glob.activeDialog = dialogEl; glob.activeDialog = $dialog;
dialogEl.dialog({ $dialog.dialog({
modal: true, modal: true,
width: $(window).width(), 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() { async function execute() {
const sqlQuery = queryEl.val(); const sqlQuery = codeEditor.getValue();
const result = await server.post("sql/execute", { const result = await server.post("sql/execute", {
query: sqlQuery query: sqlQuery
@@ -34,8 +54,8 @@ const sqlConsole = (function() {
const rows = result.rows; const rows = result.rows;
resultHeadEl.empty(); $resultHead.empty();
resultBodyEl.empty(); $resultBody.empty();
if (rows.length > 0) { if (rows.length > 0) {
const result = rows[0]; const result = rows[0];
@@ -45,7 +65,7 @@ const sqlConsole = (function() {
rowEl.append($("<th>").html(key)); rowEl.append($("<th>").html(key));
} }
resultHeadEl.append(rowEl); $resultHead.append(rowEl);
} }
for (const result of rows) { for (const result of rows) {
@@ -55,15 +75,15 @@ const sqlConsole = (function() {
rowEl.append($("<td>").html(result[key])); rowEl.append($("<td>").html(result[key]));
} }
resultBodyEl.append(rowEl); $resultBody.append(rowEl);
} }
} }
$(document).bind('keydown', 'alt+o', showDialog); $(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 { return {
showDialog 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", () => { $(document).bind('keydown', "ctrl+shift+up", () => {
const node = noteTree.getCurrentNode(); const node = noteTree.getCurrentNode();
node.navigate($.ui.keyCode.UP, true); 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 // 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) => { $.ui.autocomplete.filter = (array, terms) => {
if (!terms) { if (!terms) {
return []; return array;
} }
const startDate = new Date(); const startDate = new Date();
@@ -144,6 +126,10 @@ $.ui.autocomplete.filter = (array, terms) => {
if (found) { if (found) {
results.push(item); results.push(item);
if (results.length > 100) {
break;
}
} }
} }
@@ -210,4 +196,19 @@ $(document).ready(() => {
executeScript(script); executeScript(script);
} }
}); });
}); });
if (isElectron()) {
require('electron').ipcRenderer.on('create-day-sub-note', async function(event, parentNoteId) {
// this might occur when day note had to be created
if (!noteTree.noteExists(parentNoteId)) {
await noteTree.reload();
}
await noteTree.activateNode(parentNoteId);
const node = noteTree.getCurrentNode();
await noteTree.createNote(node, node.data.noteId, 'into', node.data.isProtected);
});
}

View File

@@ -9,6 +9,8 @@ const noteEditor = (function() {
const unprotectButton = $("#unprotect-button"); const unprotectButton = $("#unprotect-button");
const noteDetailWrapperEl = $("#note-detail-wrapper"); const noteDetailWrapperEl = $("#note-detail-wrapper");
const noteIdDisplayEl = $("#note-id-display"); const noteIdDisplayEl = $("#note-id-display");
const attributeListEl = $("#attribute-list");
const attributeListInnerEl = $("#attribute-list-inner");
let editor = null; let editor = null;
let codeEditor = null; let codeEditor = null;
@@ -114,6 +116,32 @@ const noteEditor = (function() {
isNewNoteCreated = true; isNewNoteCreated = true;
} }
function setContent(content) {
if (currentNote.detail.type === 'text') {
// temporary workaround for https://github.com/ckeditor/ckeditor5-enter/issues/49
editor.setData(content ? content : "<p></p>");
noteDetailEl.show();
noteDetailCodeEl.hide();
noteDetailRenderEl.html('').hide();
}
else if (currentNote.detail.type === 'code') {
noteDetailEl.hide();
noteDetailCodeEl.show();
noteDetailRenderEl.html('').hide();
// this needs to happen after the element is shown, otherwise the editor won't be refresheds
codeEditor.setValue(content);
const info = CodeMirror.findModeByMIME(currentNote.detail.mime);
if (info) {
codeEditor.setOption("mode", info.mime);
CodeMirror.autoLoadMode(codeEditor, info.mode);
}
}
}
async function loadNoteToEditor(noteId) { async function loadNoteToEditor(noteId) {
currentNote = await loadNote(noteId); currentNote = await loadNote(noteId);
@@ -144,30 +172,7 @@ const noteEditor = (function() {
noteType.setNoteType(currentNote.detail.type); noteType.setNoteType(currentNote.detail.type);
noteType.setNoteMime(currentNote.detail.mime); noteType.setNoteMime(currentNote.detail.mime);
if (currentNote.detail.type === 'text') { if (currentNote.detail.type === 'render') {
// temporary workaround for https://github.com/ckeditor/ckeditor5-enter/issues/49
editor.setData(currentNote.detail.content ? currentNote.detail.content : "<p></p>");
noteDetailEl.show();
noteDetailCodeEl.hide();
noteDetailRenderEl.html('').hide();
}
else if (currentNote.detail.type === 'code') {
noteDetailEl.hide();
noteDetailCodeEl.show();
noteDetailRenderEl.html('').hide();
// this needs to happen after the element is shown, otherwise the editor won't be refresheds
codeEditor.setValue(currentNote.detail.content);
const info = CodeMirror.findModeByMIME(currentNote.detail.mime);
if (info) {
codeEditor.setOption("mode", info.mime);
CodeMirror.autoLoadMode(codeEditor, info.mode);
}
}
else if (currentNote.detail.type === 'render') {
noteDetailEl.hide(); noteDetailEl.hide();
noteDetailCodeEl.hide(); noteDetailCodeEl.hide();
noteDetailRenderEl.html('').show(); noteDetailRenderEl.html('').show();
@@ -177,7 +182,7 @@ const noteEditor = (function() {
noteDetailRenderEl.html(subTree); noteDetailRenderEl.html(subTree);
} }
else { else {
throwError("Unrecognized type " + currentNote.detail.type); setContent(currentNote.detail.content);
} }
noteChangeDisabled = false; noteChangeDisabled = false;
@@ -187,6 +192,27 @@ const noteEditor = (function() {
// after loading new note make sure editor is scrolled to the top // after loading new note make sure editor is scrolled to the top
noteDetailWrapperEl.scrollTop(0); 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) { async function loadNote(noteId) {
@@ -290,6 +316,8 @@ const noteEditor = (function() {
newNoteCreated, newNoteCreated,
getEditor, getEditor,
focus, focus,
executeCurrentNote executeCurrentNote,
loadAttributeList,
setContent
}; };
})(); })();

View File

@@ -3,7 +3,7 @@
const noteTree = (function() { const noteTree = (function() {
const treeEl = $("#tree"); const treeEl = $("#tree");
const parentListEl = $("#parent-list"); const parentListEl = $("#parent-list");
const parentListListEl = $("#parent-list-list"); const parentListListEl = $("#parent-list-inner");
let startNotePath = null; let startNotePath = null;
let notesTreeMap = {}; let notesTreeMap = {};
@@ -14,6 +14,8 @@ const noteTree = (function() {
let parentChildToNoteTreeId = {}; let parentChildToNoteTreeId = {};
let noteIdToTitle = {}; let noteIdToTitle = {};
let hiddenInAutocomplete = {};
function getNoteTreeId(parentNoteId, childNoteId) { function getNoteTreeId(parentNoteId, childNoteId) {
assertArguments(parentNoteId, childNoteId); assertArguments(parentNoteId, childNoteId);
@@ -640,16 +642,21 @@ const noteTree = (function() {
return document.location.hash.substr(1); // strip initial # return document.location.hash.substr(1); // strip initial #
} }
function loadTree() { async function loadTree() {
return server.get('tree').then(resp => { const resp = await server.get('tree');
startNotePath = resp.start_note_path; startNotePath = resp.start_note_path;
if (document.location.hash) { if (document.location.hash) {
startNotePath = getNotePathFromAddress(); startNotePath = getNotePathFromAddress();
} }
return prepareNoteTree(resp.notes); hiddenInAutocomplete = {};
});
for (const noteId of resp.hiddenInAutocomplete) {
hiddenInAutocomplete[noteId] = true;
}
return prepareNoteTree(resp.notes);
} }
$(() => loadTree().then(noteTree => initFancyTree(noteTree))); $(() => loadTree().then(noteTree => initFancyTree(noteTree)));
@@ -706,6 +713,10 @@ const noteTree = (function() {
const autocompleteItems = []; const autocompleteItems = [];
for (const childNoteId of parentToChildren[parentNoteId]) { for (const childNoteId of parentToChildren[parentNoteId]) {
if (hiddenInAutocomplete[childNoteId]) {
continue;
}
const childNotePath = (notePath ? (notePath + '/') : '') + childNoteId; const childNotePath = (notePath ? (notePath + '/') : '') + childNoteId;
const childTitlePath = (titlePath ? (titlePath + ' / ') : '') + getNoteTitle(childNoteId, parentNoteId); const childTitlePath = (titlePath ? (titlePath + ' / ') : '') + getNoteTitle(childNoteId, parentNoteId);
@@ -775,7 +786,7 @@ const noteTree = (function() {
}; };
if (target === 'after') { if (target === 'after') {
node.appendSibling(newNode).setActive(true); await node.appendSibling(newNode).setActive(true);
} }
else if (target === 'into') { else if (target === 'into') {
if (!node.getChildren() && node.isFolder()) { if (!node.getChildren() && node.isFolder()) {
@@ -785,7 +796,7 @@ const noteTree = (function() {
node.addChildren(newNode); node.addChildren(newNode);
} }
node.getLastChild().setActive(true); await node.getLastChild().setActive(true);
node.folder = true; node.folder = true;
node.renderTitle(); node.renderTitle();
@@ -794,6 +805,8 @@ const noteTree = (function() {
throwError("Unrecognized target: " + target); throwError("Unrecognized target: " + target);
} }
clearSelectedNodes(); // to unmark previously active node
showMessage("Created!"); showMessage("Created!");
} }
@@ -803,6 +816,10 @@ const noteTree = (function() {
await reload(); await reload();
} }
function noteExists(noteId) {
return !!childToParents[noteId];
}
$(document).bind('keydown', 'ctrl+o', e => { $(document).bind('keydown', 'ctrl+o', e => {
const node = getCurrentNode(); const node = getCurrentNode();
const parentNoteId = node.data.parentNoteId; const parentNoteId = node.data.parentNoteId;
@@ -876,6 +893,7 @@ const noteTree = (function() {
removeParentChildRelation, removeParentChildRelation,
setParentChildRelation, setParentChildRelation,
getSelectedNodes, getSelectedNodes,
sortAlphabetically sortAlphabetically,
noteExists
}; };
})(); })();

View File

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

View File

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

File diff suppressed because one or more lines are too long

View File

@@ -5,12 +5,18 @@
display: grid; display: grid;
grid-template-areas: "header header" grid-template-areas: "header header"
"tree-actions title" "tree-actions title"
"search note-content"
"tree note-content" "tree note-content"
"parent-list note-content"; "parent-list note-content"
"parent-list attribute-list";
grid-template-columns: 2fr 5fr; grid-template-columns: 2fr 5fr;
grid-template-rows: auto grid-template-rows: auto
auto auto
1fr; auto
1fr
auto
auto;
justify-content: center; justify-content: center;
grid-gap: 10px; grid-gap: 10px;
} }
@@ -108,7 +114,7 @@ span.fancytree-active:not(.fancytree-focused) .fancytree-title {
} }
#header-title { #header-title {
padding: 5px 50px 5px 10px; padding: 5px 20px 5px 10px;
font-size: large; font-size: large;
font-weight: bold; font-weight: bold;
} }
@@ -134,6 +140,7 @@ div.ui-tooltip {
margin-left: 20px; margin-left: 20px;
border-top: 2px solid #eee; border-top: 2px solid #eee;
padding-top: 10px; padding-top: 10px;
grid-area: parent-list;
} }
#parent-list ul { #parent-list ul {
@@ -190,11 +197,6 @@ div.ui-tooltip {
float: right; float: right;
} }
#note-id-display {
color: lightgrey;
margin-left: 10px;
}
#note-source { #note-source {
height: 98%; height: 98%;
width: 100%; width: 100%;
@@ -243,8 +245,9 @@ div.ui-tooltip {
#note-id-display { #note-id-display {
position: absolute; position: absolute;
right: 10px; right: 10px;
bottom: 5px; bottom: 8px;
z-index: 1000; z-index: 1000;
color: lightgrey;
} }
#note-type-dropdown { #note-type-dropdown {
@@ -253,4 +256,16 @@ div.ui-tooltip {
overflow-x: hidden; 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 sync_table = require('../../services/sync_table');
const utils = require('../../services/utils'); const utils = require('../../services/utils');
const wrap = require('express-promise-wrap').wrap; 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; 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('/: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 noteId = req.params.noteId;
const attributes = req.body; const attributes = req.body;
const now = utils.nowDate(); const now = utils.nowDate();
@@ -22,19 +23,26 @@ router.put('/:noteId/attributes', auth.checkApiAuth, wrap(async (req, res, next)
await sql.doInTransaction(async () => { await sql.doInTransaction(async () => {
for (const attr of attributes) { for (const attr of attributes) {
if (attr.attributeId) { if (attr.attributeId) {
await sql.execute("UPDATE attributes SET name = ?, value = ?, dateModified = ? WHERE attributeId = ?", await sql.execute("UPDATE attributes SET name = ?, value = ?, dateModified = ?, isDeleted = ?, position = ? WHERE attributeId = ?",
[attr.name, attr.value, now, attr.attributeId]); [attr.name, attr.value, now, attr.isDeleted, attr.position, attr.attributeId]);
} }
else { 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(); attr.attributeId = utils.newAttributeId();
await sql.insert("attributes", { await sql.insert("attributes", {
attributeId: attr.attributeId, attributeId: attr.attributeId,
noteId: noteId, noteId: noteId,
name: attr.name, name: attr.name,
value: attr.value, value: attr.value,
dateCreated: now, position: attr.position,
dateModified: now dateCreated: now,
dateModified: now,
isDeleted: false
}); });
} }
@@ -42,7 +50,29 @@ 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])); 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 WHERE isDeleted = 0");
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 isDeleted = 0 AND name = ? AND value != '' ORDER BY value", [attributeName]);
res.send(values);
})); }));
module.exports = router; module.exports = router;

View File

@@ -4,16 +4,8 @@ const express = require('express');
const router = express.Router(); const router = express.Router();
const sql = require('../../services/sql'); const sql = require('../../services/sql');
const auth = require('../../services/auth'); const auth = require('../../services/auth');
const utils = require('../../services/utils'); const image = require('../../services/image');
const sync_table = require('../../services/sync_table');
const multer = require('multer')(); 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 wrap = require('express-promise-wrap').wrap;
const RESOURCE_DIR = require('../../services/resource_dir').RESOURCE_DIR; const RESOURCE_DIR = require('../../services/resource_dir').RESOURCE_DIR;
const fs = require('fs'); 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); return res.status(400).send("Unknown image type: " + file.mimetype);
} }
const now = utils.nowDate(); const {fileName, imageId} = await image.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();
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);
});
res.send({ res.send({
uploaded: true, 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; module.exports = router;

View File

@@ -66,7 +66,7 @@ async function importNotes(dir, parentNoteId) {
const noteText = fs.readFileSync(path, "utf8"); const noteText = fs.readFileSync(path, "utf8");
const noteId = utils.newNoteId(); const noteId = utils.newNoteId();
const noteTreeId = utils.newnoteRevisionId(); const noteTreeId = utils.newNoteRevisionId();
const now = utils.nowDate(); 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) => { router.post('/sync', wrap(async (req, res, next) => {
const timestampStr = req.body.timestamp; const timestampStr = req.body.timestamp;
const timestamp = utils.parseDate(timestampStr); const timestamp = utils.parseDateTime(timestampStr);
const now = new Date(); const now = new Date();

View File

@@ -58,15 +58,114 @@ router.put('/:noteId', auth.checkApiAuth, wrap(async (req, res, next) => {
})); }));
router.get('/', 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 {query, params} = getSearchQuery(attrFilters, searchText);
const noteIds = await sql.getColumn(`SELECT noteId FROM notes
WHERE isDeleted = 0 AND isProtected = 0 AND (title LIKE ? OR content LIKE ?)`, [search, search]); console.log(query, params);
const noteIds = await sql.getColumn(query, params);
res.send(noteIds); 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 DISTINCT 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) => { router.put('/:noteId/sort', auth.checkApiAuth, wrap(async (req, res, next) => {
const noteId = req.params.noteId; const noteId = req.params.noteId;
const sourceId = req.headers.source_id; const sourceId = req.headers.source_id;

View File

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

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

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])); 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) => { router.put('/notes', auth.checkApiAuth, wrap(async (req, res, next) => {
await syncUpdate.updateNote(req.body.entity, req.body.sourceId); 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({}); 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; module.exports = router;

View File

@@ -29,8 +29,20 @@ router.get('/', auth.checkApiAuth, wrap(async (req, res, next) => {
protected_session.decryptNotes(req, notes); protected_session.decryptNotes(req, notes);
const hiddenInAutocomplete = await sql.getColumn(`
SELECT
DISTINCT noteId
FROM
attributes
JOIN notes USING(noteId)
WHERE
attributes.name = 'hide_in_autocomplete'
AND attributes.isDeleted = 0
AND notes.isDeleted = 0`);
res.send({ res.send({
notes: notes, notes: notes,
hiddenInAutocomplete: hiddenInAutocomplete,
start_note_path: await options.getOption('start_note_path') start_note_path: await options.getOption('start_note_path')
}); });
})); }));

View File

@@ -61,10 +61,8 @@ router.put('/:noteTreeId/move-before/:beforeNoteTreeId', auth.checkApiAuth, wrap
await sync_table.addNoteReorderingSync(beforeNote.parentNoteId, sourceId); await sync_table.addNoteReorderingSync(beforeNote.parentNoteId, sourceId);
const now = utils.nowDate();
await sql.execute("UPDATE note_tree SET parentNoteId = ?, notePosition = ?, dateModified = ? WHERE noteTreeId = ?", await sql.execute("UPDATE note_tree SET parentNoteId = ?, notePosition = ?, dateModified = ? WHERE noteTreeId = ?",
[beforeNote.parentNoteId, beforeNote.notePosition, now, noteTreeId]); [beforeNote.parentNoteId, beforeNote.notePosition, utils.nowDate(), noteTreeId]);
await sync_table.addNoteTreeSync(noteTreeId, sourceId); await sync_table.addNoteTreeSync(noteTreeId, sourceId);
}); });

View File

@@ -28,6 +28,7 @@ const cleanupRoute = require('./api/cleanup');
const imageRoute = require('./api/image'); const imageRoute = require('./api/image');
const attributesRoute = require('./api/attributes'); const attributesRoute = require('./api/attributes');
const scriptRoute = require('./api/script'); const scriptRoute = require('./api/script');
const senderRoute = require('./api/sender');
function register(app) { function register(app) {
app.use('/', indexRoute); app.use('/', indexRoute);
@@ -40,7 +41,7 @@ function register(app) {
app.use('/api/notes', notesApiRoute); app.use('/api/notes', notesApiRoute);
app.use('/api/tree', treeChangesApiRoute); app.use('/api/tree', treeChangesApiRoute);
app.use('/api/notes', cloningApiRoute); app.use('/api/notes', cloningApiRoute);
app.use('/api/notes', attributesRoute); app.use('/api', attributesRoute);
app.use('/api/notes-history', noteHistoryApiRoute); app.use('/api/notes-history', noteHistoryApiRoute);
app.use('/api/recent-changes', recentChangesApiRoute); app.use('/api/recent-changes', recentChangesApiRoute);
app.use('/api/settings', settingsApiRoute); app.use('/api/settings', settingsApiRoute);
@@ -59,6 +60,7 @@ function register(app) {
app.use('/api/cleanup', cleanupRoute); app.use('/api/cleanup', cleanupRoute);
app.use('/api/images', imageRoute); app.use('/api/images', imageRoute);
app.use('/api/script', scriptRoute); app.use('/api/script', scriptRoute);
app.use('/api/sender', senderRoute);
} }
module.exports = { module.exports = {

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

@@ -0,0 +1,13 @@
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);
};
$(document).bind('keydown', "alt+t", window.goToday);

View File

@@ -40,7 +40,8 @@
await this.createNote(parentNoteId, 'data', jsonContent, { await this.createNote(parentNoteId, 'data', jsonContent, {
json: true, json: true,
attributes: { attributes: {
date_data: date date_data: date,
hide_in_autocomplete: null
} }
}); });
} }
@@ -65,10 +66,14 @@
}); });
} }
data.sort((a, b) => a.date < b.date ? -1 : +1);
return data; return data;
}); });
var config = { const ctx = $("#canvas")[0].getContext("2d");
new Chart(ctx, {
type: 'line', type: 'line',
data: { data: {
labels: data.map(row => row.date), labels: data.map(row => row.date),
@@ -80,10 +85,7 @@
fill: false fill: false
}] }]
} }
}; });
var ctx = $("#canvas")[0].getContext("2d");
new Chart(ctx, config);
} }
$("#weight-form").submit(event => { $("#weight-form").submit(event => {

View File

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

View File

@@ -5,13 +5,23 @@ const utils = require('./utils');
const sync_table = require('./sync_table'); const sync_table = require('./sync_table');
const Repository = require('./repository'); const Repository = require('./repository');
const BUILTIN_ATTRIBUTES = [
'run_on_startup',
'disable_versioning',
'calendar_root',
'hide_in_autocomplete'
];
async function getNoteAttributeMap(noteId) { async function getNoteAttributeMap(noteId) {
return await sql.getMap(`SELECT name, value FROM attributes WHERE noteId = ?`, [noteId]); return await sql.getMap(`SELECT name, value FROM attributes WHERE noteId = ?`, [noteId]);
} }
async function getNoteIdWithAttribute(name, value) { async function getNoteIdWithAttribute(name, value) {
return await sql.getValue(`SELECT notes.noteId FROM notes JOIN attributes USING(noteId) 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) { async function getNotesWithAttribute(dataKey, name, value) {
@@ -21,11 +31,11 @@ async function getNotesWithAttribute(dataKey, name, value) {
if (value !== undefined) { if (value !== undefined) {
notes = await repository.getEntities(`SELECT notes.* FROM notes JOIN attributes USING(noteId) 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 { else {
notes = await repository.getEntities(`SELECT notes.* FROM notes JOIN attributes USING(noteId) 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; return notes;
@@ -39,7 +49,7 @@ async function getNoteWithAttribute(dataKey, name, value) {
async function getNoteIdsWithAttribute(name) { async function getNoteIdsWithAttribute(name) {
return await sql.getColumn(`SELECT DISTINCT notes.noteId FROM notes JOIN attributes USING(noteId) 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) { async function createAttribute(noteId, name, value = null, sourceId = null) {
@@ -52,7 +62,8 @@ async function createAttribute(noteId, name, value = null, sourceId = null) {
name: name, name: name,
value: value, value: value,
dateModified: now, dateModified: now,
dateCreated: now dateCreated: now,
isDeleted: false
}); });
await sync_table.addAttributeSync(attributeId, sourceId); await sync_table.addAttributeSync(attributeId, sourceId);
@@ -64,5 +75,6 @@ module.exports = {
getNotesWithAttribute, getNotesWithAttribute,
getNoteWithAttribute, getNoteWithAttribute,
getNoteIdsWithAttribute, getNoteIdsWithAttribute,
createAttribute createAttribute,
BUILTIN_ATTRIBUTES
}; };

View File

@@ -10,7 +10,7 @@ const sync_mutex = require('./sync_mutex');
async function regularBackup() { async function regularBackup() {
const now = new Date(); 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); console.log(lastBackupDate);

View File

@@ -223,6 +223,8 @@ async function runAllChecks() {
await runSyncRowChecks("recent_notes", "noteTreeId", errorList); await runSyncRowChecks("recent_notes", "noteTreeId", errorList);
await runSyncRowChecks("images", "imageId", errorList); await runSyncRowChecks("images", "imageId", errorList);
await runSyncRowChecks("note_images", "noteImageId", errorList); await runSyncRowChecks("note_images", "noteImageId", errorList);
await runSyncRowChecks("attributes", "attributeId", errorList);
await runSyncRowChecks("api_tokens", "apiTokenId", errorList);
if (errorList.length === 0) { if (errorList.length === 0) {
// we run this only if basic checks passed since this assumes basic data consistency // 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 sql = require('./sql');
const notes = require('./notes'); const notes = require('./notes');
const attributes = require('./attributes'); const attributes = require('./attributes');
const utils = require('./utils');
const CALENDAR_ROOT_ATTRIBUTE = 'calendar_root'; const CALENDAR_ROOT_ATTRIBUTE = 'calendar_root';
const YEAR_ATTRIBUTE = 'year_note'; const YEAR_ATTRIBUTE = 'year_note';
const MONTH_ATTRIBUTE = 'month_note'; const MONTH_ATTRIBUTE = 'month_note';
const DATE_ATTRIBUTE = 'date_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) { async function createNote(parentNoteId, noteTitle, noteText) {
return (await notes.createNewNote(parentNoteId, { return (await notes.createNewNote(parentNoteId, {
title: noteTitle, title: noteTitle,
@@ -72,7 +76,11 @@ async function getMonthNoteId(dateTimeStr, rootNoteId) {
monthNoteId = await getNoteStartingWith(yearNoteId, monthNumber); monthNoteId = await getNoteStartingWith(yearNoteId, monthNumber);
if (!monthNoteId) { 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); await attributes.createAttribute(monthNoteId, MONTH_ATTRIBUTE, monthStr);
@@ -97,7 +105,11 @@ async function getDateNoteId(dateTimeStr, rootNoteId = null) {
dateNoteId = await getNoteStartingWith(monthNoteId, dayNumber); dateNoteId = await getNoteStartingWith(monthNoteId, dayNumber);
if (!dateNoteId) { 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); 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

@@ -5,32 +5,6 @@ const sync_table = require('./sync_table');
const attributes = require('./attributes'); const attributes = require('./attributes');
const protected_session = require('./protected_session'); 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) { async function createNewNote(parentNoteId, noteOpts, dataKey, sourceId) {
const noteId = utils.newNoteId(); const noteId = utils.newNoteId();
const noteTreeId = utils.newNoteTreeId(); const noteTreeId = utils.newNoteTreeId();
@@ -180,10 +154,10 @@ async function saveNoteHistory(noteId, dataKey, sourceId, nowStr) {
note.isProtected = false; note.isProtected = false;
} }
const newnoteRevisionId = utils.newnoteRevisionId(); const newNoteRevisionId = utils.newNoteRevisionId();
await sql.insert('note_revisions', { await sql.insert('note_revisions', {
noteRevisionId: newnoteRevisionId, noteRevisionId: newNoteRevisionId,
noteId: noteId, noteId: noteId,
// title and text should be decrypted now // title and text should be decrypted now
title: oldNote.title, title: oldNote.title,
@@ -193,7 +167,7 @@ async function saveNoteHistory(noteId, dataKey, sourceId, nowStr) {
dateModifiedTo: nowStr dateModifiedTo: nowStr
}); });
await sync_table.addNoteHistorySync(newnoteRevisionId, sourceId); await sync_table.addNoteHistorySync(newNoteRevisionId, sourceId);
} }
async function saveNoteImages(noteId, noteText, sourceId) { async function saveNoteImages(noteId, noteText, sourceId) {
@@ -261,7 +235,7 @@ async function updateNote(noteId, newNote, dataKey, sourceId) {
"SELECT noteRevisionId FROM note_revisions WHERE noteId = ? AND dateModifiedTo >= ?", [noteId, historyCutoff]); "SELECT noteRevisionId FROM note_revisions WHERE noteId = ? AND dateModifiedTo >= ?", [noteId, historyCutoff]);
await sql.doInTransaction(async () => { 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' if (attributesMap.disable_versioning !== 'true'
&& !existingnoteRevisionId && !existingnoteRevisionId

View File

@@ -54,6 +54,8 @@ function ScriptContext(noteId, dataKey) {
return noteId; return noteId;
}; };
this.createAttribute = attributes.createAttribute;
this.updateEntity = this.repository.updateEntity; this.updateEntity = this.repository.updateEntity;
this.log = function(message) { this.log = function(message) {

View File

@@ -149,6 +149,9 @@ async function pullSync(syncContext) {
else if (sync.entityName === 'attributes') { else if (sync.entityName === 'attributes') {
await syncUpdate.updateAttribute(resp, syncContext.sourceId); await syncUpdate.updateAttribute(resp, syncContext.sourceId);
} }
else if (sync.entityName === 'api_tokens') {
await syncUpdate.updateApiToken(resp, syncContext.sourceId);
}
else { else {
throw new Error(`Unrecognized entity type ${sync.entityName} in sync #${sync.id}`); 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') { else if (sync.entityName === 'attributes') {
entity = await sql.getRow('SELECT * FROM attributes WHERE attributeId = ?', [sync.entityId]); 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 { else {
throw new Error(`Unrecognized entity type ${sync.entityName} in sync #${sync.id}`); 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); await addEntitySync("attributes", attributeId, sourceId);
} }
async function addApiTokenSync(apiTokenId, sourceId) {
await addEntitySync("api_tokens", apiTokenId, sourceId);
}
async function addEntitySync(entityName, entityId, sourceId) { async function addEntitySync(entityName, entityId, sourceId) {
await sql.replace("sync", { await sql.replace("sync", {
entityName: entityName, entityName: entityName,
@@ -93,6 +97,7 @@ async function fillAllSyncRows() {
await fillSyncRows("images", "imageId"); await fillSyncRows("images", "imageId");
await fillSyncRows("note_images", "noteImageId"); await fillSyncRows("note_images", "noteImageId");
await fillSyncRows("attributes", "attributeId"); await fillSyncRows("attributes", "attributeId");
await fillSyncRows("api_tokens", "apiTokenId");
} }
module.exports = { module.exports = {
@@ -105,6 +110,7 @@ module.exports = {
addImageSync, addImageSync,
addNoteImageSync, addNoteImageSync,
addAttributeSync, addAttributeSync,
addApiTokenSync,
addEntitySync, addEntitySync,
cleanupSyncRowsForMissingEntities, cleanupSyncRowsForMissingEntities,
fillAllSyncRows 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 = { module.exports = {
updateNote, updateNote,
updateNoteTree, updateNoteTree,
@@ -146,5 +160,6 @@ module.exports = {
updateRecentNotes, updateRecentNotes,
updateImage, updateImage,
updateNoteImage, updateNoteImage,
updateAttribute updateAttribute,
updateApiToken
}; };

View File

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

View File

@@ -11,7 +11,7 @@ function newNoteTreeId() {
return randomString(12); return randomString(12);
} }
function newnoteRevisionId() { function newNoteRevisionId() {
return randomString(12); return randomString(12);
} }
@@ -27,6 +27,10 @@ function newAttributeId() {
return randomString(12); return randomString(12);
} }
function newApiTokenId() {
return randomString(12);
}
function randomString(length) { function randomString(length) {
return randtoken.generate(length); return randtoken.generate(length);
} }
@@ -39,6 +43,14 @@ function nowDate() {
return dateStr(new Date()); return dateStr(new Date());
} }
function localDate() {
const date = new Date();
return date.getFullYear() + "-"
+ (date.getMonth() < 9 ? "0" : "") + (date.getMonth() + 1) + "-"
+ (date.getDate() < 10 ? "0" : "") + date.getDate();
}
function dateStr(date) { function dateStr(date) {
return date.toISOString(); return date.toISOString();
} }
@@ -47,7 +59,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(). * @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 * 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 { try {
return new Date(Date.parse(str)); return new Date(Date.parse(str));
} }
@@ -56,6 +68,12 @@ function parseDate(str) {
} }
} }
function parseDate(str) {
const datePart = str.substr(0, 10);
return parseDateTime(datePart + "T12:00:00.000Z");
}
function toBase64(plainText) { function toBase64(plainText) {
return Buffer.from(plainText).toString('base64'); return Buffer.from(plainText).toString('base64');
} }
@@ -115,14 +133,17 @@ module.exports = {
randomSecureToken, randomSecureToken,
randomString, randomString,
nowDate, nowDate,
localDate,
dateStr, dateStr,
parseDate, parseDate,
parseDateTime,
newNoteId, newNoteId,
newNoteTreeId, newNoteTreeId,
newnoteRevisionId, newNoteRevisionId,
newImageId, newImageId,
newNoteImageId, newNoteImageId,
newAttributeId, newAttributeId,
newApiTokenId,
toBase64, toBase64,
fromBase64, fromBase64,
hmac, hmac,

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="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="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="recentChanges.showDialog();">Recent changes</button>
<button class="btn btn-xs" onclick="eventLog.showDialog();">Event log</button> </div>
<div id="plugin-buttons">
</div> </div>
<div> <div>
<button class="btn btn-xs" onclick="syncNow();" title="Number of outstanding changes to be pushed to server"> <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>) Sync now (<span id="changes-to-push-count">0</span>)
</button> </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;"> <form action="logout" id="logout-button" method="POST" style="display: inline;">
<input type="submit" class="btn btn-xs" value="Logout"> <input type="submit" class="btn btn-xs" value="Logout">
@@ -51,14 +56,13 @@
<img src="images/icons/search.png" alt="Search in notes"/> <img src="images/icons/search.png" alt="Search in notes"/>
</a> </a>
</div> </div>
</div>
<div id="search-box" style="display: none; padding: 10px; margin-top: 10px;"> <div id="search-box" class="hide-toggle" style="grid-area: search; display: none; padding: 10px; margin-top: 10px;">
<p> <div style="display: flex; align-items: center;">
<label>Search:</label> <label>Search:</label>
<input name="search-text" autocomplete="off"> <input name="search-text" style="flex-grow: 100; margin-left: 5px; margin-right: 5px;" autocomplete="off">
<button id="reset-search-button">&times;</button> <button id="reset-search-button" class="btn btn-sm" title="Reset search">&times;</button>
<span id="matches"></span>
</p>
</div> </div>
</div> </div>
@@ -68,7 +72,7 @@
<div id="parent-list" class="hide-toggle"> <div id="parent-list" class="hide-toggle">
<p><strong>Note locations:</strong></p> <p><strong>Note locations:</strong></p>
<ul id="parent-list-list"></ul> <ul id="parent-list-inner"></ul>
</div> </div>
<div class="hide-toggle" style="grid-area: title;"> <div class="hide-toggle" style="grid-area: title;">
@@ -138,23 +142,16 @@
<div id="note-detail-render"></div> <div id="note-detail-render"></div>
</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>
<div id="recent-notes-dialog" title="Recent notes" style="display: none;"> <div id="recent-notes-dialog" title="Recent notes" style="display: none;">
<select id="recent-notes-select-box" size="20" style="width: 100%"> <input id="recent-notes-search-input" class="form-control"/>
</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>
</div> </div>
<div id="add-link-dialog" title="Add link" style="display: none;"> <div id="add-link-dialog" title="Add link" style="display: none;">
@@ -363,8 +360,11 @@
</div> </div>
<div id="sql-console-dialog" title="SQL console" style="display: none; padding: 20px;"> <div id="sql-console-dialog" title="SQL console" style="display: none; padding: 20px;">
<textarea style="width: 100%; height: 100px" id="sql-console-query"></textarea> <div style="height: 150px; width: 100%; border: 1px solid #ccc; margin-bottom: 10px;" id="sql-console-query"></div>
<button class="btn btn-danger" id="sql-console-execute">Execute <kbd>CTRL+ENTER</kbd></button>
<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%;"> <table id="sql-console-results" class="table table-striped" style="overflow: scroll; width: 100%;">
<thead></thead> <thead></thead>
@@ -378,29 +378,40 @@
<div id="attributes-dialog" title="Note attributes" style="display: none; padding: 20px;"> <div id="attributes-dialog" title="Note attributes" style="display: none; padding: 20px;">
<form data-bind="submit: save"> <form data-bind="submit: save">
<div style="display: flex; justify-content: space-between; padding: 15px; padding-top: 0;"> <div style="text-align: center">
<button class="btn-default" type="button" data-bind="click: addNewRow">Add new attribute</button> <button class="btn btn-large" style="width: 200px;" id="save-attributes-button" type="submit">Save changes <kbd>enter</kbd></button>
<button class="btn-primary" type="submit">Save</button>
</div> </div>
<div style="height: 97%; overflow: auto"> <div style="height: 97%; overflow: auto">
<table id="attributes-table" class="table"> <table id="attributes-table" class="table">
<thead> <thead>
<tr> <tr>
<th></th>
<th>ID</th> <th>ID</th>
<th>Name</th> <th>Name</th>
<th>Value</th> <th>Value</th>
<th></th>
</tr> </tr>
</thead> </thead>
<tbody data-bind="foreach: attributes"> <tbody data-bind="foreach: attributes">
<tr> <tr data-bind="if: isDeleted == 0">
<td data-bind="text: attributeId"></td> <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> <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: 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>
<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>
<td title="Delete" style="padding: 13px;">
<span class="glyphicon glyphicon-trash" data-bind="click: $parent.deleteAttribute"></span>
</td> </td>
</tr> </tr>
</tbody> </tbody>
@@ -492,7 +503,7 @@
<script src="javascripts/link.js"></script> <script src="javascripts/link.js"></script>
<script src="javascripts/sync.js"></script> <script src="javascripts/sync.js"></script>
<script src="javascripts/messaging.js"></script> <script src="javascripts/messaging.js"></script>
<script src="javascripts/api.js"></script>
<script type="text/javascript"> <script type="text/javascript">
// we hide container initally because otherwise it is rendered first without CSS and then flickers into // we hide container initally because otherwise it is rendered first without CSS and then flickers into