Compare commits

...

119 Commits

Author SHA1 Message Date
azivner
ec4b28f97c release 0.4.1 2018-01-17 23:59:03 -05:00
azivner
651a9fb327 fix note sorting with sync - deleted note trees should not be sorted 2018-01-17 20:19:41 -05:00
azivner
b642a567bb fix #5 2018-01-16 23:22:13 -05:00
azivner
9c85303c94 fix attribute sync 2018-01-16 20:30:33 -05:00
azivner
06bb39cffd small refactorings of reddit plugin 2018-01-16 20:11:50 -05:00
azivner
7ea23586fe improvements to search, fixing issue #1 2018-01-15 20:54:22 -05:00
azivner
31a69a96c0 release 0.4.0-beta 2018-01-14 22:08:42 -05:00
azivner
d30a57d388 delete now works with multi-select 2018-01-14 21:39:21 -05:00
azivner
45a92313d5 attribute sync fixes 2018-01-13 23:33:09 -05:00
azivner
359ff58be8 Merge branch 'stable' 2018-01-13 23:27:11 -05:00
azivner
5173afde82 changes in formatting of reddit comments 2018-01-13 23:24:40 -05:00
azivner
36f920b975 alt+s is now shortcut for sorting, search is not triggered with ctrl+s 2018-01-13 23:03:17 -05:00
azivner
9839ea019e reddit plugin configuration from file, not from options now. Scheduling, refactoring of sync mutex 2018-01-13 22:51:39 -05:00
azivner
fbfaff6ab8 reddit plugin - recognizing existing date structure 2018-01-13 21:32:29 -05:00
azivner
9860c8deef refactoring of routes - noteId needs to go to /notes noteTreeId to /tree 2018-01-13 20:53:00 -05:00
azivner
16eb156033 refactoring of note changes / cloning 2018-01-13 18:02:41 -05:00
azivner
4f649c2e21 added note alphabetical sorting to context menu 2018-01-13 17:00:40 -05:00
azivner
a375c55371 reddit plugin refactoring, performance improvemnts etc. 2018-01-13 15:25:09 -05:00
azivner
416cf9a2bc release 0.3.3 2018-01-12 23:30:47 -05:00
azivner
ccd222cf12 reddit plugin - import from multiple accounts, paging, structuring per year, month, day 2018-01-12 22:44:55 -05:00
azivner
983d3f2cc5 Merge branch 'stable' 2018-01-12 20:15:33 -05:00
azivner
92271a84b7 fix for setup & db upgrade - we still need internal redirects 2018-01-12 20:05:17 -05:00
azivner
7d456240a8 prevent drag & drop to navigate away from trilium 2018-01-12 18:42:00 -05:00
azivner
453f22df14 work in progress reddit plugin 2018-01-11 23:54:17 -05:00
azivner
0575924cf1 added is_synced to options to better differentiate between synced and not synced options 2018-01-11 22:45:25 -05:00
azivner
231c245c87 saving attributes 2018-01-11 21:40:09 -05:00
azivner
8fe6a9353a added dialog and read only view of attributes 2018-01-11 00:01:16 -05:00
azivner
b250ad593c added basic infrastructure for attributes 2018-01-09 22:09:45 -05:00
azivner
58362405c6 workaround for CKEditor bug with setting empty data 2018-01-09 20:01:02 -05:00
azivner
0bdf900e2e release 0.3.2 2018-01-08 23:35:02 -05:00
azivner
90de4b8600 fixes in build script 2018-01-08 23:34:54 -05:00
azivner
4e5a95a1ac fix scrypt binary for windows 2018-01-08 20:40:20 -05:00
azivner
77278fe09e added small image to demo document 2018-01-07 23:52:32 -05:00
azivner
cfbeba80db release 0.3.1 2018-01-07 22:23:41 -05:00
azivner
865d298631 release 0.3.1 2018-01-07 22:10:13 -05:00
azivner
86b1410952 release 0.3.1 2018-01-07 21:56:39 -05:00
azivner
29eb88bac3 added consistency check 2018-01-07 20:16:31 -05:00
azivner
31b4186e17 optional cleanup of unused images 2018-01-07 14:07:59 -05:00
azivner
bde9e825c8 added binary dependencies to repo 2018-01-07 11:49:35 -05:00
azivner
0e8285a7e4 updated schema with image tables 2018-01-07 10:35:24 -05:00
azivner
780f462e94 release 0.3.0 2018-01-07 10:04:46 -05:00
azivner
488e657cc4 forgotten express-promise-wrap in package.json 2018-01-07 10:04:43 -05:00
azivner
8bc2a21d80 auth exception for images in electron 2018-01-07 09:59:05 -05:00
azivner
743d72a0c3 added express-promise-wrap to catch and respond to unhandled exceptions immediately, previously the requests just hanged 2018-01-07 09:35:44 -05:00
azivner
20b1357be6 gif support 2018-01-07 09:22:55 -05:00
azivner
d9f2bb37e7 PNG alpha channel is converted to white instead of default ugly black 2018-01-07 08:26:42 -05:00
azivner
97c1b3061f correct detection of optimized image format 2018-01-07 08:24:04 -05:00
azivner
c022fcf196 reloading note tree when we have note detail changes 2018-01-06 22:56:54 -05:00
azivner
b5baab056c enter on title will put focus on editor 2018-01-06 22:53:37 -05:00
azivner
edc9a1a2bf creating / updating notes_image rows 2018-01-06 22:38:53 -05:00
azivner
c0e45a73a8 relation between notes and images 2018-01-06 21:49:02 -05:00
azivner
784cd62df1 image sync 2018-01-06 15:56:00 -05:00
azivner
91cf090820 compressing PNGs with pngquant 2018-01-06 13:53:02 -05:00
azivner
d9f29cbf27 resizing and optimizing jpeg images with mozjpeg 2018-01-06 12:38:25 -05:00
azivner
23a5e38e02 added basic support for uploading and serving files 2018-01-05 23:54:02 -05:00
azivner
663bd1a8fe added sync mutex for consistency checks and backup 2018-01-04 21:37:36 -05:00
azivner
a6a687c4a6 release 0.2.2 2018-01-03 23:05:00 -05:00
azivner
f2aaf8b0a3 ctrl+click doesn't activate the node and selects only if it's not selected yet, otherwise deselects 2018-01-03 22:54:13 -05:00
azivner
01ede22504 added backspace to move to parent node 2018-01-03 22:49:53 -05:00
azivner
b6d617aefa returned missing requires 2018-01-03 22:43:01 -05:00
azivner
7921850186 added missing map file and unminified context menu js file 2018-01-03 22:36:27 -05:00
azivner
244a4562b1 not unique key between parent_note_id and note_id in schema 2018-01-03 22:15:06 -05:00
azivner
07c33979c3 consistent checking of is_deleted, some small refactorings 2018-01-03 22:13:02 -05:00
azivner
353a9b24c1 added consistency check for unique non-deleted parent note <-> note relationship 2018-01-03 21:33:19 -05:00
azivner
548ecd4171 removed unique index again - from now on the invariant is that there's unique undeleted relationship between note and parent note 2018-01-03 21:29:13 -05:00
azivner
8d9b0db316 release 0.2.1 2018-01-02 22:46:50 -05:00
azivner
96a44a9a0c added some showcase formatting to the welcome page in the demo database 2018-01-02 22:14:53 -05:00
azivner
b545100cad removed loader animation - it was causing issues with initial focus on the tree (probably by stealing focus) 2018-01-02 22:01:38 -05:00
azivner
e32289720c not hiding the elements for alt-m, just lowering opacity 2018-01-02 20:52:36 -05:00
azivner
550bb77ca9 fixed switching between note clones 2018-01-02 20:16:17 -05:00
azivner
664a87cdd5 checks against moving note to where it already exists 2018-01-02 19:56:45 -05:00
azivner
53ee1fa5ed note_id - parent_note_id index needs to be unique 2018-01-02 19:20:42 -05:00
azivner
361d8a4216 release 0.2.0 2018-01-01 23:29:34 -05:00
azivner
ae6e222c50 fix for moving note inside not-yet folder 2018-01-01 23:28:00 -05:00
azivner
37995f1ce5 info message about copying / cutting into clipboard 2018-01-01 22:33:21 -05:00
azivner
ad7fa5e096 better conflict detection 2018-01-01 22:28:19 -05:00
azivner
3585982758 measuring and logging time to compute content hash and consistency checks 2018-01-01 19:47:50 -05:00
azivner
c776f298f2 tree cycle consistency check 2018-01-01 19:41:22 -05:00
azivner
f07c427da1 fixed race condition when changing note path in URL hash which caused flickering 2018-01-01 18:53:52 -05:00
azivner
e560072f8b drag & drop support for multi select plus some fixes 2018-01-01 18:29:06 -05:00
azivner
3f976a3821 ctrl-a selects all nodes at the current level (without children) 2018-01-01 18:11:23 -05:00
azivner
274bb32696 multi-select in note tree and clipboard operations on the selection 2018-01-01 17:59:59 -05:00
azivner
99b163a042 fix for forced note sync 2017-12-30 21:55:44 -05:00
azivner
fdcc833f6d added force note sync functionality to context menu 2017-12-30 21:44:26 -05:00
azivner
a8e45019e4 failing early when decryption fails 2017-12-30 20:14:05 -05:00
azivner
7f7028873c fixed synced issue when changing protected status of note history 2017-12-30 19:38:30 -05:00
azivner
2d2d76a715 enter on note tree can also give focus to the editor 2017-12-28 22:57:35 -05:00
azivner
69cbfaae17 release 0.1.2 2017-12-28 21:17:25 -05:00
azivner
aebce8f12b making not focused active tree item more visible 2017-12-28 20:53:31 -05:00
azivner
045ca1f0bf global CTRL-SHIFT-arrows to move in the note tree without losing focus in the note editor 2017-12-28 20:38:57 -05:00
azivner
bf2db6eac7 after loading new note make sure editor is scrolled to the top 2017-12-28 20:13:54 -05:00
azivner
cf84114f91 removed unused methods 2017-12-28 19:58:33 -05:00
azivner
6426157bb3 title in fancytree needs to be escaped for HTML special characters 2017-12-28 19:00:31 -05:00
azivner
332fc16852 fix pasting into non-expanded non-loaded node (UI only) 2017-12-27 22:44:27 -05:00
azivner
da2cd57428 CTRL+ENTER on note in tree pane switches to the editor 2017-12-27 21:16:47 -05:00
azivner
de9bab1181 fixed keyboard controlled clipboard 2017-12-27 21:12:54 -05:00
azivner
136375cf11 refactored detection of whether user is initialized 2017-12-27 18:23:24 -05:00
azivner
eabc7f80b7 release 0.1.1 2017-12-27 17:41:07 -05:00
azivner
6405d6e066 added linux ia32 build 2017-12-27 17:39:41 -05:00
azivner
f6d481a9e2 fix resource path bug 2017-12-27 16:44:15 -05:00
azivner
695f0e5879 release 0.1.0 2017-12-26 22:59:46 -05:00
azivner
ae337e4500 don't open dev tools for search page in electron 2017-12-26 22:24:47 -05:00
azivner
19ffa14f10 hide logout button in electron since it doesn't do anything 2017-12-26 22:19:42 -05:00
azivner
bf3f26fde8 using textarea instead of pre for note source since that's easier to copy to clipboard 2017-12-26 20:54:41 -05:00
azivner
dece400207 release 0.0.11 2017-12-26 19:57:44 -05:00
azivner
baab745462 some visual tweaks 2017-12-26 19:54:43 -05:00
azivner
0d3b3ec7c5 alt+s toggles search instead of just showing it 2017-12-26 19:16:04 -05:00
azivner
7aff20bb0d added show note source 2017-12-26 18:15:29 -05:00
azivner
5acf84aece Calling project "Trilium Notes" in some places as it is more self explanatory 2017-12-26 12:55:40 -05:00
azivner
c58a0df76d added list of wiki pages into README 2017-12-26 11:08:23 -05:00
azivner
20c14a1920 fix collapse tree keyboard shortcut 2017-12-26 11:00:04 -05:00
azivner
04063d8a9c sql console reports successful execution (useful when query doesn't produce any result set like in update/delete) 2017-12-26 10:21:33 -05:00
azivner
dd69e0135b added collapse sub-tree in context menu 2017-12-26 10:00:08 -05:00
azivner
ab6e78f726 keyboard shortcut titles on buttons 2017-12-26 09:44:16 -05:00
azivner
9029d18178 fix go to external link by double clicking 2017-12-26 09:10:54 -05:00
azivner
e9a1791e3d fix unwanted movement with navigation keys 2017-12-25 21:47:32 -05:00
azivner
70e13c8a20 release 0.0.10 2017-12-25 21:06:39 -05:00
azivner
7e1cc729f9 have to add changed files to index before committing 2017-12-25 21:06:35 -05:00
azivner
87e7828440 release scripts fixes 2017-12-25 21:05:08 -05:00
109 changed files with 5578 additions and 976 deletions

View File

@@ -1,5 +1,5 @@
# Trilium
Hierarchical note taking application. Picture tells a thousand words:
# Trilium Notes
Trilium Notes is a hierarchical note taking application. Picture tells a thousand words:
![](https://raw.githubusercontent.com/wiki/zadam/trilium/images/screenshot.png)
@@ -8,7 +8,7 @@ Hierarchical note taking application. Picture tells a thousand words:
* Notes can be arranged into arbitrarily deep hierarchy
* Notes can have more than 1 parents - see [cloning](https://github.com/zadam/trilium/wiki/Cloning-notes)
* WYSIWYG (What You See Is What You Get) editing
* Fast and easy [navigation between notes](https://github.com/zadam/trilium/wiki/Note-navigation) inspired by IDEs
* Fast and easy [navigation between notes](https://github.com/zadam/trilium/wiki/Note-navigation)
* Seamless note versioning
* 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
@@ -17,11 +17,11 @@ Hierarchical note taking application. Picture tells a thousand words:
## Builds
* If you want to install Trilium on server, follow [this page](https://github.com/zadam/trilium/wiki/Installation-as-webapp)
* If you want to use Trilium on the desktop, download binary release for your platfor from [latest release](https://github.com/zadam/trilium/releases/latest), unzip the package and run ```trilium``` executable.
* If you want to use Trilium on the desktop, download binary release for your platform from [latest release](https://github.com/zadam/trilium/releases/latest), unzip the package and run ```trilium``` executable.
## Supported platforms
Desktop (electron) builds are available for Linux and Windows, both for 64 bits.
Desktop (electron-based) 64-bit builds are available for Linux and Windows.
Requirements for web based installation are [outlined here](https://github.com/zadam/trilium/wiki/Installation-as-webapp).
@@ -29,4 +29,15 @@ Currently only recent Chrome and Firefox are supported (tested) browsers. Other
## Documentation
See [wiki](https://github.com/zadam/trilium/wiki/Home) for complete list of available pages.
List of documentation pages:
* [Installation as webapp](https://github.com/zadam/trilium/wiki/Installation-as-webapp)
* [Note navigation](https://github.com/zadam/trilium/wiki/Note-navigation)
* [Tree manipulation](https://github.com/zadam/trilium/wiki/Tree-manipulation)
* [Links](https://github.com/zadam/trilium/wiki/Links)
* [Cloning notes](https://github.com/zadam/trilium/wiki/Cloning-notes)
* [Protected notes](https://github.com/zadam/trilium/wiki/Protected-notes)
* [Synchronization](https://github.com/zadam/trilium/wiki/Synchronization)
* [Document](https://github.com/zadam/trilium/wiki/Document)
* [Keyboard shortcuts](https://github.com/zadam/trilium/wiki/Keyboard-shortcuts)
* [Troubleshooting](https://github.com/zadam/trilium/wiki/Troubleshooting)

2
app.js
View File

@@ -73,6 +73,8 @@ require('./services/backup');
// trigger consistency checks timer
require('./services/consistency_checks');
require('./plugins/reddit');
module.exports = {
app,
sessionParser

View File

@@ -1,18 +1,34 @@
#!/usr/bin/env bash
echo 'module.exports = { build_date:"'`date --iso-8601=seconds`'", build_revision: "'`git log -1 --format="%H"`'" };' > services/build.js
echo "Deleting dist"
echo "Deleting existing builds"
rm -r dist/*
cp -r ../trilium-node-binaries/sqlite/* node_modules/sqlite3/lib/binding/
echo "Rebuilding binaries for linux-ia32"
./node_modules/.bin/electron-rebuild --arch=ia32
cp -r ../trilium-node-binaries/scrypt/* node_modules/scrypt/bin/
./node_modules/.bin/electron-packager . --out=dist --platform=linux --arch=ia32 --overwrite
./node_modules/.bin/electron-rebuild
./node_modules/.bin/electron-packager . --out=dist --platform=win32 --arch=x64 --overwrite
./node_modules/.bin/electron-packager . --out=dist --platform=linux,win32 --overwrite
# we build x64 as second so that we keep X64 binaries in node_modules for local development
echo "Rebuilding binaries for linux-x64"
./node_modules/.bin/electron-rebuild --arch=x64
# can't copy this before the packaging because the same file name is used for both linux and windows build
cp ../trilium-node-binaries/scrypt.node ./dist/trilium-win32-x64/resources/app/node_modules/scrypt/build/Release/
./node_modules/.bin/electron-packager . --out=dist --platform=linux --arch=x64 --overwrite
echo "Copying required windows binaries"
WIN_RES_DIR=./dist/trilium-win32-x64/resources/app
cp -r bin/deps/sqlite/* $WIN_RES_DIR/node_modules/sqlite3/lib/binding/
cp bin/deps/image/cjpeg.exe $WIN_RES_DIR/node_modules/mozjpeg/vendor/
cp bin/deps/image/pngquant.exe $WIN_RES_DIR/node_modules/pngquant-bin/vendor/
cp bin/deps/image/gifsicle.exe $WIN_RES_DIR/node_modules/giflossy/vendor/
cp bin/deps/scrypt.node $WIN_RES_DIR/node_modules/scrypt/build/Release/
echo "Cleaning up unnecessary binaries from all builds"
rm -r ./dist/trilium-linux-ia32/resources/app/bin/deps
rm -r ./dist/trilium-linux-x64/resources/app/bin/deps
rm -r ./dist/trilium-win32-x64/resources/app/bin/deps

BIN
bin/deps/image/cjpeg.exe Normal file

Binary file not shown.

BIN
bin/deps/image/gifsicle.exe Normal file

Binary file not shown.

BIN
bin/deps/image/pngquant.exe Normal file

Binary file not shown.

BIN
bin/deps/scrypt.node Normal file

Binary file not shown.

Binary file not shown.

View File

@@ -4,8 +4,11 @@ VERSION=`jq -r ".version" package.json`
cd dist
echo "Packaging windows electron distribution..."
7z a trilium-windows-${VERSION}.7z trilium-win32-x64
echo "Packaging linux x64 electron distribution..."
7z a trilium-linux-x64-${VERSION}.7z trilium-linux-x64
echo "Packaging linux electron distribution..."
7z a trilium-linux-${VERSION}.7z trilium-linux-x64
echo "Packaging linux ia32 electron distribution..."
7z a trilium-linux-ia32-${VERSION}.7z trilium-linux-ia32
echo "Packaging windows x64 electron distribution..."
7z a trilium-windows-x64-${VERSION}.7z trilium-win32-x64

View File

@@ -18,35 +18,61 @@ if ! git diff-index --quiet HEAD --; then
exit 1
fi
echo "Releasing Trilium $VERSION"
jq '.version = "'$VERSION'"' package.json|sponge package.json
git add package.json
echo 'module.exports = { build_date:"'`date --iso-8601=seconds`'", build_revision: "'`git log -1 --format="%H"`'" };' > services/build.js
git add services/build.js
TAG=v$VERSION
git commit -m "$VERSION"
echo "Committing package.json version change"
git commit -m "release $VERSION"
git push
echo "Tagging commit with $TAG"
git tag $TAG
git push origin $TAG
echo "Releasing Trilium $VERSION"
bin/build.sh
build
bin/package.sh
package
LINUX_X64_BUILD=trilium-linux-x64-$VERSION.7z
LINUX_IA32_BUILD=trilium-linux-ia32-$VERSION.7z
WINDOWS_X64_BUILD=trilium-windows-x64-$VERSION.7z
LINUX_BUILD=trilium-linux-$VERSION.7z
WINDOWS_BUILD=trilium-windows-$VERSION.7z
echo "Creating release in GitHub"
github-release release \
--tag $TAG \
--name "$TAG release"
github-release upload \
--tag $TAG \
--name "$LINUX_BUILD" \
--file "dist/$LINUX_BUILD"
echo "Uploading linux x64 build"
github-release upload \
--tag $TAG \
--name "$WINDOWS_BUILD" \
--file "dist/$WINDOWS_BUILD"
--name "$LINUX_X64_BUILD" \
--file "dist/$LINUX_X64_BUILD"
echo "Uploading linux ia32 build"
github-release upload \
--tag $TAG \
--name "$LINUX_IA32_BUILD" \
--file "dist/$LINUX_IA32_BUILD"
echo "Uploading windows x64 build"
github-release upload \
--tag $TAG \
--name "$WINDOWS_X64_BUILD" \
--file "dist/$WINDOWS_X64_BUILD"
echo "Release finished!"

BIN
db/image-deleted.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.4 KiB

1
db/main_images.sql Normal file

File diff suppressed because one or more lines are too long

View File

@@ -1,5 +1,5 @@
INSERT INTO notes (note_id, note_title, note_text, is_protected, is_deleted, date_created, date_modified) VALUES ('root', 'root', 'root', 0, 0, '2017-12-22T11:41:07.000Z', '2017-12-22T11:41:07.000Z');
INSERT INTO notes (note_id, note_title, note_text, is_protected, is_deleted, date_created, date_modified) VALUES ('1Heh2acXfPNt', 'Trilium Demo', '<p>Welcome to Trilium!</p><p>&nbsp;</p><p>This is initial document provided by default Trilium to showcase some of its features and also give you some ideas how you might structure your notes. You can play with it, modify note content and tree structure as you wish.</p><p>&nbsp;</p><p>If you need any help, visit Trilium wesite: <a href="https://github.com/zadam/trilium">https://github.com/zadam/trilium</a></p><p>&nbsp;</p><p>Once you''re finished with experimenting and want to cleanup these pages, you can simply delete them all.</p>', 0, 0, '2017-12-23T00:46:39.304Z', '2017-12-23T04:08:45.445Z');
INSERT INTO notes (note_id, note_title, note_text, is_protected, is_deleted, date_created, date_modified) VALUES ('1Heh2acXfPNt', 'Trilium Demo', '<figure class="image image-style-side"><img src="/api/images/ed64aET6i379/trilium-small.png"></figure><p><strong>Welcome to Trilium Notes!</strong></p><p>&nbsp;</p><p>This is initial document provided by default Trilium to showcase some of its features and also give you some ideas how you might structure your notes. You can play with it, modify note content and tree structure as you wish.</p><p>&nbsp;</p><p>If you need any help, visit Trilium website: <a href="https://github.com/zadam/trilium">https://github.com/zadam/trilium</a></p><h3>Cleanup</h3><p>Once you''re finished with experimenting and want to cleanup these pages, you can simply delete them all.</p><h3>Formatting</h3><p>Trilium supports classic formatting like <i>italic</i>, <strong>bold</strong>, <i><strong>bold and italic</strong></i>. Of course you can add links like this one pointing to <a href="http://www.google.com">google.com</a></p><h4>Lists</h4><p><strong>Ordered:</strong></p><ol><li>First Item</li><li>Second item<ol><li>First sub-item</li><li>Second sub-item</li></ol></li></ol><p>&nbsp;</p><p><strong>Unordered:</strong></p><ul><li>Item</li><li>Another item<ul><li>Sub-item<ul><li>Sub-sub-item</li></ul></li></ul></li></ul><h4>Block quotes</h4><blockquote><p>Whereof one cannot speak, thereof one must be silent”</p><p> Ludwig Wittgenstein</p></blockquote><p>&nbsp;</p>', 0, 0, '2017-12-23T00:46:39.304Z', '2017-12-23T04:08:45.445Z');
INSERT INTO notes (note_id, note_title, note_text, is_protected, is_deleted, date_created, date_modified) VALUES ('3RkyK9LI18dO', 'Journal', '<p>Expand note on the left pane to see content.</p>', 0, 0, '2017-12-23T01:20:04.181Z', '2017-12-23T18:07:55.377Z');
INSERT INTO notes (note_id, note_title, note_text, is_protected, is_deleted, date_created, date_modified) VALUES ('L1Ox40M1aEyy', '2016', '<p>No content.</p><p>&nbsp;</p><p>&nbsp;</p><p>&nbsp;</p><p>&nbsp;</p>', 0, 0, '2017-12-23T01:20:45.365Z', '2017-12-23T16:40:43.129Z');
INSERT INTO notes (note_id, note_title, note_text, is_protected, is_deleted, date_created, date_modified) VALUES ('HJusZTbBU494', '2017', '<p>No content.</p>', 0, 0, '2017-12-23T01:20:50.709Z', '2017-12-23T16:41:03.119Z');

1
db/main_notes_image.sql Normal file
View File

@@ -0,0 +1 @@
INSERT INTO notes_image (note_image_id, note_id, image_id, is_deleted, date_modified, date_created) VALUES ('2EtgRRPfk4Fi', '1Heh2acXfPNt', 'ed64aET6i379', 0, '2018-01-08T04:41:30.663Z', '2018-01-08T04:41:30.663Z');

View File

@@ -1,6 +1,7 @@
CREATE TABLE IF NOT EXISTS "options" (
`opt_name` TEXT NOT NULL PRIMARY KEY,
`opt_value` TEXT,
`is_synced` INTEGER NOT NULL DEFAULT 0,
`date_modified` INT
);
CREATE TABLE IF NOT EXISTS "sync" (
@@ -67,9 +68,6 @@ CREATE INDEX `IDX_sync_sync_date` ON `sync` (
CREATE INDEX `IDX_notes_is_deleted` ON `notes` (
`is_deleted`
);
CREATE INDEX `IDX_notes_tree_note_tree_id` ON `notes_tree` (
`note_tree_id`
);
CREATE INDEX `IDX_notes_tree_note_id_parent_note_id` ON `notes_tree` (
`note_id`,
`parent_note_id`
@@ -86,3 +84,41 @@ CREATE INDEX `IDX_notes_history_note_date_modified_from` ON `notes_history` (
CREATE INDEX `IDX_notes_history_note_date_modified_to` ON `notes_history` (
`date_modified_to`
);
CREATE TABLE images
(
image_id TEXT PRIMARY KEY NOT NULL,
format TEXT NOT NULL,
checksum TEXT NOT NULL,
name TEXT NOT NULL,
data BLOB,
is_deleted INT NOT NULL DEFAULT 0,
date_modified TEXT NOT NULL,
date_created TEXT NOT NULL
);
CREATE TABLE notes_image
(
note_image_id TEXT PRIMARY KEY NOT NULL,
note_id TEXT NOT NULL,
image_id TEXT NOT NULL,
is_deleted INT NOT NULL DEFAULT 0,
date_modified TEXT NOT NULL,
date_created TEXT NOT NULL
);
CREATE INDEX notes_image_note_id_index ON notes_image (note_id);
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,
note_id TEXT NOT NULL,
name TEXT NOT NULL,
value TEXT,
date_created TEXT NOT NULL,
date_modified TEXT NOT NULL
);
CREATE INDEX attributes_note_id_index ON attributes (note_id);
CREATE UNIQUE INDEX attributes_note_id_name_index ON attributes (note_id, name);

View File

@@ -2,6 +2,7 @@
const electron = require('electron');
const path = require('path');
const config = require('./services/config');
const url = require("url");
const app = electron.app;
@@ -21,6 +22,7 @@ function createMainWindow() {
const win = new electron.BrowserWindow({
width: 1200,
height: 900,
title: 'Trilium Notes',
icon: path.join(__dirname, 'public/images/app-icons/png/256x256.png')
});
@@ -37,6 +39,16 @@ function createMainWindow() {
}
});
// prevent drag & drop to navigate away from trilium
win.webContents.on('will-navigate', (ev, targetUrl) => {
const parsedUrl = url.parse(targetUrl);
// we still need to allow internal redirects from setup and migration pages
if (parsedUrl.hostname !== 'localhost' || (parsedUrl.path && parsedUrl.path !== '/')) {
ev.preventDefault();
}
});
return win;
}

View File

@@ -0,0 +1,6 @@
DROP INDEX IDX_notes_tree_note_id_parent_note_id;
CREATE UNIQUE INDEX `IDX_notes_tree_note_id_parent_note_id` ON `notes_tree` (
`note_id`,
`parent_note_id`
);

View File

@@ -0,0 +1,9 @@
DROP INDEX IDX_notes_tree_note_id_parent_note_id;
CREATE INDEX `IDX_notes_tree_note_id_parent_note_id` ON `notes_tree` (
`note_id`,
`parent_note_id`
);
-- dropping this as it's just duplicate of primary key
DROP INDEX IDX_notes_tree_note_tree_id;

View File

@@ -0,0 +1,11 @@
CREATE TABLE images
(
image_id TEXT PRIMARY KEY NOT NULL,
format TEXT NOT NULL,
checksum TEXT NOT NULL,
name TEXT NOT NULL,
data BLOB,
is_deleted INT NOT NULL DEFAULT 0,
date_modified TEXT NOT NULL,
date_created TEXT NOT NULL
);

View File

@@ -0,0 +1,16 @@
DROP TABLE images;
CREATE TABLE images
(
image_id TEXT PRIMARY KEY NOT NULL,
note_id TEXT NOT NULL,
format TEXT NOT NULL,
checksum TEXT NOT NULL,
name TEXT NOT NULL,
data BLOB,
is_deleted INT NOT NULL DEFAULT 0,
date_modified TEXT NOT NULL,
date_created TEXT NOT NULL
);
CREATE INDEX images_note_id_index ON images (note_id);

View File

@@ -0,0 +1,27 @@
DROP TABLE images;
CREATE TABLE images
(
image_id TEXT PRIMARY KEY NOT NULL,
format TEXT NOT NULL,
checksum TEXT NOT NULL,
name TEXT NOT NULL,
data BLOB,
is_deleted INT NOT NULL DEFAULT 0,
date_modified TEXT NOT NULL,
date_created TEXT NOT NULL
);
CREATE TABLE notes_image
(
note_image_id TEXT PRIMARY KEY NOT NULL,
note_id TEXT NOT NULL,
image_id TEXT NOT NULL,
is_deleted INT NOT NULL DEFAULT 0,
date_modified TEXT NOT NULL,
date_created TEXT NOT NULL
);
CREATE INDEX notes_image_note_id_index ON notes_image (note_id);
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);

View File

@@ -0,0 +1,12 @@
CREATE TABLE attributes
(
attribute_id TEXT PRIMARY KEY NOT NULL,
note_id TEXT NOT NULL,
name TEXT NOT NULL,
value TEXT,
date_created TEXT NOT NULL,
date_modified TEXT NOT NULL
);
CREATE INDEX attributes_note_id_index ON attributes (note_id);
CREATE UNIQUE INDEX attributes_note_id_name_index ON attributes (note_id, name);

View File

@@ -0,0 +1,5 @@
ALTER TABLE options ADD COLUMN is_synced INTEGER NOT NULL DEFAULT 0;
UPDATE options SET is_synced = 1 WHERE opt_name IN ('username', 'password_verification_hash', 'password_verification_salt',
'password_derived_key_salt', 'encrypted_data_key', 'encrypted_data_key_iv',
'protected_session_timeout', 'history_snapshot_time_interval');

2351
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,7 +1,12 @@
{
"name": "trilium",
"description": "Trilium",
"version": "0.0.9",
"description": "Trilium Notes",
"version": "0.4.1",
"license": "AGPL-3.0-only",
"repository": {
"type": "git",
"url": "https://github.com/zadam/trilium.git"
},
"scripts": {
"start": "node ./bin/www",
"test-electron": "xo",
@@ -14,6 +19,8 @@
"publish-forge": "electron-forge publish"
},
"dependencies": {
"async-mutex": "^0.1.3",
"axios": "^0.17.1",
"body-parser": "~1.18.2",
"cookie-parser": "~1.4.3",
"debug": "~3.1.0",
@@ -23,20 +30,31 @@
"electron-debug": "^1.0.0",
"electron-in-page-search": "^1.2.4",
"express": "~4.16.2",
"express-promise-wrap": "^0.2.2",
"express-session": "^1.15.6",
"fs-extra": "^4.0.2",
"helmet": "^3.9.0",
"html": "^1.0.0",
"image-type": "^3.0.0",
"imagemin": "^5.3.1",
"imagemin-giflossy": "^5.1.10",
"imagemin-mozjpeg": "^7.0.0",
"imagemin-pngquant": "^5.0.1",
"ini": "^1.3.4",
"jimp": "^0.2.28",
"moment": "^2.20.1",
"multer": "^1.3.0",
"rand-token": "^0.4.0",
"request": "^2.83.0",
"request-promise": "^4.2.2",
"rimraf": "^2.6.2",
"sanitize-filename": "^1.6.1",
"scrypt": "^6.0.3",
"serve-favicon": "~2.4.5",
"session-file-store": "^1.1.2",
"simple-node-logger": "^0.93.30",
"sqlite": "^2.9.0",
"unescape": "^1.0.1",
"ws": "^3.3.2"
},
"devDependencies": {

231
plugins/reddit.js Normal file
View File

@@ -0,0 +1,231 @@
"use strict";
const sql = require('../services/sql');
const notes = require('../services/notes');
const axios = require('axios');
const log = require('../services/log');
const utils = require('../services/utils');
const unescape = require('unescape');
const attributes = require('../services/attributes');
const sync_mutex = require('../services/sync_mutex');
const config = require('../services/config');
const CALENDAR_ROOT_ATTRIBUTE = 'calendar_root';
const YEAR_ATTRIBUTE = 'year_note';
const MONTH_ATTRIBUTE = 'month_note';
const DATE_ATTRIBUTE = 'date_note';
// "reddit" date note is subnote of date note which contains all reddit comments from that date
const REDDIT_DATE_ATTRIBUTE = 'reddit_date_note';
async function createNote(parentNoteId, noteTitle, noteText) {
return (await notes.createNewNote(parentNoteId, {
note_title: noteTitle,
note_text: noteText,
target: 'into',
is_protected: false
})).noteId;
}
function redditId(kind, id) {
return kind + "_" + id;
}
async function getNoteStartingWith(parentNoteId, startsWith) {
return await sql.getFirstValue(`SELECT note_id FROM notes JOIN notes_tree USING(note_id)
WHERE parent_note_id = ? AND note_title LIKE '${startsWith}%'
AND notes.is_deleted = 0 AND is_protected = 0
AND notes_tree.is_deleted = 0`, [parentNoteId]);
}
async function getRootNoteId() {
let rootNoteId = await sql.getFirstValue(`SELECT notes.note_id FROM notes JOIN attributes USING(note_id)
WHERE attributes.name = '${CALENDAR_ROOT_ATTRIBUTE}' AND notes.is_deleted = 0`);
if (!rootNoteId) {
rootNoteId = (await notes.createNewNote('root', {
note_title: 'Calendar',
target: 'into',
is_protected: false
})).noteId;
await attributes.createAttribute(rootNoteId, CALENDAR_ROOT_ATTRIBUTE);
}
return rootNoteId;
}
async function getYearNoteId(dateTimeStr, rootNoteId) {
const yearStr = dateTimeStr.substr(0, 4);
let yearNoteId = await attributes.getNoteIdWithAttribute(YEAR_ATTRIBUTE, yearStr);
if (!yearNoteId) {
yearNoteId = await getNoteStartingWith(rootNoteId, yearStr);
if (!yearNoteId) {
yearNoteId = await createNote(rootNoteId, yearStr);
}
await attributes.createAttribute(yearNoteId, YEAR_ATTRIBUTE, yearStr);
}
return yearNoteId;
}
async function getMonthNoteId(dateTimeStr, rootNoteId) {
const monthStr = dateTimeStr.substr(0, 7);
const monthNumber = dateTimeStr.substr(5, 2);
let monthNoteId = await attributes.getNoteIdWithAttribute(MONTH_ATTRIBUTE, monthStr);
if (!monthNoteId) {
const yearNoteId = await getYearNoteId(dateTimeStr, rootNoteId);
monthNoteId = await getNoteStartingWith(yearNoteId, monthNumber);
if (!monthNoteId) {
monthNoteId = await createNote(yearNoteId, monthNumber);
}
await attributes.createAttribute(monthNoteId, MONTH_ATTRIBUTE, monthStr);
}
return monthNoteId;
}
async function getDateNoteId(dateTimeStr, rootNoteId) {
const dateStr = dateTimeStr.substr(0, 10);
const dayNumber = dateTimeStr.substr(8, 2);
let dateNoteId = await attributes.getNoteIdWithAttribute(DATE_ATTRIBUTE, dateStr);
if (!dateNoteId) {
const monthNoteId = await getMonthNoteId(dateTimeStr, rootNoteId);
dateNoteId = await getNoteStartingWith(monthNoteId, dayNumber);
if (!dateNoteId) {
dateNoteId = await createNote(monthNoteId, dayNumber);
}
await attributes.createAttribute(dateNoteId, DATE_ATTRIBUTE, dateStr);
}
return dateNoteId;
}
async function getDateNoteIdForReddit(dateTimeStr, rootNoteId) {
const dateStr = dateTimeStr.substr(0, 10);
let redditDateNoteId = await attributes.getNoteIdWithAttribute(REDDIT_DATE_ATTRIBUTE, dateStr);
if (!redditDateNoteId) {
const dateNoteId = await getDateNoteId(dateTimeStr, rootNoteId);
redditDateNoteId = await createNote(dateNoteId, "Reddit");
await attributes.createAttribute(redditDateNoteId, REDDIT_DATE_ATTRIBUTE, dateStr);
}
return redditDateNoteId;
}
async function importComments(rootNoteId, accountName, afterId = null) {
let url = `https://www.reddit.com/user/${accountName}.json`;
if (afterId) {
url += "?after=" + afterId;
}
const response = await axios.get(url);
const listing = response.data;
if (listing.kind !== 'Listing') {
log.info(`Reddit: Unknown object kind ${listing.kind}`);
return;
}
const children = listing.data.children;
let importedComments = 0;
for (const child of children) {
const comment = child.data;
let commentNoteId = await attributes.getNoteIdWithAttribute('reddit_id', redditId(child.kind, comment.id));
if (commentNoteId) {
continue;
}
const dateTimeStr = utils.dateStr(new Date(comment.created_utc * 1000));
const permaLink = 'https://reddit.com' + comment.permalink;
const noteText =
`<p><a href="${permaLink}">${permaLink}</a></p>
<p>author: <a href="https://reddit.com/u/${comment.author}">${comment.author}</a>,
subreddit: <a href="https://reddit.com/r/${comment.subreddit}">${comment.subreddit}</a>,
karma: ${comment.score}, created at ${dateTimeStr}</p><p></p>`
+ unescape(comment.body_html);
let parentNoteId = await getDateNoteIdForReddit(dateTimeStr, rootNoteId);
commentNoteId = await createNote(parentNoteId, comment.link_title, noteText);
log.info("Reddit: Imported comment to note " + commentNoteId);
importedComments++;
await sql.doInTransaction(async () => {
await attributes.createAttribute(commentNoteId, "reddit_kind", child.kind);
await attributes.createAttribute(commentNoteId, "reddit_id", redditId(child.kind, comment.id));
await attributes.createAttribute(commentNoteId, "reddit_created_utc", comment.created_utc);
});
}
// if there have been no imported comments on this page, there shouldn't be any to import
// on the next page since those are older
if (listing.data.after && importedComments > 0) {
importedComments += await importComments(rootNoteId, accountName, listing.data.after);
}
return importedComments;
}
let redditAccounts = [];
async function runImport() {
const rootNoteId = await getRootNoteId();
// technically mutex shouldn't be necessary but we want to avoid doing potentially expensive import
// concurrently with sync
await sync_mutex.doExclusively(async () => {
let importedComments = 0;
for (const account of redditAccounts) {
importedComments += await importComments(rootNoteId, account);
}
log.info(`Reddit: Imported ${importedComments} comments.`);
});
}
sql.dbReady.then(async () => {
if (!config['Reddit'] || config['Reddit']['enabled'] !== true) {
return;
}
const redditAccountsStr = config['Reddit']['accounts'];
if (!redditAccountsStr) {
log.info("Reddit: No reddit accounts defined in option 'reddit_accounts'");
}
redditAccounts = redditAccountsStr.split(",").map(s => s.trim());
const pollingIntervalInSeconds = config['Reddit']['pollingIntervalInSeconds'] || (4 * 3600);
setInterval(runImport, pollingIntervalInSeconds * 1000);
setTimeout(runImport, 10000); // 10 seconds after startup - intentionally after initial sync
});

View File

@@ -0,0 +1,33 @@
"use strict";
const cloning = (function() {
async function cloneNoteTo(childNoteId, parentNoteId, prefix) {
const resp = await server.put('notes/' + childNoteId + '/clone-to/' + parentNoteId, {
prefix: prefix
});
if (!resp.success) {
alert(resp.message);
return;
}
await noteTree.reload();
}
// beware that first arg is noteId and second is noteTreeId!
async function cloneNoteAfter(noteId, afterNoteTreeId) {
const resp = await server.put('notes/' + noteId + '/clone-after/' + afterNoteTreeId);
if (!resp.success) {
alert(resp.message);
return;
}
await noteTree.reload();
}
return {
cloneNoteAfter,
cloneNoteTo
};
})();

View File

@@ -3,54 +3,72 @@
const contextMenu = (function() {
const treeEl = $("#tree");
let clipboardId = null;
let clipboardIds = [];
let clipboardMode = null;
function pasteAfter(node) {
if (clipboardMode === 'cut') {
const subjectNode = treeUtils.getNodeByKey(clipboardId);
for (const nodeKey of clipboardIds) {
const subjectNode = treeUtils.getNodeByKey(nodeKey);
treeChanges.moveAfterNode(subjectNode, node);
treeChanges.moveAfterNode([subjectNode], node);
}
clipboardIds = [];
clipboardMode = null;
}
else if (clipboardMode === 'copy') {
treeChanges.cloneNoteAfter(clipboardId, node.data.note_tree_id);
for (const noteId of clipboardIds) {
cloning.cloneNoteAfter(noteId, node.data.note_tree_id);
}
// copy will keep clipboardIds and clipboardMode so it's possible to paste into multiple places
}
else if (clipboardId === null) {
else if (clipboardIds.length === 0) {
// just do nothing
}
else {
throwError("Unrecognized clipboard mode=" + clipboardMode);
}
clipboardId = null;
clipboardMode = null;
}
function pasteInto(node) {
if (clipboardMode === 'cut') {
const subjectNode = treeUtils.getNodeByKey(clipboardId);
for (const nodeKey of clipboardIds) {
const subjectNode = treeUtils.getNodeByKey(nodeKey);
treeChanges.moveToNode(subjectNode, node);
treeChanges.moveToNode([subjectNode], node);
}
clipboardIds = [];
clipboardMode = null;
}
else if (clipboardMode === 'copy') {
treeChanges.cloneNoteTo(clipboardId, node.data.note_id);
for (const noteId of clipboardIds) {
cloning.cloneNoteTo(noteId, node.data.note_id);
}
// copy will keep clipboardIds and clipboardMode so it's possible to paste into multiple places
}
else if (clipboardIds.length === 0) {
// just do nothing
}
else {
throwError("Unrecognized clipboard mode=" + mode);
}
clipboardId = null;
clipboardMode = null;
}
function copy(node) {
clipboardId = node.data.note_id;
function copy(nodes) {
clipboardIds = nodes.map(node => node.data.note_id);
clipboardMode = 'copy';
showMessage("Note(s) have been copied into clipboard.");
}
function cut(node) {
clipboardId = node.key;
function cut(nodes) {
clipboardIds = nodes.map(node => node.key);
clipboardMode = 'cut';
showMessage("Note(s) have been cut into clipboard.");
}
const contextMenuSettings = {
@@ -69,13 +87,18 @@ const contextMenu = (function() {
{title: "Copy / clone <kbd>Ctrl+C</kbd>", cmd: "copy", uiIcon: "ui-icon-copy"},
{title: "Cut <kbd>Ctrl+X</kbd>", cmd: "cut", uiIcon: "ui-icon-scissors"},
{title: "Paste into <kbd>Ctrl+V</kbd>", cmd: "pasteInto", uiIcon: "ui-icon-clipboard"},
{title: "Paste after", cmd: "pasteAfter", uiIcon: "ui-icon-clipboard"}
{title: "Paste after", cmd: "pasteAfter", uiIcon: "ui-icon-clipboard"},
{title: "----"},
{title: "Collapse sub-tree <kbd>Alt+-</kbd>", cmd: "collapse-sub-tree", uiIcon: "ui-icon-minus"},
{title: "Force note sync", cmd: "force-note-sync", uiIcon: "ui-icon-refresh"},
{title: "Sort alphabetically <kbd>Alt+s</kbd>", cmd: "sort-alphabetically", uiIcon: " ui-icon-arrowthick-2-n-s"}
],
beforeOpen: (event, ui) => {
const node = $.ui.fancytree.getNode(ui.target);
// Modify menu entries depending on node status
treeEl.contextmenu("enableEntry", "pasteAfter", clipboardId !== null);
treeEl.contextmenu("enableEntry", "pasteInto", clipboardId !== null);
treeEl.contextmenu("enableEntry", "pasteAfter", clipboardIds.length > 0);
treeEl.contextmenu("enableEntry", "pasteInto", clipboardIds.length > 0);
// Activate node on right-click
node.setActive();
@@ -106,10 +129,10 @@ const contextMenu = (function() {
protected_session.protectSubTree(node.data.note_id, false);
}
else if (ui.cmd === "copy") {
copy(node);
copy(noteTree.getSelectedNodes());
}
else if (ui.cmd === "cut") {
cut(node);
cut(noteTree.getSelectedNodes());
}
else if (ui.cmd === "pasteAfter") {
pasteAfter(node);
@@ -118,7 +141,16 @@ const contextMenu = (function() {
pasteInto(node);
}
else if (ui.cmd === "delete") {
treeChanges.deleteNode(node);
treeChanges.deleteNodes(noteTree.getSelectedNodes(true));
}
else if (ui.cmd === "collapse-sub-tree") {
noteTree.collapseTree(node);
}
else if (ui.cmd === "force-note-sync") {
forceNoteSync(node.data.note_id);
}
else if (ui.cmd === "sort-alphabetically") {
noteTree.sortAlphabetically(node.data.note_id);
}
else {
messaging.logError("Unknown command: " + ui.cmd);

View File

@@ -78,14 +78,14 @@ const addLink = (function() {
else if (linkType === 'selected-to-current') {
const prefix = clonePrefixEl.val();
treeChanges.cloneNoteTo(noteId, noteEditor.getCurrentNoteId(), prefix);
cloning.cloneNoteTo(noteId, noteEditor.getCurrentNoteId(), prefix);
dialogEl.dialog("close");
}
else if (linkType === 'current-to-selected') {
const prefix = clonePrefixEl.val();
treeChanges.cloneNoteTo(noteEditor.getCurrentNoteId(), noteId, prefix);
cloning.cloneNoteTo(noteEditor.getCurrentNoteId(), noteId, prefix);
dialogEl.dialog("close");
}

View File

@@ -0,0 +1,62 @@
"use strict";
const attributesDialog = (function() {
const dialogEl = $("#attributes-dialog");
const attributesModel = new AttributesModel();
function AttributesModel() {
const self = this;
this.attributes = ko.observableArray();
this.loadAttributes = async function() {
const noteId = noteEditor.getCurrentNoteId();
const attributes = await server.get('notes/' + noteId + '/attributes');
this.attributes(attributes);
};
this.addNewRow = function() {
self.attributes.push({
attribute_id: '',
name: '',
value: ''
});
};
this.save = async function() {
const noteId = noteEditor.getCurrentNoteId();
const attributes = await server.put('notes/' + noteId + '/attributes', this.attributes());
self.attributes(attributes);
showMessage("Attributes have been saved.");
};
}
async function showDialog() {
glob.activeDialog = dialogEl;
dialogEl.dialog({
modal: true,
width: 800,
height: 700
});
attributesModel.loadAttributes();
}
$(document).bind('keydown', 'alt+a', e => {
showDialog();
e.preventDefault();
});
ko.applyBindings(attributesModel);
return {
showDialog
};
})();

View File

@@ -13,7 +13,7 @@ const editTreePrefix = (function() {
await dialogEl.dialog({
modal: true,
width: 800
width: 500
});
const currentNode = noteTree.getCurrentNode();

View File

@@ -29,7 +29,7 @@ const noteHistory = (function() {
for (const item of historyItems) {
const dateModified = parseDate(item.date_modified_from);
$("#note-history-list").append($('<option>', {
listEl.append($('<option>', {
value: item.note_history_id,
text: formatDateTime(dateModified)
}));
@@ -42,6 +42,9 @@ const noteHistory = (function() {
listEl.val(noteHistoryId).trigger('change');
}
else {
titleEl.text("No history for this note yet...");
}
}
$(document).bind('keydown', 'alt+h', e => {

View File

@@ -0,0 +1,57 @@
"use strict";
const noteSource = (function() {
const dialogEl = $("#note-source-dialog");
const noteSourceEl = $("#note-source");
function showDialog() {
glob.activeDialog = dialogEl;
dialogEl.dialog({
modal: true,
width: 800,
height: 500
});
const noteText = noteEditor.getCurrentNote().detail.note_text;
noteSourceEl.text(formatHtml(noteText));
}
function formatHtml(str) {
const div = document.createElement('div');
div.innerHTML = str.trim();
return formatNode(div, 0).innerHTML.trim();
}
function formatNode(node, level) {
const indentBefore = new Array(level++ + 1).join(' ');
const indentAfter = new Array(level - 1).join(' ');
let textNode;
for (let i = 0; i < node.children.length; i++) {
textNode = document.createTextNode('\n' + indentBefore);
node.insertBefore(textNode, node.children[i]);
formatNode(node.children[i], level);
if (node.lastElementChild === node.children[i]) {
textNode = document.createTextNode('\n' + indentAfter);
node.appendChild(textNode);
}
}
return node;
}
$(document).bind('keydown', 'ctrl+u', e => {
showDialog();
e.preventDefault();
});
return {
showDialog
};
})();

View File

@@ -86,13 +86,13 @@ const recentNotes = (function() {
}
async function addCurrentAsChild() {
await treeChanges.cloneNoteTo(noteEditor.getCurrentNoteId(), getSelectedNoteId());
await cloning.cloneNoteTo(noteEditor.getCurrentNoteId(), getSelectedNoteId());
dialogEl.dialog("close");
}
async function addRecentAsChild() {
await treeChanges.cloneNoteTo(getSelectedNoteId(), noteEditor.getCurrentNoteId());
await cloning.cloneNoteTo(getSelectedNoteId(), noteEditor.getCurrentNoteId());
dialogEl.dialog("close");
}

View File

@@ -157,6 +157,7 @@ settings.addModule((async function () {
const fillSyncRowsButton = $("#fill-sync-rows-button");
const anonymizeButton = $("#anonymize-button");
const cleanupSoftDeletedButton = $("#cleanup-soft-deleted-items-button");
const cleanupUnusedImagesButton = $("#cleanup-unused-images-button");
const vacuumDatabaseButton = $("#vacuum-database-button");
forceFullSyncButton.click(async () => {
@@ -186,6 +187,14 @@ settings.addModule((async function () {
}
});
cleanupUnusedImagesButton.click(async () => {
if (confirm("Do you really want to clean up unused images?")) {
await server.post('cleanup/cleanup-unused-images');
showMessage("Unused images have been cleaned up");
}
});
vacuumDatabaseButton.click(async () => {
await server.post('cleanup/vacuum-database');

View File

@@ -28,6 +28,9 @@ const sqlConsole = (function() {
showError(result.error);
return;
}
else {
showMessage("Query was executed successfully.");
}
const rows = result.rows;

View File

@@ -46,14 +46,19 @@ const dragAndDropSetup = {
// This function MUST be defined to enable dropping of items on the tree.
// data.hitMode is 'before', 'after', or 'over'.
const nodeToMove = data.otherNode;
nodeToMove.setSelected(true);
const selectedNodes = noteTree.getSelectedNodes();
if (data.hitMode === "before") {
treeChanges.moveBeforeNode(data.otherNode, node);
treeChanges.moveBeforeNode(selectedNodes, node);
}
else if (data.hitMode === "after") {
treeChanges.moveAfterNode(data.otherNode, node);
treeChanges.moveAfterNode(selectedNodes, node);
}
else if (data.hitMode === "over") {
treeChanges.moveToNode(data.otherNode, node);
treeChanges.moveToNode(selectedNodes, node);
}
else {
throw new Exception("Unknown hitMode=" + data.hitMode);

View File

@@ -6,10 +6,7 @@ jQuery.hotkeys.options.filterContentEditable = false;
jQuery.hotkeys.options.filterTextInputs = false;
$(document).bind('keydown', 'alt+m', e => {
const toggle = $(".hide-toggle");
const hidden = toggle.css('visibility') === 'hidden';
toggle.css('visibility', hidden ? 'visible' : 'hidden');
$(".hide-toggle").toggleClass("suppressed");
e.preventDefault();
});
@@ -49,7 +46,7 @@ $(document).bind('keydown', 'ctrl+f', () => {
const searchInPage = require('electron-in-page-search').default;
const remote = require('electron').remote;
const inPageSearch = searchInPage(remote.getCurrentWebContents(), { openDevToolsOfSearchWindow: true });
const inPageSearch = searchInPage(remote.getCurrentWebContents());
inPageSearch.openSearchWindow();
@@ -57,6 +54,44 @@ $(document).bind('keydown', 'ctrl+f', () => {
}
});
$(document).bind('keydown', "ctrl+shift+left", () => {
const node = noteTree.getCurrentNode();
node.navigate($.ui.keyCode.LEFT, true);
$("#note-detail").focus();
return false;
});
$(document).bind('keydown', "ctrl+shift+right", () => {
const node = noteTree.getCurrentNode();
node.navigate($.ui.keyCode.RIGHT, true);
$("#note-detail").focus();
return false;
});
$(document).bind('keydown', "ctrl+shift+up", () => {
const node = noteTree.getCurrentNode();
node.navigate($.ui.keyCode.UP, true);
$("#note-detail").focus();
return false;
});
$(document).bind('keydown', "ctrl+shift+down", () => {
const node = noteTree.getCurrentNode();
node.navigate($.ui.keyCode.DOWN, true);
$("#note-detail").focus();
return false;
});
$("#note-title").bind('keydown', 'return', () => $("#note-detail").focus());
$(window).on('beforeunload', () => {
// this makes sure that when user e.g. reloads the page or navigates away from the page, the note's content is saved
// this sends the request asynchronously and doesn't wait for result
@@ -122,27 +157,6 @@ $(document).tooltip({
}
});
let appShown = false;
function showAppIfHidden() {
if (!appShown) {
appShown = true;
$("#container").show();
// Get a reference to the loader's div
const loaderDiv = document.getElementById("loader-wrapper");
// When the transition ends remove loader's div from display
// so that we can access the map with gestures or clicks
loaderDiv.addEventListener("transitionend", function(){
loaderDiv.style.display = "none";
}, true);
// Kick off the CSS transition
loaderDiv.style.opacity = 0.0;
}
}
window.onerror = function (msg, url, lineNo, columnNo, error) {
const string = msg.toLowerCase();
@@ -164,4 +178,6 @@ window.onerror = function (msg, url, lineNo, columnNo, error) {
messaging.logError(message);
return false;
};
};
$("#logout-button").toggle(!isElectron());

View File

@@ -42,16 +42,22 @@ const link = (function() {
e.preventDefault();
const linkEl = $(e.target);
const notePath = linkEl.attr("note-path") ? linkEl.attr("note-path") : getNotePathFromLink(linkEl.attr('href'));
let notePath = linkEl.attr("note-path");
if (!notePath) {
return;
}
const address = linkEl.attr("note-path") ? linkEl.attr("note-path") : linkEl.attr('href');
if (notePath.startsWith('http')) {
window.open(notePath, '_blank');
if (!address) {
return;
}
return;
if (address.startsWith('http')) {
window.open(address, '_blank');
return;
}
notePath = getNotePathFromLink(address);
}
noteTree.activateNode(notePath);
@@ -84,8 +90,8 @@ const link = (function() {
// when click on link popup, in case of internal link, just go the the referenced note instead of default behavior
// of opening the link in new window/tab
$(document).on('click', "a[action='note']", goToLink);
$(document).on('click', 'div.popover-content a, div.ui-tooltip-content', goToLink);
$(document).on('dblclick', '#note-detail a, div.ui-tooltip-content', goToLink);
$(document).on('click', 'div.popover-content a, div.ui-tooltip-content a', goToLink);
$(document).on('dblclick', '#note-detail a', goToLink);
return {
getNodePathFromLabel,

View File

@@ -29,7 +29,9 @@ const messaging = (function() {
const syncData = message.data.filter(sync => sync.source_id !== glob.sourceId);
if (syncData.some(sync => sync.entity_name === 'notes_tree')) {
if (syncData.some(sync => sync.entity_name === 'notes_tree')
|| syncData.some(sync => sync.entity_name === 'notes')) {
console.log(now(), "Reloading tree because of background changes");
noteTree.reload();
@@ -47,6 +49,9 @@ const messaging = (function() {
recentNotes.reload();
}
// we don't detect image changes here since images themselves are immutable and references should be
// updated in note detail as well
changesToPushCountEl.html(message.changesToPushCount);
}
else if (message.type === 'sync-hash-check-failed') {

View File

@@ -121,14 +121,16 @@ const noteEditor = (function() {
noteTitleEl.val(currentNote.detail.note_title);
editor.setData(currentNote.detail.note_text);
// temporary workaround for https://github.com/ckeditor/ckeditor5-enter/issues/49
editor.setData(currentNote.detail.note_text ? currentNote.detail.note_text : "<p></p>");
noteChangeDisabled = false;
setNoteBackgroundIfProtected(currentNote);
noteTree.setNoteTreeBackgroundBasedOnProtectedStatus(noteId);
showAppIfHidden();
// after loading new note make sure editor is scrolled to the top
noteDetailWrapperEl.scrollTop(0);
}
async function loadNote(noteId) {

View File

@@ -4,6 +4,7 @@ const noteTree = (function() {
const treeEl = $("#tree");
const parentListEl = $("#parent-list");
const parentListListEl = $("#parent-list-list");
const noteDetailEl = $("#note-detail");
let startNotePath = null;
let notesTreeMap = {};
@@ -59,23 +60,6 @@ const noteTree = (function() {
return treeUtils.getNotePath(node);
}
function getCurrentNoteId() {
const node = getCurrentNode();
return node ? node.data.note_id : null;
}
function getCurrentClones() {
const noteId = getCurrentNoteId();
if (noteId) {
return getNodesByNoteId(noteId);
}
else {
return [];
}
}
function getNodesByNoteTreeId(noteTreeId) {
assertArguments(noteTreeId);
@@ -185,13 +169,15 @@ const noteTree = (function() {
const noteTreeId = getNoteTreeId(parentNoteId, noteId);
const noteTree = notesTreeMap[noteTreeId];
const title = (noteTree.prefix ? (noteTree.prefix + " - ") : "") + noteIdToTitle[noteTree.note_id];
const node = {
note_id: noteTree.note_id,
parent_note_id: noteTree.parent_note_id,
note_tree_id: noteTree.note_tree_id,
is_protected: noteTree.is_protected,
prefix: noteTree.prefix,
title: (noteTree.prefix ? (noteTree.prefix + " - ") : "") + noteIdToTitle[noteTree.note_id],
title: escapeHtml(title),
extraClasses: getExtraClasses(noteTree),
refKey: noteTree.note_id,
expanded: noteTree.is_expanded
@@ -214,7 +200,7 @@ const noteTree = (function() {
return noteList;
}
async function activateNode(notePath) {
async function expandToNote(notePath, expandOpts) {
assertArguments(notePath);
const runPath = getRunPath(notePath);
@@ -223,22 +209,30 @@ const noteTree = (function() {
let parentNoteId = 'root';
//console.log(now(), "Run path: ", runPath);
for (const childNoteId of runPath) {
const node = getNodesByNoteId(childNoteId).find(node => node.data.parent_note_id === parentNoteId);
if (childNoteId === noteId) {
await node.setActive();
return node;
}
else {
await node.setExpanded();
await node.setExpanded(true, expandOpts);
}
parentNoteId = childNoteId;
}
}
async function activateNode(notePath) {
assertArguments(notePath);
const node = await expandToNote(notePath);
await node.setActive();
clearSelectedNodes();
}
/**
* Accepts notePath and tries to resolve it. Part of the path might not be valid because of note moving (which causes
* path change) or other corruption, in that case this will try to get some other valid path to the correct note.
@@ -382,7 +376,7 @@ const noteTree = (function() {
const expandedNum = isExpanded ? 1 : 0;
await server.put('notes/' + noteTreeId + '/expanded/' + expandedNum);
await server.put('tree/' + noteTreeId + '/expanded/' + expandedNum);
}
function setCurrentNotePathToHash(node) {
@@ -396,53 +390,149 @@ const noteTree = (function() {
recentNotes.addRecentNote(currentNoteTreeId, currentNotePath);
}
function getSelectedNodes(stopOnParents = false) {
return getTree().getSelectedNodes(stopOnParents);
}
function clearSelectedNodes() {
for (const selectedNode of getSelectedNodes()) {
selectedNode.setSelected(false);
}
const currentNode = getCurrentNode();
if (currentNode) {
currentNode.setSelected(true);
}
}
function initFancyTree(noteTree) {
assertArguments(noteTree);
const keybindings = {
"del": node => {
treeChanges.deleteNode(node);
treeChanges.deleteNodes(getSelectedNodes(true));
},
"shift+up": node => {
"ctrl+up": node => {
const beforeNode = node.getPrevSibling();
if (beforeNode !== null) {
treeChanges.moveBeforeNode(node, beforeNode);
treeChanges.moveBeforeNode([node], beforeNode);
}
return false;
},
"shift+down": node => {
"ctrl+down": node => {
let afterNode = node.getNextSibling();
if (afterNode !== null) {
treeChanges.moveAfterNode(node, afterNode);
treeChanges.moveAfterNode([node], afterNode);
}
return false;
},
"shift+left": node => {
"ctrl+left": node => {
treeChanges.moveNodeUpInHierarchy(node);
return false;
},
"shift+right": node => {
"ctrl+right": node => {
let toNode = node.getPrevSibling();
if (toNode !== null) {
treeChanges.moveToNode(node, toNode);
treeChanges.moveToNode([node], toNode);
}
return false;
},
"shift+up": node => {
node.navigate($.ui.keyCode.UP, true).then(() => {
const currentNode = getCurrentNode();
if (currentNode.isSelected()) {
node.setSelected(false);
}
currentNode.setSelected(true);
});
return false;
},
"shift+down": node => {
node.navigate($.ui.keyCode.DOWN, true).then(() => {
const currentNode = getCurrentNode();
if (currentNode.isSelected()) {
node.setSelected(false);
}
currentNode.setSelected(true);
});
return false;
},
"f2": node => {
editTreePrefix.showDialog(node);
},
"alt+-": node => {
collapseTree(node);
},
"alt+s": node => {
sortAlphabetically(node.data.note_id);
return false;
},
"ctrl+a": node => {
for (const child of node.getParent().getChildren()) {
child.setSelected(true);
}
return false;
},
"ctrl+c": () => {
contextMenu.copy(getSelectedNodes());
return false;
},
"ctrl+x": () => {
contextMenu.cut(getSelectedNodes());
return false;
},
"ctrl+v": node => {
contextMenu.pasteInto(node);
return false;
},
"return": node => {
noteDetailEl.focus();
},
"backspace": node => {
if (!isTopLevelNode(node)) {
node.getParent().setActive().then(() => clearSelectedNodes());
}
},
// code below shouldn't be necessary normally, however there's some problem with interaction with context menu plugin
// after opening context menu, standard shortcuts don't work, but they are detected here
// so we essentially takeover the standard handling with our implementation.
"left": node => {
node.navigate($.ui.keyCode.LEFT, true);
node.navigate($.ui.keyCode.LEFT, true).then(() => clearSelectedNodes());
return false;
},
"right": node => {
node.navigate($.ui.keyCode.RIGHT, true);
node.navigate($.ui.keyCode.RIGHT, true).then(() => clearSelectedNodes());
return false;
},
"up": node => {
node.navigate($.ui.keyCode.UP, true);
node.navigate($.ui.keyCode.UP, true).then(() => clearSelectedNodes());
return false;
},
"down": node => {
node.navigate($.ui.keyCode.DOWN, true);
node.navigate($.ui.keyCode.DOWN, true).then(() => clearSelectedNodes());
return false;
}
};
@@ -452,6 +542,24 @@ const noteTree = (function() {
extensions: ["hotkeys", "filter", "dnd", "clones"],
source: noteTree,
scrollParent: $("#tree"),
click: (event, data) => {
const targetType = data.targetType;
const node = data.node;
if (targetType === 'title' || targetType === 'icon') {
if (!event.ctrlKey) {
node.setActive();
node.setSelected(true);
clearSelectedNodes();
}
else {
node.setSelected(!node.isSelected());
}
return false;
}
},
activate: (event, data) => {
const node = data.node.data;
@@ -482,9 +590,6 @@ const noteTree = (function() {
// so waiting a second helps
setTimeout(scrollToCurrentNote, 1000);
}
else {
showAppIfHidden();
}
},
hotkeys: {
keydown: keybindings
@@ -502,57 +607,6 @@ const noteTree = (function() {
mode: "hide" // Grayout unmatched nodes (pass "hide" to remove unmatched node instead)
},
dnd: dragAndDropSetup,
keydown: (event, data) => {
const node = data.node;
// Eat keyboard events, when a menu is open
if ($(".contextMenu:visible").length > 0)
return false;
switch (event.which) {
// Open context menu on [Space] key (simulate right click)
case 32: // [Space]
$(node.span).trigger("mousedown", {
preventDefault: true,
button: 2
})
.trigger("mouseup", {
preventDefault: true,
pageX: node.span.offsetLeft,
pageY: node.span.offsetTop,
button: 2
});
return false;
// Handle Ctrl-C, -X and -V
case 67:
if (event.ctrlKey) { // Ctrl-C
contextMenu.copy(node);
showMessage("Note copied into clipboard.");
return false;
}
break;
case 88:
if (event.ctrlKey) { // Ctrl-X
contextMenu.cut(node);
showMessage("Note cut into clipboard.");
return false;
}
break;
case 86:
if (event.ctrlKey) { // Ctrl-V
contextMenu.pasteInto(node);
showMessage("Note pasted from clipboard into current note.");
return false;
}
break;
}
},
lazyLoad: function(event, data){
const node = data.node.data;
@@ -595,13 +649,17 @@ const noteTree = (function() {
$(() => loadTree().then(noteTree => initFancyTree(noteTree)));
function collapseTree() {
treeEl.fancytree("getRootNode").visit(node => {
node.setExpanded(false);
});
function collapseTree(node = null) {
if (!node) {
node = treeEl.fancytree("getRootNode");
}
node.setExpanded(false);
node.visit(node => node.setExpanded(false));
}
$(document).bind('keydown', 'alt+c', collapseTree);
$(document).bind('keydown', 'alt+c', () => collapseTree()); // don't use shortened form since collapseTree() accepts argument
function scrollToCurrentNote() {
const node = getCurrentNode();
@@ -714,16 +772,32 @@ const noteTree = (function() {
if (target === 'after') {
node.appendSibling(newNode).setActive(true);
}
else {
node.addChildren(newNode).setActive(true);
else if (target === 'into') {
if (!node.getChildren() && node.isFolder()) {
await node.setExpanded();
}
else {
node.addChildren(newNode);
}
node.getLastChild().setActive(true);
node.folder = true;
node.renderTitle();
}
else {
throwError("Unrecognized target: " + target);
}
showMessage("Created!");
}
async function sortAlphabetically(noteId) {
await server.put('notes/' + noteId + '/sort');
await reload();
}
$(document).bind('keydown', 'ctrl+o', e => {
console.log("pressed O");
@@ -747,7 +821,7 @@ const noteTree = (function() {
$(document).bind('keydown', 'ctrl+del', e => {
const node = getCurrentNode();
treeChanges.deleteNode(node);
treeChanges.deleteNodes([node]);
e.preventDefault();
});
@@ -757,7 +831,11 @@ const noteTree = (function() {
$(window).bind('hashchange', function() {
const notePath = getNotePathFromAddress();
activateNode(notePath);
if (getCurrentNotePath() !== notePath) {
console.log("Switching to " + notePath + " because of hash change");
activateNode(notePath);
}
});
if (isElectron()) {
@@ -781,6 +859,7 @@ const noteTree = (function() {
setNoteTreeBackgroundBasedOnProtectedStatus,
setProtected,
getCurrentNode,
expandToNote,
activateNode,
getCurrentNotePath,
getNoteTitle,
@@ -792,6 +871,8 @@ const noteTree = (function() {
setPrefix,
getNotePathTitle,
removeParentChildRelation,
setParentChildRelation
setParentChildRelation,
getSelectedNodes,
sortAlphabetically
};
})();

View File

@@ -22,9 +22,6 @@ const protected_session = (function() {
const dfd = $.Deferred();
if (requireProtectedSession && !isProtectedSessionAvailable()) {
// if this is entry point then we need to show the app even before the note is loaded
showAppIfHidden();
protectedSessionDeferred = dfd;
dialogEl.dialog({
@@ -145,7 +142,7 @@ const protected_session = (function() {
async function protectSubTree(noteId, protect) {
await ensureProtectedSession(true, true);
await server.put('tree/' + noteId + "/protect-sub-tree/" + (protect ? 1 : 0));
await server.put('notes/' + noteId + "/protect-sub-tree/" + (protect ? 1 : 0));
showMessage("Request to un/protect sub tree has finished successfully");

View File

@@ -8,14 +8,10 @@ const searchTree = (function() {
resetSearchButton.click(resetSearch);
function showSearch() {
searchBoxEl.show();
searchInputEl.focus();
}
function toggleSearch() {
if (searchBoxEl.is(":hidden")) {
showSearch();
searchBoxEl.show();
searchInputEl.focus();
}
else {
resetSearch();
@@ -34,7 +30,7 @@ const searchTree = (function() {
return treeEl.fancytree('getTree');
}
searchInputEl.keyup(e => {
searchInputEl.keyup(async e => {
const searchText = searchInputEl.val();
if (e && e.which === $.ui.keyCode.ESCAPE || $.trim(searchText) === "") {
@@ -43,16 +39,22 @@ const searchTree = (function() {
}
if (e && e.which === $.ui.keyCode.ENTER) {
server.get('notes?search=' + searchText).then(resp => {
// Pass a string to perform case insensitive matching
getTree().filterBranches(node => {
return resp.includes(node.data.note_id);
});
});
const noteIds = await server.get('notes?search=' + encodeURIComponent(searchText));
for (const noteId of noteIds) {
await noteTree.expandToNote(noteId, {noAnimation: true, noEvents: true});
}
// Pass a string to perform case insensitive matching
getTree().filterBranches(node => noteIds.includes(node.data.note_id));
}
}).focus();
$(document).bind('keydown', 'alt+s', showSearch);
$(document).bind('keydown', 'ctrl+s', e => {
toggleSearch();
e.preventDefault();
});
return {
toggleSearch

View File

@@ -91,6 +91,7 @@ const server = (function() {
get,
post,
put,
remove
remove,
getHeaders
}
})();

View File

@@ -13,4 +13,10 @@ async function syncNow() {
showError("Sync failed: " + result.message);
}
}
async function forceNoteSync(noteId) {
const result = await server.post('sync/force-note-sync/' + noteId);
showMessage("Note added to sync queue.");
}

View File

@@ -1,80 +1,79 @@
"use strict";
const treeChanges = (function() {
async function moveBeforeNode(node, beforeNode) {
await server.put('notes/' + node.data.note_tree_id + '/move-before/' + beforeNode.data.note_tree_id);
async function moveBeforeNode(nodesToMove, beforeNode) {
for (const nodeToMove of nodesToMove) {
const resp = await server.put('tree/' + nodeToMove.data.note_tree_id + '/move-before/' + beforeNode.data.note_tree_id);
changeNode(node, node => node.moveTo(beforeNode, 'before'));
if (!resp.success) {
alert(resp.message);
return;
}
changeNode(nodeToMove, node => node.moveTo(beforeNode, 'before'));
}
}
async function moveAfterNode(node, afterNode) {
await server.put('notes/' + node.data.note_tree_id + '/move-after/' + afterNode.data.note_tree_id);
async function moveAfterNode(nodesToMove, afterNode) {
for (const nodeToMove of nodesToMove) {
const resp = await server.put('tree/' + nodeToMove.data.note_tree_id + '/move-after/' + afterNode.data.note_tree_id);
changeNode(node, node => node.moveTo(afterNode, 'after'));
if (!resp.success) {
alert(resp.message);
return;
}
changeNode(nodeToMove, node => node.moveTo(afterNode, 'after'));
}
}
// beware that first arg is noteId and second is noteTreeId!
async function cloneNoteAfter(noteId, afterNoteTreeId) {
const resp = await server.put('notes/' + noteId + '/clone-after/' + afterNoteTreeId);
async function moveToNode(nodesToMove, toNode) {
for (const nodeToMove of nodesToMove) {
const resp = await server.put('tree/' + nodeToMove.data.note_tree_id + '/move-to/' + toNode.data.note_id);
if (!resp.success) {
alert(resp.message);
if (!resp.success) {
alert(resp.message);
return;
}
changeNode(nodeToMove, node => {
// first expand which will force lazy load and only then move the node
// if this is not expanded before moving, then lazy load won't happen because it already contains node
// this doesn't work if this isn't a folder yet, that's why we expand second time below
toNode.setExpanded(true);
node.moveTo(toNode);
toNode.folder = true;
toNode.renderTitle();
// this expands the note in case it become the folder only after the move
toNode.setExpanded(true);
});
}
}
async function deleteNodes(nodes) {
if (nodes.length === 0 || !confirm('Are you sure you want to delete select note(s) and all the sub-notes?')) {
return;
}
await noteTree.reload();
}
async function moveToNode(node, toNode) {
await server.put('notes/' + node.data.note_tree_id + '/move-to/' + toNode.data.note_id);
changeNode(node, node => {
node.moveTo(toNode);
toNode.setExpanded(true);
toNode.folder = true;
toNode.renderTitle();
});
}
async function cloneNoteTo(childNoteId, parentNoteId, prefix) {
const resp = await server.put('notes/' + childNoteId + '/clone-to/' + parentNoteId, {
prefix: prefix
});
if (!resp.success) {
alert(resp.message);
return;
for (const node of nodes) {
await server.remove('tree/' + node.data.note_tree_id);
}
await noteTree.reload();
}
async function deleteNode(node) {
if (!confirm('Are you sure you want to delete note "' + node.title + '"?')) {
return;
}
await server.remove('notes/' + node.data.note_tree_id);
if (!isTopLevelNode(node) && node.getParent().getChildren().length <= 1) {
node.getParent().folder = false;
node.getParent().renderTitle();
}
let next = node.getNextSibling();
// following code assumes that nodes contain only top-most selected nodes - getSelectedNodes has been
// called with stopOnParent=true
let next = nodes[nodes.length - 1].getNextSibling();
if (!next) {
next = node.getPrevSibling();
next = nodes[0].getPrevSibling();
}
if (!next && !isTopLevelNode(node)) {
next = node.getParent();
if (!next && !isTopLevelNode(nodes[0])) {
next = nodes[0].getParent();
}
node.remove();
if (next) {
// activate next element after this one is deleted so we don't lose focus
next.setActive();
@@ -83,6 +82,8 @@ const treeChanges = (function() {
}
noteTree.reload();
showMessage("Note(s) has been deleted.");
}
async function moveNodeUpInHierarchy(node) {
@@ -90,7 +91,12 @@ const treeChanges = (function() {
return;
}
await server.put('notes/' + node.data.note_tree_id + '/move-after/' + node.getParent().data.note_tree_id);
const resp = await server.put('tree/' + node.data.note_tree_id + '/move-after/' + node.getParent().data.note_tree_id);
if (!resp.success) {
alert(resp.message);
return;
}
if (!isTopLevelNode(node) && node.getParent().getChildren().length <= 1) {
node.getParent().folder = false;
@@ -118,9 +124,7 @@ const treeChanges = (function() {
moveBeforeNode,
moveAfterNode,
moveToNode,
deleteNode,
moveNodeUpInHierarchy,
cloneNoteAfter,
cloneNoteTo
deleteNodes,
moveNodeUpInHierarchy
};
})();

View File

@@ -37,7 +37,7 @@ const treeUtils = (function() {
const title = (prefix ? (prefix + " - ") : "") + noteTitle;
node.setTitle(title);
node.setTitle(escapeHtml(title));
}
return {

View File

@@ -93,4 +93,8 @@ function isTopLevelNode(node) {
function isRootNode(node) {
return node.key === "root_1";
}
function escapeHtml(str) {
return $('<div/>').text(str).html();
}

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,631 @@
/*******************************************************************************
* jquery.ui-contextmenu.js plugin.
*
* jQuery plugin that provides a context menu (based on the jQueryUI menu widget).
*
* @see https://github.com/mar10/jquery-ui-contextmenu
*
* Copyright (c) 2013-2017, Martin Wendt (http://wwWendt.de). Licensed MIT.
*/
(function( factory ) {
"use strict";
if ( typeof define === "function" && define.amd ) {
// AMD. Register as an anonymous module.
define([ "jquery", "jquery-ui/ui/widgets/menu" ], factory );
} else {
// Browser globals
factory( jQuery );
}
}(function( $ ) {
"use strict";
var supportSelectstart = "onselectstart" in document.createElement("div"),
match = $.ui.menu.version.match(/^(\d)\.(\d+)/),
uiVersion = {
major: parseInt(match[1], 10),
minor: parseInt(match[2], 10)
},
isLTE110 = ( uiVersion.major < 2 && uiVersion.minor <= 10 ),
isLTE111 = ( uiVersion.major < 2 && uiVersion.minor <= 11 );
$.widget("moogle.contextmenu", {
version: "@VERSION",
options: {
addClass: "ui-contextmenu", // Add this class to the outer <ul>
closeOnWindowBlur: true, // Close menu when window loses focus
autoFocus: false, // Set keyboard focus to first entry on open
autoTrigger: true, // open menu on browser's `contextmenu` event
delegate: null, // selector
hide: { effect: "fadeOut", duration: "fast" },
ignoreParentSelect: true, // Don't trigger 'select' for sub-menu parents
menu: null, // selector or jQuery pointing to <UL>, or a definition hash
position: null, // popup positon
preventContextMenuForPopup: false, // prevent opening the browser's system
// context menu on menu entries
preventSelect: false, // disable text selection of target
show: { effect: "slideDown", duration: "fast" },
taphold: false, // open menu on taphold events (requires external plugins)
uiMenuOptions: {}, // Additional options, used when UI Menu is created
// Events:
beforeOpen: $.noop, // menu about to open; return `false` to prevent opening
blur: $.noop, // menu option lost focus
close: $.noop, // menu was closed
create: $.noop, // menu was initialized
createMenu: $.noop, // menu was initialized (original UI Menu)
focus: $.noop, // menu option got focus
open: $.noop, // menu was opened
select: $.noop // menu option was selected; return `false` to prevent closing
},
/** Constructor */
_create: function() {
var cssText, eventNames, targetId,
opts = this.options;
this.$headStyle = null;
this.$menu = null;
this.menuIsTemp = false;
this.currentTarget = null;
this.extraData = {};
this.previousFocus = null;
if (opts.delegate == null) {
$.error("ui-contextmenu: Missing required option `delegate`.");
}
if (opts.preventSelect) {
// Create a global style for all potential menu targets
// If the contextmenu was bound to `document`, we apply the
// selector relative to the <body> tag instead
targetId = ($(this.element).is(document) ? $("body")
: this.element).uniqueId().attr("id");
cssText = "#" + targetId + " " + opts.delegate + " { " +
"-webkit-user-select: none; " +
"-khtml-user-select: none; " +
"-moz-user-select: none; " +
"-ms-user-select: none; " +
"user-select: none; " +
"}";
this.$headStyle = $("<style class='moogle-contextmenu-style' />")
.prop("type", "text/css")
.appendTo("head");
try {
this.$headStyle.html(cssText);
} catch ( e ) {
// issue #47: fix for IE 6-8
this.$headStyle[0].styleSheet.cssText = cssText;
}
// TODO: the selectstart is not supported by FF?
if (supportSelectstart) {
this.element.on("selectstart" + this.eventNamespace, opts.delegate,
function(event) {
event.preventDefault();
});
}
}
this._createUiMenu(opts.menu);
eventNames = "contextmenu" + this.eventNamespace;
if (opts.taphold) {
eventNames += " taphold" + this.eventNamespace;
}
this.element.on(eventNames, opts.delegate, $.proxy(this._openMenu, this));
},
/** Destructor, called on $().contextmenu("destroy"). */
_destroy: function() {
this.element.off(this.eventNamespace);
this._createUiMenu(null);
if (this.$headStyle) {
this.$headStyle.remove();
this.$headStyle = null;
}
},
/** (Re)Create jQuery UI Menu. */
_createUiMenu: function(menuDef) {
var ct, ed,
opts = this.options;
// Remove temporary <ul> if any
if (this.isOpen()) {
// #58: 'replaceMenu' in beforeOpen causing select: to lose ui.target
ct = this.currentTarget;
ed = this.extraData;
// close without animation, to force async mode
this._closeMenu(true);
this.currentTarget = ct;
this.extraData = ed;
}
if (this.menuIsTemp) {
this.$menu.remove(); // this will also destroy ui.menu
} else if (this.$menu) {
this.$menu
.menu("destroy")
.removeClass(this.options.addClass)
.hide();
}
this.$menu = null;
this.menuIsTemp = false;
// If a menu definition array was passed, create a hidden <ul>
// and generate the structure now
if ( !menuDef ) {
return;
} else if ($.isArray(menuDef)) {
this.$menu = $.moogle.contextmenu.createMenuMarkup(menuDef);
this.menuIsTemp = true;
}else if ( typeof menuDef === "string" ) {
this.$menu = $(menuDef);
} else {
this.$menu = menuDef;
}
// Create - but hide - the jQuery UI Menu widget
this.$menu
.hide()
.addClass(opts.addClass)
// Create a menu instance that delegates events to our widget
.menu($.extend(true, {}, opts.uiMenuOptions, {
items: "> :not(.ui-widget-header)",
blur: $.proxy(opts.blur, this),
create: $.proxy(opts.createMenu, this),
focus: $.proxy(opts.focus, this),
select: $.proxy(function(event, ui) {
// User selected a menu entry
var retval,
isParent = $.moogle.contextmenu.isMenu(ui.item),
actionHandler = ui.item.data("actionHandler");
ui.cmd = ui.item.attr("data-command");
ui.target = $(this.currentTarget);
ui.extraData = this.extraData;
// ignore clicks, if they only open a sub-menu
if ( !isParent || !opts.ignoreParentSelect) {
retval = this._trigger.call(this, "select", event, ui);
if ( actionHandler ) {
retval = actionHandler.call(this, event, ui);
}
if ( retval !== false ) {
this._closeMenu.call(this);
}
event.preventDefault();
}
}, this)
}));
},
/** Open popup (called on 'contextmenu' event). */
_openMenu: function(event, recursive) {
var res, promise, ui,
opts = this.options,
posOption = opts.position,
self = this,
manualTrigger = !!event.isTrigger;
if ( !opts.autoTrigger && !manualTrigger ) {
// ignore browser's `contextmenu` events
return;
}
// Prevent browser from opening the system context menu
event.preventDefault();
this.currentTarget = event.target;
this.extraData = event._extraData || {};
ui = { menu: this.$menu, target: $(this.currentTarget), extraData: this.extraData,
originalEvent: event, result: null };
if ( !recursive ) {
res = this._trigger("beforeOpen", event, ui);
promise = (ui.result && $.isFunction(ui.result.promise)) ? ui.result : null;
ui.result = null;
if ( res === false ) {
this.currentTarget = null;
return false;
} else if ( promise ) {
// Handler returned a Deferred or Promise. Delay menu open until
// the promise is resolved
promise.done(function() {
self._openMenu(event, true);
});
this.currentTarget = null;
return false;
}
ui.menu = this.$menu; // Might have changed in beforeOpen
}
// Register global event handlers that close the dropdown-menu
$(document).on("keydown" + this.eventNamespace, function(event) {
if ( event.which === $.ui.keyCode.ESCAPE ) {
self._closeMenu();
}
}).on("mousedown" + this.eventNamespace + " touchstart" + this.eventNamespace,
function(event) {
// Close menu when clicked outside menu
if ( !$(event.target).closest(".ui-menu-item").length ) {
self._closeMenu();
}
});
$(window).on("blur" + this.eventNamespace, function(event) {
if ( opts.closeOnWindowBlur ) {
self._closeMenu();
}
});
// required for custom positioning (issue #18 and #13).
if ($.isFunction(posOption)) {
posOption = posOption(event, ui);
}
posOption = $.extend({
my: "left top",
at: "left bottom",
// if called by 'open' method, event does not have pageX/Y
of: (event.pageX === undefined) ? event.target : event,
collision: "fit"
}, posOption);
// Update entry statuses from callbacks
this._updateEntries(this.$menu);
// Finally display the popup
this.$menu
.show() // required to fix positioning error
.css({
position: "absolute",
left: 0,
top: 0
}).position(posOption)
.hide(); // hide again, so we can apply nice effects
if ( opts.preventContextMenuForPopup ) {
this.$menu.on("contextmenu" + this.eventNamespace, function(event) {
event.preventDefault();
});
}
this._show(this.$menu, opts.show, function() {
var $first;
// Set focus to first active menu entry
if ( opts.autoFocus ) {
self.previousFocus = $(event.target);
// self.$menu.focus();
$first = self.$menu
.children("li.ui-menu-item")
.not(".ui-state-disabled")
.first();
self.$menu.menu("focus", null, $first).focus();
}
self._trigger.call(self, "open", event, ui);
});
},
/** Close popup. */
_closeMenu: function(immediately) {
var self = this,
hideOpts = immediately ? false : this.options.hide,
ui = { menu: this.$menu, target: $(this.currentTarget), extraData: this.extraData };
// Note: we don't want to unbind the 'contextmenu' event
$(document)
.off("mousedown" + this.eventNamespace)
.off("touchstart" + this.eventNamespace)
.off("keydown" + this.eventNamespace);
$(window)
.off("blur" + this.eventNamespace);
self.currentTarget = null; // issue #44 after hide animation is too late
self.extraData = {};
if ( this.$menu ) { // #88: widget might have been destroyed already
this.$menu
.off("contextmenu" + this.eventNamespace);
this._hide(this.$menu, hideOpts, function() {
if ( self.previousFocus ) {
self.previousFocus.focus();
self.previousFocus = null;
}
self._trigger("close", null, ui);
});
} else {
self._trigger("close", null, ui);
}
},
/** Handle $().contextmenu("option", key, value) calls. */
_setOption: function(key, value) {
switch (key) {
case "menu":
this.replaceMenu(value);
break;
}
$.Widget.prototype._setOption.apply(this, arguments);
},
/** Return ui-menu entry (<LI> tag). */
_getMenuEntry: function(cmd) {
return this.$menu.find("li[data-command=" + cmd + "]");
},
/** Close context menu. */
close: function() {
if (this.isOpen()) {
this._closeMenu();
}
},
/* Apply status callbacks when menu is opened. */
_updateEntries: function() {
var self = this,
ui = {
menu: this.$menu, target: $(this.currentTarget), extraData: this.extraData };
$.each(this.$menu.find(".ui-menu-item"), function(i, o) {
var $entry = $(o),
fn = $entry.data("disabledHandler"),
res = fn ? fn({ type: "disabled" }, ui) : null;
ui.item = $entry;
ui.cmd = $entry.attr("data-command");
// Evaluate `disabled()` callback
if ( res != null ) {
self.enableEntry(ui.cmd, !res);
self.showEntry(ui.cmd, res !== "hide");
}
// Evaluate `title()` callback
fn = $entry.data("titleHandler"),
res = fn ? fn({ type: "title" }, ui) : null;
if ( res != null ) {
self.setTitle(ui.cmd, "" + res);
}
// Evaluate `tooltip()` callback
fn = $entry.data("tooltipHandler"),
res = fn ? fn({ type: "tooltip" }, ui) : null;
if ( res != null ) {
$entry.attr("title", "" + res);
}
});
},
/** Enable or disable the menu command. */
enableEntry: function(cmd, flag) {
this._getMenuEntry(cmd).toggleClass("ui-state-disabled", (flag === false));
},
/** Return ui-menu entry (LI tag) as jQuery object. */
getEntry: function(cmd) {
return this._getMenuEntry(cmd);
},
/** Return ui-menu entry wrapper as jQuery object.
UI 1.10: this is the <a> tag inside the LI
UI 1.11: this is the LI istself
UI 1.12: this is the <div> tag inside the LI
*/
getEntryWrapper: function(cmd) {
return this._getMenuEntry(cmd).find(">[role=menuitem]").addBack("[role=menuitem]");
},
/** Return Menu element (UL). */
getMenu: function() {
return this.$menu;
},
/** Return true if menu is open. */
isOpen: function() {
// return this.$menu && this.$menu.is(":visible");
return !!this.$menu && !!this.currentTarget;
},
/** Open context menu on a specific target (must match options.delegate)
* Optional `extraData` is passed to event handlers as `ui.extraData`.
*/
open: function(targetOrEvent, extraData) {
// Fake a 'contextmenu' event
extraData = extraData || {};
var isEvent = (targetOrEvent && targetOrEvent.type && targetOrEvent.target),
event = isEvent ? targetOrEvent : {},
target = isEvent ? targetOrEvent.target : targetOrEvent,
e = jQuery.Event("contextmenu", {
target: $(target).get(0),
pageX: event.pageX,
pageY: event.pageY,
originalEvent: isEvent ? targetOrEvent : undefined,
_extraData: extraData
});
return this.element.trigger(e);
},
/** Replace the menu altogether. */
replaceMenu: function(data) {
this._createUiMenu(data);
},
/** Redefine a whole menu entry. */
setEntry: function(cmd, entry) {
var $ul,
$entryLi = this._getMenuEntry(cmd);
if (typeof entry === "string") {
window.console && window.console.warn(
"setEntry(cmd, t) with a plain string title is deprecated since v1.18." +
"Use setTitle(cmd, '" + entry + "') instead.");
return this.setTitle(cmd, entry);
}
$entryLi.empty();
entry.cmd = entry.cmd || cmd;
$.moogle.contextmenu.createEntryMarkup(entry, $entryLi);
if ($.isArray(entry.children)) {
$ul = $("<ul/>").appendTo($entryLi);
$.moogle.contextmenu.createMenuMarkup(entry.children, $ul);
}
// #110: jQuery UI 1.12: refresh only works when this class is not set:
$entryLi.removeClass("ui-menu-item");
this.getMenu().menu("refresh");
},
/** Set icon (pass null to remove). */
setIcon: function(cmd, icon) {
return this.updateEntry(cmd, { uiIcon: icon });
},
/** Set title. */
setTitle: function(cmd, title) {
return this.updateEntry(cmd, { title: title });
},
// /** Set tooltip (pass null to remove). */
// setTooltip: function(cmd, tooltip) {
// this._getMenuEntry(cmd).attr("title", tooltip);
// },
/** Show or hide the menu command. */
showEntry: function(cmd, flag) {
this._getMenuEntry(cmd).toggle(flag !== false);
},
/** Redefine selective attributes of a menu entry. */
updateEntry: function(cmd, entry) {
var $icon, $wrapper,
$entryLi = this._getMenuEntry(cmd);
if ( entry.title !== undefined ) {
$.moogle.contextmenu.updateTitle($entryLi, "" + entry.title);
}
if ( entry.tooltip !== undefined ) {
if ( entry.tooltip === null ) {
$entryLi.removeAttr("title");
} else {
$entryLi.attr("title", entry.tooltip);
}
}
if ( entry.uiIcon !== undefined ) {
$wrapper = this.getEntryWrapper(cmd),
$icon = $wrapper.find("span.ui-icon").not(".ui-menu-icon");
$icon.remove();
if ( entry.uiIcon ) {
$wrapper.append($("<span class='ui-icon' />").addClass(entry.uiIcon));
}
}
if ( entry.hide !== undefined ) {
$entryLi.toggle(!entry.hide);
} else if ( entry.show !== undefined ) {
// Note: `show` is an undocumented variant. `hide: false` is preferred
$entryLi.toggle(!!entry.show);
}
// if ( entry.isHeader !== undefined ) {
// $entryLi.toggleClass("ui-widget-header", !!entry.isHeader);
// }
if ( entry.data !== undefined ) {
$entryLi.data(entry.data);
}
// Set/clear class names, but handle ui-state-disabled separately
if ( entry.disabled === undefined ) {
entry.disabled = $entryLi.hasClass("ui-state-disabled");
}
if ( entry.setClass ) {
if ( $entryLi.hasClass("ui-menu-item") ) {
entry.setClass += " ui-menu-item";
}
$entryLi.removeClass();
$entryLi.addClass(entry.setClass);
} else if ( entry.addClass ) {
$entryLi.addClass(entry.addClass);
}
$entryLi.toggleClass("ui-state-disabled", !!entry.disabled);
// // #110: jQuery UI 1.12: refresh only works when this class is not set:
// $entryLi.removeClass("ui-menu-item");
// this.getMenu().menu("refresh");
}
});
/*
* Global functions
*/
$.extend($.moogle.contextmenu, {
/** Convert a menu description into a into a <li> content. */
createEntryMarkup: function(entry, $parentLi) {
var $wrapper = null;
$parentLi.attr("data-command", entry.cmd);
if ( !/[^\-\u2014\u2013\s]/.test( entry.title ) ) {
// hyphen, em dash, en dash: separator as defined by UI Menu 1.10
$parentLi.text(entry.title);
} else {
if ( isLTE110 ) {
// jQuery UI Menu 1.10 or before required an `<a>` tag
$wrapper = $("<a/>", {
html: "" + entry.title,
href: "#"
}).appendTo($parentLi);
} else if ( isLTE111 ) {
// jQuery UI Menu 1.11 preferes to avoid `<a>` tags or <div> wrapper
$parentLi.html("" + entry.title);
$wrapper = $parentLi;
} else {
// jQuery UI Menu 1.12 introduced `<div>` wrappers
$wrapper = $("<div/>", {
html: "" + entry.title
}).appendTo($parentLi);
}
if ( entry.uiIcon ) {
$wrapper.append($("<span class='ui-icon' />").addClass(entry.uiIcon));
}
// Store option callbacks in entry's data
$.each( [ "action", "disabled", "title", "tooltip" ], function(i, attr) {
if ( $.isFunction(entry[attr]) ) {
$parentLi.data(attr + "Handler", entry[attr]);
}
});
if ( entry.disabled === true ) {
$parentLi.addClass("ui-state-disabled");
}
if ( entry.isHeader ) {
$parentLi.addClass("ui-widget-header");
}
if ( entry.addClass ) {
$parentLi.addClass(entry.addClass);
}
if ( $.isPlainObject(entry.data) ) {
$parentLi.data(entry.data);
}
if ( typeof entry.tooltip === "string" ) {
$parentLi.attr("title", entry.tooltip);
}
}
},
/** Convert a nested array of command objects into a <ul> structure. */
createMenuMarkup: function(options, $parentUl) {
var i, menu, $ul, $li;
if ( $parentUl == null ) {
$parentUl = $("<ul class='ui-helper-hidden' />").appendTo("body");
}
for (i = 0; i < options.length; i++) {
menu = options[i];
$li = $("<li/>").appendTo($parentUl);
$.moogle.contextmenu.createEntryMarkup(menu, $li);
if ( $.isArray(menu.children) ) {
$ul = $("<ul/>").appendTo($li);
$.moogle.contextmenu.createMenuMarkup(menu.children, $ul);
}
}
return $parentUl;
},
/** Returns true if the menu item has child menu items */
isMenu: function(item) {
if ( isLTE110 ) {
return item.has(">a[aria-haspopup='true']").length > 0;
} else if ( isLTE111 ) { // jQuery UI 1.11 used no tag wrappers
return item.is("[aria-haspopup='true']");
} else {
return item.has(">div[aria-haspopup='true']").length > 0;
}
},
/** Replace the title of elem', but retain icons andchild entries. */
replaceFirstTextNodeChild: function(elem, html) {
var $icons = elem.find(">span.ui-icon,>ul.ui-menu").detach();
elem
.empty()
.html(html)
.append($icons);
},
/** Updates the menu item's title */
updateTitle: function(item, title) {
if ( isLTE110 ) { // jQuery UI 1.10 and before used <a> tags
$.moogle.contextmenu.replaceFirstTextNodeChild($("a", item), title);
} else if ( isLTE111 ) { // jQuery UI 1.11 used no tag wrappers
$.moogle.contextmenu.replaceFirstTextNodeChild(item, title);
} else { // jQuery UI 1.12+ introduced <div> tag wrappers
$.moogle.contextmenu.replaceFirstTextNodeChild($("div", item), title);
}
}
});
}));

File diff suppressed because one or more lines are too long

124
public/libraries/knockout.min.js vendored Normal file
View File

@@ -0,0 +1,124 @@
/*!
* Knockout JavaScript library v3.4.2
* (c) The Knockout.js team - http://knockoutjs.com/
* License: MIT (http://www.opensource.org/licenses/mit-license.php)
*/
(function() {(function(n){var x=this||(0,eval)("this"),t=x.document,M=x.navigator,u=x.jQuery,H=x.JSON;(function(n){"function"===typeof define&&define.amd?define(["exports","require"],n):"object"===typeof exports&&"object"===typeof module?n(module.exports||exports):n(x.ko={})})(function(N,O){function J(a,c){return null===a||typeof a in R?a===c:!1}function S(b,c){var d;return function(){d||(d=a.a.setTimeout(function(){d=n;b()},c))}}function T(b,c){var d;return function(){clearTimeout(d);d=a.a.setTimeout(b,c)}}function U(a,
c){c&&c!==E?"beforeChange"===c?this.Ob(a):this.Ja(a,c):this.Pb(a)}function V(a,c){null!==c&&c.k&&c.k()}function W(a,c){var d=this.Mc,e=d[s];e.T||(this.ob&&this.Oa[c]?(d.Sb(c,a,this.Oa[c]),this.Oa[c]=null,--this.ob):e.s[c]||d.Sb(c,a,e.t?{$:a}:d.yc(a)),a.Ha&&a.Hc())}function K(b,c,d,e){a.d[b]={init:function(b,g,h,l,m){var k,r;a.m(function(){var q=g(),p=a.a.c(q),p=!d!==!p,A=!r;if(A||c||p!==k)A&&a.xa.Ca()&&(r=a.a.wa(a.f.childNodes(b),!0)),p?(A||a.f.fa(b,a.a.wa(r)),a.hb(e?e(m,q):m,b)):a.f.za(b),k=p},null,
{i:b});return{controlsDescendantBindings:!0}}};a.h.va[b]=!1;a.f.aa[b]=!0}var a="undefined"!==typeof N?N:{};a.b=function(b,c){for(var d=b.split("."),e=a,f=0;f<d.length-1;f++)e=e[d[f]];e[d[d.length-1]]=c};a.H=function(a,c,d){a[c]=d};a.version="3.4.2";a.b("version",a.version);a.options={deferUpdates:!1,useOnlyNativeEvents:!1};a.a=function(){function b(a,b){for(var c in a)a.hasOwnProperty(c)&&b(c,a[c])}function c(a,b){if(b)for(var c in b)b.hasOwnProperty(c)&&(a[c]=b[c]);return a}function d(a,b){a.__proto__=
b;return a}function e(b,c,d,e){var m=b[c].match(r)||[];a.a.r(d.match(r),function(b){a.a.ra(m,b,e)});b[c]=m.join(" ")}var f={__proto__:[]}instanceof Array,g="function"===typeof Symbol,h={},l={};h[M&&/Firefox\/2/i.test(M.userAgent)?"KeyboardEvent":"UIEvents"]=["keyup","keydown","keypress"];h.MouseEvents="click dblclick mousedown mouseup mousemove mouseover mouseout mouseenter mouseleave".split(" ");b(h,function(a,b){if(b.length)for(var c=0,d=b.length;c<d;c++)l[b[c]]=a});var m={propertychange:!0},k=
t&&function(){for(var a=3,b=t.createElement("div"),c=b.getElementsByTagName("i");b.innerHTML="\x3c!--[if gt IE "+ ++a+"]><i></i><![endif]--\x3e",c[0];);return 4<a?a:n}(),r=/\S+/g;return{gc:["authenticity_token",/^__RequestVerificationToken(_.*)?$/],r:function(a,b){for(var c=0,d=a.length;c<d;c++)b(a[c],c)},o:function(a,b){if("function"==typeof Array.prototype.indexOf)return Array.prototype.indexOf.call(a,b);for(var c=0,d=a.length;c<d;c++)if(a[c]===b)return c;return-1},Vb:function(a,b,c){for(var d=
0,e=a.length;d<e;d++)if(b.call(c,a[d],d))return a[d];return null},Na:function(b,c){var d=a.a.o(b,c);0<d?b.splice(d,1):0===d&&b.shift()},Wb:function(b){b=b||[];for(var c=[],d=0,e=b.length;d<e;d++)0>a.a.o(c,b[d])&&c.push(b[d]);return c},ib:function(a,b){a=a||[];for(var c=[],d=0,e=a.length;d<e;d++)c.push(b(a[d],d));return c},Ma:function(a,b){a=a||[];for(var c=[],d=0,e=a.length;d<e;d++)b(a[d],d)&&c.push(a[d]);return c},ta:function(a,b){if(b instanceof Array)a.push.apply(a,b);else for(var c=0,d=b.length;c<
d;c++)a.push(b[c]);return a},ra:function(b,c,d){var e=a.a.o(a.a.Bb(b),c);0>e?d&&b.push(c):d||b.splice(e,1)},la:f,extend:c,$a:d,ab:f?d:c,D:b,Ea:function(a,b){if(!a)return a;var c={},d;for(d in a)a.hasOwnProperty(d)&&(c[d]=b(a[d],d,a));return c},rb:function(b){for(;b.firstChild;)a.removeNode(b.firstChild)},nc:function(b){b=a.a.W(b);for(var c=(b[0]&&b[0].ownerDocument||t).createElement("div"),d=0,e=b.length;d<e;d++)c.appendChild(a.ba(b[d]));return c},wa:function(b,c){for(var d=0,e=b.length,m=[];d<e;d++){var k=
b[d].cloneNode(!0);m.push(c?a.ba(k):k)}return m},fa:function(b,c){a.a.rb(b);if(c)for(var d=0,e=c.length;d<e;d++)b.appendChild(c[d])},uc:function(b,c){var d=b.nodeType?[b]:b;if(0<d.length){for(var e=d[0],m=e.parentNode,k=0,f=c.length;k<f;k++)m.insertBefore(c[k],e);k=0;for(f=d.length;k<f;k++)a.removeNode(d[k])}},Ba:function(a,b){if(a.length){for(b=8===b.nodeType&&b.parentNode||b;a.length&&a[0].parentNode!==b;)a.splice(0,1);for(;1<a.length&&a[a.length-1].parentNode!==b;)a.length--;if(1<a.length){var c=
a[0],d=a[a.length-1];for(a.length=0;c!==d;)a.push(c),c=c.nextSibling;a.push(d)}}return a},wc:function(a,b){7>k?a.setAttribute("selected",b):a.selected=b},cb:function(a){return null===a||a===n?"":a.trim?a.trim():a.toString().replace(/^[\s\xa0]+|[\s\xa0]+$/g,"")},sd:function(a,b){a=a||"";return b.length>a.length?!1:a.substring(0,b.length)===b},Rc:function(a,b){if(a===b)return!0;if(11===a.nodeType)return!1;if(b.contains)return b.contains(3===a.nodeType?a.parentNode:a);if(b.compareDocumentPosition)return 16==
(b.compareDocumentPosition(a)&16);for(;a&&a!=b;)a=a.parentNode;return!!a},qb:function(b){return a.a.Rc(b,b.ownerDocument.documentElement)},Tb:function(b){return!!a.a.Vb(b,a.a.qb)},A:function(a){return a&&a.tagName&&a.tagName.toLowerCase()},Zb:function(b){return a.onError?function(){try{return b.apply(this,arguments)}catch(c){throw a.onError&&a.onError(c),c;}}:b},setTimeout:function(b,c){return setTimeout(a.a.Zb(b),c)},dc:function(b){setTimeout(function(){a.onError&&a.onError(b);throw b;},0)},q:function(b,
c,d){var e=a.a.Zb(d);d=k&&m[c];if(a.options.useOnlyNativeEvents||d||!u)if(d||"function"!=typeof b.addEventListener)if("undefined"!=typeof b.attachEvent){var f=function(a){e.call(b,a)},l="on"+c;b.attachEvent(l,f);a.a.G.qa(b,function(){b.detachEvent(l,f)})}else throw Error("Browser doesn't support addEventListener or attachEvent");else b.addEventListener(c,e,!1);else u(b).bind(c,e)},Fa:function(b,c){if(!b||!b.nodeType)throw Error("element must be a DOM node when calling triggerEvent");var d;"input"===
a.a.A(b)&&b.type&&"click"==c.toLowerCase()?(d=b.type,d="checkbox"==d||"radio"==d):d=!1;if(a.options.useOnlyNativeEvents||!u||d)if("function"==typeof t.createEvent)if("function"==typeof b.dispatchEvent)d=t.createEvent(l[c]||"HTMLEvents"),d.initEvent(c,!0,!0,x,0,0,0,0,0,!1,!1,!1,!1,0,b),b.dispatchEvent(d);else throw Error("The supplied element doesn't support dispatchEvent");else if(d&&b.click)b.click();else if("undefined"!=typeof b.fireEvent)b.fireEvent("on"+c);else throw Error("Browser doesn't support triggering events");
else u(b).trigger(c)},c:function(b){return a.I(b)?b():b},Bb:function(b){return a.I(b)?b.p():b},fb:function(b,c,d){var k;c&&("object"===typeof b.classList?(k=b.classList[d?"add":"remove"],a.a.r(c.match(r),function(a){k.call(b.classList,a)})):"string"===typeof b.className.baseVal?e(b.className,"baseVal",c,d):e(b,"className",c,d))},bb:function(b,c){var d=a.a.c(c);if(null===d||d===n)d="";var e=a.f.firstChild(b);!e||3!=e.nodeType||a.f.nextSibling(e)?a.f.fa(b,[b.ownerDocument.createTextNode(d)]):e.data=
d;a.a.Wc(b)},vc:function(a,b){a.name=b;if(7>=k)try{a.mergeAttributes(t.createElement("<input name='"+a.name+"'/>"),!1)}catch(c){}},Wc:function(a){9<=k&&(a=1==a.nodeType?a:a.parentNode,a.style&&(a.style.zoom=a.style.zoom))},Sc:function(a){if(k){var b=a.style.width;a.style.width=0;a.style.width=b}},nd:function(b,c){b=a.a.c(b);c=a.a.c(c);for(var d=[],e=b;e<=c;e++)d.push(e);return d},W:function(a){for(var b=[],c=0,d=a.length;c<d;c++)b.push(a[c]);return b},bc:function(a){return g?Symbol(a):a},xd:6===k,
yd:7===k,C:k,ic:function(b,c){for(var d=a.a.W(b.getElementsByTagName("input")).concat(a.a.W(b.getElementsByTagName("textarea"))),e="string"==typeof c?function(a){return a.name===c}:function(a){return c.test(a.name)},k=[],m=d.length-1;0<=m;m--)e(d[m])&&k.push(d[m]);return k},kd:function(b){return"string"==typeof b&&(b=a.a.cb(b))?H&&H.parse?H.parse(b):(new Function("return "+b))():null},Gb:function(b,c,d){if(!H||!H.stringify)throw Error("Cannot find JSON.stringify(). Some browsers (e.g., IE < 8) don't support it natively, but you can overcome this by adding a script reference to json2.js, downloadable from http://www.json.org/json2.js");
return H.stringify(a.a.c(b),c,d)},ld:function(c,d,e){e=e||{};var k=e.params||{},m=e.includeFields||this.gc,f=c;if("object"==typeof c&&"form"===a.a.A(c))for(var f=c.action,l=m.length-1;0<=l;l--)for(var g=a.a.ic(c,m[l]),h=g.length-1;0<=h;h--)k[g[h].name]=g[h].value;d=a.a.c(d);var r=t.createElement("form");r.style.display="none";r.action=f;r.method="post";for(var n in d)c=t.createElement("input"),c.type="hidden",c.name=n,c.value=a.a.Gb(a.a.c(d[n])),r.appendChild(c);b(k,function(a,b){var c=t.createElement("input");
c.type="hidden";c.name=a;c.value=b;r.appendChild(c)});t.body.appendChild(r);e.submitter?e.submitter(r):r.submit();setTimeout(function(){r.parentNode.removeChild(r)},0)}}}();a.b("utils",a.a);a.b("utils.arrayForEach",a.a.r);a.b("utils.arrayFirst",a.a.Vb);a.b("utils.arrayFilter",a.a.Ma);a.b("utils.arrayGetDistinctValues",a.a.Wb);a.b("utils.arrayIndexOf",a.a.o);a.b("utils.arrayMap",a.a.ib);a.b("utils.arrayPushAll",a.a.ta);a.b("utils.arrayRemoveItem",a.a.Na);a.b("utils.extend",a.a.extend);a.b("utils.fieldsIncludedWithJsonPost",
a.a.gc);a.b("utils.getFormFields",a.a.ic);a.b("utils.peekObservable",a.a.Bb);a.b("utils.postJson",a.a.ld);a.b("utils.parseJson",a.a.kd);a.b("utils.registerEventHandler",a.a.q);a.b("utils.stringifyJson",a.a.Gb);a.b("utils.range",a.a.nd);a.b("utils.toggleDomNodeCssClass",a.a.fb);a.b("utils.triggerEvent",a.a.Fa);a.b("utils.unwrapObservable",a.a.c);a.b("utils.objectForEach",a.a.D);a.b("utils.addOrRemoveItem",a.a.ra);a.b("utils.setTextContent",a.a.bb);a.b("unwrap",a.a.c);Function.prototype.bind||(Function.prototype.bind=
function(a){var c=this;if(1===arguments.length)return function(){return c.apply(a,arguments)};var d=Array.prototype.slice.call(arguments,1);return function(){var e=d.slice(0);e.push.apply(e,arguments);return c.apply(a,e)}});a.a.e=new function(){function a(b,g){var h=b[d];if(!h||"null"===h||!e[h]){if(!g)return n;h=b[d]="ko"+c++;e[h]={}}return e[h]}var c=0,d="__ko__"+(new Date).getTime(),e={};return{get:function(c,d){var e=a(c,!1);return e===n?n:e[d]},set:function(c,d,e){if(e!==n||a(c,!1)!==n)a(c,!0)[d]=
e},clear:function(a){var b=a[d];return b?(delete e[b],a[d]=null,!0):!1},J:function(){return c++ +d}}};a.b("utils.domData",a.a.e);a.b("utils.domData.clear",a.a.e.clear);a.a.G=new function(){function b(b,c){var e=a.a.e.get(b,d);e===n&&c&&(e=[],a.a.e.set(b,d,e));return e}function c(d){var e=b(d,!1);if(e)for(var e=e.slice(0),l=0;l<e.length;l++)e[l](d);a.a.e.clear(d);a.a.G.cleanExternalData(d);if(f[d.nodeType])for(e=d.firstChild;d=e;)e=d.nextSibling,8===d.nodeType&&c(d)}var d=a.a.e.J(),e={1:!0,8:!0,9:!0},
f={1:!0,9:!0};return{qa:function(a,c){if("function"!=typeof c)throw Error("Callback must be a function");b(a,!0).push(c)},tc:function(c,e){var f=b(c,!1);f&&(a.a.Na(f,e),0==f.length&&a.a.e.set(c,d,n))},ba:function(b){if(e[b.nodeType]&&(c(b),f[b.nodeType])){var d=[];a.a.ta(d,b.getElementsByTagName("*"));for(var l=0,m=d.length;l<m;l++)c(d[l])}return b},removeNode:function(b){a.ba(b);b.parentNode&&b.parentNode.removeChild(b)},cleanExternalData:function(a){u&&"function"==typeof u.cleanData&&u.cleanData([a])}}};
a.ba=a.a.G.ba;a.removeNode=a.a.G.removeNode;a.b("cleanNode",a.ba);a.b("removeNode",a.removeNode);a.b("utils.domNodeDisposal",a.a.G);a.b("utils.domNodeDisposal.addDisposeCallback",a.a.G.qa);a.b("utils.domNodeDisposal.removeDisposeCallback",a.a.G.tc);(function(){var b=[0,"",""],c=[1,"<table>","</table>"],d=[3,"<table><tbody><tr>","</tr></tbody></table>"],e=[1,"<select multiple='multiple'>","</select>"],f={thead:c,tbody:c,tfoot:c,tr:[2,"<table><tbody>","</tbody></table>"],td:d,th:d,option:e,optgroup:e},
g=8>=a.a.C;a.a.na=function(c,d){var e;if(u)if(u.parseHTML)e=u.parseHTML(c,d)||[];else{if((e=u.clean([c],d))&&e[0]){for(var k=e[0];k.parentNode&&11!==k.parentNode.nodeType;)k=k.parentNode;k.parentNode&&k.parentNode.removeChild(k)}}else{(e=d)||(e=t);var k=e.parentWindow||e.defaultView||x,r=a.a.cb(c).toLowerCase(),q=e.createElement("div"),p;p=(r=r.match(/^<([a-z]+)[ >]/))&&f[r[1]]||b;r=p[0];p="ignored<div>"+p[1]+c+p[2]+"</div>";"function"==typeof k.innerShiv?q.appendChild(k.innerShiv(p)):(g&&e.appendChild(q),
q.innerHTML=p,g&&q.parentNode.removeChild(q));for(;r--;)q=q.lastChild;e=a.a.W(q.lastChild.childNodes)}return e};a.a.Eb=function(b,c){a.a.rb(b);c=a.a.c(c);if(null!==c&&c!==n)if("string"!=typeof c&&(c=c.toString()),u)u(b).html(c);else for(var d=a.a.na(c,b.ownerDocument),e=0;e<d.length;e++)b.appendChild(d[e])}})();a.b("utils.parseHtmlFragment",a.a.na);a.b("utils.setHtml",a.a.Eb);a.N=function(){function b(c,e){if(c)if(8==c.nodeType){var f=a.N.pc(c.nodeValue);null!=f&&e.push({Qc:c,hd:f})}else if(1==c.nodeType)for(var f=
0,g=c.childNodes,h=g.length;f<h;f++)b(g[f],e)}var c={};return{yb:function(a){if("function"!=typeof a)throw Error("You can only pass a function to ko.memoization.memoize()");var b=(4294967296*(1+Math.random())|0).toString(16).substring(1)+(4294967296*(1+Math.random())|0).toString(16).substring(1);c[b]=a;return"\x3c!--[ko_memo:"+b+"]--\x3e"},Bc:function(a,b){var f=c[a];if(f===n)throw Error("Couldn't find any memo with ID "+a+". Perhaps it's already been unmemoized.");try{return f.apply(null,b||[]),
!0}finally{delete c[a]}},Cc:function(c,e){var f=[];b(c,f);for(var g=0,h=f.length;g<h;g++){var l=f[g].Qc,m=[l];e&&a.a.ta(m,e);a.N.Bc(f[g].hd,m);l.nodeValue="";l.parentNode&&l.parentNode.removeChild(l)}},pc:function(a){return(a=a.match(/^\[ko_memo\:(.*?)\]$/))?a[1]:null}}}();a.b("memoization",a.N);a.b("memoization.memoize",a.N.yb);a.b("memoization.unmemoize",a.N.Bc);a.b("memoization.parseMemoText",a.N.pc);a.b("memoization.unmemoizeDomNodeAndDescendants",a.N.Cc);a.Z=function(){function b(){if(e)for(var b=
e,c=0,m;g<e;)if(m=d[g++]){if(g>b){if(5E3<=++c){g=e;a.a.dc(Error("'Too much recursion' after processing "+c+" task groups."));break}b=e}try{m()}catch(k){a.a.dc(k)}}}function c(){b();g=e=d.length=0}var d=[],e=0,f=1,g=0;return{scheduler:x.MutationObserver?function(a){var b=t.createElement("div");(new MutationObserver(a)).observe(b,{attributes:!0});return function(){b.classList.toggle("foo")}}(c):t&&"onreadystatechange"in t.createElement("script")?function(a){var b=t.createElement("script");b.onreadystatechange=
function(){b.onreadystatechange=null;t.documentElement.removeChild(b);b=null;a()};t.documentElement.appendChild(b)}:function(a){setTimeout(a,0)},Za:function(b){e||a.Z.scheduler(c);d[e++]=b;return f++},cancel:function(a){a-=f-e;a>=g&&a<e&&(d[a]=null)},resetForTesting:function(){var a=e-g;g=e=d.length=0;return a},rd:b}}();a.b("tasks",a.Z);a.b("tasks.schedule",a.Z.Za);a.b("tasks.runEarly",a.Z.rd);a.Aa={throttle:function(b,c){b.throttleEvaluation=c;var d=null;return a.B({read:b,write:function(e){clearTimeout(d);
d=a.a.setTimeout(function(){b(e)},c)}})},rateLimit:function(a,c){var d,e,f;"number"==typeof c?d=c:(d=c.timeout,e=c.method);a.gb=!1;f="notifyWhenChangesStop"==e?T:S;a.Wa(function(a){return f(a,d)})},deferred:function(b,c){if(!0!==c)throw Error("The 'deferred' extender only accepts the value 'true', because it is not supported to turn deferral off once enabled.");b.gb||(b.gb=!0,b.Wa(function(c){var e,f=!1;return function(){if(!f){a.Z.cancel(e);e=a.Z.Za(c);try{f=!0,b.notifySubscribers(n,"dirty")}finally{f=
!1}}}}))},notify:function(a,c){a.equalityComparer="always"==c?null:J}};var R={undefined:1,"boolean":1,number:1,string:1};a.b("extenders",a.Aa);a.zc=function(b,c,d){this.$=b;this.jb=c;this.Pc=d;this.T=!1;a.H(this,"dispose",this.k)};a.zc.prototype.k=function(){this.T=!0;this.Pc()};a.K=function(){a.a.ab(this,D);D.ub(this)};var E="change",D={ub:function(a){a.F={change:[]};a.Qb=1},Y:function(b,c,d){var e=this;d=d||E;var f=new a.zc(e,c?b.bind(c):b,function(){a.a.Na(e.F[d],f);e.Ka&&e.Ka(d)});e.ua&&e.ua(d);
e.F[d]||(e.F[d]=[]);e.F[d].push(f);return f},notifySubscribers:function(b,c){c=c||E;c===E&&this.Kb();if(this.Ra(c)){var d=c===E&&this.Fc||this.F[c].slice(0);try{a.l.Xb();for(var e=0,f;f=d[e];++e)f.T||f.jb(b)}finally{a.l.end()}}},Pa:function(){return this.Qb},Zc:function(a){return this.Pa()!==a},Kb:function(){++this.Qb},Wa:function(b){var c=this,d=a.I(c),e,f,g,h;c.Ja||(c.Ja=c.notifySubscribers,c.notifySubscribers=U);var l=b(function(){c.Ha=!1;d&&h===c&&(h=c.Mb?c.Mb():c());var a=f||c.Ua(g,h);f=e=!1;
a&&c.Ja(g=h)});c.Pb=function(a){c.Fc=c.F[E].slice(0);c.Ha=e=!0;h=a;l()};c.Ob=function(a){e||(g=a,c.Ja(a,"beforeChange"))};c.Hc=function(){c.Ua(g,c.p(!0))&&(f=!0)}},Ra:function(a){return this.F[a]&&this.F[a].length},Xc:function(b){if(b)return this.F[b]&&this.F[b].length||0;var c=0;a.a.D(this.F,function(a,b){"dirty"!==a&&(c+=b.length)});return c},Ua:function(a,c){return!this.equalityComparer||!this.equalityComparer(a,c)},extend:function(b){var c=this;b&&a.a.D(b,function(b,e){var f=a.Aa[b];"function"==
typeof f&&(c=f(c,e)||c)});return c}};a.H(D,"subscribe",D.Y);a.H(D,"extend",D.extend);a.H(D,"getSubscriptionsCount",D.Xc);a.a.la&&a.a.$a(D,Function.prototype);a.K.fn=D;a.lc=function(a){return null!=a&&"function"==typeof a.Y&&"function"==typeof a.notifySubscribers};a.b("subscribable",a.K);a.b("isSubscribable",a.lc);a.xa=a.l=function(){function b(a){d.push(e);e=a}function c(){e=d.pop()}var d=[],e,f=0;return{Xb:b,end:c,sc:function(b){if(e){if(!a.lc(b))throw Error("Only subscribable things can act as dependencies");
e.jb.call(e.Lc,b,b.Gc||(b.Gc=++f))}},w:function(a,d,e){try{return b(),a.apply(d,e||[])}finally{c()}},Ca:function(){if(e)return e.m.Ca()},Va:function(){if(e)return e.Va}}}();a.b("computedContext",a.xa);a.b("computedContext.getDependenciesCount",a.xa.Ca);a.b("computedContext.isInitial",a.xa.Va);a.b("ignoreDependencies",a.wd=a.l.w);var F=a.a.bc("_latestValue");a.O=function(b){function c(){if(0<arguments.length)return c.Ua(c[F],arguments[0])&&(c.ia(),c[F]=arguments[0],c.ha()),this;a.l.sc(c);return c[F]}
c[F]=b;a.a.la||a.a.extend(c,a.K.fn);a.K.fn.ub(c);a.a.ab(c,B);a.options.deferUpdates&&a.Aa.deferred(c,!0);return c};var B={equalityComparer:J,p:function(){return this[F]},ha:function(){this.notifySubscribers(this[F])},ia:function(){this.notifySubscribers(this[F],"beforeChange")}};a.a.la&&a.a.$a(B,a.K.fn);var I=a.O.md="__ko_proto__";B[I]=a.O;a.Qa=function(b,c){return null===b||b===n||b[I]===n?!1:b[I]===c?!0:a.Qa(b[I],c)};a.I=function(b){return a.Qa(b,a.O)};a.Da=function(b){return"function"==typeof b&&
b[I]===a.O||"function"==typeof b&&b[I]===a.B&&b.$c?!0:!1};a.b("observable",a.O);a.b("isObservable",a.I);a.b("isWriteableObservable",a.Da);a.b("isWritableObservable",a.Da);a.b("observable.fn",B);a.H(B,"peek",B.p);a.H(B,"valueHasMutated",B.ha);a.H(B,"valueWillMutate",B.ia);a.ma=function(b){b=b||[];if("object"!=typeof b||!("length"in b))throw Error("The argument passed when initializing an observable array must be an array, or null, or undefined.");b=a.O(b);a.a.ab(b,a.ma.fn);return b.extend({trackArrayChanges:!0})};
a.ma.fn={remove:function(b){for(var c=this.p(),d=[],e="function"!=typeof b||a.I(b)?function(a){return a===b}:b,f=0;f<c.length;f++){var g=c[f];e(g)&&(0===d.length&&this.ia(),d.push(g),c.splice(f,1),f--)}d.length&&this.ha();return d},removeAll:function(b){if(b===n){var c=this.p(),d=c.slice(0);this.ia();c.splice(0,c.length);this.ha();return d}return b?this.remove(function(c){return 0<=a.a.o(b,c)}):[]},destroy:function(b){var c=this.p(),d="function"!=typeof b||a.I(b)?function(a){return a===b}:b;this.ia();
for(var e=c.length-1;0<=e;e--)d(c[e])&&(c[e]._destroy=!0);this.ha()},destroyAll:function(b){return b===n?this.destroy(function(){return!0}):b?this.destroy(function(c){return 0<=a.a.o(b,c)}):[]},indexOf:function(b){var c=this();return a.a.o(c,b)},replace:function(a,c){var d=this.indexOf(a);0<=d&&(this.ia(),this.p()[d]=c,this.ha())}};a.a.la&&a.a.$a(a.ma.fn,a.O.fn);a.a.r("pop push reverse shift sort splice unshift".split(" "),function(b){a.ma.fn[b]=function(){var a=this.p();this.ia();this.Yb(a,b,arguments);
var d=a[b].apply(a,arguments);this.ha();return d===a?this:d}});a.a.r(["slice"],function(b){a.ma.fn[b]=function(){var a=this();return a[b].apply(a,arguments)}});a.b("observableArray",a.ma);a.Aa.trackArrayChanges=function(b,c){function d(){if(!e){e=!0;l=b.notifySubscribers;b.notifySubscribers=function(a,b){b&&b!==E||++h;return l.apply(this,arguments)};var c=[].concat(b.p()||[]);f=null;g=b.Y(function(d){d=[].concat(d||[]);if(b.Ra("arrayChange")){var e;if(!f||1<h)f=a.a.lb(c,d,b.kb);e=f}c=d;f=null;h=0;
e&&e.length&&b.notifySubscribers(e,"arrayChange")})}}b.kb={};c&&"object"==typeof c&&a.a.extend(b.kb,c);b.kb.sparse=!0;if(!b.Yb){var e=!1,f=null,g,h=0,l,m=b.ua,k=b.Ka;b.ua=function(a){m&&m.call(b,a);"arrayChange"===a&&d()};b.Ka=function(a){k&&k.call(b,a);"arrayChange"!==a||b.Ra("arrayChange")||(l&&(b.notifySubscribers=l,l=n),g.k(),e=!1)};b.Yb=function(b,c,d){function k(a,b,c){return m[m.length]={status:a,value:b,index:c}}if(e&&!h){var m=[],l=b.length,g=d.length,G=0;switch(c){case "push":G=l;case "unshift":for(c=
0;c<g;c++)k("added",d[c],G+c);break;case "pop":G=l-1;case "shift":l&&k("deleted",b[G],G);break;case "splice":c=Math.min(Math.max(0,0>d[0]?l+d[0]:d[0]),l);for(var l=1===g?l:Math.min(c+(d[1]||0),l),g=c+g-2,G=Math.max(l,g),n=[],s=[],w=2;c<G;++c,++w)c<l&&s.push(k("deleted",b[c],c)),c<g&&n.push(k("added",d[w],c));a.a.hc(s,n);break;default:return}f=m}}}};var s=a.a.bc("_state");a.m=a.B=function(b,c,d){function e(){if(0<arguments.length){if("function"===typeof f)f.apply(g.sb,arguments);else throw Error("Cannot write a value to a ko.computed unless you specify a 'write' option. If you wish to read the current value, don't pass any parameters.");
return this}a.l.sc(e);(g.V||g.t&&e.Sa())&&e.U();return g.M}"object"===typeof b?d=b:(d=d||{},b&&(d.read=b));if("function"!=typeof d.read)throw Error("Pass a function that returns the value of the ko.computed");var f=d.write,g={M:n,da:!0,V:!0,Ta:!1,Hb:!1,T:!1,Ya:!1,t:!1,od:d.read,sb:c||d.owner,i:d.disposeWhenNodeIsRemoved||d.i||null,ya:d.disposeWhen||d.ya,pb:null,s:{},L:0,fc:null};e[s]=g;e.$c="function"===typeof f;a.a.la||a.a.extend(e,a.K.fn);a.K.fn.ub(e);a.a.ab(e,z);d.pure?(g.Ya=!0,g.t=!0,a.a.extend(e,
Y)):d.deferEvaluation&&a.a.extend(e,Z);a.options.deferUpdates&&a.Aa.deferred(e,!0);g.i&&(g.Hb=!0,g.i.nodeType||(g.i=null));g.t||d.deferEvaluation||e.U();g.i&&e.ca()&&a.a.G.qa(g.i,g.pb=function(){e.k()});return e};var z={equalityComparer:J,Ca:function(){return this[s].L},Sb:function(a,c,d){if(this[s].Ya&&c===this)throw Error("A 'pure' computed must not be called recursively");this[s].s[a]=d;d.Ia=this[s].L++;d.pa=c.Pa()},Sa:function(){var a,c,d=this[s].s;for(a in d)if(d.hasOwnProperty(a)&&(c=d[a],this.oa&&
c.$.Ha||c.$.Zc(c.pa)))return!0},gd:function(){this.oa&&!this[s].Ta&&this.oa(!1)},ca:function(){var a=this[s];return a.V||0<a.L},qd:function(){this.Ha?this[s].V&&(this[s].da=!0):this.ec()},yc:function(a){if(a.gb&&!this[s].i){var c=a.Y(this.gd,this,"dirty"),d=a.Y(this.qd,this);return{$:a,k:function(){c.k();d.k()}}}return a.Y(this.ec,this)},ec:function(){var b=this,c=b.throttleEvaluation;c&&0<=c?(clearTimeout(this[s].fc),this[s].fc=a.a.setTimeout(function(){b.U(!0)},c)):b.oa?b.oa(!0):b.U(!0)},U:function(b){var c=
this[s],d=c.ya,e=!1;if(!c.Ta&&!c.T){if(c.i&&!a.a.qb(c.i)||d&&d()){if(!c.Hb){this.k();return}}else c.Hb=!1;c.Ta=!0;try{e=this.Vc(b)}finally{c.Ta=!1}c.L||this.k();return e}},Vc:function(b){var c=this[s],d=!1,e=c.Ya?n:!c.L,f={Mc:this,Oa:c.s,ob:c.L};a.l.Xb({Lc:f,jb:W,m:this,Va:e});c.s={};c.L=0;f=this.Uc(c,f);this.Ua(c.M,f)&&(c.t||this.notifySubscribers(c.M,"beforeChange"),c.M=f,c.t?this.Kb():b&&this.notifySubscribers(c.M),d=!0);e&&this.notifySubscribers(c.M,"awake");return d},Uc:function(b,c){try{var d=
b.od;return b.sb?d.call(b.sb):d()}finally{a.l.end(),c.ob&&!b.t&&a.a.D(c.Oa,V),b.da=b.V=!1}},p:function(a){var c=this[s];(c.V&&(a||!c.L)||c.t&&this.Sa())&&this.U();return c.M},Wa:function(b){a.K.fn.Wa.call(this,b);this.Mb=function(){this[s].da?this.U():this[s].V=!1;return this[s].M};this.oa=function(a){this.Ob(this[s].M);this[s].V=!0;a&&(this[s].da=!0);this.Pb(this)}},k:function(){var b=this[s];!b.t&&b.s&&a.a.D(b.s,function(a,b){b.k&&b.k()});b.i&&b.pb&&a.a.G.tc(b.i,b.pb);b.s=null;b.L=0;b.T=!0;b.da=
!1;b.V=!1;b.t=!1;b.i=null}},Y={ua:function(b){var c=this,d=c[s];if(!d.T&&d.t&&"change"==b){d.t=!1;if(d.da||c.Sa())d.s=null,d.L=0,c.U()&&c.Kb();else{var e=[];a.a.D(d.s,function(a,b){e[b.Ia]=a});a.a.r(e,function(a,b){var e=d.s[a],l=c.yc(e.$);l.Ia=b;l.pa=e.pa;d.s[a]=l})}d.T||c.notifySubscribers(d.M,"awake")}},Ka:function(b){var c=this[s];c.T||"change"!=b||this.Ra("change")||(a.a.D(c.s,function(a,b){b.k&&(c.s[a]={$:b.$,Ia:b.Ia,pa:b.pa},b.k())}),c.t=!0,this.notifySubscribers(n,"asleep"))},Pa:function(){var b=
this[s];b.t&&(b.da||this.Sa())&&this.U();return a.K.fn.Pa.call(this)}},Z={ua:function(a){"change"!=a&&"beforeChange"!=a||this.p()}};a.a.la&&a.a.$a(z,a.K.fn);var P=a.O.md;a.m[P]=a.O;z[P]=a.m;a.bd=function(b){return a.Qa(b,a.m)};a.cd=function(b){return a.Qa(b,a.m)&&b[s]&&b[s].Ya};a.b("computed",a.m);a.b("dependentObservable",a.m);a.b("isComputed",a.bd);a.b("isPureComputed",a.cd);a.b("computed.fn",z);a.H(z,"peek",z.p);a.H(z,"dispose",z.k);a.H(z,"isActive",z.ca);a.H(z,"getDependenciesCount",z.Ca);a.rc=
function(b,c){if("function"===typeof b)return a.m(b,c,{pure:!0});b=a.a.extend({},b);b.pure=!0;return a.m(b,c)};a.b("pureComputed",a.rc);(function(){function b(a,f,g){g=g||new d;a=f(a);if("object"!=typeof a||null===a||a===n||a instanceof RegExp||a instanceof Date||a instanceof String||a instanceof Number||a instanceof Boolean)return a;var h=a instanceof Array?[]:{};g.save(a,h);c(a,function(c){var d=f(a[c]);switch(typeof d){case "boolean":case "number":case "string":case "function":h[c]=d;break;case "object":case "undefined":var k=
g.get(d);h[c]=k!==n?k:b(d,f,g)}});return h}function c(a,b){if(a instanceof Array){for(var c=0;c<a.length;c++)b(c);"function"==typeof a.toJSON&&b("toJSON")}else for(c in a)b(c)}function d(){this.keys=[];this.Lb=[]}a.Ac=function(c){if(0==arguments.length)throw Error("When calling ko.toJS, pass the object you want to convert.");return b(c,function(b){for(var c=0;a.I(b)&&10>c;c++)b=b();return b})};a.toJSON=function(b,c,d){b=a.Ac(b);return a.a.Gb(b,c,d)};d.prototype={save:function(b,c){var d=a.a.o(this.keys,
b);0<=d?this.Lb[d]=c:(this.keys.push(b),this.Lb.push(c))},get:function(b){b=a.a.o(this.keys,b);return 0<=b?this.Lb[b]:n}}})();a.b("toJS",a.Ac);a.b("toJSON",a.toJSON);(function(){a.j={u:function(b){switch(a.a.A(b)){case "option":return!0===b.__ko__hasDomDataOptionValue__?a.a.e.get(b,a.d.options.zb):7>=a.a.C?b.getAttributeNode("value")&&b.getAttributeNode("value").specified?b.value:b.text:b.value;case "select":return 0<=b.selectedIndex?a.j.u(b.options[b.selectedIndex]):n;default:return b.value}},ja:function(b,
c,d){switch(a.a.A(b)){case "option":switch(typeof c){case "string":a.a.e.set(b,a.d.options.zb,n);"__ko__hasDomDataOptionValue__"in b&&delete b.__ko__hasDomDataOptionValue__;b.value=c;break;default:a.a.e.set(b,a.d.options.zb,c),b.__ko__hasDomDataOptionValue__=!0,b.value="number"===typeof c?c:""}break;case "select":if(""===c||null===c)c=n;for(var e=-1,f=0,g=b.options.length,h;f<g;++f)if(h=a.j.u(b.options[f]),h==c||""==h&&c===n){e=f;break}if(d||0<=e||c===n&&1<b.size)b.selectedIndex=e;break;default:if(null===
c||c===n)c="";b.value=c}}}})();a.b("selectExtensions",a.j);a.b("selectExtensions.readValue",a.j.u);a.b("selectExtensions.writeValue",a.j.ja);a.h=function(){function b(b){b=a.a.cb(b);123===b.charCodeAt(0)&&(b=b.slice(1,-1));var c=[],d=b.match(e),r,h=[],p=0;if(d){d.push(",");for(var A=0,y;y=d[A];++A){var v=y.charCodeAt(0);if(44===v){if(0>=p){c.push(r&&h.length?{key:r,value:h.join("")}:{unknown:r||h.join("")});r=p=0;h=[];continue}}else if(58===v){if(!p&&!r&&1===h.length){r=h.pop();continue}}else 47===
v&&A&&1<y.length?(v=d[A-1].match(f))&&!g[v[0]]&&(b=b.substr(b.indexOf(y)+1),d=b.match(e),d.push(","),A=-1,y="/"):40===v||123===v||91===v?++p:41===v||125===v||93===v?--p:r||h.length||34!==v&&39!==v||(y=y.slice(1,-1));h.push(y)}}return c}var c=["true","false","null","undefined"],d=/^(?:[$_a-z][$\w]*|(.+)(\.\s*[$_a-z][$\w]*|\[.+\]))$/i,e=RegExp("\"(?:[^\"\\\\]|\\\\.)*\"|'(?:[^'\\\\]|\\\\.)*'|/(?:[^/\\\\]|\\\\.)*/w*|[^\\s:,/][^,\"'{}()/:[\\]]*[^\\s,\"'{}()/:[\\]]|[^\\s]","g"),f=/[\])"'A-Za-z0-9_$]+$/,
g={"in":1,"return":1,"typeof":1},h={};return{va:[],ga:h,Ab:b,Xa:function(e,m){function k(b,e){var m;if(!A){var l=a.getBindingHandler(b);if(l&&l.preprocess&&!(e=l.preprocess(e,b,k)))return;if(l=h[b])m=e,0<=a.a.o(c,m)?m=!1:(l=m.match(d),m=null===l?!1:l[1]?"Object("+l[1]+")"+l[2]:m),l=m;l&&g.push("'"+b+"':function(_z){"+m+"=_z}")}p&&(e="function(){return "+e+" }");f.push("'"+b+"':"+e)}m=m||{};var f=[],g=[],p=m.valueAccessors,A=m.bindingParams,y="string"===typeof e?b(e):e;a.a.r(y,function(a){k(a.key||
a.unknown,a.value)});g.length&&k("_ko_property_writers","{"+g.join(",")+" }");return f.join(",")},fd:function(a,b){for(var c=0;c<a.length;c++)if(a[c].key==b)return!0;return!1},Ga:function(b,c,d,e,f){if(b&&a.I(b))!a.Da(b)||f&&b.p()===e||b(e);else if((b=c.get("_ko_property_writers"))&&b[d])b[d](e)}}}();a.b("expressionRewriting",a.h);a.b("expressionRewriting.bindingRewriteValidators",a.h.va);a.b("expressionRewriting.parseObjectLiteral",a.h.Ab);a.b("expressionRewriting.preProcessBindings",a.h.Xa);a.b("expressionRewriting._twoWayBindings",
a.h.ga);a.b("jsonExpressionRewriting",a.h);a.b("jsonExpressionRewriting.insertPropertyAccessorsIntoJson",a.h.Xa);(function(){function b(a){return 8==a.nodeType&&g.test(f?a.text:a.nodeValue)}function c(a){return 8==a.nodeType&&h.test(f?a.text:a.nodeValue)}function d(a,d){for(var e=a,f=1,l=[];e=e.nextSibling;){if(c(e)&&(f--,0===f))return l;l.push(e);b(e)&&f++}if(!d)throw Error("Cannot find closing comment tag to match: "+a.nodeValue);return null}function e(a,b){var c=d(a,b);return c?0<c.length?c[c.length-
1].nextSibling:a.nextSibling:null}var f=t&&"\x3c!--test--\x3e"===t.createComment("test").text,g=f?/^\x3c!--\s*ko(?:\s+([\s\S]+))?\s*--\x3e$/:/^\s*ko(?:\s+([\s\S]+))?\s*$/,h=f?/^\x3c!--\s*\/ko\s*--\x3e$/:/^\s*\/ko\s*$/,l={ul:!0,ol:!0};a.f={aa:{},childNodes:function(a){return b(a)?d(a):a.childNodes},za:function(c){if(b(c)){c=a.f.childNodes(c);for(var d=0,e=c.length;d<e;d++)a.removeNode(c[d])}else a.a.rb(c)},fa:function(c,d){if(b(c)){a.f.za(c);for(var e=c.nextSibling,f=0,l=d.length;f<l;f++)e.parentNode.insertBefore(d[f],
e)}else a.a.fa(c,d)},qc:function(a,c){b(a)?a.parentNode.insertBefore(c,a.nextSibling):a.firstChild?a.insertBefore(c,a.firstChild):a.appendChild(c)},kc:function(c,d,e){e?b(c)?c.parentNode.insertBefore(d,e.nextSibling):e.nextSibling?c.insertBefore(d,e.nextSibling):c.appendChild(d):a.f.qc(c,d)},firstChild:function(a){return b(a)?!a.nextSibling||c(a.nextSibling)?null:a.nextSibling:a.firstChild},nextSibling:function(a){b(a)&&(a=e(a));return a.nextSibling&&c(a.nextSibling)?null:a.nextSibling},Yc:b,vd:function(a){return(a=
(f?a.text:a.nodeValue).match(g))?a[1]:null},oc:function(d){if(l[a.a.A(d)]){var k=d.firstChild;if(k){do if(1===k.nodeType){var f;f=k.firstChild;var g=null;if(f){do if(g)g.push(f);else if(b(f)){var h=e(f,!0);h?f=h:g=[f]}else c(f)&&(g=[f]);while(f=f.nextSibling)}if(f=g)for(g=k.nextSibling,h=0;h<f.length;h++)g?d.insertBefore(f[h],g):d.appendChild(f[h])}while(k=k.nextSibling)}}}}})();a.b("virtualElements",a.f);a.b("virtualElements.allowedBindings",a.f.aa);a.b("virtualElements.emptyNode",a.f.za);a.b("virtualElements.insertAfter",
a.f.kc);a.b("virtualElements.prepend",a.f.qc);a.b("virtualElements.setDomNodeChildren",a.f.fa);(function(){a.S=function(){this.Kc={}};a.a.extend(a.S.prototype,{nodeHasBindings:function(b){switch(b.nodeType){case 1:return null!=b.getAttribute("data-bind")||a.g.getComponentNameForNode(b);case 8:return a.f.Yc(b);default:return!1}},getBindings:function(b,c){var d=this.getBindingsString(b,c),d=d?this.parseBindingsString(d,c,b):null;return a.g.Rb(d,b,c,!1)},getBindingAccessors:function(b,c){var d=this.getBindingsString(b,
c),d=d?this.parseBindingsString(d,c,b,{valueAccessors:!0}):null;return a.g.Rb(d,b,c,!0)},getBindingsString:function(b){switch(b.nodeType){case 1:return b.getAttribute("data-bind");case 8:return a.f.vd(b);default:return null}},parseBindingsString:function(b,c,d,e){try{var f=this.Kc,g=b+(e&&e.valueAccessors||""),h;if(!(h=f[g])){var l,m="with($context){with($data||{}){return{"+a.h.Xa(b,e)+"}}}";l=new Function("$context","$element",m);h=f[g]=l}return h(c,d)}catch(k){throw k.message="Unable to parse bindings.\nBindings value: "+
b+"\nMessage: "+k.message,k;}}});a.S.instance=new a.S})();a.b("bindingProvider",a.S);(function(){function b(a){return function(){return a}}function c(a){return a()}function d(b){return a.a.Ea(a.l.w(b),function(a,c){return function(){return b()[c]}})}function e(c,e,k){return"function"===typeof c?d(c.bind(null,e,k)):a.a.Ea(c,b)}function f(a,b){return d(this.getBindings.bind(this,a,b))}function g(b,c,d){var e,k=a.f.firstChild(c),f=a.S.instance,m=f.preprocessNode;if(m){for(;e=k;)k=a.f.nextSibling(e),
m.call(f,e);k=a.f.firstChild(c)}for(;e=k;)k=a.f.nextSibling(e),h(b,e,d)}function h(b,c,d){var e=!0,k=1===c.nodeType;k&&a.f.oc(c);if(k&&d||a.S.instance.nodeHasBindings(c))e=m(c,null,b,d).shouldBindDescendants;e&&!r[a.a.A(c)]&&g(b,c,!k)}function l(b){var c=[],d={},e=[];a.a.D(b,function X(k){if(!d[k]){var f=a.getBindingHandler(k);f&&(f.after&&(e.push(k),a.a.r(f.after,function(c){if(b[c]){if(-1!==a.a.o(e,c))throw Error("Cannot combine the following bindings, because they have a cyclic dependency: "+e.join(", "));
X(c)}}),e.length--),c.push({key:k,jc:f}));d[k]=!0}});return c}function m(b,d,e,k){var m=a.a.e.get(b,q);if(!d){if(m)throw Error("You cannot apply bindings multiple times to the same element.");a.a.e.set(b,q,!0)}!m&&k&&a.xc(b,e);var g;if(d&&"function"!==typeof d)g=d;else{var h=a.S.instance,r=h.getBindingAccessors||f,p=a.B(function(){(g=d?d(e,b):r.call(h,b,e))&&e.Q&&e.Q();return g},null,{i:b});g&&p.ca()||(p=null)}var s;if(g){var t=p?function(a){return function(){return c(p()[a])}}:function(a){return g[a]},
u=function(){return a.a.Ea(p?p():g,c)};u.get=function(a){return g[a]&&c(t(a))};u.has=function(a){return a in g};k=l(g);a.a.r(k,function(c){var d=c.jc.init,k=c.jc.update,f=c.key;if(8===b.nodeType&&!a.f.aa[f])throw Error("The binding '"+f+"' cannot be used with virtual elements");try{"function"==typeof d&&a.l.w(function(){var a=d(b,t(f),u,e.$data,e);if(a&&a.controlsDescendantBindings){if(s!==n)throw Error("Multiple bindings ("+s+" and "+f+") are trying to control descendant bindings of the same element. You cannot use these bindings together on the same element.");
s=f}}),"function"==typeof k&&a.B(function(){k(b,t(f),u,e.$data,e)},null,{i:b})}catch(m){throw m.message='Unable to process binding "'+f+": "+g[f]+'"\nMessage: '+m.message,m;}})}return{shouldBindDescendants:s===n}}function k(b){return b&&b instanceof a.R?b:new a.R(b)}a.d={};var r={script:!0,textarea:!0,template:!0};a.getBindingHandler=function(b){return a.d[b]};a.R=function(b,c,d,e,k){function f(){var k=g?b():b,m=a.a.c(k);c?(c.Q&&c.Q(),a.a.extend(l,c),l.Q=r):(l.$parents=[],l.$root=m,l.ko=a);l.$rawData=
k;l.$data=m;d&&(l[d]=m);e&&e(l,c,m);return l.$data}function m(){return h&&!a.a.Tb(h)}var l=this,g="function"==typeof b&&!a.I(b),h,r;k&&k.exportDependencies?f():(r=a.B(f,null,{ya:m,i:!0}),r.ca()&&(l.Q=r,r.equalityComparer=null,h=[],r.Dc=function(b){h.push(b);a.a.G.qa(b,function(b){a.a.Na(h,b);h.length||(r.k(),l.Q=r=n)})}))};a.R.prototype.createChildContext=function(b,c,d,e){return new a.R(b,this,c,function(a,b){a.$parentContext=b;a.$parent=b.$data;a.$parents=(b.$parents||[]).slice(0);a.$parents.unshift(a.$parent);
d&&d(a)},e)};a.R.prototype.extend=function(b){return new a.R(this.Q||this.$data,this,null,function(c,d){c.$rawData=d.$rawData;a.a.extend(c,"function"==typeof b?b():b)})};a.R.prototype.ac=function(a,b){return this.createChildContext(a,b,null,{exportDependencies:!0})};var q=a.a.e.J(),p=a.a.e.J();a.xc=function(b,c){if(2==arguments.length)a.a.e.set(b,p,c),c.Q&&c.Q.Dc(b);else return a.a.e.get(b,p)};a.La=function(b,c,d){1===b.nodeType&&a.f.oc(b);return m(b,c,k(d),!0)};a.Ic=function(b,c,d){d=k(d);return a.La(b,
e(c,d,b),d)};a.hb=function(a,b){1!==b.nodeType&&8!==b.nodeType||g(k(a),b,!0)};a.Ub=function(a,b){!u&&x.jQuery&&(u=x.jQuery);if(b&&1!==b.nodeType&&8!==b.nodeType)throw Error("ko.applyBindings: first parameter should be your view model; second parameter should be a DOM node");b=b||x.document.body;h(k(a),b,!0)};a.nb=function(b){switch(b.nodeType){case 1:case 8:var c=a.xc(b);if(c)return c;if(b.parentNode)return a.nb(b.parentNode)}return n};a.Oc=function(b){return(b=a.nb(b))?b.$data:n};a.b("bindingHandlers",
a.d);a.b("applyBindings",a.Ub);a.b("applyBindingsToDescendants",a.hb);a.b("applyBindingAccessorsToNode",a.La);a.b("applyBindingsToNode",a.Ic);a.b("contextFor",a.nb);a.b("dataFor",a.Oc)})();(function(b){function c(c,e){var m=f.hasOwnProperty(c)?f[c]:b,k;m?m.Y(e):(m=f[c]=new a.K,m.Y(e),d(c,function(b,d){var e=!(!d||!d.synchronous);g[c]={definition:b,dd:e};delete f[c];k||e?m.notifySubscribers(b):a.Z.Za(function(){m.notifySubscribers(b)})}),k=!0)}function d(a,b){e("getConfig",[a],function(c){c?e("loadComponent",
[a,c],function(a){b(a,c)}):b(null,null)})}function e(c,d,f,k){k||(k=a.g.loaders.slice(0));var g=k.shift();if(g){var q=g[c];if(q){var p=!1;if(q.apply(g,d.concat(function(a){p?f(null):null!==a?f(a):e(c,d,f,k)}))!==b&&(p=!0,!g.suppressLoaderExceptions))throw Error("Component loaders must supply values by invoking the callback, not by returning values synchronously.");}else e(c,d,f,k)}else f(null)}var f={},g={};a.g={get:function(d,e){var f=g.hasOwnProperty(d)?g[d]:b;f?f.dd?a.l.w(function(){e(f.definition)}):
a.Z.Za(function(){e(f.definition)}):c(d,e)},$b:function(a){delete g[a]},Nb:e};a.g.loaders=[];a.b("components",a.g);a.b("components.get",a.g.get);a.b("components.clearCachedDefinition",a.g.$b)})();(function(){function b(b,c,d,e){function g(){0===--y&&e(h)}var h={},y=2,v=d.template;d=d.viewModel;v?f(c,v,function(c){a.g.Nb("loadTemplate",[b,c],function(a){h.template=a;g()})}):g();d?f(c,d,function(c){a.g.Nb("loadViewModel",[b,c],function(a){h[l]=a;g()})}):g()}function c(a,b,d){if("function"===typeof b)d(function(a){return new b(a)});
else if("function"===typeof b[l])d(b[l]);else if("instance"in b){var e=b.instance;d(function(){return e})}else"viewModel"in b?c(a,b.viewModel,d):a("Unknown viewModel value: "+b)}function d(b){switch(a.a.A(b)){case "script":return a.a.na(b.text);case "textarea":return a.a.na(b.value);case "template":if(e(b.content))return a.a.wa(b.content.childNodes)}return a.a.wa(b.childNodes)}function e(a){return x.DocumentFragment?a instanceof DocumentFragment:a&&11===a.nodeType}function f(a,b,c){"string"===typeof b.require?
O||x.require?(O||x.require)([b.require],c):a("Uses require, but no AMD loader is present"):c(b)}function g(a){return function(b){throw Error("Component '"+a+"': "+b);}}var h={};a.g.register=function(b,c){if(!c)throw Error("Invalid configuration for "+b);if(a.g.wb(b))throw Error("Component "+b+" is already registered");h[b]=c};a.g.wb=function(a){return h.hasOwnProperty(a)};a.g.ud=function(b){delete h[b];a.g.$b(b)};a.g.cc={getConfig:function(a,b){b(h.hasOwnProperty(a)?h[a]:null)},loadComponent:function(a,
c,d){var e=g(a);f(e,c,function(c){b(a,e,c,d)})},loadTemplate:function(b,c,f){b=g(b);if("string"===typeof c)f(a.a.na(c));else if(c instanceof Array)f(c);else if(e(c))f(a.a.W(c.childNodes));else if(c.element)if(c=c.element,x.HTMLElement?c instanceof HTMLElement:c&&c.tagName&&1===c.nodeType)f(d(c));else if("string"===typeof c){var l=t.getElementById(c);l?f(d(l)):b("Cannot find element with ID "+c)}else b("Unknown element type: "+c);else b("Unknown template value: "+c)},loadViewModel:function(a,b,d){c(g(a),
b,d)}};var l="createViewModel";a.b("components.register",a.g.register);a.b("components.isRegistered",a.g.wb);a.b("components.unregister",a.g.ud);a.b("components.defaultLoader",a.g.cc);a.g.loaders.push(a.g.cc);a.g.Ec=h})();(function(){function b(b,e){var f=b.getAttribute("params");if(f){var f=c.parseBindingsString(f,e,b,{valueAccessors:!0,bindingParams:!0}),f=a.a.Ea(f,function(c){return a.m(c,null,{i:b})}),g=a.a.Ea(f,function(c){var e=c.p();return c.ca()?a.m({read:function(){return a.a.c(c())},write:a.Da(e)&&
function(a){c()(a)},i:b}):e});g.hasOwnProperty("$raw")||(g.$raw=f);return g}return{$raw:{}}}a.g.getComponentNameForNode=function(b){var c=a.a.A(b);if(a.g.wb(c)&&(-1!=c.indexOf("-")||"[object HTMLUnknownElement]"==""+b||8>=a.a.C&&b.tagName===c))return c};a.g.Rb=function(c,e,f,g){if(1===e.nodeType){var h=a.g.getComponentNameForNode(e);if(h){c=c||{};if(c.component)throw Error('Cannot use the "component" binding on a custom element matching a component');var l={name:h,params:b(e,f)};c.component=g?function(){return l}:
l}}return c};var c=new a.S;9>a.a.C&&(a.g.register=function(a){return function(b){t.createElement(b);return a.apply(this,arguments)}}(a.g.register),t.createDocumentFragment=function(b){return function(){var c=b(),f=a.g.Ec,g;for(g in f)f.hasOwnProperty(g)&&c.createElement(g);return c}}(t.createDocumentFragment))})();(function(b){function c(b,c,d){c=c.template;if(!c)throw Error("Component '"+b+"' has no template");b=a.a.wa(c);a.f.fa(d,b)}function d(a,b,c,d){var e=a.createViewModel;return e?e.call(a,
d,{element:b,templateNodes:c}):d}var e=0;a.d.component={init:function(f,g,h,l,m){function k(){var a=r&&r.dispose;"function"===typeof a&&a.call(r);q=r=null}var r,q,p=a.a.W(a.f.childNodes(f));a.a.G.qa(f,k);a.m(function(){var l=a.a.c(g()),h,v;"string"===typeof l?h=l:(h=a.a.c(l.name),v=a.a.c(l.params));if(!h)throw Error("No component name specified");var n=q=++e;a.g.get(h,function(e){if(q===n){k();if(!e)throw Error("Unknown component '"+h+"'");c(h,e,f);var l=d(e,f,p,v);e=m.createChildContext(l,b,function(a){a.$component=
l;a.$componentTemplateNodes=p});r=l;a.hb(e,f)}})},null,{i:f});return{controlsDescendantBindings:!0}}};a.f.aa.component=!0})();var Q={"class":"className","for":"htmlFor"};a.d.attr={update:function(b,c){var d=a.a.c(c())||{};a.a.D(d,function(c,d){d=a.a.c(d);var g=!1===d||null===d||d===n;g&&b.removeAttribute(c);8>=a.a.C&&c in Q?(c=Q[c],g?b.removeAttribute(c):b[c]=d):g||b.setAttribute(c,d.toString());"name"===c&&a.a.vc(b,g?"":d.toString())})}};(function(){a.d.checked={after:["value","attr"],init:function(b,
c,d){function e(){var e=b.checked,f=p?g():e;if(!a.xa.Va()&&(!l||e)){var h=a.l.w(c);if(k){var m=r?h.p():h;q!==f?(e&&(a.a.ra(m,f,!0),a.a.ra(m,q,!1)),q=f):a.a.ra(m,f,e);r&&a.Da(h)&&h(m)}else a.h.Ga(h,d,"checked",f,!0)}}function f(){var d=a.a.c(c());b.checked=k?0<=a.a.o(d,g()):h?d:g()===d}var g=a.rc(function(){return d.has("checkedValue")?a.a.c(d.get("checkedValue")):d.has("value")?a.a.c(d.get("value")):b.value}),h="checkbox"==b.type,l="radio"==b.type;if(h||l){var m=c(),k=h&&a.a.c(m)instanceof Array,
r=!(k&&m.push&&m.splice),q=k?g():n,p=l||k;l&&!b.name&&a.d.uniqueName.init(b,function(){return!0});a.m(e,null,{i:b});a.a.q(b,"click",e);a.m(f,null,{i:b});m=n}}};a.h.ga.checked=!0;a.d.checkedValue={update:function(b,c){b.value=a.a.c(c())}}})();a.d.css={update:function(b,c){var d=a.a.c(c());null!==d&&"object"==typeof d?a.a.D(d,function(c,d){d=a.a.c(d);a.a.fb(b,c,d)}):(d=a.a.cb(String(d||"")),a.a.fb(b,b.__ko__cssValue,!1),b.__ko__cssValue=d,a.a.fb(b,d,!0))}};a.d.enable={update:function(b,c){var d=a.a.c(c());
d&&b.disabled?b.removeAttribute("disabled"):d||b.disabled||(b.disabled=!0)}};a.d.disable={update:function(b,c){a.d.enable.update(b,function(){return!a.a.c(c())})}};a.d.event={init:function(b,c,d,e,f){var g=c()||{};a.a.D(g,function(g){"string"==typeof g&&a.a.q(b,g,function(b){var m,k=c()[g];if(k){try{var r=a.a.W(arguments);e=f.$data;r.unshift(e);m=k.apply(e,r)}finally{!0!==m&&(b.preventDefault?b.preventDefault():b.returnValue=!1)}!1===d.get(g+"Bubble")&&(b.cancelBubble=!0,b.stopPropagation&&b.stopPropagation())}})})}};
a.d.foreach={mc:function(b){return function(){var c=b(),d=a.a.Bb(c);if(!d||"number"==typeof d.length)return{foreach:c,templateEngine:a.X.vb};a.a.c(c);return{foreach:d.data,as:d.as,includeDestroyed:d.includeDestroyed,afterAdd:d.afterAdd,beforeRemove:d.beforeRemove,afterRender:d.afterRender,beforeMove:d.beforeMove,afterMove:d.afterMove,templateEngine:a.X.vb}}},init:function(b,c){return a.d.template.init(b,a.d.foreach.mc(c))},update:function(b,c,d,e,f){return a.d.template.update(b,a.d.foreach.mc(c),
d,e,f)}};a.h.va.foreach=!1;a.f.aa.foreach=!0;a.d.hasfocus={init:function(b,c,d){function e(e){b.__ko_hasfocusUpdating=!0;var f=b.ownerDocument;if("activeElement"in f){var g;try{g=f.activeElement}catch(k){g=f.body}e=g===b}f=c();a.h.Ga(f,d,"hasfocus",e,!0);b.__ko_hasfocusLastValue=e;b.__ko_hasfocusUpdating=!1}var f=e.bind(null,!0),g=e.bind(null,!1);a.a.q(b,"focus",f);a.a.q(b,"focusin",f);a.a.q(b,"blur",g);a.a.q(b,"focusout",g)},update:function(b,c){var d=!!a.a.c(c());b.__ko_hasfocusUpdating||b.__ko_hasfocusLastValue===
d||(d?b.focus():b.blur(),!d&&b.__ko_hasfocusLastValue&&b.ownerDocument.body.focus(),a.l.w(a.a.Fa,null,[b,d?"focusin":"focusout"]))}};a.h.ga.hasfocus=!0;a.d.hasFocus=a.d.hasfocus;a.h.ga.hasFocus=!0;a.d.html={init:function(){return{controlsDescendantBindings:!0}},update:function(b,c){a.a.Eb(b,c())}};K("if");K("ifnot",!1,!0);K("with",!0,!1,function(a,c){return a.ac(c)});var L={};a.d.options={init:function(b){if("select"!==a.a.A(b))throw Error("options binding applies only to SELECT elements");for(;0<
b.length;)b.remove(0);return{controlsDescendantBindings:!0}},update:function(b,c,d){function e(){return a.a.Ma(b.options,function(a){return a.selected})}function f(a,b,c){var d=typeof b;return"function"==d?b(a):"string"==d?a[b]:c}function g(c,e){if(A&&k)a.j.ja(b,a.a.c(d.get("value")),!0);else if(p.length){var f=0<=a.a.o(p,a.j.u(e[0]));a.a.wc(e[0],f);A&&!f&&a.l.w(a.a.Fa,null,[b,"change"])}}var h=b.multiple,l=0!=b.length&&h?b.scrollTop:null,m=a.a.c(c()),k=d.get("valueAllowUnset")&&d.has("value"),r=
d.get("optionsIncludeDestroyed");c={};var q,p=[];k||(h?p=a.a.ib(e(),a.j.u):0<=b.selectedIndex&&p.push(a.j.u(b.options[b.selectedIndex])));m&&("undefined"==typeof m.length&&(m=[m]),q=a.a.Ma(m,function(b){return r||b===n||null===b||!a.a.c(b._destroy)}),d.has("optionsCaption")&&(m=a.a.c(d.get("optionsCaption")),null!==m&&m!==n&&q.unshift(L)));var A=!1;c.beforeRemove=function(a){b.removeChild(a)};m=g;d.has("optionsAfterRender")&&"function"==typeof d.get("optionsAfterRender")&&(m=function(b,c){g(0,c);
a.l.w(d.get("optionsAfterRender"),null,[c[0],b!==L?b:n])});a.a.Db(b,q,function(c,e,g){g.length&&(p=!k&&g[0].selected?[a.j.u(g[0])]:[],A=!0);e=b.ownerDocument.createElement("option");c===L?(a.a.bb(e,d.get("optionsCaption")),a.j.ja(e,n)):(g=f(c,d.get("optionsValue"),c),a.j.ja(e,a.a.c(g)),c=f(c,d.get("optionsText"),g),a.a.bb(e,c));return[e]},c,m);a.l.w(function(){k?a.j.ja(b,a.a.c(d.get("value")),!0):(h?p.length&&e().length<p.length:p.length&&0<=b.selectedIndex?a.j.u(b.options[b.selectedIndex])!==p[0]:
p.length||0<=b.selectedIndex)&&a.a.Fa(b,"change")});a.a.Sc(b);l&&20<Math.abs(l-b.scrollTop)&&(b.scrollTop=l)}};a.d.options.zb=a.a.e.J();a.d.selectedOptions={after:["options","foreach"],init:function(b,c,d){a.a.q(b,"change",function(){var e=c(),f=[];a.a.r(b.getElementsByTagName("option"),function(b){b.selected&&f.push(a.j.u(b))});a.h.Ga(e,d,"selectedOptions",f)})},update:function(b,c){if("select"!=a.a.A(b))throw Error("values binding applies only to SELECT elements");var d=a.a.c(c()),e=b.scrollTop;
d&&"number"==typeof d.length&&a.a.r(b.getElementsByTagName("option"),function(b){var c=0<=a.a.o(d,a.j.u(b));b.selected!=c&&a.a.wc(b,c)});b.scrollTop=e}};a.h.ga.selectedOptions=!0;a.d.style={update:function(b,c){var d=a.a.c(c()||{});a.a.D(d,function(c,d){d=a.a.c(d);if(null===d||d===n||!1===d)d="";b.style[c]=d})}};a.d.submit={init:function(b,c,d,e,f){if("function"!=typeof c())throw Error("The value for a submit binding must be a function");a.a.q(b,"submit",function(a){var d,e=c();try{d=e.call(f.$data,
b)}finally{!0!==d&&(a.preventDefault?a.preventDefault():a.returnValue=!1)}})}};a.d.text={init:function(){return{controlsDescendantBindings:!0}},update:function(b,c){a.a.bb(b,c())}};a.f.aa.text=!0;(function(){if(x&&x.navigator)var b=function(a){if(a)return parseFloat(a[1])},c=x.opera&&x.opera.version&&parseInt(x.opera.version()),d=x.navigator.userAgent,e=b(d.match(/^(?:(?!chrome).)*version\/([^ ]*) safari/i)),f=b(d.match(/Firefox\/([^ ]*)/));if(10>a.a.C)var g=a.a.e.J(),h=a.a.e.J(),l=function(b){var c=
this.activeElement;(c=c&&a.a.e.get(c,h))&&c(b)},m=function(b,c){var d=b.ownerDocument;a.a.e.get(d,g)||(a.a.e.set(d,g,!0),a.a.q(d,"selectionchange",l));a.a.e.set(b,h,c)};a.d.textInput={init:function(b,d,g){function l(c,d){a.a.q(b,c,d)}function h(){var c=a.a.c(d());if(null===c||c===n)c="";u!==n&&c===u?a.a.setTimeout(h,4):b.value!==c&&(s=c,b.value=c)}function y(){t||(u=b.value,t=a.a.setTimeout(v,4))}function v(){clearTimeout(t);u=t=n;var c=b.value;s!==c&&(s=c,a.h.Ga(d(),g,"textInput",c))}var s=b.value,
t,u,x=9==a.a.C?y:v;10>a.a.C?(l("propertychange",function(a){"value"===a.propertyName&&x(a)}),8==a.a.C&&(l("keyup",v),l("keydown",v)),8<=a.a.C&&(m(b,x),l("dragend",y))):(l("input",v),5>e&&"textarea"===a.a.A(b)?(l("keydown",y),l("paste",y),l("cut",y)):11>c?l("keydown",y):4>f&&(l("DOMAutoComplete",v),l("dragdrop",v),l("drop",v)));l("change",v);a.m(h,null,{i:b})}};a.h.ga.textInput=!0;a.d.textinput={preprocess:function(a,b,c){c("textInput",a)}}})();a.d.uniqueName={init:function(b,c){if(c()){var d="ko_unique_"+
++a.d.uniqueName.Nc;a.a.vc(b,d)}}};a.d.uniqueName.Nc=0;a.d.value={after:["options","foreach"],init:function(b,c,d){if("input"!=b.tagName.toLowerCase()||"checkbox"!=b.type&&"radio"!=b.type){var e=["change"],f=d.get("valueUpdate"),g=!1,h=null;f&&("string"==typeof f&&(f=[f]),a.a.ta(e,f),e=a.a.Wb(e));var l=function(){h=null;g=!1;var e=c(),f=a.j.u(b);a.h.Ga(e,d,"value",f)};!a.a.C||"input"!=b.tagName.toLowerCase()||"text"!=b.type||"off"==b.autocomplete||b.form&&"off"==b.form.autocomplete||-1!=a.a.o(e,"propertychange")||
(a.a.q(b,"propertychange",function(){g=!0}),a.a.q(b,"focus",function(){g=!1}),a.a.q(b,"blur",function(){g&&l()}));a.a.r(e,function(c){var d=l;a.a.sd(c,"after")&&(d=function(){h=a.j.u(b);a.a.setTimeout(l,0)},c=c.substring(5));a.a.q(b,c,d)});var m=function(){var e=a.a.c(c()),f=a.j.u(b);if(null!==h&&e===h)a.a.setTimeout(m,0);else if(e!==f)if("select"===a.a.A(b)){var g=d.get("valueAllowUnset"),f=function(){a.j.ja(b,e,g)};f();g||e===a.j.u(b)?a.a.setTimeout(f,0):a.l.w(a.a.Fa,null,[b,"change"])}else a.j.ja(b,
e)};a.m(m,null,{i:b})}else a.La(b,{checkedValue:c})},update:function(){}};a.h.ga.value=!0;a.d.visible={update:function(b,c){var d=a.a.c(c()),e="none"!=b.style.display;d&&!e?b.style.display="":!d&&e&&(b.style.display="none")}};(function(b){a.d[b]={init:function(c,d,e,f,g){return a.d.event.init.call(this,c,function(){var a={};a[b]=d();return a},e,f,g)}}})("click");a.P=function(){};a.P.prototype.renderTemplateSource=function(){throw Error("Override renderTemplateSource");};a.P.prototype.createJavaScriptEvaluatorBlock=
function(){throw Error("Override createJavaScriptEvaluatorBlock");};a.P.prototype.makeTemplateSource=function(b,c){if("string"==typeof b){c=c||t;var d=c.getElementById(b);if(!d)throw Error("Cannot find template with ID "+b);return new a.v.n(d)}if(1==b.nodeType||8==b.nodeType)return new a.v.sa(b);throw Error("Unknown template type: "+b);};a.P.prototype.renderTemplate=function(a,c,d,e){a=this.makeTemplateSource(a,e);return this.renderTemplateSource(a,c,d,e)};a.P.prototype.isTemplateRewritten=function(a,
c){return!1===this.allowTemplateRewriting?!0:this.makeTemplateSource(a,c).data("isRewritten")};a.P.prototype.rewriteTemplate=function(a,c,d){a=this.makeTemplateSource(a,d);c=c(a.text());a.text(c);a.data("isRewritten",!0)};a.b("templateEngine",a.P);a.Ib=function(){function b(b,c,d,h){b=a.h.Ab(b);for(var l=a.h.va,m=0;m<b.length;m++){var k=b[m].key;if(l.hasOwnProperty(k)){var r=l[k];if("function"===typeof r){if(k=r(b[m].value))throw Error(k);}else if(!r)throw Error("This template engine does not support the '"+
k+"' binding within its templates");}}d="ko.__tr_ambtns(function($context,$element){return(function(){return{ "+a.h.Xa(b,{valueAccessors:!0})+" } })()},'"+d.toLowerCase()+"')";return h.createJavaScriptEvaluatorBlock(d)+c}var c=/(<([a-z]+\d*)(?:\s+(?!data-bind\s*=\s*)[a-z0-9\-]+(?:=(?:\"[^\"]*\"|\'[^\']*\'|[^>]*))?)*\s+)data-bind\s*=\s*(["'])([\s\S]*?)\3/gi,d=/\x3c!--\s*ko\b\s*([\s\S]*?)\s*--\x3e/g;return{Tc:function(b,c,d){c.isTemplateRewritten(b,d)||c.rewriteTemplate(b,function(b){return a.Ib.jd(b,
c)},d)},jd:function(a,f){return a.replace(c,function(a,c,d,e,k){return b(k,c,d,f)}).replace(d,function(a,c){return b(c,"\x3c!-- ko --\x3e","#comment",f)})},Jc:function(b,c){return a.N.yb(function(d,h){var l=d.nextSibling;l&&l.nodeName.toLowerCase()===c&&a.La(l,b,h)})}}}();a.b("__tr_ambtns",a.Ib.Jc);(function(){a.v={};a.v.n=function(b){if(this.n=b){var c=a.a.A(b);this.eb="script"===c?1:"textarea"===c?2:"template"==c&&b.content&&11===b.content.nodeType?3:4}};a.v.n.prototype.text=function(){var b=1===
this.eb?"text":2===this.eb?"value":"innerHTML";if(0==arguments.length)return this.n[b];var c=arguments[0];"innerHTML"===b?a.a.Eb(this.n,c):this.n[b]=c};var b=a.a.e.J()+"_";a.v.n.prototype.data=function(c){if(1===arguments.length)return a.a.e.get(this.n,b+c);a.a.e.set(this.n,b+c,arguments[1])};var c=a.a.e.J();a.v.n.prototype.nodes=function(){var b=this.n;if(0==arguments.length)return(a.a.e.get(b,c)||{}).mb||(3===this.eb?b.content:4===this.eb?b:n);a.a.e.set(b,c,{mb:arguments[0]})};a.v.sa=function(a){this.n=
a};a.v.sa.prototype=new a.v.n;a.v.sa.prototype.text=function(){if(0==arguments.length){var b=a.a.e.get(this.n,c)||{};b.Jb===n&&b.mb&&(b.Jb=b.mb.innerHTML);return b.Jb}a.a.e.set(this.n,c,{Jb:arguments[0]})};a.b("templateSources",a.v);a.b("templateSources.domElement",a.v.n);a.b("templateSources.anonymousTemplate",a.v.sa)})();(function(){function b(b,c,d){var e;for(c=a.f.nextSibling(c);b&&(e=b)!==c;)b=a.f.nextSibling(e),d(e,b)}function c(c,d){if(c.length){var e=c[0],f=c[c.length-1],g=e.parentNode,h=
a.S.instance,n=h.preprocessNode;if(n){b(e,f,function(a,b){var c=a.previousSibling,d=n.call(h,a);d&&(a===e&&(e=d[0]||b),a===f&&(f=d[d.length-1]||c))});c.length=0;if(!e)return;e===f?c.push(e):(c.push(e,f),a.a.Ba(c,g))}b(e,f,function(b){1!==b.nodeType&&8!==b.nodeType||a.Ub(d,b)});b(e,f,function(b){1!==b.nodeType&&8!==b.nodeType||a.N.Cc(b,[d])});a.a.Ba(c,g)}}function d(a){return a.nodeType?a:0<a.length?a[0]:null}function e(b,e,f,h,q){q=q||{};var p=(b&&d(b)||f||{}).ownerDocument,n=q.templateEngine||g;
a.Ib.Tc(f,n,p);f=n.renderTemplate(f,h,q,p);if("number"!=typeof f.length||0<f.length&&"number"!=typeof f[0].nodeType)throw Error("Template engine must return an array of DOM nodes");p=!1;switch(e){case "replaceChildren":a.f.fa(b,f);p=!0;break;case "replaceNode":a.a.uc(b,f);p=!0;break;case "ignoreTargetNode":break;default:throw Error("Unknown renderMode: "+e);}p&&(c(f,h),q.afterRender&&a.l.w(q.afterRender,null,[f,h.$data]));return f}function f(b,c,d){return a.I(b)?b():"function"===typeof b?b(c,d):b}
var g;a.Fb=function(b){if(b!=n&&!(b instanceof a.P))throw Error("templateEngine must inherit from ko.templateEngine");g=b};a.Cb=function(b,c,k,h,q){k=k||{};if((k.templateEngine||g)==n)throw Error("Set a template engine before calling renderTemplate");q=q||"replaceChildren";if(h){var p=d(h);return a.B(function(){var g=c&&c instanceof a.R?c:new a.R(c,null,null,null,{exportDependencies:!0}),n=f(b,g.$data,g),g=e(h,q,n,g,k);"replaceNode"==q&&(h=g,p=d(h))},null,{ya:function(){return!p||!a.a.qb(p)},i:p&&
"replaceNode"==q?p.parentNode:p})}return a.N.yb(function(d){a.Cb(b,c,k,d,"replaceNode")})};a.pd=function(b,d,g,h,q){function p(a,b){c(b,t);g.afterRender&&g.afterRender(b,a);t=null}function s(a,c){t=q.createChildContext(a,g.as,function(a){a.$index=c});var d=f(b,a,t);return e(null,"ignoreTargetNode",d,t,g)}var t;return a.B(function(){var b=a.a.c(d)||[];"undefined"==typeof b.length&&(b=[b]);b=a.a.Ma(b,function(b){return g.includeDestroyed||b===n||null===b||!a.a.c(b._destroy)});a.l.w(a.a.Db,null,[h,b,
s,g,p])},null,{i:h})};var h=a.a.e.J();a.d.template={init:function(b,c){var d=a.a.c(c());if("string"==typeof d||d.name)a.f.za(b);else{if("nodes"in d){if(d=d.nodes||[],a.I(d))throw Error('The "nodes" option must be a plain, non-observable array.');}else d=a.f.childNodes(b);d=a.a.nc(d);(new a.v.sa(b)).nodes(d)}return{controlsDescendantBindings:!0}},update:function(b,c,d,e,f){var g=c();c=a.a.c(g);d=!0;e=null;"string"==typeof c?c={}:(g=c.name,"if"in c&&(d=a.a.c(c["if"])),d&&"ifnot"in c&&(d=!a.a.c(c.ifnot)));
"foreach"in c?e=a.pd(g||b,d&&c.foreach||[],c,b,f):d?(f="data"in c?f.ac(c.data,c.as):f,e=a.Cb(g||b,f,c,b)):a.f.za(b);f=e;(c=a.a.e.get(b,h))&&"function"==typeof c.k&&c.k();a.a.e.set(b,h,f&&f.ca()?f:n)}};a.h.va.template=function(b){b=a.h.Ab(b);return 1==b.length&&b[0].unknown||a.h.fd(b,"name")?null:"This template engine does not support anonymous templates nested within its templates"};a.f.aa.template=!0})();a.b("setTemplateEngine",a.Fb);a.b("renderTemplate",a.Cb);a.a.hc=function(a,c,d){if(a.length&&
c.length){var e,f,g,h,l;for(e=f=0;(!d||e<d)&&(h=a[f]);++f){for(g=0;l=c[g];++g)if(h.value===l.value){h.moved=l.index;l.moved=h.index;c.splice(g,1);e=g=0;break}e+=g}}};a.a.lb=function(){function b(b,d,e,f,g){var h=Math.min,l=Math.max,m=[],k,n=b.length,q,p=d.length,s=p-n||1,t=n+p+1,v,u,x;for(k=0;k<=n;k++)for(u=v,m.push(v=[]),x=h(p,k+s),q=l(0,k-1);q<=x;q++)v[q]=q?k?b[k-1]===d[q-1]?u[q-1]:h(u[q]||t,v[q-1]||t)+1:q+1:k+1;h=[];l=[];s=[];k=n;for(q=p;k||q;)p=m[k][q]-1,q&&p===m[k][q-1]?l.push(h[h.length]={status:e,
value:d[--q],index:q}):k&&p===m[k-1][q]?s.push(h[h.length]={status:f,value:b[--k],index:k}):(--q,--k,g.sparse||h.push({status:"retained",value:d[q]}));a.a.hc(s,l,!g.dontLimitMoves&&10*n);return h.reverse()}return function(a,d,e){e="boolean"===typeof e?{dontLimitMoves:e}:e||{};a=a||[];d=d||[];return a.length<d.length?b(a,d,"added","deleted",e):b(d,a,"deleted","added",e)}}();a.b("utils.compareArrays",a.a.lb);(function(){function b(b,c,d,h,l){var m=[],k=a.B(function(){var k=c(d,l,a.a.Ba(m,b))||[];0<
m.length&&(a.a.uc(m,k),h&&a.l.w(h,null,[d,k,l]));m.length=0;a.a.ta(m,k)},null,{i:b,ya:function(){return!a.a.Tb(m)}});return{ea:m,B:k.ca()?k:n}}var c=a.a.e.J(),d=a.a.e.J();a.a.Db=function(e,f,g,h,l){function m(b,c){w=q[c];u!==c&&(D[b]=w);w.tb(u++);a.a.Ba(w.ea,e);t.push(w);z.push(w)}function k(b,c){if(b)for(var d=0,e=c.length;d<e;d++)c[d]&&a.a.r(c[d].ea,function(a){b(a,d,c[d].ka)})}f=f||[];h=h||{};var r=a.a.e.get(e,c)===n,q=a.a.e.get(e,c)||[],p=a.a.ib(q,function(a){return a.ka}),s=a.a.lb(p,f,h.dontLimitMoves),
t=[],v=0,u=0,x=[],z=[];f=[];for(var D=[],p=[],w,C=0,B,E;B=s[C];C++)switch(E=B.moved,B.status){case "deleted":E===n&&(w=q[v],w.B&&(w.B.k(),w.B=n),a.a.Ba(w.ea,e).length&&(h.beforeRemove&&(t.push(w),z.push(w),w.ka===d?w=null:f[C]=w),w&&x.push.apply(x,w.ea)));v++;break;case "retained":m(C,v++);break;case "added":E!==n?m(C,E):(w={ka:B.value,tb:a.O(u++)},t.push(w),z.push(w),r||(p[C]=w))}a.a.e.set(e,c,t);k(h.beforeMove,D);a.a.r(x,h.beforeRemove?a.ba:a.removeNode);for(var C=0,r=a.f.firstChild(e),F;w=z[C];C++){w.ea||
a.a.extend(w,b(e,g,w.ka,l,w.tb));for(v=0;s=w.ea[v];r=s.nextSibling,F=s,v++)s!==r&&a.f.kc(e,s,F);!w.ad&&l&&(l(w.ka,w.ea,w.tb),w.ad=!0)}k(h.beforeRemove,f);for(C=0;C<f.length;++C)f[C]&&(f[C].ka=d);k(h.afterMove,D);k(h.afterAdd,p)}})();a.b("utils.setDomNodeChildrenFromArrayMapping",a.a.Db);a.X=function(){this.allowTemplateRewriting=!1};a.X.prototype=new a.P;a.X.prototype.renderTemplateSource=function(b,c,d,e){if(c=(9>a.a.C?0:b.nodes)?b.nodes():null)return a.a.W(c.cloneNode(!0).childNodes);b=b.text();
return a.a.na(b,e)};a.X.vb=new a.X;a.Fb(a.X.vb);a.b("nativeTemplateEngine",a.X);(function(){a.xb=function(){var a=this.ed=function(){if(!u||!u.tmpl)return 0;try{if(0<=u.tmpl.tag.tmpl.open.toString().indexOf("__"))return 2}catch(a){}return 1}();this.renderTemplateSource=function(b,e,f,g){g=g||t;f=f||{};if(2>a)throw Error("Your version of jQuery.tmpl is too old. Please upgrade to jQuery.tmpl 1.0.0pre or later.");var h=b.data("precompiled");h||(h=b.text()||"",h=u.template(null,"{{ko_with $item.koBindingContext}}"+
h+"{{/ko_with}}"),b.data("precompiled",h));b=[e.$data];e=u.extend({koBindingContext:e},f.templateOptions);e=u.tmpl(h,b,e);e.appendTo(g.createElement("div"));u.fragments={};return e};this.createJavaScriptEvaluatorBlock=function(a){return"{{ko_code ((function() { return "+a+" })()) }}"};this.addTemplate=function(a,b){t.write("<script type='text/html' id='"+a+"'>"+b+"\x3c/script>")};0<a&&(u.tmpl.tag.ko_code={open:"__.push($1 || '');"},u.tmpl.tag.ko_with={open:"with($1) {",close:"} "})};a.xb.prototype=
new a.P;var b=new a.xb;0<b.ed&&a.Fb(b);a.b("jqueryTmplTemplateEngine",a.xb)})()})})();})();

View File

@@ -74,6 +74,12 @@ span.fancytree-node.fancytree-active-clone:not(.fancytree-active) .fancytree-tit
font-weight: bold;
}
/* By default not focused active tree item is not easily visible, this makes it more visible */
span.fancytree-active:not(.fancytree-focused) .fancytree-title {
background-color: #ddd !important;
border-color: #555 !important;
}
.ui-autocomplete {
max-height: 300px;
overflow-y: auto;
@@ -168,22 +174,29 @@ div.ui-tooltip {
}
/* Allow to use <kbd> elements inside the title to define shortcut hints. */
.ui-menu kbd {
margin-left: 30px;
float: right;
.ui-menu kbd, button kbd {
color: black;
border: none;
background-color: transparent;
box-shadow: none;
}
#note-id-display {
color: lightgrey;
.ui-menu kbd {
margin-left: 30px;
float: right;
}
#loader-wrapper{position:fixed;top:0;left:0;width:100%;height:100%;z-index:1000;background-color:#fff;opacity:1;transition:opacity 2s ease}
#loader{display:block;position:relative;left:50%;top:50%;width:150px;height:150px;margin:-75px 0 0 -75px;border-radius:50%;border:3px solid transparent;border-top-color:#777;-webkit-animation:spin 2s linear infinite;animation:spin 2s linear infinite}
#loader:before{content:"";position:absolute;top:5px;left:5px;right:5px;bottom:5px;border-radius:50%;border:3px solid transparent;border-top-color:#aaa;-webkit-animation:spin 3s linear infinite;animation:spin 3s linear infinite}
#loader:after{content:"";position:absolute;top:15px;left:15px;right:15px;bottom:15px;border-radius:50%;border:3px solid transparent;border-top-color:#ddd;-webkit-animation:spin 1.5s linear infinite;animation:spin 1.5s linear infinite}
@-webkit-keyframes spin{0%{-webkit-transform:rotate(0deg);-ms-transform:rotate(0deg);transform:rotate(0deg)}100%{-webkit-transform:rotate(360deg);-ms-transform:rotate(360deg);transform:rotate(360deg)}}
@keyframes spin{0%{-webkit-transform:rotate(0deg);-ms-transform:rotate(0deg);transform:rotate(0deg)}100%{-webkit-transform:rotate(360deg);-ms-transform:rotate(360deg);transform:rotate(360deg)}}
#note-id-display {
color: lightgrey;
margin-left: 10px;
}
#note-source {
height: 98%;
width: 100%;
overflow: scroll;
}
.suppressed {
filter: opacity(7%);
}

View File

@@ -4,11 +4,12 @@ const express = require('express');
const router = express.Router();
const anonymization = require('../../services/anonymization');
const auth = require('../../services/auth');
const wrap = require('express-promise-wrap').wrap;
router.post('/anonymize', auth.checkApiAuth, async (req, res, next) => {
router.post('/anonymize', auth.checkApiAuth, wrap(async (req, res, next) => {
await anonymization.anonymize();
res.send({});
});
}));
module.exports = router;

View File

@@ -4,9 +4,10 @@ const express = require('express');
const router = express.Router();
const app_info = require('../../services/app_info');
const auth = require('../../services/auth');
const wrap = require('express-promise-wrap').wrap;
router.get('', auth.checkApiAuth, async (req, res, next) => {
router.get('', auth.checkApiAuth, wrap(async (req, res, next) => {
res.send(app_info);
});
}));
module.exports = router;

48
routes/api/attributes.js Normal file
View File

@@ -0,0 +1,48 @@
"use strict";
const express = require('express');
const router = express.Router();
const sql = require('../../services/sql');
const auth = require('../../services/auth');
const sync_table = require('../../services/sync_table');
const utils = require('../../services/utils');
const wrap = require('express-promise-wrap').wrap;
router.get('/:noteId/attributes', auth.checkApiAuth, wrap(async (req, res, next) => {
const noteId = req.params.noteId;
res.send(await sql.getAll("SELECT * FROM attributes WHERE note_id = ? ORDER BY date_created", [noteId]));
}));
router.put('/:noteId/attributes', auth.checkApiAuth, wrap(async (req, res, next) => {
const noteId = req.params.noteId;
const attributes = req.body;
const now = utils.nowDate();
await sql.doInTransaction(async () => {
for (const attr of attributes) {
if (attr.attribute_id) {
await sql.execute("UPDATE attributes SET name = ?, value = ?, date_modified = ? WHERE attribute_id = ?",
[attr.name, attr.value, now, attr.attribute_id]);
}
else {
attr.attribute_id = utils.newAttributeId();
await sql.insert("attributes", {
attribute_id: attr.attribute_id,
note_id: noteId,
name: attr.name,
value: attr.value,
date_created: now,
date_modified: now
});
}
await sync_table.addAttributeSync(attr.attribute_id);
}
});
res.send(await sql.getAll("SELECT * FROM attributes WHERE note_id = ? ORDER BY date_created", [noteId]));
}));
module.exports = router;

View File

@@ -7,8 +7,9 @@ const utils = require('../../services/utils');
const sync_table = require('../../services/sync_table');
const auth = require('../../services/auth');
const log = require('../../services/log');
const wrap = require('express-promise-wrap').wrap;
router.post('/cleanup-soft-deleted-items', auth.checkApiAuth, async (req, res, next) => {
router.post('/cleanup-soft-deleted-items', auth.checkApiAuth, wrap(async (req, res, next) => {
await sql.doInTransaction(async () => {
const noteIdsToDelete = await sql.getFirstColumn("SELECT note_id FROM notes WHERE is_deleted = 1");
const noteIdsSql = noteIdsToDelete
@@ -19,8 +20,16 @@ router.post('/cleanup-soft-deleted-items', auth.checkApiAuth, async (req, res, n
await sql.execute(`DELETE FROM notes_history WHERE note_id IN (${noteIdsSql})`);
await sql.execute(`DELETE FROM notes_image WHERE note_id IN (${noteIdsSql})`);
await sql.execute(`DELETE FROM attributes WHERE note_id IN (${noteIdsSql})`);
await sql.execute("DELETE FROM notes_tree WHERE is_deleted = 1");
await sql.execute("DELETE FROM notes_image WHERE is_deleted = 1");
await sql.execute("DELETE FROM images WHERE is_deleted = 1");
await sql.execute("DELETE FROM notes WHERE is_deleted = 1");
await sql.execute("DELETE FROM recent_notes");
@@ -34,14 +43,41 @@ router.post('/cleanup-soft-deleted-items', auth.checkApiAuth, async (req, res, n
});
res.send({});
});
}));
router.post('/vacuum-database', auth.checkApiAuth, async (req, res, next) => {
router.post('/cleanup-unused-images', auth.checkApiAuth, wrap(async (req, res, next) => {
const sourceId = req.headers.source_id;
await sql.doInTransaction(async () => {
const unusedImageIds = await sql.getFirstColumn(`
SELECT images.image_id
FROM images
LEFT JOIN notes_image ON notes_image.image_id = images.image_id AND notes_image.is_deleted = 0
WHERE
images.is_deleted = 0
AND notes_image.note_image_id IS NULL`);
const now = utils.nowDate();
for (const imageId of unusedImageIds) {
log.info(`Deleting unused image: ${imageId}`);
await sql.execute("UPDATE images SET is_deleted = 1, data = null, date_modified = ? WHERE image_id = ?",
[now, imageId]);
await sync_table.addImageSync(imageId, sourceId);
}
});
res.send({});
}));
router.post('/vacuum-database', auth.checkApiAuth, wrap(async (req, res, next) => {
await sql.execute("VACUUM");
log.info("Database has been vacuumed.");
res.send({});
});
}));
module.exports = router;

84
routes/api/cloning.js Normal file
View File

@@ -0,0 +1,84 @@
"use strict";
const express = require('express');
const router = express.Router();
const sql = require('../../services/sql');
const auth = require('../../services/auth');
const utils = require('../../services/utils');
const sync_table = require('../../services/sync_table');
const wrap = require('express-promise-wrap').wrap;
const tree = require('../../services/tree');
router.put('/:childNoteId/clone-to/:parentNoteId', auth.checkApiAuth, wrap(async (req, res, next) => {
const parentNoteId = req.params.parentNoteId;
const childNoteId = req.params.childNoteId;
const prefix = req.body.prefix;
const sourceId = req.headers.source_id;
if (!await tree.validateParentChild(res, parentNoteId, childNoteId)) {
return;
}
const maxNotePos = await sql.getFirstValue('SELECT MAX(note_position) FROM notes_tree WHERE parent_note_id = ? AND is_deleted = 0', [parentNoteId]);
const newNotePos = maxNotePos === null ? 0 : maxNotePos + 1;
await sql.doInTransaction(async () => {
const noteTree = {
note_tree_id: utils.newNoteTreeId(),
note_id: childNoteId,
parent_note_id: parentNoteId,
prefix: prefix,
note_position: newNotePos,
is_expanded: 0,
date_modified: utils.nowDate(),
is_deleted: 0
};
await sql.replace("notes_tree", noteTree);
await sync_table.addNoteTreeSync(noteTree.note_tree_id, sourceId);
await sql.execute("UPDATE notes_tree SET is_expanded = 1 WHERE note_id = ?", [parentNoteId]);
});
res.send({ success: true });
}));
router.put('/:noteId/clone-after/:afterNoteTreeId', auth.checkApiAuth, wrap(async (req, res, next) => {
const noteId = req.params.noteId;
const afterNoteTreeId = req.params.afterNoteTreeId;
const sourceId = req.headers.source_id;
const afterNote = await tree.getNoteTree(afterNoteTreeId);
if (!await tree.validateParentChild(res, afterNote.parent_note_id, noteId)) {
return;
}
await sql.doInTransaction(async () => {
// we don't change date_modified so other changes are prioritized in case of conflict
// also we would have to sync all those modified note trees otherwise hash checks would fail
await sql.execute("UPDATE notes_tree SET note_position = note_position + 1 WHERE parent_note_id = ? AND note_position > ? AND is_deleted = 0",
[afterNote.parent_note_id, afterNote.note_position]);
await sync_table.addNoteReorderingSync(afterNote.parent_note_id, sourceId);
const noteTree = {
note_tree_id: utils.newNoteTreeId(),
note_id: noteId,
parent_note_id: afterNote.parent_note_id,
note_position: afterNote.note_position + 1,
is_expanded: 0,
date_modified: utils.nowDate(),
is_deleted: 0
};
await sql.replace("notes_tree", noteTree);
await sync_table.addNoteTreeSync(noteTree.note_tree_id, sourceId);
});
res.send({ success: true });
}));
module.exports = router;

View File

@@ -4,14 +4,15 @@ const express = require('express');
const router = express.Router();
const sql = require('../../services/sql');
const auth = require('../../services/auth');
const wrap = require('express-promise-wrap').wrap;
router.get('', auth.checkApiAuth, async (req, res, next) => {
router.get('', auth.checkApiAuth, wrap(async (req, res, next) => {
await deleteOld();
const result = await sql.getAll("SELECT * FROM event_log ORDER BY date_added DESC");
res.send(result);
});
}));
async function deleteOld() {
const cutoffId = await sql.getFirstValue("SELECT id FROM event_log ORDER BY id DESC LIMIT 1000, 1");

View File

@@ -8,8 +8,9 @@ const sql = require('../../services/sql');
const data_dir = require('../../services/data_dir');
const html = require('html');
const auth = require('../../services/auth');
const wrap = require('express-promise-wrap').wrap;
router.get('/:noteId/to/:directory', auth.checkApiAuth, async (req, res, next) => {
router.get('/:noteId/to/:directory', auth.checkApiAuth, wrap(async (req, res, next) => {
const noteId = req.params.noteId;
const directory = req.params.directory.replace(/[^0-9a-zA-Z_-]/gi, '');
@@ -30,7 +31,7 @@ router.get('/:noteId/to/:directory', auth.checkApiAuth, async (req, res, next) =
await exportNote(noteTreeId, completeExportDir);
res.send({});
});
}));
async function exportNote(noteTreeId, dir) {
const noteTree = await sql.getFirst("SELECT * FROM notes_tree WHERE note_tree_id = ?", [noteTreeId]);

148
routes/api/image.js Normal file
View File

@@ -0,0 +1,148 @@
"use strict";
const express = require('express');
const router = express.Router();
const sql = require('../../services/sql');
const auth = require('../../services/auth');
const utils = require('../../services/utils');
const sync_table = require('../../services/sync_table');
const multer = require('multer')();
const imagemin = require('imagemin');
const imageminMozJpeg = require('imagemin-mozjpeg');
const imageminPngQuant = require('imagemin-pngquant');
const imageminGifLossy = require('imagemin-giflossy');
const jimp = require('jimp');
const imageType = require('image-type');
const sanitizeFilename = require('sanitize-filename');
const wrap = require('express-promise-wrap').wrap;
const RESOURCE_DIR = require('../../services/resource_dir').RESOURCE_DIR;
const fs = require('fs');
router.get('/:imageId/:filename', auth.checkApiAuthOrElectron, wrap(async (req, res, next) => {
const image = await sql.getFirst("SELECT * FROM images WHERE image_id = ?", [req.params.imageId]);
if (!image) {
return res.status(404).send({});
}
else if (image.data === null) {
res.set('Content-Type', 'image/png');
return res.send(fs.readFileSync(RESOURCE_DIR + '/db/image-deleted.png'));
}
res.set('Content-Type', 'image/' + image.format);
res.send(image.data);
}));
router.post('', auth.checkApiAuthOrElectron, multer.single('upload'), wrap(async (req, res, next) => {
const sourceId = req.headers.source_id;
const noteId = req.query.noteId;
const file = req.file;
const note = await sql.getFirst("SELECT * FROM notes WHERE note_id = ?", [noteId]);
if (!note) {
return res.status(404).send(`Note ${noteId} doesn't exist.`);
}
if (!["image/png", "image/jpeg", "image/gif"].includes(file.mimetype)) {
return res.status(400).send("Unknown image type: " + file.mimetype);
}
const now = utils.nowDate();
const resizedImage = await resize(file.buffer);
const optimizedImage = await optimize(resizedImage);
const imageFormat = imageType(optimizedImage);
const fileNameWithouExtension = file.originalname.replace(/\.[^/.]+$/, "");
const fileName = sanitizeFilename(fileNameWithouExtension + "." + imageFormat.ext);
const imageId = utils.newImageId();
await sql.doInTransaction(async () => {
await sql.insert("images", {
image_id: imageId,
format: imageFormat.ext,
name: fileName,
checksum: utils.hash(optimizedImage),
data: optimizedImage,
is_deleted: 0,
date_modified: now,
date_created: now
});
await sync_table.addImageSync(imageId, sourceId);
const noteImageId = utils.newNoteImageId();
await sql.insert("notes_image", {
note_image_id: noteImageId,
note_id: noteId,
image_id: imageId,
is_deleted: 0,
date_modified: now,
date_created: now
});
await sync_table.addNoteImageSync(noteImageId, sourceId);
});
res.send({
uploaded: true,
url: `/api/images/${imageId}/${fileName}`
});
}));
const MAX_SIZE = 1000;
const MAX_BYTE_SIZE = 200000; // images should have under 100 KBs
async function resize(buffer) {
const image = await jimp.read(buffer);
if (image.bitmap.width > image.bitmap.height && image.bitmap.width > MAX_SIZE) {
image.resize(MAX_SIZE, jimp.AUTO);
}
else if (image.bitmap.height > MAX_SIZE) {
image.resize(jimp.AUTO, MAX_SIZE);
}
else if (buffer.byteLength <= MAX_BYTE_SIZE) {
return buffer;
}
// we do resizing with max quality which will be trimmed during optimization step next
image.quality(100);
// when converting PNG to JPG we lose alpha channel, this is replaced by white to match Trilium white background
image.background(0xFFFFFFFF);
// getBuffer doesn't support promises so this workaround
return await new Promise((resolve, reject) => image.getBuffer(jimp.MIME_JPEG, (err, data) => {
if (err) {
reject(err);
}
else {
resolve(data);
}
}));
}
async function optimize(buffer) {
return await imagemin.buffer(buffer, {
plugins: [
imageminMozJpeg({
quality: 50
}),
imageminPngQuant({
quality: "0-70"
}),
imageminGifLossy({
lossy: 80,
optimize: '3' // needs to be string
})
]
});
}
module.exports = router;

View File

@@ -8,8 +8,9 @@ const data_dir = require('../../services/data_dir');
const utils = require('../../services/utils');
const sync_table = require('../../services/sync_table');
const auth = require('../../services/auth');
const wrap = require('express-promise-wrap').wrap;
router.get('/:directory/to/:parentNoteId', auth.checkApiAuth, async (req, res, next) => {
router.get('/:directory/to/:parentNoteId', auth.checkApiAuth, wrap(async (req, res, next) => {
const directory = req.params.directory.replace(/[^0-9a-zA-Z_-]/gi, '');
const parentNoteId = req.params.parentNoteId;
@@ -18,7 +19,7 @@ router.get('/:directory/to/:parentNoteId', auth.checkApiAuth, async (req, res, n
await sql.doInTransaction(async () => await importNotes(dir, parentNoteId));
res.send({});
});
}));
async function importNotes(dir, parentNoteId) {
const parent = await sql.getFirst("SELECT * FROM notes WHERE note_id = ?", [parentNoteId]);

View File

@@ -9,8 +9,9 @@ const auth = require('../../services/auth');
const password_encryption = require('../../services/password_encryption');
const protected_session = require('../../services/protected_session');
const app_info = require('../../services/app_info');
const wrap = require('express-promise-wrap').wrap;
router.post('/sync', async (req, res, next) => {
router.post('/sync', wrap(async (req, res, next) => {
const timestampStr = req.body.timestamp;
const timestamp = utils.parseDate(timestampStr);
@@ -44,10 +45,10 @@ router.post('/sync', async (req, res, next) => {
res.send({
sourceId: source_id.getCurrentSourceId()
});
});
}));
// this is for entering protected mode so user has to be already logged-in (that's the reason we don't require username)
router.post('/protected', auth.checkApiAuth, async (req, res, next) => {
router.post('/protected', auth.checkApiAuth, wrap(async (req, res, next) => {
const password = req.body.password;
if (!await password_encryption.verifyPassword(password)) {
@@ -67,6 +68,6 @@ router.post('/protected', auth.checkApiAuth, async (req, res, next) => {
success: true,
protectedSessionId: protectedSessionId
});
});
}));
module.exports = router;

View File

@@ -6,20 +6,21 @@ const auth = require('../../services/auth');
const options = require('../../services/options');
const migration = require('../../services/migration');
const app_info = require('../../services/app_info');
const wrap = require('express-promise-wrap').wrap;
router.get('', auth.checkApiAuthForMigrationPage, async (req, res, next) => {
router.get('', auth.checkApiAuthForMigrationPage, wrap(async (req, res, next) => {
res.send({
db_version: parseInt(await options.getOption('db_version')),
app_db_version: app_info.db_version
});
});
}));
router.post('', auth.checkApiAuthForMigrationPage, async (req, res, next) => {
router.post('', auth.checkApiAuthForMigrationPage, wrap(async (req, res, next) => {
const migrations = await migration.migrate();
res.send({
migrations: migrations
});
});
}));
module.exports = router;

View File

@@ -7,8 +7,9 @@ const auth = require('../../services/auth');
const data_encryption = require('../../services/data_encryption');
const protected_session = require('../../services/protected_session');
const sync_table = require('../../services/sync_table');
const wrap = require('express-promise-wrap').wrap;
router.get('/:noteId', auth.checkApiAuth, async (req, res, next) => {
router.get('/:noteId', auth.checkApiAuth, wrap(async (req, res, next) => {
const noteId = req.params.noteId;
const history = await sql.getAll("SELECT * FROM notes_history WHERE note_id = ? order by date_modified_to desc", [noteId]);
@@ -22,9 +23,9 @@ router.get('/:noteId', auth.checkApiAuth, async (req, res, next) => {
}
res.send(history);
});
}));
router.put('', auth.checkApiAuth, async (req, res, next) => {
router.put('', auth.checkApiAuth, wrap(async (req, res, next) => {
const sourceId = req.headers.source_id;
await sql.doInTransaction(async () => {
@@ -34,6 +35,6 @@ router.put('', auth.checkApiAuth, async (req, res, next) => {
});
res.send();
});
}));
module.exports = router;

View File

@@ -6,10 +6,13 @@ const auth = require('../../services/auth');
const sql = require('../../services/sql');
const notes = require('../../services/notes');
const log = require('../../services/log');
const utils = require('../../services/utils');
const protected_session = require('../../services/protected_session');
const data_encryption = require('../../services/data_encryption');
const tree = require('../../services/tree');
const wrap = require('express-promise-wrap').wrap;
router.get('/:noteId', auth.checkApiAuth, async (req, res, next) => {
router.get('/:noteId', auth.checkApiAuth, wrap(async (req, res, next) => {
const noteId = req.params.noteId;
const detail = await sql.getFirst("SELECT * FROM notes WHERE note_id = ?", [noteId]);
@@ -30,9 +33,9 @@ router.get('/:noteId', auth.checkApiAuth, async (req, res, next) => {
res.send({
detail: detail
});
});
}));
router.post('/:parentNoteId/children', auth.checkApiAuth, async (req, res, next) => {
router.post('/:parentNoteId/children', auth.checkApiAuth, wrap(async (req, res, next) => {
const sourceId = req.headers.source_id;
const parentNoteId = req.params.parentNoteId;
const note = req.body;
@@ -43,9 +46,9 @@ router.post('/:parentNoteId/children', auth.checkApiAuth, async (req, res, next)
'note_id': noteId,
'note_tree_id': noteTreeId
});
});
}));
router.put('/:noteId', auth.checkApiAuth, async (req, res, next) => {
router.put('/:noteId', auth.checkApiAuth, wrap(async (req, res, next) => {
const note = req.body;
const noteId = req.params.noteId;
const sourceId = req.headers.source_id;
@@ -54,28 +57,39 @@ router.put('/:noteId', auth.checkApiAuth, async (req, res, next) => {
await notes.updateNote(noteId, note, dataKey, sourceId);
res.send({});
});
}));
router.get('/', auth.checkApiAuth, wrap(async (req, res, next) => {
const search = '%' + utils.sanitizeSql(req.query.search) + '%';
// searching in protected notes is pointless because of encryption
const noteIds = await sql.getFirstColumn(`SELECT note_id FROM notes
WHERE is_deleted = 0 AND is_protected = 0 AND (note_title LIKE ? OR note_text LIKE ?)`, [search, search]);
res.send(noteIds);
}));
router.put('/:noteId/sort', auth.checkApiAuth, wrap(async (req, res, next) => {
const noteId = req.params.noteId;
const sourceId = req.headers.source_id;
const dataKey = protected_session.getDataKey(req);
await tree.sortNotesAlphabetically(noteId, dataKey, sourceId);
res.send({});
}));
router.put('/:noteId/protect-sub-tree/:isProtected', auth.checkApiAuth, wrap(async (req, res, next) => {
const noteId = req.params.noteId;
const isProtected = !!parseInt(req.params.isProtected);
const dataKey = protected_session.getDataKey(req);
const sourceId = req.headers.source_id;
router.delete('/:noteTreeId', auth.checkApiAuth, async (req, res, next) => {
await sql.doInTransaction(async () => {
await notes.deleteNote(req.params.noteTreeId, req.headers.source_id);
await notes.protectNoteRecursively(noteId, dataKey, isProtected, sourceId);
});
res.send({});
});
router.get('/', auth.checkApiAuth, async (req, res, next) => {
const search = '%' + req.query.search + '%';
const result = await sql.getAll("SELECT note_id FROM notes WHERE note_title liKE ? OR note_text LIKE ?", [search, search]);
const noteIdList = [];
for (const res of result) {
noteIdList.push(res.note_id);
}
res.send(noteIdList);
});
}));
module.exports = router;

View File

@@ -1,227 +0,0 @@
"use strict";
const express = require('express');
const router = express.Router();
const sql = require('../../services/sql');
const utils = require('../../services/utils');
const auth = require('../../services/auth');
const sync_table = require('../../services/sync_table');
router.put('/:noteTreeId/move-to/:parentNoteId', auth.checkApiAuth, async (req, res, next) => {
const noteTreeId = req.params.noteTreeId;
const parentNoteId = req.params.parentNoteId;
const sourceId = req.headers.source_id;
const maxNotePos = await sql.getFirstValue('SELECT MAX(note_position) FROM notes_tree WHERE parent_note_id = ? AND is_deleted = 0', [parentNoteId]);
const newNotePos = maxNotePos === null ? 0 : maxNotePos + 1;
const now = utils.nowDate();
await sql.doInTransaction(async () => {
await sql.execute("UPDATE notes_tree SET parent_note_id = ?, note_position = ?, date_modified = ? WHERE note_tree_id = ?",
[parentNoteId, newNotePos, now, noteTreeId]);
await sync_table.addNoteTreeSync(noteTreeId, sourceId);
});
res.send({});
});
router.put('/:noteTreeId/move-before/:beforeNoteTreeId', auth.checkApiAuth, async (req, res, next) => {
const noteTreeId = req.params.noteTreeId;
const beforeNoteTreeId = req.params.beforeNoteTreeId;
const sourceId = req.headers.source_id;
const beforeNote = await sql.getFirst("SELECT * FROM notes_tree WHERE note_tree_id = ?", [beforeNoteTreeId]);
if (beforeNote) {
await sql.doInTransaction(async () => {
// we don't change date_modified so other changes are prioritized in case of conflict
// also we would have to sync all those modified note trees otherwise hash checks would fail
await sql.execute("UPDATE notes_tree SET note_position = note_position + 1 WHERE parent_note_id = ? AND note_position >= ? AND is_deleted = 0",
[beforeNote.parent_note_id, beforeNote.note_position]);
await sync_table.addNoteReorderingSync(beforeNote.parent_note_id, sourceId);
const now = utils.nowDate();
await sql.execute("UPDATE notes_tree SET parent_note_id = ?, note_position = ?, date_modified = ? WHERE note_tree_id = ?",
[beforeNote.parent_note_id, beforeNote.note_position, now, noteTreeId]);
await sync_table.addNoteTreeSync(noteTreeId, sourceId);
});
res.send({});
}
else {
res.status(500).send("Before note " + beforeNoteTreeId + " doesn't exist.");
}
});
router.put('/:noteTreeId/move-after/:afterNoteTreeId', auth.checkApiAuth, async (req, res, next) => {
const noteTreeId = req.params.noteTreeId;
const afterNoteTreeId = req.params.afterNoteTreeId;
const sourceId = req.headers.source_id;
const afterNote = await sql.getFirst("SELECT * FROM notes_tree WHERE note_tree_id = ?", [afterNoteTreeId]);
if (afterNote) {
await sql.doInTransaction(async () => {
// we don't change date_modified so other changes are prioritized in case of conflict
// also we would have to sync all those modified note trees otherwise hash checks would fail
await sql.execute("UPDATE notes_tree SET note_position = note_position + 1 WHERE parent_note_id = ? AND note_position > ? AND is_deleted = 0",
[afterNote.parent_note_id, afterNote.note_position]);
await sync_table.addNoteReorderingSync(afterNote.parent_note_id, sourceId);
await sql.execute("UPDATE notes_tree SET parent_note_id = ?, note_position = ?, date_modified = ? WHERE note_tree_id = ?",
[afterNote.parent_note_id, afterNote.note_position + 1, utils.nowDate(), noteTreeId]);
await sync_table.addNoteTreeSync(noteTreeId, sourceId);
});
res.send({});
}
else {
res.status(500).send("After note " + afterNoteTreeId + " doesn't exist.");
}
});
router.put('/:childNoteId/clone-to/:parentNoteId', auth.checkApiAuth, async (req, res, next) => {
const parentNoteId = req.params.parentNoteId;
const childNoteId = req.params.childNoteId;
const prefix = req.body.prefix;
const sourceId = req.headers.source_id;
const existing = await sql.getFirst('SELECT * FROM notes_tree WHERE note_id = ? AND parent_note_id = ?', [childNoteId, parentNoteId]);
if (existing && !existing.is_deleted) {
return res.send({
success: false,
message: 'This note already exists in target parent note.'
});
}
if (!await checkCycle(parentNoteId, childNoteId)) {
return res.send({
success: false,
message: 'Cloning note here would create cycle.'
});
}
const maxNotePos = await sql.getFirstValue('SELECT MAX(note_position) FROM notes_tree WHERE parent_note_id = ? AND is_deleted = 0', [parentNoteId]);
const newNotePos = maxNotePos === null ? 0 : maxNotePos + 1;
await sql.doInTransaction(async () => {
const noteTree = {
note_tree_id: utils.newNoteTreeId(),
note_id: childNoteId,
parent_note_id: parentNoteId,
prefix: prefix,
note_position: newNotePos,
is_expanded: 0,
date_modified: utils.nowDate(),
is_deleted: 0
};
await sql.replace("notes_tree", noteTree);
await sync_table.addNoteTreeSync(noteTree.note_tree_id, sourceId);
await sql.execute("UPDATE notes_tree SET is_expanded = 1 WHERE note_id = ?", [parentNoteId]);
});
res.send({
success: true
});
});
router.put('/:noteId/clone-after/:afterNoteTreeId', auth.checkApiAuth, async (req, res, next) => {
const noteId = req.params.noteId;
const afterNoteTreeId = req.params.afterNoteTreeId;
const sourceId = req.headers.source_id;
const afterNote = await sql.getFirst("SELECT * FROM notes_tree WHERE note_tree_id = ?", [afterNoteTreeId]);
if (!afterNote) {
return res.status(500).send("After note " + afterNoteTreeId + " doesn't exist.");
}
if (!await checkCycle(afterNote.parent_note_id, noteId)) {
return res.send({
success: false,
message: 'Cloning note here would create cycle.'
});
}
const existing = await sql.getFirstValue('SELECT * FROM notes_tree WHERE note_id = ? AND parent_note_id = ?', [noteId, afterNote.parent_note_id]);
if (existing && !existing.is_deleted) {
return res.send({
success: false,
message: 'This note already exists in target parent note.'
});
}
await sql.doInTransaction(async () => {
// we don't change date_modified so other changes are prioritized in case of conflict
// also we would have to sync all those modified note trees otherwise hash checks would fail
await sql.execute("UPDATE notes_tree SET note_position = note_position + 1 WHERE parent_note_id = ? AND note_position > ? AND is_deleted = 0",
[afterNote.parent_note_id, afterNote.note_position]);
await sync_table.addNoteReorderingSync(afterNote.parent_note_id, sourceId);
const noteTree = {
note_tree_id: utils.newNoteTreeId(),
note_id: noteId,
parent_note_id: afterNote.parent_note_id,
note_position: afterNote.note_position + 1,
is_expanded: 0,
date_modified: utils.nowDate(),
is_deleted: 0
};
await sql.replace("notes_tree", noteTree);
await sync_table.addNoteTreeSync(noteTree.note_tree_id, sourceId);
});
res.send({
success: true
});
});
async function checkCycle(parentNoteId, childNoteId) {
if (parentNoteId === 'root') {
return true;
}
if (parentNoteId === childNoteId) {
return false;
}
const parentNoteIds = await sql.getFirstColumn("SELECT DISTINCT parent_note_id FROM notes_tree WHERE note_id = ?", [parentNoteId]);
for (const pid of parentNoteIds) {
if (!await checkCycle(pid, childNoteId)) {
return false;
}
}
return true;
}
router.put('/:noteTreeId/expanded/:expanded', auth.checkApiAuth, async (req, res, next) => {
const noteTreeId = req.params.noteTreeId;
const expanded = req.params.expanded;
await sql.doInTransaction(async () => {
await sql.execute("UPDATE notes_tree SET is_expanded = ? WHERE note_tree_id = ?", [expanded, noteTreeId]);
// we don't sync expanded attribute
});
res.send({});
});
module.exports = router;

View File

@@ -5,11 +5,12 @@ const router = express.Router();
const sql = require('../../services/sql');
const changePassword = require('../../services/change_password');
const auth = require('../../services/auth');
const wrap = require('express-promise-wrap').wrap;
router.post('/change', auth.checkApiAuth, async (req, res, next) => {
router.post('/change', auth.checkApiAuth, wrap(async (req, res, next) => {
const result = await changePassword.changePassword(req.body['current_password'], req.body['new_password'], req);
res.send(result);
});
}));
module.exports = router;

View File

@@ -4,8 +4,9 @@ const express = require('express');
const router = express.Router();
const sql = require('../../services/sql');
const auth = require('../../services/auth');
const wrap = require('express-promise-wrap').wrap;
router.get('/', auth.checkApiAuth, async (req, res, next) => {
router.get('/', auth.checkApiAuth, wrap(async (req, res, next) => {
const recentChanges = await sql.getAll(
`SELECT
notes.is_deleted AS current_is_deleted,
@@ -19,6 +20,6 @@ router.get('/', auth.checkApiAuth, async (req, res, next) => {
LIMIT 1000`);
res.send(recentChanges);
});
}));
module.exports = router;

View File

@@ -7,12 +7,13 @@ const auth = require('../../services/auth');
const utils = require('../../services/utils');
const sync_table = require('../../services/sync_table');
const options = require('../../services/options');
const wrap = require('express-promise-wrap').wrap;
router.get('', auth.checkApiAuth, async (req, res, next) => {
router.get('', auth.checkApiAuth, wrap(async (req, res, next) => {
res.send(await getRecentNotes());
});
}));
router.put('/:noteTreeId/:notePath', auth.checkApiAuth, async (req, res, next) => {
router.put('/:noteTreeId/:notePath', auth.checkApiAuth, wrap(async (req, res, next) => {
const noteTreeId = req.params.noteTreeId;
const notePath = req.params.notePath;
const sourceId = req.headers.source_id;
@@ -31,7 +32,7 @@ router.put('/:noteTreeId/:notePath', auth.checkApiAuth, async (req, res, next) =
});
res.send(await getRecentNotes());
});
}));
async function getRecentNotes() {
return await sql.getAll(`

View File

@@ -5,24 +5,25 @@ const router = express.Router();
const sql = require('../../services/sql');
const options = require('../../services/options');
const auth = require('../../services/auth');
const wrap = require('express-promise-wrap').wrap;
// options allowed to be updated directly in settings dialog
const ALLOWED_OPTIONS = ['protected_session_timeout', 'history_snapshot_time_interval'];
router.get('/all', auth.checkApiAuth, async (req, res, next) => {
router.get('/all', auth.checkApiAuth, wrap(async (req, res, next) => {
const settings = await sql.getMap("SELECT opt_name, opt_value FROM options");
res.send(settings);
});
}));
router.get('/', auth.checkApiAuth, async (req, res, next) => {
router.get('/', auth.checkApiAuth, wrap(async (req, res, next) => {
const settings = await sql.getMap("SELECT opt_name, opt_value FROM options WHERE opt_name IN ("
+ ALLOWED_OPTIONS.map(x => '?').join(",") + ")", ALLOWED_OPTIONS);
res.send(settings);
});
}));
router.post('/', auth.checkApiAuth, async (req, res, next) => {
router.post('/', auth.checkApiAuth, wrap(async (req, res, next) => {
const body = req.body;
const sourceId = req.headers.source_id;
@@ -38,6 +39,6 @@ router.post('/', auth.checkApiAuth, async (req, res, next) => {
else {
res.send("not allowed option to set");
}
});
}));
module.exports = router;

View File

@@ -8,8 +8,9 @@ const sql = require('../../services/sql');
const utils = require('../../services/utils');
const my_scrypt = require('../../services/my_scrypt');
const password_encryption = require('../../services/password_encryption');
const wrap = require('express-promise-wrap').wrap;
router.post('', auth.checkAppNotInitialized, async (req, res, next) => {
router.post('', auth.checkAppNotInitialized, wrap(async (req, res, next) => {
const { username, password } = req.body;
await sql.doInTransaction(async () => {
@@ -27,6 +28,6 @@ router.post('', auth.checkAppNotInitialized, async (req, res, next) => {
sql.setDbReadyAsResolved();
res.send({});
});
}));
module.exports = router;

View File

@@ -4,8 +4,9 @@ const express = require('express');
const router = express.Router();
const auth = require('../../services/auth');
const sql = require('../../services/sql');
const wrap = require('express-promise-wrap').wrap;
router.post('/execute', auth.checkApiAuth, async (req, res, next) => {
router.post('/execute', auth.checkApiAuth, wrap(async (req, res, next) => {
const query = req.body.query;
try {
@@ -20,6 +21,6 @@ router.post('/execute', auth.checkApiAuth, async (req, res, next) => {
error: e.message
});
}
});
}));
module.exports = router;

View File

@@ -10,19 +10,20 @@ const sql = require('../../services/sql');
const options = require('../../services/options');
const content_hash = require('../../services/content_hash');
const log = require('../../services/log');
const wrap = require('express-promise-wrap').wrap;
router.get('/check', auth.checkApiAuth, async (req, res, next) => {
router.get('/check', auth.checkApiAuth, wrap(async (req, res, next) => {
res.send({
'hashes': await content_hash.getHashes(),
'max_sync_id': await sql.getFirstValue('SELECT MAX(id) FROM sync')
});
});
}));
router.post('/now', auth.checkApiAuth, async (req, res, next) => {
router.post('/now', auth.checkApiAuth, wrap(async (req, res, next) => {
res.send(await sync.sync());
});
}));
router.post('/fill-sync-rows', auth.checkApiAuth, async (req, res, next) => {
router.post('/fill-sync-rows', auth.checkApiAuth, wrap(async (req, res, next) => {
await sql.doInTransaction(async () => {
await sync_table.fillAllSyncRows();
});
@@ -30,9 +31,9 @@ router.post('/fill-sync-rows', auth.checkApiAuth, async (req, res, next) => {
log.info("Sync rows have been filled.");
res.send({});
});
}));
router.post('/force-full-sync', auth.checkApiAuth, async (req, res, next) => {
router.post('/force-full-sync', auth.checkApiAuth, wrap(async (req, res, next) => {
await sql.doInTransaction(async () => {
await options.setOption('last_synced_pull', 0);
await options.setOption('last_synced_push', 0);
@@ -44,94 +45,160 @@ router.post('/force-full-sync', auth.checkApiAuth, async (req, res, next) => {
sync.sync();
res.send({});
});
}));
router.get('/changed', auth.checkApiAuth, async (req, res, next) => {
router.post('/force-note-sync/:noteId', auth.checkApiAuth, wrap(async (req, res, next) => {
const noteId = req.params.noteId;
await sql.doInTransaction(async () => {
await sync_table.addNoteSync(noteId);
for (const noteTreeId of await sql.getFirstColumn("SELECT note_tree_id FROM notes_tree WHERE is_deleted = 0 AND note_id = ?", [noteId])) {
await sync_table.addNoteTreeSync(noteTreeId);
await sync_table.addRecentNoteSync(noteTreeId);
}
for (const noteHistoryId of await sql.getFirstColumn("SELECT note_history_id FROM notes_history WHERE note_id = ?", [noteId])) {
await sync_table.addNoteHistorySync(noteHistoryId);
}
});
log.info("Forcing note sync for " + noteId);
// not awaiting for the job to finish (will probably take a long time)
sync.sync();
res.send({});
}));
router.get('/changed', auth.checkApiAuth, wrap(async (req, res, next) => {
const lastSyncId = parseInt(req.query.lastSyncId);
res.send(await sql.getAll("SELECT * FROM sync WHERE id > ?", [lastSyncId]));
});
}));
router.get('/notes/:noteId', auth.checkApiAuth, async (req, res, next) => {
router.get('/notes/:noteId', auth.checkApiAuth, wrap(async (req, res, next) => {
const noteId = req.params.noteId;
res.send({
entity: await sql.getFirst("SELECT * FROM notes WHERE note_id = ?", [noteId])
});
});
}));
router.get('/notes_tree/:noteTreeId', auth.checkApiAuth, async (req, res, next) => {
router.get('/notes_tree/:noteTreeId', auth.checkApiAuth, wrap(async (req, res, next) => {
const noteTreeId = req.params.noteTreeId;
res.send(await sql.getFirst("SELECT * FROM notes_tree WHERE note_tree_id = ?", [noteTreeId]));
});
}));
router.get('/notes_history/:noteHistoryId', auth.checkApiAuth, async (req, res, next) => {
router.get('/notes_history/:noteHistoryId', auth.checkApiAuth, wrap(async (req, res, next) => {
const noteHistoryId = req.params.noteHistoryId;
res.send(await sql.getFirst("SELECT * FROM notes_history WHERE note_history_id = ?", [noteHistoryId]));
});
}));
router.get('/options/:optName', auth.checkApiAuth, async (req, res, next) => {
router.get('/options/:optName', auth.checkApiAuth, wrap(async (req, res, next) => {
const optName = req.params.optName;
const opt = await sql.getFirst("SELECT * FROM options WHERE opt_name = ?", [optName]);
if (!options.SYNCED_OPTIONS.includes(optName)) {
if (!opt.is_synced) {
res.send("This option can't be synced.");
}
else {
res.send(await sql.getFirst("SELECT * FROM options WHERE opt_name = ?", [optName]));
res.send(opt);
}
});
}));
router.get('/notes_reordering/:noteTreeParentId', auth.checkApiAuth, async (req, res, next) => {
router.get('/notes_reordering/:noteTreeParentId', auth.checkApiAuth, wrap(async (req, res, next) => {
const noteTreeParentId = req.params.noteTreeParentId;
res.send({
parent_note_id: noteTreeParentId,
ordering: await sql.getMap("SELECT note_tree_id, note_position FROM notes_tree WHERE parent_note_id = ?", [noteTreeParentId])
ordering: await sql.getMap("SELECT note_tree_id, note_position FROM notes_tree WHERE parent_note_id = ? AND is_deleted = 0", [noteTreeParentId])
});
});
}));
router.get('/recent_notes/:noteTreeId', auth.checkApiAuth, async (req, res, next) => {
router.get('/recent_notes/:noteTreeId', auth.checkApiAuth, wrap(async (req, res, next) => {
const noteTreeId = req.params.noteTreeId;
res.send(await sql.getFirst("SELECT * FROM recent_notes WHERE note_tree_id = ?", [noteTreeId]));
});
}));
router.put('/notes', auth.checkApiAuth, async (req, res, next) => {
router.get('/images/:imageId', auth.checkApiAuth, wrap(async (req, res, next) => {
const imageId = req.params.imageId;
const entity = await sql.getFirst("SELECT * FROM images WHERE image_id = ?", [imageId]);
if (entity && entity.data !== null) {
entity.data = entity.data.toString('base64');
}
res.send(entity);
}));
router.get('/notes_image/:noteImageId', auth.checkApiAuth, wrap(async (req, res, next) => {
const noteImageId = req.params.noteImageId;
res.send(await sql.getFirst("SELECT * FROM notes_image WHERE note_image_id = ?", [noteImageId]));
}));
router.get('/attributes/:attributeId', auth.checkApiAuth, wrap(async (req, res, next) => {
const attributeId = req.params.attributeId;
res.send(await sql.getFirst("SELECT * FROM attributes WHERE attribute_id = ?", [attributeId]));
}));
router.put('/notes', auth.checkApiAuth, wrap(async (req, res, next) => {
await syncUpdate.updateNote(req.body.entity, req.body.sourceId);
res.send({});
});
}));
router.put('/notes_tree', auth.checkApiAuth, async (req, res, next) => {
router.put('/notes_tree', auth.checkApiAuth, wrap(async (req, res, next) => {
await syncUpdate.updateNoteTree(req.body.entity, req.body.sourceId);
res.send({});
});
}));
router.put('/notes_history', auth.checkApiAuth, async (req, res, next) => {
router.put('/notes_history', auth.checkApiAuth, wrap(async (req, res, next) => {
await syncUpdate.updateNoteHistory(req.body.entity, req.body.sourceId);
res.send({});
});
}));
router.put('/notes_reordering', auth.checkApiAuth, async (req, res, next) => {
router.put('/notes_reordering', auth.checkApiAuth, wrap(async (req, res, next) => {
await syncUpdate.updateNoteReordering(req.body.entity, req.body.sourceId);
res.send({});
});
}));
router.put('/options', auth.checkApiAuth, async (req, res, next) => {
router.put('/options', auth.checkApiAuth, wrap(async (req, res, next) => {
await syncUpdate.updateOptions(req.body.entity, req.body.sourceId);
res.send({});
});
}));
router.put('/recent_notes', auth.checkApiAuth, async (req, res, next) => {
router.put('/recent_notes', auth.checkApiAuth, wrap(async (req, res, next) => {
await syncUpdate.updateRecentNotes(req.body.entity, req.body.sourceId);
res.send({});
});
}));
router.put('/images', auth.checkApiAuth, wrap(async (req, res, next) => {
await syncUpdate.updateImage(req.body.entity, req.body.sourceId);
res.send({});
}));
router.put('/notes_image', auth.checkApiAuth, wrap(async (req, res, next) => {
await syncUpdate.updateNoteImage(req.body.entity, req.body.sourceId);
res.send({});
}));
router.put('/attributes', auth.checkApiAuth, wrap(async (req, res, next) => {
await syncUpdate.updateAttribute(req.body.entity, req.body.sourceId);
res.send({});
}));
module.exports = router;

View File

@@ -8,18 +8,24 @@ const utils = require('../../services/utils');
const auth = require('../../services/auth');
const protected_session = require('../../services/protected_session');
const data_encryption = require('../../services/data_encryption');
const notes = require('../../services/notes');
const sync_table = require('../../services/sync_table');
const wrap = require('express-promise-wrap').wrap;
router.get('/', auth.checkApiAuth, async (req, res, next) => {
const notes = await sql.getAll("SELECT "
+ "notes_tree.*, "
+ "notes.note_title, "
+ "notes.is_protected "
+ "FROM notes_tree "
+ "JOIN notes ON notes.note_id = notes_tree.note_id "
+ "WHERE notes.is_deleted = 0 AND notes_tree.is_deleted = 0 "
+ "ORDER BY note_position");
router.get('/', auth.checkApiAuth, wrap(async (req, res, next) => {
const notes = await sql.getAll(`
SELECT
notes_tree.*,
notes.note_title,
notes.is_protected
FROM
notes_tree
JOIN
notes ON notes.note_id = notes_tree.note_id
WHERE
notes.is_deleted = 0
AND notes_tree.is_deleted = 0
ORDER BY
note_position`);
const dataKey = protected_session.getDataKey(req);
@@ -33,22 +39,9 @@ router.get('/', auth.checkApiAuth, async (req, res, next) => {
notes: notes,
start_note_path: await options.getOption('start_note_path')
});
});
}));
router.put('/:noteId/protect-sub-tree/:isProtected', auth.checkApiAuth, async (req, res, next) => {
const noteId = req.params.noteId;
const isProtected = !!parseInt(req.params.isProtected);
const dataKey = protected_session.getDataKey(req);
const sourceId = req.headers.source_id;
await sql.doInTransaction(async () => {
await notes.protectNoteRecursively(noteId, dataKey, isProtected, sourceId);
});
res.send({});
});
router.put('/:noteTreeId/set-prefix', auth.checkApiAuth, async (req, res, next) => {
router.put('/:noteTreeId/set-prefix', auth.checkApiAuth, wrap(async (req, res, next) => {
const noteTreeId = req.params.noteTreeId;
const sourceId = req.headers.source_id;
const prefix = utils.isEmptyOrWhitespace(req.body.prefix) ? null : req.body.prefix;
@@ -60,6 +53,6 @@ router.put('/:noteTreeId/set-prefix', auth.checkApiAuth, async (req, res, next)
});
res.send({});
});
}));
module.exports = router;

125
routes/api/tree_changes.js Normal file
View File

@@ -0,0 +1,125 @@
"use strict";
const express = require('express');
const router = express.Router();
const sql = require('../../services/sql');
const auth = require('../../services/auth');
const utils = require('../../services/utils');
const sync_table = require('../../services/sync_table');
const tree = require('../../services/tree');
const notes = require('../../services/notes');
const wrap = require('express-promise-wrap').wrap;
/**
* Code in this file deals with moving and cloning note tree rows. Relationship between note and parent note is unique
* for not deleted note trees. There may be multiple deleted note-parent note relationships.
*/
router.put('/:noteTreeId/move-to/:parentNoteId', auth.checkApiAuth, wrap(async (req, res, next) => {
const noteTreeId = req.params.noteTreeId;
const parentNoteId = req.params.parentNoteId;
const sourceId = req.headers.source_id;
const noteToMove = await tree.getNoteTree(noteTreeId);
if (!await tree.validateParentChild(res, parentNoteId, noteToMove.note_id, noteTreeId)) {
return;
}
const maxNotePos = await sql.getFirstValue('SELECT MAX(note_position) FROM notes_tree WHERE parent_note_id = ? AND is_deleted = 0', [parentNoteId]);
const newNotePos = maxNotePos === null ? 0 : maxNotePos + 1;
const now = utils.nowDate();
await sql.doInTransaction(async () => {
await sql.execute("UPDATE notes_tree SET parent_note_id = ?, note_position = ?, date_modified = ? WHERE note_tree_id = ?",
[parentNoteId, newNotePos, now, noteTreeId]);
await sync_table.addNoteTreeSync(noteTreeId, sourceId);
});
res.send({ success: true });
}));
router.put('/:noteTreeId/move-before/:beforeNoteTreeId', auth.checkApiAuth, wrap(async (req, res, next) => {
const noteTreeId = req.params.noteTreeId;
const beforeNoteTreeId = req.params.beforeNoteTreeId;
const sourceId = req.headers.source_id;
const noteToMove = await tree.getNoteTree(noteTreeId);
const beforeNote = await tree.getNoteTree(beforeNoteTreeId);
if (!await tree.validateParentChild(res, beforeNote.parent_note_id, noteToMove.note_id, noteTreeId)) {
return;
}
await sql.doInTransaction(async () => {
// we don't change date_modified so other changes are prioritized in case of conflict
// also we would have to sync all those modified note trees otherwise hash checks would fail
await sql.execute("UPDATE notes_tree SET note_position = note_position + 1 WHERE parent_note_id = ? AND note_position >= ? AND is_deleted = 0",
[beforeNote.parent_note_id, beforeNote.note_position]);
await sync_table.addNoteReorderingSync(beforeNote.parent_note_id, sourceId);
const now = utils.nowDate();
await sql.execute("UPDATE notes_tree SET parent_note_id = ?, note_position = ?, date_modified = ? WHERE note_tree_id = ?",
[beforeNote.parent_note_id, beforeNote.note_position, now, noteTreeId]);
await sync_table.addNoteTreeSync(noteTreeId, sourceId);
});
res.send({ success: true });
}));
router.put('/:noteTreeId/move-after/:afterNoteTreeId', auth.checkApiAuth, wrap(async (req, res, next) => {
const noteTreeId = req.params.noteTreeId;
const afterNoteTreeId = req.params.afterNoteTreeId;
const sourceId = req.headers.source_id;
const noteToMove = await tree.getNoteTree(noteTreeId);
const afterNote = await tree.getNoteTree(afterNoteTreeId);
if (!await tree.validateParentChild(res, afterNote.parent_note_id, noteToMove.note_id, noteTreeId)) {
return;
}
await sql.doInTransaction(async () => {
// we don't change date_modified so other changes are prioritized in case of conflict
// also we would have to sync all those modified note trees otherwise hash checks would fail
await sql.execute("UPDATE notes_tree SET note_position = note_position + 1 WHERE parent_note_id = ? AND note_position > ? AND is_deleted = 0",
[afterNote.parent_note_id, afterNote.note_position]);
await sync_table.addNoteReorderingSync(afterNote.parent_note_id, sourceId);
await sql.execute("UPDATE notes_tree SET parent_note_id = ?, note_position = ?, date_modified = ? WHERE note_tree_id = ?",
[afterNote.parent_note_id, afterNote.note_position + 1, utils.nowDate(), noteTreeId]);
await sync_table.addNoteTreeSync(noteTreeId, sourceId);
});
res.send({ success: true });
}));
router.put('/:noteTreeId/expanded/:expanded', auth.checkApiAuth, wrap(async (req, res, next) => {
const noteTreeId = req.params.noteTreeId;
const expanded = req.params.expanded;
await sql.doInTransaction(async () => {
await sql.execute("UPDATE notes_tree SET is_expanded = ? WHERE note_tree_id = ?", [expanded, noteTreeId]);
// we don't sync expanded attribute
});
res.send({});
}));
router.delete('/:noteTreeId', auth.checkApiAuth, wrap(async (req, res, next) => {
await sql.doInTransaction(async () => {
await notes.deleteNote(req.params.noteTreeId, req.headers.source_id);
});
res.send({});
}));
module.exports = router;

View File

@@ -5,12 +5,13 @@ const router = express.Router();
const auth = require('../services/auth');
const source_id = require('../services/source_id');
const sql = require('../services/sql');
const wrap = require('express-promise-wrap').wrap;
router.get('', auth.checkAuth, async (req, res, next) => {
router.get('', auth.checkAuth, wrap(async (req, res, next) => {
res.render('index', {
sourceId: await source_id.generateSourceId(),
maxSyncIdAtLoad: await sql.getFirstValue("SELECT MAX(id) FROM sync")
});
});
}));
module.exports = router;

View File

@@ -5,12 +5,13 @@ const router = express.Router();
const utils = require('../services/utils');
const options = require('../services/options');
const my_scrypt = require('../services/my_scrypt');
const wrap = require('express-promise-wrap').wrap;
router.get('', (req, res, next) => {
router.get('', wrap(async (req, res, next) => {
res.render('login', { 'failedAuth': false });
});
}));
router.post('', async (req, res, next) => {
router.post('', wrap(async (req, res, next) => {
const userName = await options.getOption('username');
const guessedPassword = req.body.password;
@@ -32,7 +33,7 @@ router.post('', async (req, res, next) => {
else {
res.render('login', {'failedAuth': true});
}
});
}));
async function verifyPassword(guessed_password) {

View File

@@ -2,14 +2,15 @@
const express = require('express');
const router = express.Router();
const wrap = require('express-promise-wrap').wrap;
router.post('', async (req, res, next) => {
router.post('', wrap(async (req, res, next) => {
req.session.regenerate(() => {
req.session.loggedIn = false;
res.redirect('/');
});
});
}));
module.exports = router;

View File

@@ -3,9 +3,10 @@
const express = require('express');
const router = express.Router();
const auth = require('../services/auth');
const wrap = require('express-promise-wrap').wrap;
router.get('', auth.checkAuthForMigrationPage, (req, res, next) => {
router.get('', auth.checkAuthForMigrationPage, wrap(async (req, res, next) => {
res.render('migration', {});
});
}));
module.exports = router;

View File

@@ -7,7 +7,8 @@ const setupRoute = require('./setup');
// API routes
const treeApiRoute = require('./api/tree');
const notesApiRoute = require('./api/notes');
const notesMoveApiRoute = require('./api/notes_move');
const treeChangesApiRoute = require('./api/tree_changes');
const cloningApiRoute = require('./api/cloning');
const noteHistoryApiRoute = require('./api/note_history');
const recentChangesApiRoute = require('./api/recent_changes');
const settingsApiRoute = require('./api/settings');
@@ -24,6 +25,8 @@ const setupApiRoute = require('./api/setup');
const sqlRoute = require('./api/sql');
const anonymizationRoute = require('./api/anonymization');
const cleanupRoute = require('./api/cleanup');
const imageRoute = require('./api/image');
const attributesRoute = require('./api/attributes');
function register(app) {
app.use('/', indexRoute);
@@ -34,7 +37,9 @@ function register(app) {
app.use('/api/tree', treeApiRoute);
app.use('/api/notes', notesApiRoute);
app.use('/api/notes', notesMoveApiRoute);
app.use('/api/tree', treeChangesApiRoute);
app.use('/api/notes', cloningApiRoute);
app.use('/api/notes', attributesRoute);
app.use('/api/notes-history', noteHistoryApiRoute);
app.use('/api/recent-changes', recentChangesApiRoute);
app.use('/api/settings', settingsApiRoute);
@@ -51,6 +56,7 @@ function register(app) {
app.use('/api/sql', sqlRoute);
app.use('/api/anonymization', anonymizationRoute);
app.use('/api/cleanup', cleanupRoute);
app.use('/api/images', imageRoute);
}
module.exports = {

View File

@@ -3,9 +3,10 @@
const express = require('express');
const router = express.Router();
const auth = require('../services/auth');
const wrap = require('express-promise-wrap').wrap;
router.get('', auth.checkAppNotInitialized, (req, res, next) => {
router.get('', auth.checkAppNotInitialized, wrap(async (req, res, next) => {
res.render('setup', {});
});
}));
module.exports = router;

View File

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

31
services/attributes.js Normal file
View File

@@ -0,0 +1,31 @@
"use strict";
const sql = require('./sql');
const utils = require('./utils');
const sync_table = require('./sync_table');
async function getNoteIdWithAttribute(name, value) {
return await sql.getFirstValue(`SELECT notes.note_id FROM notes JOIN attributes USING(note_id)
WHERE notes.is_deleted = 0 AND attributes.name = ? AND attributes.value = ?`, [name, value]);
}
async function createAttribute(noteId, name, value = null, sourceId = null) {
const now = utils.nowDate();
const attributeId = utils.newAttributeId();
await sql.insert("attributes", {
attribute_id: attributeId,
note_id: noteId,
name: name,
value: value,
date_modified: now,
date_created: now
});
await sync_table.addAttributeSync(attributeId, sourceId);
}
module.exports = {
getNoteIdWithAttribute,
createAttribute
};

View File

@@ -3,12 +3,9 @@
const migration = require('./migration');
const sql = require('./sql');
const utils = require('./utils');
const options = require('./options');
async function checkAuth(req, res, next) {
const username = await options.getOption('username');
if (!username) {
if (!await sql.isUserInitialized()) {
res.redirect("setup");
}
else if (!req.session.loggedIn && !utils.isElectron()) {
@@ -31,6 +28,20 @@ async function checkAuthForMigrationPage(req, res, next) {
}
}
// for electron things which need network stuff
// currently we're doing that for file upload because handling form data seems to be difficult
async function checkApiAuthOrElectron(req, res, next) {
if (!req.session.loggedIn && !utils.isElectron()) {
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
}
}
async function checkApiAuth(req, res, next) {
if (!req.session.loggedIn) {
res.status(401).send("Not authorized");
@@ -53,9 +64,7 @@ async function checkApiAuthForMigrationPage(req, res, next) {
}
async function checkAppNotInitialized(req, res, next) {
const username = await options.getOption('username');
if (username) {
if (await sql.isUserInitialized()) {
res.status(400).send("App already initialized.");
}
else {
@@ -68,5 +77,6 @@ module.exports = {
checkAuthForMigrationPage,
checkApiAuth,
checkApiAuthForMigrationPage,
checkAppNotInitialized
checkAppNotInitialized,
checkApiAuthOrElectron
};

View File

@@ -6,6 +6,7 @@ const fs = require('fs-extra');
const dataDir = require('./data_dir');
const log = require('./log');
const sql = require('./sql');
const sync_mutex = require('./sync_mutex');
async function regularBackup() {
const now = new Date();
@@ -21,16 +22,20 @@ async function regularBackup() {
}
async function backupNow() {
const now = utils.nowDate();
// we don't want to backup DB in the middle of sync with potentially inconsistent DB state
const backupFile = dataDir.BACKUP_DIR + "/" + "backup-" + utils.getDateTimeForFile() + ".db";
await sync_mutex.doExclusively(async () => {
const now = utils.nowDate();
fs.copySync(dataDir.DOCUMENT_PATH, backupFile);
const backupFile = dataDir.BACKUP_DIR + "/" + "backup-" + utils.getDateTimeForFile() + ".db";
log.info("Created backup at " + backupFile);
fs.copySync(dataDir.DOCUMENT_PATH, backupFile);
await sql.doInTransaction(async () => {
await options.setOption('last_backup_date', now);
log.info("Created backup at " + backupFile);
await sql.doInTransaction(async () => {
await options.setOption('last_backup_date', now);
});
});
}

View File

@@ -1 +1 @@
module.exports = { build_date:"2017-12-25T10:07:19-05:00", build_revision: "583123ab0a5df7dca5b19f2c67ab8ef853e72673" };
module.exports = { build_date:"2018-01-17T23:59:03-05:00", build_revision: "651a9fb3272c85d287c16d5a4978464fb7d2490d" };

View File

@@ -4,8 +4,9 @@ const ini = require('ini');
const fs = require('fs');
const dataDir = require('./data_dir');
const path = require('path');
const resource_dir = require('./resource_dir');
const configSampleFilePath = path.resolve(__dirname, "..", "config-sample.ini");
const configSampleFilePath = path.resolve(resource_dir.RESOURCE_DIR, "config-sample.ini");
const configFilePath = dataDir.TRILIUM_DATA_DIR + '/config.ini';

View File

@@ -3,8 +3,12 @@
const sql = require('./sql');
const log = require('./log');
const messaging = require('./messaging');
const sync_mutex = require('./sync_mutex');
const utils = require('./utils');
async function runCheck(query, errorText, errorList) {
utils.assertArguments(query, errorText, errorList);
const result = await sql.getFirstColumn(query);
if (result.length > 0) {
@@ -17,6 +21,46 @@ async function runCheck(query, errorText, errorList) {
}
}
async function checkTreeCycles(errorList) {
const childToParents = {};
const rows = await sql.getAll("SELECT note_id, parent_note_id FROM notes_tree WHERE is_deleted = 0");
for (const row of rows) {
const childNoteId = row.note_id;
const parentNoteId = row.parent_note_id;
if (!childToParents[childNoteId]) {
childToParents[childNoteId] = [];
}
childToParents[childNoteId].push(parentNoteId);
}
function checkTreeCycle(noteId, path, errorList) {
if (noteId === 'root') {
return;
}
for (const parentNoteId of childToParents[noteId]) {
if (path.includes(parentNoteId)) {
errorList.push(`Tree cycle detected at parent-child relationship: ${parentNoteId} - ${noteId}, whole path: ${path}`);
}
else {
const newPath = path.slice();
newPath.push(noteId);
checkTreeCycle(parentNoteId, newPath, errorList);
}
}
}
const noteIds = Object.keys(childToParents);
for (const noteId of noteIds) {
checkTreeCycle(noteId, [], errorList);
}
}
async function runSyncRowChecks(table, key, errorList) {
await runCheck(`
SELECT
@@ -40,7 +84,7 @@ async function runSyncRowChecks(table, key, errorList) {
`Missing ${table} records for existing sync rows`, errorList);
}
async function runChecks() {
async function runAllChecks() {
const errorList = [];
await runCheck(`
@@ -77,15 +121,15 @@ async function runChecks() {
await runCheck(`
SELECT
child.note_id
child.note_tree_id
FROM
notes_tree
JOIN notes AS child ON child.note_id = notes_tree.note_id
JOIN notes AS parent ON notes_tree.parent_note_id = parent.note_id
notes_tree AS child
WHERE
parent.is_deleted = 1
AND child.is_deleted = 0`,
"Parent note is deleted but child note is not for these child note IDs", errorList);
child.is_deleted = 0
AND child.parent_note_id != 'root'
AND (SELECT COUNT(*) FROM notes_tree AS parent WHERE parent.note_id = child.parent_note_id
AND parent.is_deleted = 0) = 0`,
"All parent note trees are deleted but child note tree is not for these child note tree IDs", errorList);
// we do extra JOIN to eliminate orphan notes without note tree (which are reported separately)
await runCheck(`
@@ -97,7 +141,7 @@ async function runChecks() {
WHERE
(SELECT COUNT(*) FROM notes_tree WHERE notes.note_id = notes_tree.note_id AND notes_tree.is_deleted = 0) = 0
AND notes.is_deleted = 0
`, );
`, 'No undeleted note trees for note IDs', errorList);
await runCheck(`
SELECT
@@ -119,16 +163,76 @@ async function runChecks() {
notes.note_id IS NULL`,
"Missing notes records for following note history ID > note ID", errorList);
await runCheck(`
SELECT
notes_tree.parent_note_id || ' > ' || notes_tree.note_id
FROM
notes_tree
WHERE
notes_tree.is_deleted = 0
GROUP BY
notes_tree.parent_note_id,
notes_tree.note_id
HAVING
COUNT(*) > 1`,
"Duplicate undeleted parent note <-> note relationship - parent note ID > note ID", errorList);
await runCheck(`
SELECT
images.image_id
FROM
images
LEFT JOIN notes_image ON notes_image.image_id = images.image_id
WHERE
notes_image.note_image_id IS NULL`,
"Image with no note relation", errorList);
await runCheck(`
SELECT
notes_image.note_image_id
FROM
notes_image
JOIN images USING(image_id)
WHERE
notes_image.is_deleted = 0
AND images.is_deleted = 1`,
"Note image is not deleted while image is deleted for note_image_id", errorList);
await runSyncRowChecks("notes", "note_id", errorList);
await runSyncRowChecks("notes_history", "note_history_id", errorList);
await runSyncRowChecks("notes_tree", "note_tree_id", errorList);
await runSyncRowChecks("recent_notes", "note_tree_id", errorList);
await runSyncRowChecks("images", "image_id", errorList);
await runSyncRowChecks("notes_image", "note_image_id", errorList);
if (errorList.length === 0) {
// we run this only if basic checks passed since this assumes basic data consistency
await checkTreeCycles(errorList);
}
return errorList;
}
async function runChecks() {
let errorList;
let elapsedTimeMs;
await sync_mutex.doExclusively(async () => {
const startTime = new Date();
errorList = await runAllChecks();
elapsedTimeMs = new Date().getTime() - startTime.getTime();
});
if (errorList.length > 0) {
log.info(`Consistency checks failed (took ${elapsedTimeMs}ms) with these errors: ` + JSON.stringify(errorList));
messaging.sendMessageToAllClients({type: 'consistency-checks-failed'});
}
else {
log.info("All consistency checks passed.");
log.info(`All consistency checks passed (took ${elapsedTimeMs}ms)`);
}
}

View File

@@ -1,6 +1,7 @@
const sql = require('./sql');
const utils = require('./utils');
const options = require('./options');
const log = require('./log');
function getHash(rows) {
let hash = '';
@@ -13,55 +14,91 @@ function getHash(rows) {
}
async function getHashes() {
const optionsQuestionMarks = Array(options.SYNCED_OPTIONS.length).fill('?').join(',');
const startTime = new Date();
return {
notes: getHash(await sql.getAll(`SELECT
note_id,
note_title,
note_text,
date_modified,
is_protected,
is_deleted
FROM notes
ORDER BY note_id`)),
const hashes = {
notes: getHash(await sql.getAll(`
SELECT
note_id,
note_title,
note_text,
date_modified,
is_protected,
is_deleted
FROM notes
ORDER BY note_id`)),
notes_tree: getHash(await sql.getAll(`SELECT
note_tree_id,
note_id,
parent_note_id,
note_position,
date_modified,
is_deleted,
prefix
FROM notes_tree
ORDER BY note_tree_id`)),
notes_tree: getHash(await sql.getAll(`
SELECT
note_tree_id,
note_id,
parent_note_id,
note_position,
date_modified,
is_deleted,
prefix
FROM notes_tree
ORDER BY note_tree_id`)),
notes_history: getHash(await sql.getAll(`SELECT
note_history_id,
note_id,
note_title,
note_text,
date_modified_from,
date_modified_to
FROM notes_history
ORDER BY note_history_id`)),
notes_history: getHash(await sql.getAll(`
SELECT
note_history_id,
note_id,
note_title,
note_text,
date_modified_from,
date_modified_to
FROM notes_history
ORDER BY note_history_id`)),
recent_notes: getHash(await sql.getAll(`SELECT
note_tree_id,
note_path,
date_accessed,
is_deleted
FROM recent_notes
ORDER BY note_path`)),
recent_notes: getHash(await sql.getAll(`
SELECT
note_tree_id,
note_path,
date_accessed,
is_deleted
FROM recent_notes
ORDER BY note_path`)),
options: getHash(await sql.getAll(`SELECT
opt_name,
opt_value
FROM options
WHERE opt_name IN (${optionsQuestionMarks})
ORDER BY opt_name`, options.SYNCED_OPTIONS))
options: getHash(await sql.getAll(`
SELECT
opt_name,
opt_value
FROM options
WHERE is_synced = 1
ORDER BY opt_name`)),
// we don't include image data on purpose because they are quite large, checksum is good enough
// to represent the data anyway
images: getHash(await sql.getAll(`
SELECT
image_id,
format,
checksum,
name,
is_deleted,
date_modified,
date_created
FROM images
ORDER BY image_id`)),
attributes: getHash(await sql.getAll(`
SELECT
attribute_id,
note_id
name,
value
date_modified,
date_created
FROM attributes
ORDER BY attribute_id`))
};
const elapseTimeMs = new Date().getTime() - startTime.getTime();
log.info(`Content hash computation took ${elapseTimeMs}ms`);
return hashes;
}
module.exports = {

View File

@@ -1,6 +1,7 @@
"use strict";
const crypto = require('crypto');
const log = require('./log');
function arraysIdentical(a, b) {
let i = a.length;
@@ -72,7 +73,15 @@ function decrypt(key, iv, cipherText) {
function decryptString(dataKey, iv, cipherText) {
const buffer = decrypt(dataKey, iv, cipherText);
return buffer.toString('utf-8');
const str = buffer.toString('utf-8');
if (str === 'false') {
log.error("Could not decrypt string. Buffer: " + buffer);
throw new Error("Could not decrypt string.");
}
return str;
}
function noteTitleIv(iv) {

View File

@@ -54,6 +54,8 @@ async function sendMessage(client, message) {
async function sendMessageToAllClients(message) {
const jsonStr = JSON.stringify(message);
log.info("Sending message to all clients: " + jsonStr);
webSocketServer.clients.forEach(function each(client) {
if (client.readyState === WebSocket.OPEN) {
client.send(jsonStr);

View File

@@ -3,14 +3,7 @@ const sql = require('./sql');
const options = require('./options');
const fs = require('fs-extra');
const log = require('./log');
const path = require('path');
const MIGRATIONS_DIR = path.resolve(__dirname, "..", "migrations");
if (!fs.existsSync(MIGRATIONS_DIR)) {
log.error("Could not find migration directory: " + MIGRATIONS_DIR);
process.exit(1);
}
const resource_dir = require('./resource_dir');
async function migrate() {
const migrations = [];
@@ -20,7 +13,7 @@ async function migrate() {
const currentDbVersion = parseInt(await options.getOption('db_version'));
fs.readdirSync(MIGRATIONS_DIR).forEach(file => {
fs.readdirSync(resource_dir.MIGRATIONS_DIR).forEach(file => {
const match = file.match(/([0-9]{4})__([a-zA-Z0-9_ ]+)\.(sql|js)/);
if (match) {
@@ -53,7 +46,7 @@ async function migrate() {
await sql.doInTransaction(async () => {
if (mig.type === 'sql') {
const migrationSql = fs.readFileSync(MIGRATIONS_DIR + "/" + mig.file).toString('utf8');
const migrationSql = fs.readFileSync(resource_dir.MIGRATIONS_DIR + "/" + mig.file).toString('utf8');
console.log("Migration with SQL script: " + migrationSql);
@@ -62,7 +55,7 @@ async function migrate() {
else if (mig.type === 'js') {
console.log("Migration with JS module");
const migrationModule = require("../" + MIGRATIONS_DIR + "/" + mig.file);
const migrationModule = require("../" + resource_dir.MIGRATIONS_DIR + "/" + mig.file);
await migrationModule(db);
}
else {

View File

@@ -37,7 +37,7 @@ async function createNewNote(parentNoteId, note, sourceId) {
await sql.insert("notes", {
note_id: noteId,
note_title: note.note_title,
note_text: '',
note_text: note.note_text ? note.note_text : '',
date_created: now,
date_modified: now,
is_protected: note.is_protected
@@ -74,7 +74,7 @@ async function protectNoteRecursively(noteId, dataKey, protect, sourceId) {
await protectNote(note, dataKey, protect, sourceId);
const children = await sql.getFirstColumn("SELECT note_id FROM notes_tree WHERE parent_note_id = ?", [noteId]);
const children = await sql.getFirstColumn("SELECT note_id FROM notes_tree WHERE parent_note_id = ? AND is_deleted = 0", [noteId]);
for (const childNoteId of children) {
await protectNoteRecursively(childNoteId, dataKey, protect, sourceId);
@@ -135,6 +135,76 @@ async function protectNoteHistory(noteId, dataKey, protect, sourceId) {
}
}
async function saveNoteHistory(noteId, dataKey, sourceId, nowStr) {
const oldNote = await sql.getFirst("SELECT * FROM notes WHERE note_id = ?", [noteId]);
if (oldNote.is_protected) {
decryptNote(oldNote, dataKey);
}
const newNoteHistoryId = utils.newNoteHistoryId();
await sql.insert('notes_history', {
note_history_id: newNoteHistoryId,
note_id: noteId,
// title and text should be decrypted now
note_title: oldNote.note_title,
note_text: oldNote.note_text,
is_protected: 0, // will be fixed in the protectNoteHistory() call
date_modified_from: oldNote.date_modified,
date_modified_to: nowStr
});
await sync_table.addNoteHistorySync(newNoteHistoryId, sourceId);
}
async function saveNoteImages(noteId, noteText, sourceId) {
const existingNoteImages = await sql.getAll("SELECT * FROM notes_image WHERE note_id = ?", [noteId]);
const foundImageIds = [];
const now = utils.nowDate();
const re = /src="\/api\/images\/([a-zA-Z0-9]+)\//g;
let match;
while (match = re.exec(noteText)) {
const imageId = match[1];
const existingNoteImage = existingNoteImages.find(ni => ni.image_id === imageId);
if (!existingNoteImage) {
const noteImageId = utils.newNoteImageId();
await sql.insert("notes_image", {
note_image_id: noteImageId,
note_id: noteId,
image_id: imageId,
is_deleted: 0,
date_modified: now,
date_created: now
});
await sync_table.addNoteImageSync(noteImageId, sourceId);
}
else if (existingNoteImage.is_deleted) {
await sql.execute("UPDATE notes_image SET is_deleted = 0, date_modified = ? WHERE note_image_id = ?",
[now, existingNoteImage.note_image_id]);
await sync_table.addNoteImageSync(existingNoteImage.note_image_id, sourceId);
}
// else we don't need to do anything
foundImageIds.push(imageId);
}
// marking note images as deleted if they are not present on the page anymore
const unusedNoteImages = existingNoteImages.filter(ni => !foundImageIds.includes(ni.image_id));
for (const unusedNoteImage of unusedNoteImages) {
await sql.execute("UPDATE notes_image SET is_deleted = 1, date_modified = ? WHERE note_image_id = ?",
[now, unusedNoteImage.note_image_id]);
await sync_table.addNoteImageSync(unusedNoteImage.note_image_id, sourceId);
}
}
async function updateNote(noteId, newNote, dataKey, sourceId) {
if (newNote.detail.is_protected) {
await encryptNote(newNote, dataKey);
@@ -154,28 +224,11 @@ async function updateNote(noteId, newNote, dataKey, sourceId) {
const msSinceDateCreated = now.getTime() - utils.parseDate(newNote.detail.date_created).getTime();
if (!existingNoteHistoryId && msSinceDateCreated >= historySnapshotTimeInterval * 1000) {
const oldNote = await sql.getFirst("SELECT * FROM notes WHERE note_id = ?", [noteId]);
if (oldNote.is_protected) {
decryptNote(oldNote, dataKey);
}
const newNoteHistoryId = utils.newNoteHistoryId();
await sql.insert('notes_history', {
note_history_id: newNoteHistoryId,
note_id: noteId,
// title and text should be decrypted now
note_title: oldNote.note_title,
note_text: oldNote.note_text,
is_protected: 0, // will be fixed in the protectNoteHistory() call
date_modified_from: oldNote.date_modified,
date_modified_to: nowStr
});
await sync_table.addNoteHistorySync(newNoteHistoryId, sourceId);
await saveNoteHistory(noteId, dataKey, sourceId, nowStr);
}
await saveNoteImages(noteId, newNote.detail.note_text, sourceId);
await protectNoteHistory(noteId, dataKey, newNote.detail.is_protected);
await sql.execute("UPDATE notes SET note_title = ?, note_text = ?, is_protected = ?, date_modified = ? WHERE note_id = ?", [
@@ -190,6 +243,12 @@ async function updateNote(noteId, newNote, dataKey, sourceId) {
}
async function deleteNote(noteTreeId, sourceId) {
const noteTree = await sql.getFirstOrNull("SELECT * FROM notes_tree WHERE note_tree_id = ?", [noteTreeId]);
if (!noteTree || noteTree.is_deleted === 1) {
return;
}
const now = utils.nowDate();
await sql.execute("UPDATE notes_tree SET is_deleted = 1, date_modified = ? WHERE note_tree_id = ?", [now, noteTreeId]);

View File

@@ -3,12 +3,12 @@ const utils = require('./utils');
const sync_table = require('./sync_table');
const app_info = require('./app_info');
const SYNCED_OPTIONS = [ 'username', 'password_verification_hash', 'password_verification_salt',
'password_derived_key_salt', 'encrypted_data_key', 'encrypted_data_key_iv',
'protected_session_timeout', 'history_snapshot_time_interval' ];
async function getOptionOrNull(optName) {
return await sql.getFirstOrNull("SELECT opt_value FROM options WHERE opt_name = ?", [optName]);
}
async function getOption(optName) {
const row = await sql.getFirstOrNull("SELECT opt_value FROM options WHERE opt_name = ?", [optName]);
const row = await getOptionOrNull(optName);
if (!row) {
throw new Error("Option " + optName + " doesn't exist");
@@ -17,43 +17,59 @@ async function getOption(optName) {
return row['opt_value'];
}
async function setOption(optName, optValue, sourceId) {
if (SYNCED_OPTIONS.includes(optName)) {
async function setOption(optName, optValue, sourceId = null) {
const opt = await sql.getFirst("SELECT * FROM options WHERE opt_name = ?", [optName]);
if (!opt) {
throw new Error(`Option ${optName} doesn't exist`);
}
if (opt.is_synced) {
await sync_table.addOptionsSync(optName, sourceId);
}
await sql.replace("options", {
await sql.execute("UPDATE options SET opt_value = ?, date_modified = ? WHERE opt_name = ?",
[optValue, utils.nowDate(), optName]);
}
async function createOption(optName, optValue, isSynced, sourceId = null) {
await sql.insert("options", {
opt_name: optName,
opt_value: optValue,
is_synced: isSynced,
date_modified: utils.nowDate()
});
if (isSynced) {
await sync_table.addOptionsSync(optName, sourceId);
}
}
async function initOptions(startNotePath) {
await setOption('document_id', utils.randomSecureToken(16));
await setOption('document_secret', utils.randomSecureToken(16));
await createOption('document_id', utils.randomSecureToken(16), false);
await createOption('document_secret', utils.randomSecureToken(16), false);
await setOption('username', '');
await setOption('password_verification_hash', '');
await setOption('password_verification_salt', '');
await setOption('password_derived_key_salt', '');
await setOption('encrypted_data_key', '');
await setOption('encrypted_data_key_iv', '');
await createOption('username', '', true);
await createOption('password_verification_hash', '', true);
await createOption('password_verification_salt', '', true);
await createOption('password_derived_key_salt', '', true);
await createOption('encrypted_data_key', '', true);
await createOption('encrypted_data_key_iv', '', true);
await setOption('start_note_path', startNotePath);
await setOption('protected_session_timeout', 600);
await setOption('history_snapshot_time_interval', 600);
await setOption('last_backup_date', utils.nowDate());
await setOption('db_version', app_info.db_version);
await createOption('start_note_path', startNotePath, false);
await createOption('protected_session_timeout', 600, true);
await createOption('history_snapshot_time_interval', 600, true);
await createOption('last_backup_date', utils.nowDate(), false);
await createOption('db_version', app_info.db_version, false);
await setOption('last_synced_pull', app_info.db_version);
await setOption('last_synced_push', 0);
await setOption('last_synced_push', 0);
await createOption('last_synced_pull', app_info.db_version, false);
await createOption('last_synced_push', 0, false);
}
module.exports = {
getOption,
getOptionOrNull,
setOption,
initOptions,
SYNCED_OPTIONS
createOption
};

25
services/resource_dir.js Normal file
View File

@@ -0,0 +1,25 @@
const log = require('./log');
const path = require('path');
const fs = require('fs');
const RESOURCE_DIR = path.resolve(__dirname, "..");
const MIGRATIONS_DIR = path.resolve(RESOURCE_DIR, "migrations");
if (!fs.existsSync(MIGRATIONS_DIR)) {
log.error("Could not find migration directory: " + MIGRATIONS_DIR);
process.exit(1);
}
const DB_INIT_DIR = path.resolve(RESOURCE_DIR, "db");
if (!fs.existsSync(DB_INIT_DIR)) {
log.error("Could not find DB initialization directory: " + DB_INIT_DIR);
process.exit(1);
}
module.exports = {
RESOURCE_DIR,
MIGRATIONS_DIR,
DB_INIT_DIR
};

View File

@@ -4,8 +4,8 @@ const log = require('./log');
const dataDir = require('./data_dir');
const fs = require('fs');
const sqlite = require('sqlite');
const utils = require('./utils');
const app_info = require('./app_info');
const resource_dir = require('./resource_dir');
async function createConnection() {
return await sqlite.open(dataDir.DOCUMENT_PATH, {Promise});
@@ -28,18 +28,22 @@ const dbReady = new Promise((resolve, reject) => {
if (tableResults.length !== 1) {
log.info("Connected to db, but schema doesn't exist. Initializing schema ...");
const schema = fs.readFileSync('db/schema.sql', 'UTF-8');
const notesSql = fs.readFileSync('db/main_notes.sql', 'UTF-8');
const notesTreeSql = fs.readFileSync('db/main_notes_tree.sql', 'UTF-8');
const schema = fs.readFileSync(resource_dir.DB_INIT_DIR + '/schema.sql', 'UTF-8');
const notesSql = fs.readFileSync(resource_dir.DB_INIT_DIR + '/main_notes.sql', 'UTF-8');
const notesTreeSql = fs.readFileSync(resource_dir.DB_INIT_DIR + '/main_notes_tree.sql', 'UTF-8');
const imagesSql = fs.readFileSync(resource_dir.DB_INIT_DIR + '/main_images.sql', 'UTF-8');
const notesImageSql = fs.readFileSync(resource_dir.DB_INIT_DIR + '/main_notes_image.sql', 'UTF-8');
await doInTransaction(async () => {
await executeScript(schema);
await executeScript(notesSql);
await executeScript(notesTreeSql);
await executeScript(imagesSql);
await executeScript(notesImageSql);
const noteId = await getFirstValue("SELECT note_id FROM notes_tree WHERE parent_note_id = 'root' ORDER BY note_position");
const startNoteId = await getFirstValue("SELECT note_id FROM notes_tree WHERE parent_note_id = 'root' AND is_deleted = 0 ORDER BY note_position");
await require('./options').initOptions(noteId);
await require('./options').initOptions(startNoteId);
await require('./sync_table').fillAllSyncRows();
});
@@ -49,9 +53,7 @@ const dbReady = new Promise((resolve, reject) => {
// the database
}
else {
const username = await getFirstValue("SELECT opt_value FROM options WHERE opt_name = 'username'");
if (!username) {
if (!await isUserInitialized()) {
log.info("Login/password not initialized. DB not ready.");
return;
@@ -235,8 +237,15 @@ async function isDbUpToDate() {
return upToDate;
}
async function isUserInitialized() {
const username = await getFirstValue("SELECT opt_value FROM options WHERE opt_name = 'username'");
return !!username;
}
module.exports = {
dbReady,
isUserInitialized,
insert,
replace,
getFirstValue,

Some files were not shown because too many files have changed in this diff Show More