Compare commits

...

34 Commits

Author SHA1 Message Date
azivner
3d185a5178 release 0.18.0 2018-07-30 08:18:25 +02:00
azivner
2ff7a890bc script which shows list of edited files for a date, fixes #125 2018-07-29 20:51:28 +02:00
azivner
2eb1a9705f it's now possible to mark relation as inheritable (previously this was hardcoded for specific relation names) 2018-07-29 20:33:42 +02:00
azivner
ed1381103a #125, implementation of inheritable relations 2018-07-29 18:39:10 +02:00
azivner
170d317589 #125, basic infrastructure for scripts attached to notes via relations 2018-07-29 16:06:13 +02:00
azivner
ededc063df fix relation autocomplete 2018-07-29 12:34:40 +02:00
azivner
986eace1be schema update with relations 2018-07-29 11:47:46 +02:00
azivner
29086d8dfe fixed bug with select not firing on recent notes 2018-07-28 18:17:35 +02:00
azivner
9b3f3fde05 #126, relation list in note detail and fixes in saving 2018-07-28 17:59:55 +02:00
azivner
6a50afd952 #126, "show recent notes" now work in relations dialog 2018-07-28 17:02:48 +02:00
azivner
697eee2706 #126, autocomplete + can save relations 2018-07-27 11:28:24 +02:00
azivner
8a95afd756 #126, added skeleton of note relations, copied from similar concept of labels 2018-07-27 10:52:48 +02:00
azivner
4d6eda8fe6 #129, added input icon to trigger recent notes for easy discovery and mouse control 2018-07-27 09:22:25 +02:00
azivner
e4f459fa2b #129, removed recent notes dialog as its not necessary anymore 2018-07-26 17:35:32 +02:00
azivner
f578e001b0 #129, add link autocomplete now displays recent notes as well 2018-07-26 16:24:08 +02:00
azivner
2a08aef885 #129, recent notes are now visible in the jump to dialog 2018-07-26 16:05:09 +02:00
azivner
7564bf388c removed dangerous and unnecessary option to completely remove soft-deleted items 2018-07-26 09:21:52 +02:00
azivner
7e4d70259f soft-deleting note will delete its content and all the revisions content, fixes #132 2018-07-26 09:08:51 +02:00
azivner
5b98c1c0f3 fix context menu JS error on root note 2018-07-26 08:58:20 +02:00
azivner
02dc7b199b #98, better error reporting for sync setup 2018-07-25 22:54:37 +02:00
azivner
d39cdbfada better instructions for desktop => server instance sync setup, plus some sync fixes 2018-07-25 10:57:36 +02:00
azivner
50bb4a47ee fix sync bug 2018-07-25 10:12:34 +02:00
azivner
a4627f2ddb #98 some sync setup refactorings 2018-07-25 09:46:57 +02:00
azivner
c8253caae9 #98 proxy support for sync setup 2018-07-25 08:30:41 +02:00
azivner
0ece9bd1be sync isExpanded when it's a new branch 2018-07-24 22:03:36 +02:00
azivner
b6935abcc9 #98, sync button now shows total number of outstanding syncs instead of just pushes 2018-07-24 21:43:15 +02:00
azivner
37ab7b4641 #98, sync to server now works as well + a lot of related changes 2018-07-24 20:35:03 +02:00
azivner
013714cb5c #98, new option "initialized" which indicates if setup has been finished 2018-07-24 08:12:36 +02:00
azivner
1fe7c62f5a #98, sync setup now doesn't copy the whole DB file, but sets up minimal database and starts off sync 2018-07-23 21:15:32 +02:00
azivner
a06618d851 #98, test sync impl 2018-07-23 10:29:17 +02:00
azivner
e7460ca3a9 #98, sync is now configured in the options 2018-07-22 22:21:16 +02:00
azivner
073300bbcd #98, working sync setup from server to desktop instance + refactoring of DB initialization 2018-07-22 19:56:20 +02:00
azivner
a201661ce5 #98, fixes in the wizard 2018-07-22 14:49:59 +02:00
azivner
6235a3c886 beginning of #98, new multistep wizard, db creation after user enters username and password 2018-07-21 08:55:24 +02:00
76 changed files with 1895 additions and 641 deletions

View File

@@ -21,533 +21,529 @@
<table id="13" parent="2" name="notes"/>
<table id="14" parent="2" name="options"/>
<table id="15" parent="2" name="recent_notes"/>
<table id="16" parent="2" name="source_ids"/>
<table id="17" parent="2" name="sqlite_master">
<table id="16" parent="2" name="relations"/>
<table id="17" parent="2" name="source_ids"/>
<table id="18" parent="2" name="sqlite_master">
<System>1</System>
</table>
<table id="18" parent="2" name="sqlite_sequence">
<table id="19" parent="2" name="sqlite_sequence">
<System>1</System>
</table>
<table id="19" parent="2" name="sync"/>
<column id="20" parent="6" name="apiTokenId">
<table id="20" parent="2" name="sync"/>
<column id="21" parent="6" name="apiTokenId">
<Position>1</Position>
<DataType>TEXT|0s</DataType>
<NotNull>1</NotNull>
</column>
<column id="21" parent="6" name="token">
<column id="22" parent="6" name="token">
<Position>2</Position>
<DataType>TEXT|0s</DataType>
<NotNull>1</NotNull>
</column>
<column id="22" parent="6" name="dateCreated">
<column id="23" parent="6" name="dateCreated">
<Position>3</Position>
<DataType>TEXT|0s</DataType>
<NotNull>1</NotNull>
</column>
<column id="23" parent="6" name="isDeleted">
<column id="24" parent="6" name="isDeleted">
<Position>4</Position>
<DataType>INT|0s</DataType>
<NotNull>1</NotNull>
<DefaultExpression>0</DefaultExpression>
</column>
<column id="24" parent="6" name="hash">
<column id="25" parent="6" name="hash">
<Position>5</Position>
<DataType>TEXT|0s</DataType>
<NotNull>1</NotNull>
<DefaultExpression>&quot;&quot;</DefaultExpression>
</column>
<index id="25" parent="6" name="sqlite_autoindex_api_tokens_1">
<index id="26" parent="6" name="sqlite_autoindex_api_tokens_1">
<NameSurrogate>1</NameSurrogate>
<ColNames>apiTokenId</ColNames>
<ColumnCollations></ColumnCollations>
<Unique>1</Unique>
</index>
<key id="26" parent="6">
<key id="27" parent="6">
<ColNames>apiTokenId</ColNames>
<Primary>1</Primary>
<UnderlyingIndexName>sqlite_autoindex_api_tokens_1</UnderlyingIndexName>
</key>
<column id="27" parent="7" name="branchId">
<column id="28" parent="7" name="branchId">
<Position>1</Position>
<DataType>TEXT|0s</DataType>
<NotNull>1</NotNull>
</column>
<column id="28" parent="7" name="noteId">
<column id="29" parent="7" name="noteId">
<Position>2</Position>
<DataType>TEXT|0s</DataType>
<NotNull>1</NotNull>
</column>
<column id="29" parent="7" name="parentNoteId">
<column id="30" parent="7" name="parentNoteId">
<Position>3</Position>
<DataType>TEXT|0s</DataType>
<NotNull>1</NotNull>
</column>
<column id="30" parent="7" name="notePosition">
<column id="31" parent="7" name="notePosition">
<Position>4</Position>
<DataType>INTEGER|0s</DataType>
<NotNull>1</NotNull>
</column>
<column id="31" parent="7" name="prefix">
<column id="32" parent="7" name="prefix">
<Position>5</Position>
<DataType>TEXT|0s</DataType>
</column>
<column id="32" parent="7" name="isExpanded">
<column id="33" parent="7" name="isExpanded">
<Position>6</Position>
<DataType>BOOLEAN|0s</DataType>
</column>
<column id="33" parent="7" name="isDeleted">
<column id="34" parent="7" name="isDeleted">
<Position>7</Position>
<DataType>INTEGER|0s</DataType>
<NotNull>1</NotNull>
<DefaultExpression>0</DefaultExpression>
</column>
<column id="34" parent="7" name="dateModified">
<column id="35" parent="7" name="dateModified">
<Position>8</Position>
<DataType>TEXT|0s</DataType>
<NotNull>1</NotNull>
</column>
<column id="35" parent="7" name="hash">
<column id="36" parent="7" name="hash">
<Position>9</Position>
<DataType>TEXT|0s</DataType>
<NotNull>1</NotNull>
<DefaultExpression>&quot;&quot;</DefaultExpression>
</column>
<column id="36" parent="7" name="dateCreated">
<column id="37" parent="7" name="dateCreated">
<Position>10</Position>
<DataType>TEXT|0s</DataType>
<NotNull>1</NotNull>
<DefaultExpression>&apos;1970-01-01T00:00:00.000Z&apos;</DefaultExpression>
</column>
<index id="37" parent="7" name="sqlite_autoindex_branches_1">
<index id="38" parent="7" name="sqlite_autoindex_branches_1">
<NameSurrogate>1</NameSurrogate>
<ColNames>branchId</ColNames>
<ColumnCollations></ColumnCollations>
<Unique>1</Unique>
</index>
<index id="38" parent="7" name="IDX_branches_noteId_parentNoteId">
<index id="39" parent="7" name="IDX_branches_noteId_parentNoteId">
<ColNames>noteId
parentNoteId</ColNames>
<ColumnCollations></ColumnCollations>
</index>
<index id="39" parent="7" name="IDX_branches_noteId">
<index id="40" parent="7" name="IDX_branches_noteId">
<ColNames>noteId</ColNames>
<ColumnCollations></ColumnCollations>
</index>
<index id="40" parent="7" name="IDX_branches_parentNoteId">
<index id="41" parent="7" name="IDX_branches_parentNoteId">
<ColNames>parentNoteId</ColNames>
<ColumnCollations></ColumnCollations>
</index>
<key id="41" parent="7">
<key id="42" parent="7">
<ColNames>branchId</ColNames>
<Primary>1</Primary>
<UnderlyingIndexName>sqlite_autoindex_branches_1</UnderlyingIndexName>
</key>
<column id="42" parent="8" name="eventId">
<column id="43" parent="8" name="eventId">
<Position>1</Position>
<DataType>TEXT|0s</DataType>
<NotNull>1</NotNull>
</column>
<column id="43" parent="8" name="noteId">
<column id="44" parent="8" name="noteId">
<Position>2</Position>
<DataType>TEXT|0s</DataType>
</column>
<column id="44" parent="8" name="comment">
<column id="45" parent="8" name="comment">
<Position>3</Position>
<DataType>TEXT|0s</DataType>
</column>
<column id="45" parent="8" name="dateCreated">
<column id="46" parent="8" name="dateCreated">
<Position>4</Position>
<DataType>TEXT|0s</DataType>
<NotNull>1</NotNull>
</column>
<index id="46" parent="8" name="sqlite_autoindex_event_log_1">
<index id="47" parent="8" name="sqlite_autoindex_event_log_1">
<NameSurrogate>1</NameSurrogate>
<ColNames>eventId</ColNames>
<ColumnCollations></ColumnCollations>
<Unique>1</Unique>
</index>
<key id="47" parent="8">
<key id="48" parent="8">
<ColNames>eventId</ColNames>
<Primary>1</Primary>
<UnderlyingIndexName>sqlite_autoindex_event_log_1</UnderlyingIndexName>
</key>
<column id="48" parent="9" name="imageId">
<column id="49" parent="9" name="imageId">
<Position>1</Position>
<DataType>TEXT|0s</DataType>
<NotNull>1</NotNull>
</column>
<column id="49" parent="9" name="format">
<column id="50" parent="9" name="format">
<Position>2</Position>
<DataType>TEXT|0s</DataType>
<NotNull>1</NotNull>
</column>
<column id="50" parent="9" name="checksum">
<column id="51" parent="9" name="checksum">
<Position>3</Position>
<DataType>TEXT|0s</DataType>
<NotNull>1</NotNull>
</column>
<column id="51" parent="9" name="name">
<column id="52" parent="9" name="name">
<Position>4</Position>
<DataType>TEXT|0s</DataType>
<NotNull>1</NotNull>
</column>
<column id="52" parent="9" name="data">
<column id="53" parent="9" name="data">
<Position>5</Position>
<DataType>BLOB|0s</DataType>
</column>
<column id="53" parent="9" name="isDeleted">
<column id="54" parent="9" name="isDeleted">
<Position>6</Position>
<DataType>INT|0s</DataType>
<NotNull>1</NotNull>
<DefaultExpression>0</DefaultExpression>
</column>
<column id="54" parent="9" name="dateModified">
<column id="55" parent="9" name="dateModified">
<Position>7</Position>
<DataType>TEXT|0s</DataType>
<NotNull>1</NotNull>
</column>
<column id="55" parent="9" name="dateCreated">
<column id="56" parent="9" name="dateCreated">
<Position>8</Position>
<DataType>TEXT|0s</DataType>
<NotNull>1</NotNull>
</column>
<column id="56" parent="9" name="hash">
<column id="57" parent="9" name="hash">
<Position>9</Position>
<DataType>TEXT|0s</DataType>
<NotNull>1</NotNull>
<DefaultExpression>&quot;&quot;</DefaultExpression>
</column>
<index id="57" parent="9" name="sqlite_autoindex_images_1">
<index id="58" parent="9" name="sqlite_autoindex_images_1">
<NameSurrogate>1</NameSurrogate>
<ColNames>imageId</ColNames>
<ColumnCollations></ColumnCollations>
<Unique>1</Unique>
</index>
<key id="58" parent="9">
<key id="59" parent="9">
<ColNames>imageId</ColNames>
<Primary>1</Primary>
<UnderlyingIndexName>sqlite_autoindex_images_1</UnderlyingIndexName>
</key>
<column id="59" parent="10" name="labelId">
<column id="60" parent="10" name="labelId">
<Position>1</Position>
<DataType>TEXT|0s</DataType>
<NotNull>1</NotNull>
</column>
<column id="60" parent="10" name="noteId">
<column id="61" parent="10" name="noteId">
<Position>2</Position>
<DataType>TEXT|0s</DataType>
<NotNull>1</NotNull>
</column>
<column id="61" parent="10" name="name">
<column id="62" parent="10" name="name">
<Position>3</Position>
<DataType>TEXT|0s</DataType>
<NotNull>1</NotNull>
</column>
<column id="62" parent="10" name="value">
<column id="63" parent="10" name="value">
<Position>4</Position>
<DataType>TEXT|0s</DataType>
<NotNull>1</NotNull>
<DefaultExpression>&apos;&apos;</DefaultExpression>
</column>
<column id="63" parent="10" name="position">
<column id="64" parent="10" name="position">
<Position>5</Position>
<DataType>INT|0s</DataType>
<NotNull>1</NotNull>
<DefaultExpression>0</DefaultExpression>
</column>
<column id="64" parent="10" name="dateCreated">
<column id="65" parent="10" name="dateCreated">
<Position>6</Position>
<DataType>TEXT|0s</DataType>
<NotNull>1</NotNull>
</column>
<column id="65" parent="10" name="dateModified">
<column id="66" parent="10" name="dateModified">
<Position>7</Position>
<DataType>TEXT|0s</DataType>
<NotNull>1</NotNull>
</column>
<column id="66" parent="10" name="isDeleted">
<column id="67" parent="10" name="isDeleted">
<Position>8</Position>
<DataType>INT|0s</DataType>
<NotNull>1</NotNull>
</column>
<column id="67" parent="10" name="hash">
<column id="68" parent="10" name="hash">
<Position>9</Position>
<DataType>TEXT|0s</DataType>
<NotNull>1</NotNull>
<DefaultExpression>&quot;&quot;</DefaultExpression>
</column>
<index id="68" parent="10" name="sqlite_autoindex_labels_1">
<index id="69" parent="10" name="sqlite_autoindex_labels_1">
<NameSurrogate>1</NameSurrogate>
<ColNames>labelId</ColNames>
<ColumnCollations></ColumnCollations>
<Unique>1</Unique>
</index>
<index id="69" parent="10" name="IDX_labels_noteId">
<index id="70" parent="10" name="IDX_labels_noteId">
<ColNames>noteId</ColNames>
<ColumnCollations></ColumnCollations>
</index>
<index id="70" parent="10" name="IDX_labels_name_value">
<index id="71" parent="10" name="IDX_labels_name_value">
<ColNames>name
value</ColNames>
<ColumnCollations></ColumnCollations>
</index>
<key id="71" parent="10">
<key id="72" parent="10">
<ColNames>labelId</ColNames>
<Primary>1</Primary>
<UnderlyingIndexName>sqlite_autoindex_labels_1</UnderlyingIndexName>
</key>
<column id="72" parent="11" name="noteImageId">
<column id="73" parent="11" name="noteImageId">
<Position>1</Position>
<DataType>TEXT|0s</DataType>
<NotNull>1</NotNull>
</column>
<column id="73" parent="11" name="noteId">
<column id="74" parent="11" name="noteId">
<Position>2</Position>
<DataType>TEXT|0s</DataType>
<NotNull>1</NotNull>
</column>
<column id="74" parent="11" name="imageId">
<column id="75" parent="11" name="imageId">
<Position>3</Position>
<DataType>TEXT|0s</DataType>
<NotNull>1</NotNull>
</column>
<column id="75" parent="11" name="isDeleted">
<column id="76" parent="11" name="isDeleted">
<Position>4</Position>
<DataType>INT|0s</DataType>
<NotNull>1</NotNull>
<DefaultExpression>0</DefaultExpression>
</column>
<column id="76" parent="11" name="dateModified">
<column id="77" parent="11" name="dateModified">
<Position>5</Position>
<DataType>TEXT|0s</DataType>
<NotNull>1</NotNull>
</column>
<column id="77" parent="11" name="dateCreated">
<column id="78" parent="11" name="dateCreated">
<Position>6</Position>
<DataType>TEXT|0s</DataType>
<NotNull>1</NotNull>
</column>
<column id="78" parent="11" name="hash">
<column id="79" parent="11" name="hash">
<Position>7</Position>
<DataType>TEXT|0s</DataType>
<NotNull>1</NotNull>
<DefaultExpression>&quot;&quot;</DefaultExpression>
</column>
<index id="79" parent="11" name="sqlite_autoindex_note_images_1">
<index id="80" parent="11" name="sqlite_autoindex_note_images_1">
<NameSurrogate>1</NameSurrogate>
<ColNames>noteImageId</ColNames>
<ColumnCollations></ColumnCollations>
<Unique>1</Unique>
</index>
<index id="80" parent="11" name="IDX_note_images_noteId_imageId">
<index id="81" parent="11" name="IDX_note_images_noteId_imageId">
<ColNames>noteId
imageId</ColNames>
<ColumnCollations></ColumnCollations>
</index>
<index id="81" parent="11" name="IDX_note_images_noteId">
<index id="82" parent="11" name="IDX_note_images_noteId">
<ColNames>noteId</ColNames>
<ColumnCollations></ColumnCollations>
</index>
<index id="82" parent="11" name="IDX_note_images_imageId">
<index id="83" parent="11" name="IDX_note_images_imageId">
<ColNames>imageId</ColNames>
<ColumnCollations></ColumnCollations>
</index>
<key id="83" parent="11">
<key id="84" parent="11">
<ColNames>noteImageId</ColNames>
<Primary>1</Primary>
<UnderlyingIndexName>sqlite_autoindex_note_images_1</UnderlyingIndexName>
</key>
<column id="84" parent="12" name="noteRevisionId">
<column id="85" parent="12" name="noteRevisionId">
<Position>1</Position>
<DataType>TEXT|0s</DataType>
<NotNull>1</NotNull>
</column>
<column id="85" parent="12" name="noteId">
<column id="86" parent="12" name="noteId">
<Position>2</Position>
<DataType>TEXT|0s</DataType>
<NotNull>1</NotNull>
</column>
<column id="86" parent="12" name="title">
<column id="87" parent="12" name="title">
<Position>3</Position>
<DataType>TEXT|0s</DataType>
</column>
<column id="87" parent="12" name="content">
<column id="88" parent="12" name="content">
<Position>4</Position>
<DataType>TEXT|0s</DataType>
</column>
<column id="88" parent="12" name="isProtected">
<column id="89" parent="12" name="isProtected">
<Position>5</Position>
<DataType>INT|0s</DataType>
<NotNull>1</NotNull>
<DefaultExpression>0</DefaultExpression>
</column>
<column id="89" parent="12" name="dateModifiedFrom">
<column id="90" parent="12" name="dateModifiedFrom">
<Position>6</Position>
<DataType>TEXT|0s</DataType>
<NotNull>1</NotNull>
</column>
<column id="90" parent="12" name="dateModifiedTo">
<column id="91" parent="12" name="dateModifiedTo">
<Position>7</Position>
<DataType>TEXT|0s</DataType>
<NotNull>1</NotNull>
</column>
<column id="91" parent="12" name="type">
<column id="92" parent="12" name="type">
<Position>8</Position>
<DataType>TEXT|0s</DataType>
<NotNull>1</NotNull>
<DefaultExpression>&apos;&apos;</DefaultExpression>
</column>
<column id="92" parent="12" name="mime">
<column id="93" parent="12" name="mime">
<Position>9</Position>
<DataType>TEXT|0s</DataType>
<NotNull>1</NotNull>
<DefaultExpression>&apos;&apos;</DefaultExpression>
</column>
<column id="93" parent="12" name="hash">
<column id="94" parent="12" name="hash">
<Position>10</Position>
<DataType>TEXT|0s</DataType>
<NotNull>1</NotNull>
<DefaultExpression>&quot;&quot;</DefaultExpression>
</column>
<index id="94" parent="12" name="sqlite_autoindex_note_revisions_1">
<index id="95" parent="12" name="sqlite_autoindex_note_revisions_1">
<NameSurrogate>1</NameSurrogate>
<ColNames>noteRevisionId</ColNames>
<ColumnCollations></ColumnCollations>
<Unique>1</Unique>
</index>
<index id="95" parent="12" name="IDX_note_revisions_noteId">
<index id="96" parent="12" name="IDX_note_revisions_noteId">
<ColNames>noteId</ColNames>
<ColumnCollations></ColumnCollations>
</index>
<index id="96" parent="12" name="IDX_note_revisions_dateModifiedFrom">
<index id="97" parent="12" name="IDX_note_revisions_dateModifiedFrom">
<ColNames>dateModifiedFrom</ColNames>
<ColumnCollations></ColumnCollations>
</index>
<index id="97" parent="12" name="IDX_note_revisions_dateModifiedTo">
<index id="98" parent="12" name="IDX_note_revisions_dateModifiedTo">
<ColNames>dateModifiedTo</ColNames>
<ColumnCollations></ColumnCollations>
</index>
<key id="98" parent="12">
<key id="99" parent="12">
<ColNames>noteRevisionId</ColNames>
<Primary>1</Primary>
<UnderlyingIndexName>sqlite_autoindex_note_revisions_1</UnderlyingIndexName>
</key>
<column id="99" parent="13" name="noteId">
<column id="100" parent="13" name="noteId">
<Position>1</Position>
<DataType>TEXT|0s</DataType>
<NotNull>1</NotNull>
</column>
<column id="100" parent="13" name="title">
<column id="101" parent="13" name="title">
<Position>2</Position>
<DataType>TEXT|0s</DataType>
<NotNull>1</NotNull>
<DefaultExpression>&quot;unnamed&quot;</DefaultExpression>
</column>
<column id="101" parent="13" name="content">
<column id="102" parent="13" name="content">
<Position>3</Position>
<DataType>TEXT|0s</DataType>
<NotNull>1</NotNull>
<DefaultExpression>&quot;&quot;</DefaultExpression>
</column>
<column id="102" parent="13" name="isProtected">
<column id="103" parent="13" name="isProtected">
<Position>4</Position>
<DataType>INT|0s</DataType>
<NotNull>1</NotNull>
<DefaultExpression>0</DefaultExpression>
</column>
<column id="103" parent="13" name="isDeleted">
<column id="104" parent="13" name="isDeleted">
<Position>5</Position>
<DataType>INT|0s</DataType>
<NotNull>1</NotNull>
<DefaultExpression>0</DefaultExpression>
</column>
<column id="104" parent="13" name="dateCreated">
<column id="105" parent="13" name="dateCreated">
<Position>6</Position>
<DataType>TEXT|0s</DataType>
<NotNull>1</NotNull>
</column>
<column id="105" parent="13" name="dateModified">
<column id="106" parent="13" name="dateModified">
<Position>7</Position>
<DataType>TEXT|0s</DataType>
<NotNull>1</NotNull>
</column>
<column id="106" parent="13" name="type">
<column id="107" parent="13" name="type">
<Position>8</Position>
<DataType>TEXT|0s</DataType>
<NotNull>1</NotNull>
<DefaultExpression>&apos;text&apos;</DefaultExpression>
</column>
<column id="107" parent="13" name="mime">
<column id="108" parent="13" name="mime">
<Position>9</Position>
<DataType>TEXT|0s</DataType>
<NotNull>1</NotNull>
<DefaultExpression>&apos;text/html&apos;</DefaultExpression>
</column>
<column id="108" parent="13" name="hash">
<column id="109" parent="13" name="hash">
<Position>10</Position>
<DataType>TEXT|0s</DataType>
<NotNull>1</NotNull>
<DefaultExpression>&quot;&quot;</DefaultExpression>
</column>
<index id="109" parent="13" name="sqlite_autoindex_notes_1">
<index id="110" parent="13" name="sqlite_autoindex_notes_1">
<NameSurrogate>1</NameSurrogate>
<ColNames>noteId</ColNames>
<ColumnCollations></ColumnCollations>
<Unique>1</Unique>
</index>
<index id="110" parent="13" name="IDX_notes_type">
<index id="111" parent="13" name="IDX_notes_type">
<ColNames>type</ColNames>
<ColumnCollations></ColumnCollations>
</index>
<key id="111" parent="13">
<key id="112" parent="13">
<ColNames>noteId</ColNames>
<Primary>1</Primary>
<UnderlyingIndexName>sqlite_autoindex_notes_1</UnderlyingIndexName>
</key>
<column id="112" parent="14" name="optionId">
<column id="113" parent="14" name="name">
<Position>1</Position>
<DataType>TEXT|0s</DataType>
<NotNull>1</NotNull>
</column>
<column id="113" parent="14" name="name">
<Position>2</Position>
<DataType>TEXT|0s</DataType>
<NotNull>1</NotNull>
</column>
<column id="114" parent="14" name="value">
<Position>3</Position>
<Position>2</Position>
<DataType>TEXT|0s</DataType>
</column>
<column id="115" parent="14" name="dateModified">
<Position>4</Position>
<Position>3</Position>
<DataType>INT|0s</DataType>
</column>
<column id="116" parent="14" name="isSynced">
<Position>5</Position>
<Position>4</Position>
<DataType>INTEGER|0s</DataType>
<NotNull>1</NotNull>
<DefaultExpression>0</DefaultExpression>
</column>
<column id="117" parent="14" name="hash">
<Position>6</Position>
<Position>5</Position>
<DataType>TEXT|0s</DataType>
<NotNull>1</NotNull>
<DefaultExpression>&quot;&quot;</DefaultExpression>
</column>
<column id="118" parent="14" name="dateCreated">
<Position>7</Position>
<Position>6</Position>
<DataType>TEXT|0s</DataType>
<NotNull>1</NotNull>
<DefaultExpression>&apos;1970-01-01T00:00:00.000Z&apos;</DefaultExpression>
</column>
<index id="119" parent="14" name="sqlite_autoindex_options_1">
<NameSurrogate>1</NameSurrogate>
<ColNames>optionId</ColNames>
<ColNames>name</ColNames>
<ColumnCollations></ColumnCollations>
<Unique>1</Unique>
</index>
<key id="120" parent="14">
<ColNames>optionId</ColNames>
<ColNames>name</ColNames>
<Primary>1</Primary>
<UnderlyingIndexName>sqlite_autoindex_options_1</UnderlyingIndexName>
</key>
@@ -587,90 +583,156 @@ imageId</ColNames>
<Primary>1</Primary>
<UnderlyingIndexName>sqlite_autoindex_recent_notes_1</UnderlyingIndexName>
</key>
<column id="128" parent="16" name="sourceId">
<column id="128" parent="16" name="relationId">
<Position>1</Position>
<DataType>TEXT|0s</DataType>
<NotNull>1</NotNull>
</column>
<column id="129" parent="16" name="dateCreated">
<column id="129" parent="16" name="sourceNoteId">
<Position>2</Position>
<DataType>TEXT|0s</DataType>
<NotNull>1</NotNull>
</column>
<index id="130" parent="16" name="sqlite_autoindex_source_ids_1">
<column id="130" parent="16" name="name">
<Position>3</Position>
<DataType>TEXT|0s</DataType>
<NotNull>1</NotNull>
</column>
<column id="131" parent="16" name="targetNoteId">
<Position>4</Position>
<DataType>TEXT|0s</DataType>
<NotNull>1</NotNull>
</column>
<column id="132" parent="16" name="position">
<Position>5</Position>
<DataType>INT|0s</DataType>
<NotNull>1</NotNull>
<DefaultExpression>0</DefaultExpression>
</column>
<column id="133" parent="16" name="dateCreated">
<Position>6</Position>
<DataType>TEXT|0s</DataType>
<NotNull>1</NotNull>
</column>
<column id="134" parent="16" name="dateModified">
<Position>7</Position>
<DataType>TEXT|0s</DataType>
<NotNull>1</NotNull>
</column>
<column id="135" parent="16" name="isDeleted">
<Position>8</Position>
<DataType>INT|0s</DataType>
<NotNull>1</NotNull>
</column>
<column id="136" parent="16" name="hash">
<Position>9</Position>
<DataType>TEXT|0s</DataType>
<NotNull>1</NotNull>
<DefaultExpression>&quot;&quot;</DefaultExpression>
</column>
<index id="137" parent="16" name="sqlite_autoindex_relations_1">
<NameSurrogate>1</NameSurrogate>
<ColNames>relationId</ColNames>
<ColumnCollations></ColumnCollations>
<Unique>1</Unique>
</index>
<index id="138" parent="16" name="IDX_relation_sourceNoteId">
<ColNames>sourceNoteId</ColNames>
<ColumnCollations></ColumnCollations>
</index>
<index id="139" parent="16" name="IDX_relation_targetNoteId">
<ColNames>targetNoteId</ColNames>
<ColumnCollations></ColumnCollations>
</index>
<key id="140" parent="16">
<ColNames>relationId</ColNames>
<Primary>1</Primary>
<UnderlyingIndexName>sqlite_autoindex_relations_1</UnderlyingIndexName>
</key>
<column id="141" parent="17" name="sourceId">
<Position>1</Position>
<DataType>TEXT|0s</DataType>
<NotNull>1</NotNull>
</column>
<column id="142" parent="17" name="dateCreated">
<Position>2</Position>
<DataType>TEXT|0s</DataType>
<NotNull>1</NotNull>
</column>
<index id="143" parent="17" name="sqlite_autoindex_source_ids_1">
<NameSurrogate>1</NameSurrogate>
<ColNames>sourceId</ColNames>
<ColumnCollations></ColumnCollations>
<Unique>1</Unique>
</index>
<key id="131" parent="16">
<key id="144" parent="17">
<ColNames>sourceId</ColNames>
<Primary>1</Primary>
<UnderlyingIndexName>sqlite_autoindex_source_ids_1</UnderlyingIndexName>
</key>
<column id="132" parent="17" name="type">
<column id="145" parent="18" name="type">
<Position>1</Position>
<DataType>text|0s</DataType>
</column>
<column id="133" parent="17" name="name">
<column id="146" parent="18" name="name">
<Position>2</Position>
<DataType>text|0s</DataType>
</column>
<column id="134" parent="17" name="tbl_name">
<column id="147" parent="18" name="tbl_name">
<Position>3</Position>
<DataType>text|0s</DataType>
</column>
<column id="135" parent="17" name="rootpage">
<column id="148" parent="18" name="rootpage">
<Position>4</Position>
<DataType>integer|0s</DataType>
</column>
<column id="136" parent="17" name="sql">
<column id="149" parent="18" name="sql">
<Position>5</Position>
<DataType>text|0s</DataType>
</column>
<column id="137" parent="18" name="name">
<column id="150" parent="19" name="name">
<Position>1</Position>
</column>
<column id="138" parent="18" name="seq">
<column id="151" parent="19" name="seq">
<Position>2</Position>
</column>
<column id="139" parent="19" name="id">
<column id="152" parent="20" name="id">
<Position>1</Position>
<DataType>INTEGER|0s</DataType>
<NotNull>1</NotNull>
<SequenceIdentity>1</SequenceIdentity>
</column>
<column id="140" parent="19" name="entityName">
<column id="153" parent="20" name="entityName">
<Position>2</Position>
<DataType>TEXT|0s</DataType>
<NotNull>1</NotNull>
</column>
<column id="141" parent="19" name="entityId">
<column id="154" parent="20" name="entityId">
<Position>3</Position>
<DataType>TEXT|0s</DataType>
<NotNull>1</NotNull>
</column>
<column id="142" parent="19" name="sourceId">
<column id="155" parent="20" name="sourceId">
<Position>4</Position>
<DataType>TEXT|0s</DataType>
<NotNull>1</NotNull>
</column>
<column id="143" parent="19" name="syncDate">
<column id="156" parent="20" name="syncDate">
<Position>5</Position>
<DataType>TEXT|0s</DataType>
<NotNull>1</NotNull>
</column>
<index id="144" parent="19" name="IDX_sync_entityName_entityId">
<index id="157" parent="20" name="IDX_sync_entityName_entityId">
<ColNames>entityName
entityId</ColNames>
<ColumnCollations></ColumnCollations>
<Unique>1</Unique>
</index>
<index id="145" parent="19" name="IDX_sync_syncDate">
<index id="158" parent="20" name="IDX_sync_syncDate">
<ColNames>syncDate</ColNames>
<ColumnCollations></ColumnCollations>
</index>
<key id="146" parent="19">
<key id="159" parent="20">
<ColNames>id</ColNames>
<Primary>1</Primary>
</key>

16
bin/build-pkg.sh Executable file
View File

@@ -0,0 +1,16 @@
#!/usr/bin/env bash
PKG_DIR=dist/trilium-linux-x64-server
mkdir $PKG_DIR
pkg . --targets node8-linux-x64 --output ${PKG_DIR}/trilium
chmod +x ${PKG_DIR}/trilium
cp node_modules/sqlite3/lib/binding/node-v57-linux-x64/node_sqlite3.node ${PKG_DIR}/
cp node_modules/scrypt/build/Release/scrypt.node ${PKG_DIR}/
cd dist
7z a trilium-linux-x64-server.7z trilium-linux-x64-server

View File

@@ -9,9 +9,3 @@ https=false
# path to certificate (run "bash generate-cert.sh" to generate self-signed certificate). Relevant only if https=true
certPath=
keyPath=
[Sync]
syncServerHost=
syncServerTimeout=10000
syncProxy=
syncServerCertificate=

View File

@@ -0,0 +1,8 @@
INSERT INTO options (name, value, dateCreated, dateModified, isSynced)
VALUES ('syncServerHost', '', '2018-06-01T03:35:55.041Z', '2018-06-01T03:35:55.041Z', 0);
INSERT INTO options (name, value, dateCreated, dateModified, isSynced)
VALUES ('syncServerTimeout', '5000', '2018-06-01T03:35:55.041Z', '2018-06-01T03:35:55.041Z', 0);
INSERT INTO options (name, value, dateCreated, dateModified, isSynced)
VALUES ('syncProxy', '', '2018-06-01T03:35:55.041Z', '2018-06-01T03:35:55.041Z', 0);

View File

@@ -0,0 +1,2 @@
DELETE FROM sync WHERE entityName = 'note_tree';
DELETE FROM sync WHERE entityName = 'attributes';

View File

@@ -0,0 +1,2 @@
INSERT INTO options (name, value, dateCreated, dateModified, isSynced)
VALUES ('initialized', 'true', '2018-06-01T03:35:55.041Z', '2018-06-01T03:35:55.041Z', 0);

View File

@@ -0,0 +1,4 @@
const syncTableService = require('../../src/services/sync_table');
// options has not been filled so far which caused problems with clean-slate sync.
module.exports = async () => await syncTableService.fillAllSyncRows();

View File

@@ -0,0 +1,2 @@
UPDATE notes SET content = '' WHERE isDeleted = 1;
UPDATE note_revisions SET content = '' WHERE (SELECT isDeleted FROM notes WHERE noteId = note_revisions.noteId) = 1;

View File

@@ -0,0 +1,15 @@
CREATE TABLE relations
(
relationId TEXT not null primary key,
sourceNoteId TEXT not null,
name TEXT not null,
targetNoteId TEXT not null,
position INT default 0 not null,
dateCreated TEXT not null,
dateModified TEXT not null,
isDeleted INT not null
, hash TEXT DEFAULT "" NOT NULL);
CREATE INDEX IDX_relation_sourceNoteId
on relations (sourceNoteId);
CREATE INDEX IDX_relation_targetNoteId
on relations (targetNoteId);

View File

@@ -0,0 +1 @@
ALTER TABLE relations ADD isInheritable int DEFAULT 0 NULL;

View File

@@ -134,3 +134,19 @@ CREATE TABLE IF NOT EXISTS "options"
hash TEXT default "" not null,
dateCreated TEXT default '1970-01-01T00:00:00.000Z' not null
);
CREATE TABLE relations
(
relationId TEXT not null primary key,
sourceNoteId TEXT not null,
name TEXT not null,
targetNoteId TEXT not null,
isInheritable int DEFAULT 0 NULL,
position INT default 0 not null,
dateCreated TEXT not null,
dateModified TEXT not null,
isDeleted INT not null
, hash TEXT DEFAULT "" NOT NULL);
CREATE INDEX IDX_relation_sourceNoteId
on relations (sourceNoteId);
CREATE INDEX IDX_relation_targetNoteId
on relations (targetNoteId);

View File

@@ -1,7 +1,7 @@
{
"name": "trilium",
"description": "Trilium Notes",
"version": "0.17.0",
"version": "0.18.0",
"license": "AGPL-3.0-only",
"main": "electron.js",
"bin": {
@@ -20,8 +20,7 @@
"start-forge": "electron-forge start",
"package-forge": "electron-forge package",
"make-forge": "electron-forge make",
"publish-forge": "electron-forge publish",
"build-pkg": "pkg . --targets node8-linux-x64 --output dist/trilium-linux-x64-server.elf"
"publish-forge": "electron-forge publish"
},
"dependencies": {
"async-mutex": "^0.1.3",
@@ -119,7 +118,10 @@
"assets": [
"./db/**/*",
"./src/public/**/*",
"./src/views/**/*"
"./src/views/**/*",
"./node_modules/mozjpeg/vendor/*",
"./node_modules/pngquant-bin/vendor/*",
"./node_modules/giflossy/vendor/*"
]
}
}

View File

@@ -47,7 +47,7 @@ const sessionParser = session({
cookie: {
// path: "/",
httpOnly: true,
maxAge: 1800000
maxAge: 24 * 60 * 60 * 1000 // in milliseconds
},
store: new FileStore({
ttl: 30 * 24 * 3600,

View File

@@ -1,7 +1,6 @@
"use strict";
const utils = require('../services/utils');
const repository = require('../services/repository');
class Entity {
constructor(row = {}) {
@@ -25,7 +24,7 @@ class Entity {
}
async save() {
await repository.updateEntity(this);
await require('../services/repository').updateEntity(this);
return this;
}

View File

@@ -4,6 +4,7 @@ const Image = require('../entities/image');
const NoteImage = require('../entities/note_image');
const Branch = require('../entities/branch');
const Label = require('../entities/label');
const Relation = require('../entities/relation');
const RecentNote = require('../entities/recent_note');
const ApiToken = require('../entities/api_token');
const Option = require('../entities/option');
@@ -15,6 +16,9 @@ function createEntityFromRow(row) {
if (row.labelId) {
entity = new Label(row);
}
else if (row.relationId) {
entity = new Relation(row);
}
else if (row.noteRevisionId) {
entity = new NoteRevision(row);
}

44
src/entities/relation.js Normal file
View File

@@ -0,0 +1,44 @@
"use strict";
const Entity = require('./entity');
const repository = require('../services/repository');
const dateUtils = require('../services/date_utils');
const sql = require('../services/sql');
class Relation extends Entity {
static get tableName() { return "relations"; }
static get primaryKeyName() { return "relationId"; }
static get hashedProperties() { return ["relationId", "sourceNoteId", "name", "targetNoteId", "isInheritable", "dateModified", "dateCreated"]; }
async getSourceNote() {
return await repository.getEntity("SELECT * FROM notes WHERE noteId = ?", [this.sourceNoteId]);
}
async getTargetNote() {
return await repository.getEntity("SELECT * FROM notes WHERE noteId = ?", [this.targetNoteId]);
}
async beforeSaving() {
super.beforeSaving();
if (this.position === undefined) {
this.position = 1 + await sql.getValue(`SELECT COALESCE(MAX(position), 0) FROM relations WHERE sourceNoteId = ?`, [this.sourceNoteId]);
}
if (!this.isInheritable) {
this.isInheritable = false;
}
if (!this.isDeleted) {
this.isDeleted = false;
}
if (!this.dateCreated) {
this.dateCreated = dateUtils.nowDate();
}
this.dateModified = dateUtils.nowDate();
}
}
module.exports = Relation;

Binary file not shown.

After

Width:  |  Height:  |  Size: 381 B

View File

@@ -15,6 +15,7 @@ const $prefixFormGroup = $("#add-link-prefix-form-group");
const $linkTypeDiv = $("#add-link-type-div");
const $linkTypes = $("input[name='add-link-type']");
const $linkTypeHtml = $linkTypes.filter('input[value="html"]');
const $showRecentNotesButton = $("#add-link-show-recent-notes");
function setLinkType(linkType) {
$linkTypes.each(function () {
@@ -53,12 +54,17 @@ async function showDialog() {
$linkTitle.val(noteTitle);
}
$autoComplete.autocomplete({
await $autoComplete.autocomplete({
source: async function(request, response) {
const result = await server.get('autocomplete?query=' + encodeURIComponent(request.term));
if (result.length > 0) {
response(result);
response(result.map(row => {
return {
label: row.label,
value: row.label + ' (' + row.value + ')'
}
}));
}
else {
response([{
@@ -67,10 +73,14 @@ async function showDialog() {
}]);
}
},
minLength: 2,
change: async () => {
const val = $autoComplete.val();
const notePath = linkService.getNodePathFromLabel(val);
minLength: 0,
change: async (event, ui) => {
if (!ui.item) {
return;
}
const notePath = linkService.getNotePathFromLabel(ui.item.value);
if (!notePath) {
return;
}
@@ -81,21 +91,30 @@ async function showDialog() {
await setDefaultLinkTitle(noteId);
}
},
select: function (event, ui) {
if (ui.item.value === 'No results') {
return false;
}
},
// this is called when user goes through autocomplete list with keyboard
// at this point the item isn't selected yet so we use supplied ui.item to see WHERE the cursor is
focus: async (event, ui) => {
const notePath = linkService.getNodePathFromLabel(ui.item.value);
const notePath = linkService.getNotePathFromLabel(ui.item.value);
const noteId = treeUtils.getNoteIdFromNotePath(notePath);
await setDefaultLinkTitle(noteId);
event.preventDefault();
}
});
showRecentNotes();
}
$form.submit(() => {
const value = $autoComplete.val();
const notePath = linkService.getNodePathFromLabel(value);
const notePath = linkService.getNotePathFromLabel(value);
const noteId = treeUtils.getNoteIdFromNotePath(notePath);
if (notePath) {
@@ -153,8 +172,14 @@ function linkTypeChanged() {
$linkTypeDiv.toggle(!hasSelection());
}
function showRecentNotes() {
$autoComplete.autocomplete("search", "");
}
$linkTypes.change(linkTypeChanged);
$showRecentNotesButton.click(showRecentNotes);
export default {
showDialog
};

View File

@@ -1,13 +1,11 @@
import treeService from '../services/tree.js';
import linkService from '../services/link.js';
import server from '../services/server.js';
import searchNotesService from '../services/search_notes.js';
const $dialog = $("#jump-to-note-dialog");
const $autoComplete = $("#jump-to-note-autocomplete");
const $form = $("#jump-to-note-form");
const $jumpToNoteButton = $("#jump-to-note-button");
const $showInFullTextButton = $("#show-in-full-text-button");
const $showRecentNotesButton = $("#jump-to-note-show-recent-notes");
async function showDialog() {
glob.activeDialog = $dialog;
@@ -16,7 +14,8 @@ async function showDialog() {
$dialog.dialog({
modal: true,
width: 800
width: 800,
position: { my: "center top+100", at: "top", of: window }
});
await $autoComplete.autocomplete({
@@ -34,25 +33,22 @@ async function showDialog() {
}
},
focus: function(event, ui) {
return $(ui.item).val() !== 'No results';
event.preventDefault();
},
minLength: 2
minLength: 0,
autoFocus: true,
select: function (event, ui) {
if (ui.item.value === 'No results') {
return false;
}
treeService.activateNode(ui.item.value);
$dialog.dialog('close');
}
});
}
function getSelectedNotePath() {
const val = $autoComplete.val();
return linkService.getNodePathFromLabel(val);
}
function goToNote() {
const notePath = getSelectedNotePath();
if (notePath) {
treeService.activateNode(notePath);
$dialog.dialog('close');
}
showRecentNotes();
}
function showInFullText(e) {
@@ -69,16 +65,14 @@ function showInFullText(e) {
$dialog.dialog('close');
}
$form.submit(() => {
goToNote();
return false;
});
$jumpToNoteButton.click(goToNote);
function showRecentNotes() {
$autoComplete.autocomplete("search", "");
}
$showInFullTextButton.click(showInFullText);
$showRecentNotesButton.click(showRecentNotes);
$dialog.bind('keydown', 'ctrl+return', showInFullText);
export default {

View File

@@ -1,5 +1,4 @@
import noteDetailService from '../services/note_detail.js';
import utils from '../services/utils.js';
import server from '../services/server.js';
import infoService from "../services/info.js";
@@ -168,9 +167,9 @@ async function showDialog() {
});
}
ko.applyBindings(labelsModel, document.getElementById('labels-dialog'));
ko.applyBindings(labelsModel, $dialog[0]);
$(document).on('focus', '.label-name', function (e) {
$dialog.on('focus', '.label-name', function (e) {
if (!$(this).hasClass("ui-autocomplete-input")) {
$(this).autocomplete({
// shouldn't be required and autocomplete should just accept array of strings, but that fails
@@ -188,7 +187,7 @@ $(document).on('focus', '.label-name', function (e) {
$(this).autocomplete("search", $(this).val());
});
$(document).on('focus', '.label-value', async function (e) {
$dialog.on('focus', '.label-value', async function (e) {
if (!$(this).hasClass("ui-autocomplete-input")) {
const labelName = $(this).parent().parent().find('.label-name').val();

View File

@@ -34,8 +34,8 @@ async function showDialog() {
}
}
async function saveOptions(optionName, optionValue) {
await server.put('options/' + encodeURIComponent(optionName) + '/' + encodeURIComponent(optionValue));
async function saveOptions(options) {
await server.put('options', options);
infoService.showMessage("Options change have been saved.");
}
@@ -129,16 +129,15 @@ addTabHandler((function() {
addTabHandler((function() {
const $form = $("#protected-session-timeout-form");
const $protectedSessionTimeout = $("#protected-session-timeout-in-seconds");
const optionName = 'protectedSessionTimeout';
function optionsLoaded(options) {
$protectedSessionTimeout.val(options[optionName]);
$protectedSessionTimeout.val(options['protectedSessionTimeout']);
}
$form.submit(() => {
const protectedSessionTimeout = $protectedSessionTimeout.val();
saveOptions(optionName, protectedSessionTimeout).then(() => {
saveOptions({ 'protectedSessionTimeout': protectedSessionTimeout }).then(() => {
protectedSessionHolder.setProtectedSessionTimeout(protectedSessionTimeout);
});
@@ -153,14 +152,13 @@ addTabHandler((function() {
addTabHandler((function () {
const $form = $("#note-revision-snapshot-time-interval-form");
const $timeInterval = $("#note-revision-snapshot-time-interval-in-seconds");
const optionName = 'noteRevisionSnapshotTimeInterval';
function optionsLoaded(options) {
$timeInterval.val(options[optionName]);
$timeInterval.val(options['noteRevisionSnapshotTimeInterval']);
}
$form.submit(() => {
saveOptions(optionName, $timeInterval.val());
saveOptions({ 'noteRevisionSnapshotTimeInterval': $timeInterval.val() });
return false;
});
@@ -189,6 +187,57 @@ addTabHandler((async function () {
return {};
})());
addTabHandler((function() {
const $form = $("#sync-setup-form");
const $syncServerHost = $("#sync-server-host");
const $syncServerTimeout = $("#sync-server-timeout");
const $syncProxy = $("#sync-proxy");
const $testSyncButton = $("#test-sync-button");
const $syncToServerButton = $("#sync-to-server-button");
function optionsLoaded(options) {
$syncServerHost.val(options['syncServerHost']);
$syncServerTimeout.val(options['syncServerTimeout']);
$syncProxy.val(options['syncProxy']);
}
$form.submit(() => {
saveOptions({
'syncServerHost': $syncServerHost.val(),
'syncServerTimeout': $syncServerTimeout.val(),
'syncProxy': $syncProxy.val()
});
return false;
});
$testSyncButton.click(async () => {
const result = await server.post('sync/test');
if (result.connection === "Success") {
infoService.showMessage("Sync server handshake has been successful");
}
else {
infoService.showError("Sync server handshake failed, error: " + result.error);
}
});
$syncToServerButton.click(async () => {
const resp = await server.post("setup/sync-to-server");
if (resp.success) {
infoService.showMessage("Sync has been established to the server instance. It will take some time to finish.");
}
else {
infoService.showError('Sync setup failed: ' + resp.error);
}
});
return {
optionsLoaded
};
})());
addTabHandler((async function () {
const $forceFullSyncButton = $("#force-full-sync-button");
const $fillSyncRowsButton = $("#fill-sync-rows-button");

View File

@@ -1,74 +0,0 @@
import treeService from '../services/tree.js';
import server from '../services/server.js';
const $dialog = $("#recent-notes-dialog");
const $searchInput = $('#recent-notes-search-input');
function addRecentNote(branchId, notePath) {
setTimeout(async () => {
// we include the note into recent list only if the user stayed on the note at least 5 seconds
if (notePath && notePath === treeService.getCurrentNotePath()) {
const result = await server.put('recent-notes/' + branchId + '/' + encodeURIComponent(notePath));
}
}, 1500);
}
async function showDialog() {
glob.activeDialog = $dialog;
$dialog.dialog({
modal: true,
width: 800,
height: 100,
position: { my: "center top+100", at: "top", of: window }
});
$searchInput.val('');
const result = await server.get('recent-notes');
// remove the current note
const recNotes = result.filter(note => note.notePath !== treeService.getCurrentNotePath());
const items = recNotes.map(rn => {
return {
label: rn.title,
value: rn.notePath
};
});
$searchInput.autocomplete({
source: items,
minLength: 0,
autoFocus: true,
select: function (event, ui) {
treeService.activateNode(ui.item.value);
$searchInput.autocomplete('destroy');
$dialog.dialog('close');
},
focus: function (event, ui) {
event.preventDefault();
},
close: function (event, ui) {
if (event.keyCode === 27) { // escape closes dialog
$searchInput.autocomplete('destroy');
$dialog.dialog('close');
}
else {
// keep autocomplete open
// we're kind of abusing autocomplete to work in a way which it's not designed for
$searchInput.autocomplete("search", "");
}
},
create: () => $searchInput.autocomplete("search", ""),
classes: {
"ui-autocomplete": "recent-notes-autocomplete"
}
});
}
export default {
showDialog,
addRecentNote
};

View File

@@ -0,0 +1,250 @@
import noteDetailService from '../services/note_detail.js';
import server from '../services/server.js';
import infoService from "../services/info.js";
import linkService from "../services/link.js";
import treeUtils from "../services/tree_utils.js";
const $dialog = $("#relations-dialog");
const $saveRelationsButton = $("#save-relations-button");
const $relationsBody = $('#relations-table tbody');
const relationsModel = new RelationsModel();
let relationNames = [];
function RelationsModel() {
const self = this;
this.relations = ko.observableArray();
this.updateRelationPositions = function() {
let position = 0;
// we need to update positions by searching in the DOM, because order of the
// relations in the viewmodel (self.relations()) stays the same
$relationsBody.find('input[name="position"]').each(function() {
const relation = self.getTargetRelation(this);
relation().position = position++;
});
};
async function showRelations(relations) {
for (const relation of relations) {
relation.targetNoteId = await treeUtils.getNoteTitle(relation.targetNoteId) + " (" + relation.targetNoteId + ")";
}
self.relations(relations.map(ko.observable));
}
this.loadRelations = async function() {
const noteId = noteDetailService.getCurrentNoteId();
const relations = await server.get('notes/' + noteId + '/relations');
await showRelations(relations);
addLastEmptyRow();
relationNames = await server.get('relations/names');
// relation might not be rendered immediatelly so could not focus
setTimeout(() => $(".relation-name:last").focus(), 100);
$relationsBody.sortable({
handle: '.handle',
containment: $relationsBody,
update: this.updateRelationPositions
});
};
this.deleteRelation = function(data, event) {
const relation = self.getTargetRelation(event.target);
const relationData = relation();
if (relationData) {
relationData.isDeleted = 1;
relation(relationData);
addLastEmptyRow();
}
};
function isValid() {
for (let relations = self.relations(), i = 0; i < relations.length; i++) {
if (self.isEmptyName(i)) {
return false;
}
}
return true;
}
this.save = async function() {
// we need to defocus from input (in case of enter-triggered save) because value is updated
// on blur event (because of conflict with jQuery UI Autocomplete). Without this, input would
// stay in focus, blur wouldn't be triggered and change wouldn't be updated in the viewmodel.
$saveRelationsButton.focus();
if (!isValid()) {
alert("Please fix all validation errors and try saving again.");
return;
}
self.updateRelationPositions();
const noteId = noteDetailService.getCurrentNoteId();
const relationsToSave = self.relations()
.map(relation => relation())
.filter(relation => relation.relationId !== "" || relation.name !== "");
relationsToSave.forEach(relation => relation.targetNoteId = treeUtils.getNoteIdFromNotePath(linkService.getNotePathFromLabel(relation.targetNoteId)));
console.log(relationsToSave);
const relations = await server.put('notes/' + noteId + '/relations', relationsToSave);
await showRelations(relations);
addLastEmptyRow();
infoService.showMessage("Relations have been saved.");
noteDetailService.loadRelationList();
};
function addLastEmptyRow() {
const relations = self.relations().filter(attr => attr().isDeleted === 0);
const last = relations.length === 0 ? null : relations[relations.length - 1]();
if (!last || last.name.trim() !== "" || last.targetNoteId !== "") {
self.relations.push(ko.observable({
relationId: '',
name: '',
targetNoteId: '',
isInheritable: 0,
isDeleted: 0,
position: 0
}));
}
}
this.relationChanged = function (data, event) {
addLastEmptyRow();
const relation = self.getTargetRelation(event.target);
relation.valueHasMutated();
};
this.isNotUnique = function(index) {
const cur = self.relations()[index]();
if (cur.name.trim() === "") {
return false;
}
for (let relations = self.relations(), i = 0; i < relations.length; i++) {
const relation = relations[i]();
if (index !== i && cur.name === relation.name) {
return true;
}
}
return false;
};
this.isEmptyName = function(index) {
const cur = self.relations()[index]();
return cur.name.trim() === "" && (cur.relationId !== "" || cur.targetNoteId !== "");
};
this.getTargetRelation = function(target) {
const context = ko.contextFor(target);
const index = context.$index();
return self.relations()[index];
}
}
async function showDialog() {
glob.activeDialog = $dialog;
await relationsModel.loadRelations();
$dialog.dialog({
modal: true,
width: 900,
height: 500
});
}
ko.applyBindings(relationsModel, document.getElementById('relations-dialog'));
$dialog.on('focus', '.relation-name', function (e) {
if (!$(this).hasClass("ui-autocomplete-input")) {
$(this).autocomplete({
// shouldn't be required and autocomplete should just accept array of strings, but that fails
// because we have overriden filter() function in autocomplete.js
source: relationNames.map(relation => {
return {
label: relation,
value: relation
}
}),
minLength: 0
});
}
$(this).autocomplete("search", $(this).val());
});
async function initNoteAutocomplete($el) {
if (!$el.hasClass("ui-autocomplete-input")) {
await $el.autocomplete({
source: async function (request, response) {
const result = await server.get('autocomplete?query=' + encodeURIComponent(request.term));
if (result.length > 0) {
response(result.map(row => {
return {
label: row.label,
value: row.label + ' (' + row.value + ')'
}
}));
}
else {
response([{
label: "No results",
value: "No results"
}]);
}
},
minLength: 0,
select: function (event, ui) {
if (ui.item.value === 'No results') {
return false;
}
}
});
}
}
$dialog.on('focus', '.relation-target-note-id', async function () {
await initNoteAutocomplete($(this));
});
$dialog.on('click', '.relations-show-recent-notes', async function () {
const $autocomplete = $(this).parent().find('.relation-target-note-id');
await initNoteAutocomplete($autocomplete);
$autocomplete.autocomplete("search", "");
});
export default {
showDialog
};

View File

@@ -4,7 +4,6 @@ import labelsDialog from '../dialogs/labels.js';
import noteRevisionsDialog from '../dialogs/note_revisions.js';
import noteSourceDialog from '../dialogs/note_source.js';
import recentChangesDialog from '../dialogs/recent_changes.js';
import recentNotesDialog from '../dialogs/recent_notes.js';
import optionsDialog from '../dialogs/options.js';
import sqlConsoleDialog from '../dialogs/sql_console.js';

View File

@@ -1,8 +1,14 @@
import ScriptContext from "./script_context.js";
import server from "./server.js";
async function executeBundle(bundle) {
const apiContext = ScriptContext(bundle.note, bundle.allNotes);
async function getAndExecuteBundle(noteId, targetNote = null) {
const bundle = await server.get('script/bundle/' + noteId);
await executeBundle(bundle, targetNote);
}
async function executeBundle(bundle, targetNote) {
const apiContext = ScriptContext(bundle.note, bundle.allNotes, targetNote);
return await (function () {
return eval(`const apiContext = this; (async function() { ${bundle.script}\r\n})()`);
@@ -17,7 +23,17 @@ async function executeStartupBundles() {
}
}
async function executeRelationBundles(note, relationName) {
const bundlesToRun = await server.get("script/relation/" + note.noteId + "/" + relationName);
for (const bundle of bundlesToRun) {
await executeBundle(bundle, note);
}
}
export default {
executeBundle,
executeStartupBundles
getAndExecuteBundle,
executeStartupBundles,
executeRelationBundles
}

View File

@@ -121,7 +121,7 @@ const contextMenuOptions = {
$tree.contextmenu("enableEntry", "pasteInto", clipboardIds.length > 0 && note.type !== 'search');
$tree.contextmenu("enableEntry", "importBranch", note.type !== 'search');
$tree.contextmenu("enableEntry", "exportBranch", note.type !== 'search');
$tree.contextmenu("enableEntry", "editBranchPrefix", parentNote.type !== 'search');
$tree.contextmenu("enableEntry", "editBranchPrefix", isNotRoot && parentNote.type !== 'search');
// Activate node on right-click
node.setActive();

View File

@@ -6,13 +6,13 @@ import zoomService from "./zoom.js";
import noteRevisionsDialog from "../dialogs/note_revisions.js";
import optionsDialog from "../dialogs/options.js";
import addLinkDialog from "../dialogs/add_link.js";
import recentNotesDialog from "../dialogs/recent_notes.js";
import jumpToNoteDialog from "../dialogs/jump_to_note.js";
import noteSourceDialog from "../dialogs/note_source.js";
import recentChangesDialog from "../dialogs/recent_changes.js";
import sqlConsoleDialog from "../dialogs/sql_console.js";
import searchNotesService from "./search_notes.js";
import labelsDialog from "../dialogs/labels.js";
import relationsDialog from "../dialogs/relations.js";
import protectedSessionService from "./protected_session.js";
function registerEntrypoints() {
@@ -35,15 +35,15 @@ function registerEntrypoints() {
$("#protected-session-on").click(protectedSessionService.enterProtectedSession);
$("#protected-session-off").click(protectedSessionService.leaveProtectedSession);
$("#recent-notes-button").click(recentNotesDialog.showDialog);
utils.bindShortcut('ctrl+e', recentNotesDialog.showDialog);
$("#toggle-search-button").click(searchNotesService.toggleSearch);
utils.bindShortcut('ctrl+s', searchNotesService.toggleSearch);
$(".show-labels-button").click(labelsDialog.showDialog);
utils.bindShortcut('alt+l', labelsDialog.showDialog);
$(".show-relations-button").click(relationsDialog.showDialog);
utils.bindShortcut('alt+r', relationsDialog.showDialog);
$("#options-button").click(optionsDialog.showDialog);
utils.bindShortcut('alt+o', sqlConsoleDialog.showDialog);

View File

@@ -13,7 +13,7 @@ function getNotePathFromLink(url) {
}
}
function getNodePathFromLabel(label) {
function getNotePathFromLabel(label) {
const notePathMatch = / \(([A-Za-z0-9/]+)\)/.exec(label);
if (notePathMatch !== null) {
@@ -97,7 +97,7 @@ $(document).on('click', 'div.popover-content a, div.ui-tooltip-content a', goToL
$(document).on('dblclick', '#note-detail-text a', goToLink);
export default {
getNodePathFromLabel,
getNotePathFromLabel,
getNotePathFromLink,
createNoteLink,
addLinkToEditor,

View File

@@ -1,7 +1,7 @@
import utils from './utils.js';
import infoService from "./info.js";
const $changesToPushCount = $("#changes-to-push-count");
const $outstandingSyncsCount = $("#outstanding-syncs-count");
const messageHandlers = [];
@@ -43,7 +43,7 @@ function handleMessage(event) {
messageHandler(syncData);
}
$changesToPushCount.html(message.changesToPushCount);
$outstandingSyncsCount.html(message.outstandingSyncs);
}
else if (message.type === 'sync-hash-check-failed') {
infoService.showError("Sync check failed!", 60000);

View File

@@ -7,6 +7,7 @@ import utils from './utils.js';
import server from './server.js';
import messagingService from "./messaging.js";
import infoService from "./info.js";
import linkService from "./link.js";
import treeCache from "./tree_cache.js";
import NoteFull from "../entities/note_full.js";
import noteDetailCode from './note_detail_code.js';
@@ -14,6 +15,7 @@ import noteDetailText from './note_detail_text.js';
import noteDetailFile from './note_detail_file.js';
import noteDetailSearch from './note_detail_search.js';
import noteDetailRender from './note_detail_render.js';
import bundleService from "./bundle.js";
const $noteTitle = $("#note-title");
@@ -26,7 +28,10 @@ const $noteDetailComponentWrapper = $("#note-detail-component-wrapper");
const $noteIdDisplay = $("#note-id-display");
const $labelList = $("#label-list");
const $labelListInner = $("#label-list-inner");
const $relationList = $("#relation-list");
const $relationListInner = $("#relation-list-inner");
const $childrenOverview = $("#children-overview");
const $scriptArea = $("#note-detail-script-area");
let currentNote = null;
@@ -182,6 +187,12 @@ async function loadNoteDetail(noteId) {
const hideChildrenOverview = labels.some(label => label.name === 'hideChildrenOverview');
await showChildrenOverview(hideChildrenOverview);
await loadRelationList();
$scriptArea.html('');
await bundleService.executeRelationBundles(getCurrentNote(), 'runOnNoteView');
}
async function showChildrenOverview(hideChildrenOverview) {
@@ -230,6 +241,29 @@ async function loadLabelList() {
return labels;
}
async function loadRelationList() {
const noteId = getCurrentNoteId();
const relations = await server.get('notes/' + noteId + '/relations');
$relationListInner.html('');
if (relations.length > 0) {
for (const relation of relations) {
$relationListInner.append(relation.name + " = ");
$relationListInner.append(await linkService.createNoteLink(relation.targetNoteId));
$relationListInner.append(" ");
}
$relationList.show();
}
else {
$relationList.hide();
}
return relations;
}
async function loadNote(noteId) {
const row = await server.get('notes/' + noteId);
@@ -279,6 +313,7 @@ export default {
newNoteCreated,
focus,
loadLabelList,
loadRelationList,
saveNote,
saveNoteIfChanged,
noteChanged

View File

@@ -75,9 +75,7 @@ async function executeCurrentNote() {
const currentNote = noteDetailService.getCurrentNote();
if (currentNote.mime.endsWith("env=frontend")) {
const bundle = await server.get('script/bundle/' + noteDetailService.getCurrentNoteId());
bundleService.executeBundle(bundle);
await bundleService.getAndExecuteBundle(noteDetailService.getCurrentNoteId());
}
if (currentNote.mime.endsWith("env=backend")) {

View File

@@ -2,8 +2,9 @@ import treeService from './tree.js';
import server from './server.js';
import utils from './utils.js';
import infoService from './info.js';
import linkService from './link.js';
function ScriptApi(startNote, currentNote) {
function ScriptApi(startNote, currentNote, targetNote = null) {
const $pluginButtons = $("#plugin-buttons");
async function activateNote(notePath) {
@@ -42,7 +43,8 @@ function ScriptApi(startNote, currentNote) {
script: script,
params: prepareParams(params),
startNoteId: startNote.noteId,
currentNoteId: currentNote.noteId
currentNoteId: currentNote.noteId,
targetNoteId: targetNote ? targetNote.noteId : null
});
return ret.executionResult;
@@ -51,6 +53,7 @@ function ScriptApi(startNote, currentNote) {
return {
startNote: startNote,
currentNote: currentNote,
targetNote: targetNote,
addButtonToToolbar,
activateNote,
getInstanceName: () => window.glob.instanceName,
@@ -59,7 +62,8 @@ function ScriptApi(startNote, currentNote) {
parseDate: utils.parseDate,
showMessage: infoService.showMessage,
showError: infoService.showError,
reloadTree: treeService.reload
reloadTree: treeService.reload,
createNoteLink: linkService.createNoteLink
}
}

View File

@@ -1,13 +1,13 @@
import ScriptApi from './script_api.js';
import utils from './utils.js';
function ScriptContext(startNote, allNotes) {
function ScriptContext(startNote, allNotes, targetNote = null) {
const modules = {};
return {
modules: modules,
notes: utils.toObject(allNotes, note => [note.noteId, note]),
apis: utils.toObject(allNotes, note => [note.noteId, ScriptApi(startNote, note)]),
apis: utils.toObject(allNotes, note => [note.noteId, ScriptApi(startNote, note, targetNote)]),
require: moduleNoteIds => {
return moduleName => {
const candidates = allNotes.filter(note => moduleNoteIds.includes(note.noteId));

View File

@@ -4,11 +4,15 @@ import linkService from "./link.js";
function setupTooltip() {
$(document).tooltip({
items: "#note-detail-text a",
items: "#note-detail-wrapper a",
content: function (callback) {
const notePath = linkService.getNotePathFromLink($(this).attr("href"));
let notePath = linkService.getNotePathFromLink($(this).attr("href"));
if (notePath !== null) {
if (!notePath) {
notePath = $(this).attr("note-path");
}
if (notePath) {
const noteId = treeUtils.getNoteIdFromNotePath(notePath);
noteDetailService.loadNote(noteId).then(note => callback(note.content));

View File

@@ -8,7 +8,6 @@ import treeChangesService from './branches.js';
import treeUtils from './tree_utils.js';
import utils from './utils.js';
import server from './server.js';
import recentNotesDialog from '../dialogs/recent_notes.js';
import treeCache from './tree_cache.js';
import infoService from "./info.js";
import treeBuilder from "./tree_builder.js";
@@ -239,6 +238,15 @@ async function setExpandedToServer(branchId, isExpanded) {
await server.put('branches/' + branchId + '/expanded/' + expandedNum);
}
function addRecentNote(branchId, notePath) {
setTimeout(async () => {
// we include the note into recent list only if the user stayed on the note at least 5 seconds
if (notePath && notePath === getCurrentNotePath()) {
await server.put('recent-notes/' + branchId + '/' + encodeURIComponent(notePath));
}
}, 1500);
}
function setCurrentNotePathToHash(node) {
utils.assertArguments(node);
@@ -247,7 +255,7 @@ function setCurrentNotePathToHash(node) {
document.location.hash = currentNotePath;
recentNotesDialog.addRecentNote(currentBranchId, currentNotePath);
addRecentNote(currentBranchId, currentNotePath);
}
function getSelectedNodes(stopOnParents = false) {

View File

@@ -1,36 +1,141 @@
import server from './services/server.js';
import utils from "./services/utils.js";
$("#setup-form").submit(() => {
const username = $("#username").val();
const password1 = $("#password1").val();
const password2 = $("#password2").val();
if (!username) {
showAlert("Username can't be empty");
return false;
function SetupModel() {
if (syncInProgress) {
setInterval(checkOutstandingSyncs, 1000);
}
if (!password1) {
showAlert("Password can't be empty");
return false;
}
const serverAddress = location.protocol + '//' + location.host;
if (password1 !== password2) {
showAlert("Both password fields need be identical.");
return false;
}
$("#current-host").html(serverAddress);
server.post('setup', {
username: username,
password: password1
}).then(() => {
this.step = ko.observable(syncInProgress ? "sync-in-progress" : "setup-type");
this.setupType = ko.observable();
this.setupNewDocument = ko.observable(false);
this.setupSyncFromDesktop = ko.observable(false);
this.setupSyncFromServer = ko.observable(false);
this.username = ko.observable();
this.password1 = ko.observable();
this.password2 = ko.observable();
this.syncServerHost = ko.observable();
this.syncProxy = ko.observable();
this.instanceType = utils.isElectron() ? "desktop" : "server";
this.setupTypeSelected = this.getSetupType = () =>
this.setupNewDocument()
|| this.setupSyncFromDesktop()
|| this.setupSyncFromServer();
this.selectSetupType = () => {
this.step(this.getSetupType());
this.setupType(this.getSetupType());
};
this.back = () => {
this.step("setup-type");
this.setupNewDocument(false);
this.setupSyncFromServer(false);
this.setupSyncFromDesktop(false);
};
this.finish = async () => {
if (this.setupNewDocument()) {
const username = this.username();
const password1 = this.password1();
const password2 = this.password2();
if (!username) {
showAlert("Username can't be empty");
return;
}
if (!password1) {
showAlert("Password can't be empty");
return;
}
if (password1 !== password2) {
showAlert("Both password fields need be identical.");
return;
}
// not using server.js because it loads too many dependencies
$.post('/api/setup/new-document', {
username: username,
password: password1
}).then(() => {
window.location.replace("/");
});
}
else if (this.setupSyncFromServer()) {
const syncServerHost = this.syncServerHost();
const syncProxy = this.syncProxy();
const username = this.username();
const password = this.password1();
if (!syncServerHost) {
showAlert("Trilium server address can't be empty");
return;
}
if (!username) {
showAlert("Username can't be empty");
return;
}
if (!password) {
showAlert("Password can't be empty");
return;
}
// not using server.js because it loads too many dependencies
const resp = await $.post('/api/setup/sync-from-server', {
syncServerHost: syncServerHost,
syncProxy: syncProxy,
username: username,
password: password
});
if (resp.result === 'success') {
this.step('sync-in-progress');
setInterval(checkOutstandingSyncs, 1000);
hideAlert();
}
else {
showAlert('Sync setup failed: ' + resp.error);
}
}
};
}
async function checkOutstandingSyncs() {
const { stats, initialized } = await $.get('/api/sync/stats');
if (initialized) {
window.location.replace("/");
});
}
return false;
});
const totalOutstandingSyncs = stats.outstandingPushes + stats.outstandingPulls;
$("#outstanding-syncs").html(totalOutstandingSyncs);
}
function showAlert(message) {
$("#alert").html(message);
$("#alert").show();
}
}
function hideAlert() {
$("#alert").hide();
}
ko.applyBindings(new SetupModel(), document.getElementById('setup-dialog'));
$("#setup-dialog").show();

View File

@@ -308,13 +308,13 @@ div.ui-tooltip {
.cm-matchhighlight {background-color: #eeeeee}
#label-list {
#label-list, #relation-list {
color: #777777;
border-top: 1px solid #eee;
padding: 5px; display: none;
padding: 5px;
display: none;
}
#label-list button {
#label-list button, #relation-list button {
padding: 2px;
margin-right: 5px;
}

View File

@@ -1,20 +1,50 @@
"use strict";
const noteCacheService = require('../../services/note_cache');
const repository = require('../../services/repository');
async function getAutocomplete(req) {
const query = req.query.query;
const results = noteCacheService.findNotes(query);
let results;
if (query.trim().length === 0) {
results = await getRecentNotes();
}
else {
results = noteCacheService.findNotes(query);
}
return results.map(res => {
return {
value: res.title + ' (' + res.path + ')',
value: res.path,
label: res.title
}
});
}
async function getRecentNotes() {
const recentNotes = await repository.getEntities(`
SELECT
recent_notes.*
FROM
recent_notes
JOIN branches USING(branchId)
WHERE
recent_notes.isDeleted = 0
AND branches.isDeleted = 0
ORDER BY
dateCreated DESC
LIMIT 200`);
return recentNotes.map(rn => {
return {
path: rn.notePath,
title: noteCacheService.getNoteTitleForPath(rn.notePath.split('/'))
};
});
}
module.exports = {
getAutocomplete
};

View File

@@ -1,46 +1,9 @@
"use strict";
const sql = require('../../services/sql');
const utils = require('../../services/utils');
const syncTable = require('../../services/sync_table');
const log = require('../../services/log');
const repository = require('../../services/repository');
async function cleanupSoftDeletedItems() {
const noteIdsToDelete = await sql.getColumn("SELECT noteId FROM notes WHERE isDeleted = 1");
const noteIdsSql = noteIdsToDelete
.map(noteId => "'" + utils.sanitizeSql(noteId) + "'")
.join(', ');
await sql.execute(`DELETE FROM event_log WHERE noteId IN (${noteIdsSql})`);
await sql.execute(`DELETE FROM note_revisions WHERE noteId IN (${noteIdsSql})`);
await sql.execute(`DELETE FROM note_images WHERE noteId IN (${noteIdsSql})`);
await sql.execute(`DELETE FROM labels WHERE noteId IN (${noteIdsSql})`);
await sql.execute("DELETE FROM branches WHERE isDeleted = 1");
await sql.execute("DELETE FROM note_images WHERE isDeleted = 1");
await sql.execute("DELETE FROM images WHERE isDeleted = 1");
await sql.execute("DELETE FROM notes WHERE isDeleted = 1");
await sql.execute("DELETE FROM recent_notes");
await syncTable.cleanupSyncRowsForMissingEntities("notes", "noteId");
await syncTable.cleanupSyncRowsForMissingEntities("branches", "branchId");
await syncTable.cleanupSyncRowsForMissingEntities("note_revisions", "noteRevisionId");
await syncTable.cleanupSyncRowsForMissingEntities("recent_notes", "branchId");
await syncTable.cleanupSyncRowsForMissingEntities("images", "imageId");
await syncTable.cleanupSyncRowsForMissingEntities("note_images", "noteImageId");
await syncTable.cleanupSyncRowsForMissingEntities("labels", "labelId");
log.info("Following notes has been completely cleaned from database: " + noteIdsSql);
}
async function cleanupUnusedImages() {
const unusedImageIds = await sql.getColumn(`
SELECT images.imageId
@@ -67,7 +30,6 @@ async function vacuumDatabase() {
}
module.exports = {
cleanupSoftDeletedItems,
cleanupUnusedImages,
vacuumDatabase
};

View File

@@ -9,8 +9,13 @@ const protectedSessionService = require('../../services/protected_session');
const appInfo = require('../../services/app_info');
const eventService = require('../../services/events');
const cls = require('../../services/cls');
const sqlInit = require('../../services/sql_init');
async function loginSync(req) {
if (!await sqlInit.schemaExists()) {
return [400, { message: "DB schema does not exist, can't sync." }];
}
const timestampStr = req.body.timestamp;
const timestamp = dateUtils.parseDateTime(timestampStr);

View File

@@ -5,7 +5,8 @@ const optionService = require('../../services/options');
const log = require('../../services/log');
// options allowed to be updated directly in options dialog
const ALLOWED_OPTIONS = ['protectedSessionTimeout', 'noteRevisionSnapshotTimeInterval', 'zoomFactor', 'theme'];
const ALLOWED_OPTIONS = ['protectedSessionTimeout', 'noteRevisionSnapshotTimeInterval',
'zoomFactor', 'theme', 'syncServerHost', 'syncServerTimeout', 'syncProxy'];
async function getOptions() {
const options = await sql.getMap("SELECT name, value FROM options WHERE name IN ("
@@ -17,16 +18,35 @@ async function getOptions() {
async function updateOption(req) {
const {name, value} = req.params;
if (!update(name, value)) {
return [400, "not allowed option to change"];
}
}
async function updateOptions(req) {
for (const optionName in req.body) {
if (!update(optionName, req.body[optionName])) {
// this should be improved
// it should return 400 instead of current 500, but at least it now rollbacks transaction
throw new Error(`${optionName} is not allowed to change`);
}
}
}
async function update(name, value) {
if (!ALLOWED_OPTIONS.includes(name)) {
return [400, "not allowed option to set"];
return false;
}
log.info(`Updating option ${name} to ${value}`);
await optionService.setOption(name, value);
return true;
}
module.exports = {
getOptions,
updateOption
updateOption,
updateOptions
};

View File

@@ -1,30 +1,7 @@
"use strict";
const repository = require('../../services/repository');
const optionService = require('../../services/options');
const RecentNote = require('../../entities/recent_note');
const noteCacheService = require('../../services/note_cache');
async function getRecentNotes() {
const recentNotes = await repository.getEntities(`
SELECT
recent_notes.*
FROM
recent_notes
JOIN branches USING(branchId)
WHERE
recent_notes.isDeleted = 0
AND branches.isDeleted = 0
ORDER BY
dateCreated DESC
LIMIT 200`);
for (const rn of recentNotes) {
rn.title = noteCacheService.getNoteTitleForPath(rn.notePath.split('/'));
}
return recentNotes;
}
async function addRecentNote(req) {
const branchId = req.params.branchId;
@@ -36,11 +13,8 @@ async function addRecentNote(req) {
}).save();
await optionService.setOption('startNotePath', notePath);
return await getRecentNotes();
}
module.exports = {
getRecentNotes,
addRecentNote
};

View File

@@ -0,0 +1,64 @@
"use strict";
const sql = require('../../services/sql');
const relationService = require('../../services/relations');
const repository = require('../../services/repository');
const Relation = require('../../entities/relation');
async function getNoteRelations(req) {
const noteId = req.params.noteId;
return await repository.getEntities("SELECT * FROM relations WHERE isDeleted = 0 AND sourceNoteId = ? ORDER BY position, dateCreated", [noteId]);
}
async function updateNoteRelations(req) {
const noteId = req.params.noteId;
const relations = req.body;
for (const relation of relations) {
let relationEntity;
if (relation.relationId) {
relationEntity = await repository.getRelation(relation.relationId);
}
else {
// if it was "created" and then immediatelly deleted, we just don't create it at all
if (relation.isDeleted) {
continue;
}
relationEntity = new Relation();
relationEntity.sourceNoteId = noteId;
}
relationEntity.name = relation.name;
relationEntity.targetNoteId = relation.targetNoteId;
relationEntity.isInheritable = relation.isInheritable;
relationEntity.position = relation.position;
relationEntity.isDeleted = relation.isDeleted;
await relationEntity.save();
}
return await repository.getEntities("SELECT * FROM relations WHERE isDeleted = 0 AND sourceNoteId = ? ORDER BY position, dateCreated", [noteId]);
}
async function getAllRelationNames() {
const names = await sql.getColumn("SELECT DISTINCT name FROM relations WHERE isDeleted = 0");
for (const relationName of relationService.BUILTIN_RELATIONS) {
if (!names.includes(relationName)) {
names.push(relationName);
}
}
names.sort();
return names;
}
module.exports = {
getNoteRelations,
updateNoteRelations,
getAllRelationNames
};

View File

@@ -2,10 +2,12 @@
const labelService = require('../../services/labels');
const scriptService = require('../../services/script');
const relationService = require('../../services/relations');
const repository = require('../../services/repository');
async function exec(req) {
const result = await scriptService.executeScript(req.body.script, req.body.params, req.body.startNoteId, req.body.currentNoteId);
const result = await scriptService.executeScript(req.body.script, req.body.params, req.body.startNoteId,
req.body.currentNoteId, req.body.targetNoteId);
return { executionResult: result };
}
@@ -34,14 +36,32 @@ async function getStartupBundles() {
return bundles;
}
async function getRelationBundles(req) {
const noteId = req.params.noteId;
const relationName = req.params.relationName;
const relations = await relationService.getEffectiveRelations(noteId);
const filtered = relations.filter(relation => relation.name === relationName);
const targetNoteIds = filtered.map(relation => relation.targetNoteId);
const uniqueNoteIds = Array.from(new Set(targetNoteIds));
const bundles = [];
for (const noteId of uniqueNoteIds) {
bundles.push(await scriptService.getScriptBundleForNoteId(noteId));
}
return bundles;
}
async function getBundle(req) {
const note = await repository.getNote(req.params.noteId);
return await scriptService.getScriptBundle(note);
return await scriptService.getScriptBundleForNoteId(req.params.noteId);
}
module.exports = {
exec,
run,
getStartupBundles,
getRelationBundles,
getBundle
};

View File

@@ -1,27 +1,76 @@
"use strict";
const optionService = require('../../services/options');
const sqlInit = require('../../services/sql_init');
const utils = require('../../services/utils');
const myScryptService = require('../../services/my_scrypt');
const passwordEncryptionService = require('../../services/password_encryption');
const setupService = require('../../services/setup');
const optionService = require('../../services/options');
const syncService = require('../../services/sync');
const log = require('../../services/log');
const rp = require('request-promise');
async function setup(req) {
async function setupNewDocument(req) {
const { username, password } = req.body;
await optionService.setOption('username', username);
await sqlInit.createInitialDatabase(username, password);
}
await optionService.setOption('passwordVerificationSalt', utils.randomSecureToken(32));
await optionService.setOption('passwordDerivedKeySalt', utils.randomSecureToken(32));
async function setupSyncFromServer(req) {
const { syncServerHost, syncProxy, username, password } = req.body;
const passwordVerificationKey = utils.toBase64(await myScryptService.getVerificationHash(password));
await optionService.setOption('passwordVerificationHash', passwordVerificationKey);
return await setupService.setupSyncFromSyncServer(syncServerHost, syncProxy, username, password);
}
await passwordEncryptionService.setDataKey(password, utils.randomSecureToken(16));
async function setupSyncToSyncServer() {
log.info("Initiating sync to server");
sqlInit.setDbReadyAsResolved();
const syncServerHost = await optionService.getOption('syncServerHost');
const syncProxy = await optionService.getOption('syncProxy');
const rpOpts = {
uri: syncServerHost + '/api/setup/sync-seed',
method: 'POST',
json: true,
body: {
options: await setupService.getSyncSeedOptions()
}
};
if (syncProxy) {
rpOpts.proxy = syncProxy;
}
try {
await rp(rpOpts);
}
catch (e) {
return { success: false, error: e.message };
}
// this is completely new sync, need to reset counters. If this would not be new sync,
// the previous request would have failed.
await optionService.setOption('lastSyncedPush', 0);
await optionService.setOption('lastSyncedPull', 0);
syncService.sync();
return { success: true };
}
async function saveSyncSeed(req) {
const options = req.body.options;
await sqlInit.createDatabaseForSync(options);
}
async function getSyncSeed() {
log.info("Serving sync seed.");
return await setupService.getSyncSeedOptions();
}
module.exports = {
setup
setupNewDocument,
setupSyncFromServer,
setupSyncToSyncServer,
getSyncSeed,
saveSyncSeed
};

View File

@@ -4,10 +4,37 @@ const syncService = require('../../services/sync');
const syncUpdateService = require('../../services/sync_update');
const syncTableService = require('../../services/sync_table');
const sql = require('../../services/sql');
const sqlInit = require('../../services/sql_init');
const optionService = require('../../services/options');
const contentHashService = require('../../services/content_hash');
const log = require('../../services/log');
async function testSync() {
try {
await syncService.login();
return { connection: "Success" };
}
catch (e) {
return {
connection: "Failure",
error: e.message
};
}
}
async function getStats() {
if (!await sqlInit.schemaExists()) {
// fail silently but prevent errors from not existing options table
return {};
}
return {
initialized: await optionService.getOption('initialized') === 'true',
stats: syncService.stats
};
}
async function checkSync() {
return {
hashes: await contentHashService.getHashes(),
@@ -60,7 +87,10 @@ async function getChanged(req) {
const syncs = await sql.getRows("SELECT * FROM sync WHERE id > ? LIMIT 1000", [lastSyncId]);
return await syncService.getSyncRecords(syncs);
return {
syncs: await syncService.getSyncRecords(syncs),
maxSyncId: await sql.getValue('SELECT MAX(id) FROM sync')
};
}
async function update(req) {
@@ -72,12 +102,21 @@ async function update(req) {
}
}
async function syncFinished() {
// after first sync finishes, the application is ready to be used
// this is meaningless but at the same time harmless (idempotent) for further syncs
await sqlInit.dbInitialized();
}
module.exports = {
testSync,
checkSync,
syncNow,
fillSyncRows,
forceFullSync,
forceNoteSync,
getChanged,
update
update,
getStats,
syncFinished
};

View File

@@ -26,6 +26,7 @@ const anonymizationRoute = require('./api/anonymization');
const cleanupRoute = require('./api/cleanup');
const imageRoute = require('./api/image');
const labelsRoute = require('./api/labels');
const relationsRoute = require('./api/relations');
const scriptRoute = require('./api/script');
const senderRoute = require('./api/sender');
const filesRoute = require('./api/file_upload');
@@ -62,16 +63,21 @@ function apiRoute(method, path, routeHandler) {
route(method, path, [auth.checkApiAuth], routeHandler, apiResultHandler);
}
function route(method, path, middleware, routeHandler, resultHandler) {
function route(method, path, middleware, routeHandler, resultHandler, transactional = true) {
router[method](path, ...middleware, async (req, res, next) => {
try {
const result = await cls.init(async () => {
cls.namespace.set('sourceId', req.headers.source_id);
protectedSessionService.setProtectedSessionId(req);
return await sql.transactional(async () => {
if (transactional) {
return await sql.transactional(async () => {
return await routeHandler(req, res, next);
});
}
else {
return await routeHandler(req, res, next);
});
}
});
if (resultHandler) {
@@ -91,7 +97,7 @@ const uploadMiddleware = multer.single('upload');
function register(app) {
route(GET, '/', [auth.checkAuth], indexRoute.index);
route(GET, '/login', [], loginRoute.loginPage);
route(GET, '/login', [auth.checkAppInitialized], loginRoute.loginPage);
route(POST, '/login', [], loginRoute.login);
route(POST, '/logout', [auth.checkAuth], loginRoute.logout);
route(GET, '/setup', [auth.checkAppNotInitialized], setupRoute.setupPage);
@@ -132,6 +138,10 @@ function register(app) {
apiRoute(GET, '/api/labels/names', labelsRoute.getAllLabelNames);
apiRoute(GET, '/api/labels/values/:labelName', labelsRoute.getValuesForLabel);
apiRoute(GET, '/api/notes/:noteId/relations', relationsRoute.getNoteRelations);
apiRoute(PUT, '/api/notes/:noteId/relations', relationsRoute.updateNoteRelations);
apiRoute(GET, '/api/relations/names', relationsRoute.getAllRelationNames);
route(GET, '/api/images/:imageId/:filename', [auth.checkApiAuthOrElectron], imageRoute.returnImage);
route(POST, '/api/images', [auth.checkApiAuthOrElectron, uploadMiddleware], imageRoute.uploadImage, apiResultHandler);
@@ -139,9 +149,11 @@ function register(app) {
apiRoute(GET, '/api/options', optionsApiRoute.getOptions);
apiRoute(PUT, '/api/options/:name/:value', optionsApiRoute.updateOption);
apiRoute(PUT, '/api/options', optionsApiRoute.updateOptions);
apiRoute(POST, '/api/password/change', passwordApiRoute.changePassword);
apiRoute(POST, '/api/sync/test', syncApiRoute.testSync);
apiRoute(GET, '/api/sync/check', syncApiRoute.checkSync);
apiRoute(POST, '/api/sync/now', syncApiRoute.syncNow);
apiRoute(POST, '/api/sync/fill-sync-rows', syncApiRoute.fillSyncRows);
@@ -149,19 +161,23 @@ function register(app) {
apiRoute(POST, '/api/sync/force-note-sync/:noteId', syncApiRoute.forceNoteSync);
apiRoute(GET, '/api/sync/changed', syncApiRoute.getChanged);
apiRoute(PUT, '/api/sync/update', syncApiRoute.update);
route(GET, '/api/sync/stats', [], syncApiRoute.getStats, apiResultHandler);
apiRoute(POST, '/api/sync/finished', syncApiRoute.syncFinished);
apiRoute(GET, '/api/event-log', eventLogRoute.getEventLog);
apiRoute(GET, '/api/recent-notes', recentNotesRoute.getRecentNotes);
apiRoute(PUT, '/api/recent-notes/:branchId/:notePath', recentNotesRoute.addRecentNote);
apiRoute(GET, '/api/app-info', appInfoRoute.getAppInfo);
route(POST, '/api/setup', [auth.checkAppNotInitialized], setupApiRoute.setup, apiResultHandler);
route(POST, '/api/setup/new-document', [auth.checkAppNotInitialized], setupApiRoute.setupNewDocument, apiResultHandler);
route(POST, '/api/setup/sync-from-server', [auth.checkAppNotInitialized], setupApiRoute.setupSyncFromServer, apiResultHandler, false);
apiRoute(POST, '/api/setup/sync-to-server', setupApiRoute.setupSyncToSyncServer);
route(GET, '/api/setup/sync-seed', [auth.checkBasicAuth], setupApiRoute.getSyncSeed, apiResultHandler);
route(POST, '/api/setup/sync-seed', [auth.checkAppNotInitialized], setupApiRoute.saveSyncSeed, apiResultHandler, false);
apiRoute(POST, '/api/sql/execute', sqlRoute.execute);
apiRoute(POST, '/api/anonymization/anonymize', anonymizationRoute.anonymize);
apiRoute(POST, '/api/cleanup/cleanup-soft-deleted-items', cleanupRoute.cleanupSoftDeletedItems);
apiRoute(POST, '/api/cleanup/cleanup-unused-images', cleanupRoute.cleanupUnusedImages);
apiRoute(POST, '/api/cleanup/vacuum-database', cleanupRoute.vacuumDatabase);
@@ -169,6 +185,7 @@ function register(app) {
apiRoute(POST, '/api/script/run/:noteId', scriptRoute.run);
apiRoute(GET, '/api/script/startup', scriptRoute.getStartupBundles);
apiRoute(GET, '/api/script/bundle/:noteId', scriptRoute.getBundle);
apiRoute(GET, '/api/script/relation/:noteId/:relationName', scriptRoute.getRelationBundles);
route(POST, '/api/sender/login', [], senderRoute.login, apiResultHandler);
route(POST, '/api/sender/image', [auth.checkSenderToken], senderRoute.uploadImage, apiResultHandler);

View File

@@ -1,7 +1,25 @@
"use strict";
function setupPage(req, res) {
res.render('setup', {});
const sqlInit = require('../services/sql_init');
const setupService = require('../services/setup');
async function setupPage(req, res) {
if (await sqlInit.isDbInitialized()) {
res.redirect('/');
}
// we got here because DB is not completely initialized so if schema exists
// it means we're in sync in progress state.
const syncInProgress = await sqlInit.schemaExists();
if (syncInProgress) {
// trigger sync if it's not already running
setupService.triggerSync();
}
res.render('setup', {
syncInProgress: syncInProgress
});
}
module.exports = {

Binary file not shown.

View File

@@ -3,7 +3,7 @@
const build = require('./build');
const packageJson = require('../../package');
const APP_DB_VERSION = 100;
const APP_DB_VERSION = 107;
const SYNC_VERSION = 1;
module.exports = {

View File

@@ -1,12 +1,13 @@
"use strict";
const migrationService = require('./migration');
const sql = require('./sql');
const sqlInit = require('./sql_init');
const utils = require('./utils');
const passwordEncryptionService = require('./password_encryption');
const optionService = require('./options');
async function checkAuth(req, res, next) {
if (!await sqlInit.isUserInitialized()) {
if (!await sqlInit.isDbInitialized()) {
res.redirect("setup");
}
else if (!req.session.loggedIn && !utils.isElectron()) {
@@ -37,8 +38,17 @@ async function checkApiAuth(req, res, next) {
}
}
async function checkAppInitialized(req, res, next) {
if (!await sqlInit.isDbInitialized()) {
res.redirect("setup");
}
else {
next();
}
}
async function checkAppNotInitialized(req, res, next) {
if (await sqlInit.isUserInitialized()) {
if (await sqlInit.isDbInitialized()) {
res.status(400).send("App already initialized.");
}
else {
@@ -57,10 +67,28 @@ async function checkSenderToken(req, res, next) {
}
}
async function checkBasicAuth(req, res, next) {
const header = req.headers.authorization || '';
const token = header.split(/\s+/).pop() || '';
const auth = new Buffer.from(token, 'base64').toString();
const [username, password] = auth.split(/:/);
const dbUsername = await optionService.getOption('username');
if (dbUsername !== username || !await passwordEncryptionService.verifyPassword(password)) {
res.status(401).send("Not authorized");
}
else {
next();
}
}
module.exports = {
checkAuth,
checkApiAuth,
checkAppInitialized,
checkAppNotInitialized,
checkApiAuthOrElectron,
checkSenderToken
checkSenderToken,
checkBasicAuth
};

View File

@@ -1 +1 @@
module.exports = { buildDate:"2018-07-09T21:22:12+02:00", buildRevision: "14cffbbe625036d7284056f6a37a5e748e397148" };
module.exports = { buildDate:"2018-07-30T08:18:25+02:00", buildRevision: "2ff7a890bceec873a082e496afb349b93f787985" };

View File

@@ -13,9 +13,14 @@ function getSourceId() {
return namespace.get('sourceId');
}
function reset() {
clsHooked.reset();
}
module.exports = {
init,
wrap,
namespace,
getSourceId
getSourceId,
reset
};

View File

@@ -19,6 +19,5 @@ async function addNoteEvent(noteId, comment) {
}
module.exports = {
addEvent,
addNoteEvent
addEvent
};

View File

@@ -2,8 +2,6 @@ const WebSocket = require('ws');
const utils = require('./utils');
const log = require('./log');
const sql = require('./sql');
const optionService = require('./options');
const syncSetup = require('./sync_setup');
let webSocketServer;
@@ -65,15 +63,12 @@ async function sendMessageToAllClients(message) {
async function sendPing(client, lastSentSyncId) {
const syncData = await sql.getRows("SELECT * FROM sync WHERE id > ?", [lastSentSyncId]);
const lastSyncedPush = await optionService.getOption('lastSyncedPush');
const changesToPushCount = await sql.getValue("SELECT COUNT(*) FROM sync WHERE id > ?", [lastSyncedPush]);
const stats = require('./sync').stats;
await sendMessage(client, {
type: 'sync',
data: syncData,
changesToPushCount: syncSetup.isSyncSetup ? changesToPushCount : 0
outstandingSyncs: stats.outstandingPushes + stats.outstandingPulls
});
}

View File

@@ -56,28 +56,23 @@ async function migrate() {
else if (mig.type === 'js') {
console.log("Migration with JS module");
const migrationModule = require("../" + resourceDir.MIGRATIONS_DIR + "/" + mig.file);
await migrationModule(db);
const migrationModule = require(resourceDir.MIGRATIONS_DIR + "/" + mig.file);
await migrationModule();
}
else {
throw new Error("Unknown migration type " + mig.type);
}
await optionService.setOption("dbVersion", mig.dbVersion);
});
log.info("Migration to version " + mig.dbVersion + " has been successful.");
mig['success'] = true;
}
catch (e) {
mig['success'] = false;
mig['error'] = e.stack;
log.error("error during migration to version " + mig.dbVersion + ": " + e.stack);
log.error("migration failed, crashing hard"); // this is not very user friendly :-/
break;
process.exit(1);
}
finally {
// make sure foreign keys are enabled even if migration script disables them
@@ -85,11 +80,9 @@ async function migrate() {
}
}
if (sqlInit.isDbUpToDate()) {
sqlInit.setDbReadyAsResolved();
if (await sqlInit.isDbUpToDate()) {
await sqlInit.initDbConnection();
}
return migrations;
}
module.exports = {

View File

@@ -5,6 +5,7 @@ const repository = require('./repository');
const protectedSessionService = require('./protected_session');
const utils = require('./utils');
let loaded = false;
let noteTitles;
let protectedNoteTitles;
let noteIds;
@@ -34,6 +35,8 @@ async function load() {
for (const noteId of hiddenLabels) {
archived[noteId] = true;
}
loaded = true;
}
function findNotes(query) {
@@ -226,6 +229,10 @@ function getNotePath(noteId) {
}
eventService.subscribe(eventService.ENTITY_CHANGED, async ({entityName, entityId}) => {
if (!loaded) {
return;
}
if (entityName === 'notes') {
const note = await repository.getNote(entityId);
@@ -277,6 +284,10 @@ eventService.subscribe(eventService.ENTITY_CHANGED, async ({entityName, entityId
});
eventService.subscribe(eventService.ENTER_PROTECTED_SESSION, async () => {
if (!loaded) {
return;
}
protectedNoteTitles = await sql.getMap(`SELECT noteId, title FROM notes WHERE isDeleted = 0 AND isProtected = 1`);
for (const noteId in protectedNoteTitles) {

View File

@@ -223,8 +223,14 @@ async function deleteNote(branch) {
if (notDeletedBranches.length === 0) {
note.isDeleted = true;
note.content = '';
await note.save();
for (const noteRevision of await note.getRevisions()) {
noteRevision.content = '';
await noteRevision.save();
}
for (const childBranch of await note.getChildBranches()) {
await deleteNote(childBranch);
}

View File

@@ -1,10 +1,5 @@
const repository = require('./repository');
const utils = require('./utils');
const dateUtils = require('./date_utils');
const appInfo = require('./app_info');
async function getOption(name) {
const option = await repository.getOption(name);
const option = await require('./repository').getOption(name);
if (!option) {
throw new Error("Option " + name + " doesn't exist");
@@ -14,7 +9,7 @@ async function getOption(name) {
}
async function setOption(name, value) {
const option = await repository.getOption(name);
const option = await require('./repository').getOption(name);
if (!option) {
throw new Error(`Option ${name} doesn't exist`);
@@ -36,32 +31,8 @@ async function createOption(name, value, isSynced) {
}).save();
}
async function initOptions(startNotePath) {
await createOption('documentId', utils.randomSecureToken(16), false);
await createOption('documentSecret', utils.randomSecureToken(16), false);
await createOption('username', '', true);
await createOption('passwordVerificationHash', '', true);
await createOption('passwordVerificationSalt', '', true);
await createOption('passwordDerivedKeySalt', '', true);
await createOption('encryptedDataKey', '', true);
await createOption('encryptedDataKeyIv', '', true);
await createOption('startNotePath', startNotePath, false);
await createOption('protectedSessionTimeout', 600, true);
await createOption('noteRevisionSnapshotTimeInterval', 600, true);
await createOption('lastBackupDate', dateUtils.nowDate(), false);
await createOption('dbVersion', appInfo.dbVersion, false);
await createOption('lastSyncedPull', appInfo.dbVersion, false);
await createOption('lastSyncedPush', 0, false);
await createOption('zoomFactor', 1.0, false);
await createOption('theme', 'white', false);
}
module.exports = {
getOption,
setOption,
initOptions
createOption
};

View File

@@ -0,0 +1,54 @@
const optionService = require('./options');
const passwordEncryptionService = require('./password_encryption');
const myScryptService = require('./my_scrypt');
const appInfo = require('./app_info');
const utils = require('./utils');
const dateUtils = require('./date_utils');
async function initDocumentOptions() {
await optionService.createOption('documentId', utils.randomSecureToken(16), false);
await optionService.createOption('documentSecret', utils.randomSecureToken(16), false);
}
async function initSyncedOptions(username, password) {
await optionService.createOption('protectedSessionTimeout', 600);
await optionService.createOption('noteRevisionSnapshotTimeInterval', 600);
await optionService.createOption('username', username);
await optionService.createOption('passwordVerificationSalt', utils.randomSecureToken(32));
await optionService.createOption('passwordDerivedKeySalt', utils.randomSecureToken(32));
const passwordVerificationKey = utils.toBase64(await myScryptService.getVerificationHash(password));
await optionService.createOption('passwordVerificationHash', passwordVerificationKey);
// passwordEncryptionService expects these options to already exist
await optionService.createOption('encryptedDataKey', '');
await optionService.createOption('encryptedDataKeyIv', '');
await passwordEncryptionService.setDataKey(password, utils.randomSecureToken(16));
}
async function initNotSyncedOptions(initialized, startNotePath = 'root', syncServerHost = '', syncProxy = '') {
await optionService.createOption('startNotePath', startNotePath, false);
await optionService.createOption('lastBackupDate', dateUtils.nowDate(), false);
await optionService.createOption('dbVersion', appInfo.dbVersion, false);
await optionService.createOption('lastSyncedPull', 0, false);
await optionService.createOption('lastSyncedPush', 0, false);
await optionService.createOption('zoomFactor', 1.0, false);
await optionService.createOption('theme', 'white', false);
await optionService.createOption('syncServerHost', syncServerHost, false);
await optionService.createOption('syncServerTimeout', 5000, false);
await optionService.createOption('syncProxy', syncProxy, false);
await optionService.createOption('initialized', initialized ? 'true' : 'false', false);
}
module.exports = {
initDocumentOptions,
initSyncedOptions,
initNotSyncedOptions
};

59
src/services/relations.js Normal file
View File

@@ -0,0 +1,59 @@
"use strict";
const repository = require('./repository');
const Relation = require('../entities/relation');
const BUILTIN_RELATIONS = [
'runOnNoteView'
];
async function getNotesWithRelation(name, targetNoteId) {
let notes;
if (targetNoteId !== undefined) {
notes = await repository.getEntities(`SELECT notes.* FROM notes JOIN relations ON notes.noteId = relations.sourceNoteId
WHERE notes.isDeleted = 0 AND relations.isDeleted = 0 AND relations.name = ? AND relations.targetNoteId = ?`, [name, targetNoteId]);
}
else {
notes = await repository.getEntities(`SELECT notes.* FROM notes JOIN relations ON notes.noteId = relations.sourceNoteId
WHERE notes.isDeleted = 0 AND relations.isDeleted = 0 AND relations.name = ?`, [name]);
}
return notes;
}
async function getNoteWithRelation(name, value) {
const notes = await getNotesWithRelation(name, value);
return notes.length > 0 ? notes[0] : null;
}
async function createRelation(sourceNoteId, name, targetNoteId) {
return await new Relation({
sourceNoteId: sourceNoteId,
name: name,
targetNoteId: targetNoteId
}).save();
}
async function getEffectiveRelations(noteId) {
return await repository.getEntities(`
WITH RECURSIVE tree(noteId) AS (
SELECT ?
UNION
SELECT branches.parentNoteId FROM branches
JOIN tree ON branches.noteId = tree.noteId
JOIN notes ON notes.noteId = branches.parentNoteId
WHERE notes.isDeleted = 0 AND branches.isDeleted = 0
)
SELECT relations.* FROM relations JOIN tree ON relations.sourceNoteId = tree.noteId
WHERE relations.isDeleted = 0 AND (relations.isInheritable = 1 OR relations.sourceNoteId = ?)`, [noteId, noteId]);
}
module.exports = {
BUILTIN_RELATIONS,
getNotesWithRelation,
getNoteWithRelation,
createRelation,
getEffectiveRelations
};

View File

@@ -41,6 +41,10 @@ async function getLabel(labelId) {
return await getEntity("SELECT * FROM labels WHERE labelId = ?", [labelId]);
}
async function getRelation(relationId) {
return await getEntity("SELECT * FROM relations WHERE relationId = ?", [relationId]);
}
async function getOption(name) {
return await getEntity("SELECT * FROM options WHERE name = ?", [name]);
}
@@ -72,6 +76,7 @@ module.exports = {
getBranch,
getImage,
getLabel,
getRelation,
getOption,
updateEntity,
setEntityConstructor

View File

@@ -14,7 +14,7 @@ async function executeNote(note) {
await executeBundle(bundle);
}
async function executeBundle(bundle, startNote) {
async function executeBundle(bundle, startNote, targetNote = null) {
if (!startNote) {
// this is the default case, the only exception is when we want to preserve frontend startNote
startNote = bundle.note;
@@ -23,7 +23,7 @@ async function executeBundle(bundle, startNote) {
// last \r\n is necessary if script contains line comment on its last line
const script = "async function() {\r\n" + bundle.script + "\r\n}";
const ctx = new ScriptContext(startNote, bundle.allNotes);
const ctx = new ScriptContext(startNote, bundle.allNotes, targetNote);
if (await bundle.note.hasLabel('manualTransactionHandling')) {
return await execute(ctx, script, '');
@@ -37,9 +37,10 @@ async function executeBundle(bundle, startNote) {
* This method preserves frontend startNode - that's why we start execution from currentNote and override
* bundle's startNote.
*/
async function executeScript(script, params, startNoteId, currentNoteId) {
async function executeScript(script, params, startNoteId, currentNoteId, targetNoteId) {
const startNote = await repository.getNote(startNoteId);
const currentNote = await repository.getNote(currentNoteId);
const targetNote = await repository.getNote(targetNoteId);
currentNote.content = `return await (${script}\r\n)(${getParams(params)})`;
currentNote.type = 'code';
@@ -47,7 +48,7 @@ async function executeScript(script, params, startNoteId, currentNoteId) {
const bundle = await getScriptBundle(currentNote);
return await executeBundle(bundle, startNote);
return await executeBundle(bundle, startNote, targetNote);
}
async function execute(ctx, script, paramsStr) {
@@ -138,8 +139,14 @@ function sanitizeVariableName(str) {
return str.replace(/[^a-z0-9_]/gim, "");
}
async function getScriptBundleForNoteId(noteId) {
const note = await repository.getNote(noteId);
return await getScriptBundle(note);
}
module.exports = {
executeNote,
executeScript,
getScriptBundle
getScriptBundle,
getScriptBundleForNoteId
};

View File

@@ -9,10 +9,10 @@ const config = require('./config');
const repository = require('./repository');
const axios = require('axios');
function ScriptContext(startNote, allNotes) {
function ScriptContext(startNote, allNotes, targetNote = null) {
this.modules = {};
this.notes = utils.toObject(allNotes, note => [note.noteId, note]);
this.apis = utils.toObject(allNotes, note => [note.noteId, new ScriptApi(startNote, note)]);
this.apis = utils.toObject(allNotes, note => [note.noteId, new ScriptApi(startNote, note, targetNote)]);
this.require = moduleNoteIds => {
return moduleName => {
const candidates = allNotes.filter(note => moduleNoteIds.includes(note.noteId));
@@ -27,9 +27,10 @@ function ScriptContext(startNote, allNotes) {
};
}
function ScriptApi(startNote, currentNote) {
function ScriptApi(startNote, currentNote, targetNote) {
this.startNote = startNote;
this.currentNote = currentNote;
this.targetNote = targetNote;
this.axios = axios;
@@ -44,6 +45,7 @@ function ScriptApi(startNote, currentNote) {
this.getNote = repository.getNote;
this.getBranch = repository.getBranch;
this.getLabel = repository.getLabel;
this.getRelation = repository.getRelation;
this.getImage = repository.getImage;
this.getEntity = repository.getEntity;
this.getEntities = repository.getEntities;

70
src/services/setup.js Normal file
View File

@@ -0,0 +1,70 @@
const rp = require('request-promise');
const syncService = require('./sync');
const log = require('./log');
const sqlInit = require('./sql_init');
const repository = require('./repository');
function triggerSync() {
log.info("Triggering sync.");
// it's ok to not wait for it here
syncService.sync().then(async res => {
if (res.success) {
await sqlInit.dbInitialized();
}
});
}
async function setupSyncFromSyncServer(syncServerHost, syncProxy, username, password) {
if (await sqlInit.isDbInitialized()) {
return {
result: 'failure',
error: 'DB is already initialized.'
};
}
try {
log.info("Getting document options from sync server.");
// response is expected to contain documentId and documentSecret options
const options = await rp.get({
uri: syncServerHost + '/api/setup/sync-seed',
auth: {
'user': username,
'pass': password
},
json: true
});
if (syncProxy) {
options.proxy = syncProxy;
}
await sqlInit.createDatabaseForSync(options, syncServerHost, syncProxy);
triggerSync();
return { result: 'success' };
}
catch (e) {
log.error("Sync failed: " + e.message);
return {
result: 'failure',
error: e.message
};
}
}
async function getSyncSeedOptions() {
return [
await repository.getOption('documentId'),
await repository.getOption('documentSecret')
];
}
module.exports = {
setupSyncFromSyncServer,
getSyncSeedOptions,
triggerSync
};

View File

@@ -157,10 +157,10 @@ async function transactional(func) {
transactionActive = true;
transactionPromise = new Promise(async (resolve, reject) => {
try {
cls.namespace.set('isInTransaction', true);
await beginTransaction();
cls.namespace.set('isInTransaction', true);
ret = await func();
await commit();

View File

@@ -6,41 +6,51 @@ const resourceDir = require('./resource_dir');
const appInfo = require('./app_info');
const sql = require('./sql');
const cls = require('./cls');
const optionService = require('./options');
const Option = require('../entities/option');
async function createConnection() {
return await sqlite.open(dataDir.DOCUMENT_PATH, {Promise});
}
let schemaReadyResolve = null;
const schemaReady = new Promise((resolve, reject) => schemaReadyResolve = resolve);
let dbReadyResolve = null;
const dbReady = new Promise((resolve, reject) => {
cls.init(async () => {
const db = await createConnection();
sql.setDbConnection(db);
const dbReady = new Promise(async (resolve, reject) => {
dbReadyResolve = resolve;
await sql.execute("PRAGMA foreign_keys = ON");
// no need to create new connection now since DB stays the same all the time
const db = await createConnection();
sql.setDbConnection(db);
dbReadyResolve = () => {
log.info("DB ready.");
initDbConnection();
});
resolve(db);
};
async function schemaExists() {
const tableResults = await sql.getRows("SELECT name FROM sqlite_master WHERE type='table' AND name='options'");
const tableResults = await sql.getRows("SELECT name FROM sqlite_master WHERE type='table' AND name='notes'");
if (tableResults.length !== 1) {
await createInitialDatabase();
}
return tableResults.length === 1;
}
schemaReadyResolve();
async function isDbInitialized() {
if (!await schemaExists()) {
return false;
}
if (!await isUserInitialized()) {
log.info("Login/password not initialized. DB not ready.");
const initialized = await sql.getValue("SELECT value FROM options WHERE name = 'initialized'");
// !initialized may be removed in the future, required only for migration
return !initialized || initialized === 'true';
}
async function initDbConnection() {
await cls.init(async () => {
if (!await isDbInitialized()) {
log.info("DB not initialized, please visit setup page to initialize Trilium.");
return;
}
await sql.execute("PRAGMA foreign_keys = ON");
if (!await isDbUpToDate()) {
// avoiding circular dependency
const migrationService = require('./migration');
@@ -48,12 +58,17 @@ const dbReady = new Promise((resolve, reject) => {
await migrationService.migrate();
}
resolve(db);
log.info("DB ready.");
dbReadyResolve();
});
});
}
async function createInitialDatabase() {
log.info("Connected to db, but schema doesn't exist. Initializing schema ...");
async function createInitialDatabase(username, password) {
log.info("Creating initial database ...");
if (await isDbInitialized()) {
throw new Error("DB is already initialized");
}
const schema = fs.readFileSync(resourceDir.DB_INIT_DIR + '/schema.sql', 'UTF-8');
const notesSql = fs.readFileSync(resourceDir.DB_INIT_DIR + '/main_notes.sql', 'UTF-8');
@@ -70,18 +85,41 @@ async function createInitialDatabase() {
const startNoteId = await sql.getValue("SELECT noteId FROM branches WHERE parentNoteId = 'root' AND isDeleted = 0 ORDER BY notePosition");
await require('./options').initOptions(startNoteId);
const optionsInitService = require('./options_init');
await optionsInitService.initDocumentOptions();
await optionsInitService.initSyncedOptions(username, password);
await optionsInitService.initNotSyncedOptions(true, startNoteId);
await require('./sync_table').fillAllSyncRows();
});
log.info("Schema and initial content generated. Waiting for user to enter username/password to finish setup.");
log.info("Schema and initial content generated.");
// we don't resolve dbReady promise because user needs to setup the username and password to initialize
// the database
await initDbConnection();
}
function setDbReadyAsResolved() {
dbReadyResolve();
async function createDatabaseForSync(options, syncServerHost = '', syncProxy = '') {
log.info("Creating database for sync");
if (await isDbInitialized()) {
throw new Error("DB is already initialized");
}
const schema = fs.readFileSync(resourceDir.DB_INIT_DIR + '/schema.sql', 'UTF-8');
await sql.transactional(async () => {
await sql.executeScript(schema);
await require('./options_init').initNotSyncedOptions(false, '', syncServerHost, syncProxy);
// document options required for sync to kick off
for (const opt of options) {
await new Option(opt).save();
}
});
log.info("Schema and not synced options generated.");
}
async function isDbUpToDate() {
@@ -96,22 +134,19 @@ async function isDbUpToDate() {
return upToDate;
}
async function isUserInitialized() {
const optionsTable = await sql.getRows("SELECT name FROM sqlite_master WHERE type='table' AND name='options'");
async function dbInitialized() {
await optionService.setOption('initialized', 'true');
if (optionsTable.length !== 1) {
return false;
}
const username = await sql.getValue("SELECT value FROM options WHERE name = 'username'");
return !!username;
await initDbConnection();
}
module.exports = {
dbReady,
schemaReady,
isUserInitialized,
setDbReadyAsResolved,
isDbUpToDate
schemaExists,
isDbInitialized,
initDbConnection,
isDbUpToDate,
createInitialDatabase,
createDatabaseForSync,
dbInitialized
};

View File

@@ -10,18 +10,25 @@ const sourceIdService = require('./source_id');
const dateUtils = require('./date_utils');
const syncUpdateService = require('./sync_update');
const contentHashService = require('./content_hash');
const fs = require('fs');
const appInfo = require('./app_info');
const syncSetup = require('./sync_setup');
const syncOptions = require('./sync_options');
const syncMutexService = require('./sync_mutex');
const cls = require('./cls');
let proxyToggle = true;
let syncServerCertificate = null;
const stats = {
outstandingPushes: 0,
outstandingPulls: 0
};
async function sync() {
try {
await syncMutexService.doExclusively(async () => {
return await syncMutexService.doExclusively(async () => {
if (!await syncOptions.isSyncSetup()) {
return { success: false, message: 'Sync not configured' };
}
const syncContext = await login();
await pushSync(syncContext);
@@ -30,12 +37,14 @@ async function sync() {
await pushSync(syncContext);
await checkContentHash(syncContext);
});
await syncFinished(syncContext);
return {
success: true
};
await checkContentHash(syncContext);
return {
success: true
};
});
}
catch (e) {
proxyToggle = !proxyToggle;
@@ -49,7 +58,7 @@ async function sync() {
};
}
else {
log.info("sync failed: " + e.stack);
log.info("sync failed: " + e.message);
return {
success: false,
@@ -83,21 +92,34 @@ async function login() {
}
async function pullSync(syncContext) {
const changesUri = '/api/sync/changed?lastSyncId=' + await getLastSyncedPull();
while (true) {
const lastSyncedPull = await getLastSyncedPull();
const changesUri = '/api/sync/changed?lastSyncId=' + lastSyncedPull;
const rows = await syncRequest(syncContext, 'GET', changesUri);
const startDate = new Date();
log.info("Pulled " + rows.length + " changes from " + changesUri);
const resp = await syncRequest(syncContext, 'GET', changesUri);
stats.outstandingPulls = resp.maxSyncId - lastSyncedPull;
for (const {sync, entity} of rows) {
if (sourceIdService.isLocalSourceId(sync.sourceId)) {
log.info(`Skipping pull #${sync.id} ${sync.entityName} ${sync.entityId} because ${sync.sourceId} is a local source id.`);
}
else {
await syncUpdateService.updateEntity(sync, entity, syncContext.sourceId);
const rows = resp.syncs;
if (rows.length === 0) {
break;
}
await setLastSyncedPull(sync.id);
log.info("Pulled " + rows.length + " changes from " + changesUri + " in "
+ (new Date().getTime() - startDate.getTime()) + "ms");
for (const {sync, entity} of rows) {
if (!sourceIdService.isLocalSourceId(sync.sourceId)) {
await syncUpdateService.updateEntity(sync, entity, syncContext.sourceId);
}
stats.outstandingPulls = resp.maxSyncId - sync.id;
}
await setLastSyncedPull(rows[rows.length - 1].sync.id);
}
log.info("Finished pull");
@@ -109,9 +131,16 @@ async function pushSync(syncContext) {
while (true) {
const syncs = await sql.getRows('SELECT * FROM sync WHERE id > ? LIMIT 1000', [lastSyncedPush]);
if (syncs.length === 0) {
log.info("Nothing to push");
break;
}
const filteredSyncs = syncs.filter(sync => {
if (sync.sourceId === syncContext.sourceId) {
log.info(`Skipping push #${sync.id} ${sync.entityName} ${sync.entityId} because it originates from sync target`);
// too noisy
//log.info(`Skipping push #${sync.id} ${sync.entityName} ${sync.entityId} because it originates from sync target`);
// this may set lastSyncedPush beyond what's actually sent (because of size limit)
// so this is applied to the database only if there's no actual update
@@ -126,28 +155,33 @@ async function pushSync(syncContext) {
});
if (filteredSyncs.length === 0) {
log.info("Nothing to push");
// there still might be more syncs (because of batch limit), just all from current batch
// has been filtered out
await setLastSyncedPush(lastSyncedPush);
break;
continue;
}
const syncRecords = await getSyncRecords(filteredSyncs);
log.info(`Pushing ${syncRecords.length} syncs.`);
const startDate = new Date();
await syncRequest(syncContext, 'PUT', '/api/sync/update', {
sourceId: sourceIdService.getCurrentSourceId(),
entities: syncRecords
});
log.info(`Pushing ${syncRecords.length} syncs in ` + (new Date().getTime() - startDate.getTime()) + "ms");
lastSyncedPush = syncRecords[syncRecords.length - 1].sync.id;
await setLastSyncedPush(lastSyncedPush);
}
}
async function syncFinished(syncContext) {
await syncRequest(syncContext, 'POST', '/api/sync/finished');
}
async function checkContentHash(syncContext) {
const resp = await syncRequest(syncContext, 'GET', '/api/sync/check');
@@ -169,7 +203,7 @@ async function checkContentHash(syncContext) {
}
async function syncRequest(syncContext, method, uri, body) {
const fullUri = syncSetup.SYNC_SERVER + uri;
const fullUri = await syncOptions.getSyncServerHost() + uri;
try {
const options = {
@@ -178,21 +212,19 @@ async function syncRequest(syncContext, method, uri, body) {
jar: syncContext.cookieJar,
json: true,
body: body,
timeout: syncSetup.SYNC_TIMEOUT
timeout: await syncOptions.getSyncTimeout()
};
if (syncServerCertificate) {
options.ca = syncServerCertificate;
}
const syncProxy = await syncOptions.getSyncProxy();
if (syncSetup.SYNC_PROXY && proxyToggle) {
options.proxy = syncSetup.SYNC_PROXY;
if (syncProxy && proxyToggle) {
options.proxy = syncProxy;
}
return await rp(options);
}
catch (e) {
throw new Error(`Request to ${method} ${fullUri} failed, inner exception: ${e.stack}`);
throw new Error(`Request to ${method} ${fullUri} failed, error: ${e.message}`);
}
}
@@ -204,6 +236,7 @@ const primaryKeys = {
"images": "imageId",
"note_images": "noteImageId",
"labels": "labelId",
"relations": "relationId",
"api_tokens": "apiTokenId",
"options": "name"
};
@@ -270,24 +303,28 @@ async function setLastSyncedPush(lastSyncedPush) {
await optionService.setOption('lastSyncedPush', lastSyncedPush);
}
sqlInit.dbReady.then(() => {
if (syncSetup.isSyncSetup) {
log.info("Setting up sync to " + syncSetup.SYNC_SERVER + " with timeout " + syncSetup.SYNC_TIMEOUT);
async function updatePushStats() {
const lastSyncedPush = await optionService.getOption('lastSyncedPush');
if (syncSetup.SYNC_PROXY) {
log.info("Sync proxy: " + syncSetup.SYNC_PROXY);
}
stats.outstandingPushes = await sql.getValue("SELECT COUNT(*) FROM sync WHERE id > ?", [lastSyncedPush]);
}
if (syncSetup.SYNC_CERT_PATH) {
log.info('Sync certificate: ' + syncSetup.SYNC_CERT_PATH);
sqlInit.dbReady.then(async () => {
if (await syncOptions.isSyncSetup()) {
log.info("Setting up sync to " + await syncOptions.getSyncServerHost() + " with timeout " + await syncOptions.getSyncTimeout());
syncServerCertificate = fs.readFileSync(syncSetup.SYNC_CERT_PATH);
const syncProxy = await syncOptions.getSyncProxy();
if (syncProxy) {
log.info("Sync proxy: " + syncProxy);
}
setInterval(cls.wrap(sync), 60000);
// kickoff initial sync immediately
setTimeout(cls.wrap(sync), 1000);
setInterval(cls.wrap(updatePushStats), 1000);
}
else {
log.info("Sync server not configured, sync timer not running.")
@@ -296,5 +333,7 @@ sqlInit.dbReady.then(() => {
module.exports = {
sync,
getSyncRecords
login,
getSyncRecords,
stats
};

View File

@@ -10,12 +10,11 @@ async function doExclusively(func) {
const releaseMutex = await instance.acquire();
try {
await func();
return await func();
}
finally {
releaseMutex();
}
}
module.exports = {

View File

@@ -0,0 +1,10 @@
"use strict";
const optionService = require('./options');
module.exports = {
getSyncServerHost: async () => await optionService.getOption('syncServerHost'),
isSyncSetup: async () => !!await optionService.getOption('syncServerHost'),
getSyncTimeout: async () => await optionService.getOption('syncServerTimeout'),
getSyncProxy: async () => await optionService.getOption('syncProxy')
};

View File

@@ -1,11 +0,0 @@
"use strict";
const config = require('./config');
module.exports = {
SYNC_SERVER: config['Sync']['syncServerHost'],
isSyncSetup: !!config['Sync']['syncServerHost'],
SYNC_TIMEOUT: config['Sync']['syncServerTimeout'] || 5000,
SYNC_PROXY: config['Sync']['syncProxy'],
SYNC_CERT_PATH: config['Sync']['syncServerCertificate']
};

View File

@@ -1,7 +1,7 @@
const sql = require('./sql');
const sourceIdService = require('./source_id');
const dateUtils = require('./date_utils');
const syncSetup = require('./sync_setup');
const syncOptions = require('./sync_options');
const log = require('./log');
const cls = require('./cls');
const eventService = require('./events');
@@ -42,6 +42,10 @@ async function addLabelSync(labelId, sourceId) {
await addEntitySync("labels", labelId, sourceId);
}
async function addRelationSync(relationId, sourceId) {
await addEntitySync("relations", relationId, sourceId);
}
async function addApiTokenSync(apiTokenId, sourceId) {
await addEntitySync("api_tokens", apiTokenId, sourceId);
}
@@ -54,12 +58,6 @@ async function addEntitySync(entityName, entityId, sourceId) {
sourceId: sourceId || cls.getSourceId() || sourceIdService.getCurrentSourceId()
});
if (!syncSetup.isSyncSetup) {
// this is because the "server" instances shouldn't have outstanding pushes
// useful when you fork the DB for new "client" instance, it won't try to sync the whole DB
await sql.execute("UPDATE options SET value = (SELECT MAX(id) FROM sync) WHERE name IN('lastSyncedPush', 'lastSyncedPull')");
}
eventService.emit(eventService.ENTITY_CHANGED, {
entityName,
entityId
@@ -74,10 +72,11 @@ async function cleanupSyncRowsForMissingEntities(entityName, entityKey) {
AND sync.entityId NOT IN (SELECT ${entityKey} FROM ${entityName})`);
}
async function fillSyncRows(entityName, entityKey) {
async function fillSyncRows(entityName, entityKey, condition = '') {
await cleanupSyncRowsForMissingEntities(entityName, entityKey);
const entityIds = await sql.getColumn(`SELECT ${entityKey} FROM ${entityName}`);
const entityIds = await sql.getColumn(`SELECT ${entityKey} FROM ${entityName}`
+ (condition ? ` WHERE ${condition}` : ''));
for (const entityId of entityIds) {
const existingRows = await sql.getValue("SELECT COUNT(id) FROM sync WHERE entityName = ? AND entityId = ?", [entityName, entityId]);
@@ -106,7 +105,9 @@ async function fillAllSyncRows() {
await fillSyncRows("images", "imageId");
await fillSyncRows("note_images", "noteImageId");
await fillSyncRows("labels", "labelId");
await fillSyncRows("relations", "relationId");
await fillSyncRows("api_tokens", "apiTokenId");
await fillSyncRows("options", "name", 'isSynced = 1');
}
module.exports = {
@@ -119,6 +120,7 @@ module.exports = {
addImageSync,
addNoteImageSync,
addLabelSync,
addRelationSync,
addApiTokenSync,
addEntitySync,
cleanupSyncRowsForMissingEntities,

View File

@@ -33,11 +33,14 @@ async function updateEntity(sync, entity, sourceId) {
else if (entityName === 'labels') {
await updateLabel(entity, sourceId);
}
else if (entityName === 'relations') {
await updateRelation(entity, sourceId);
}
else if (entityName === 'api_tokens') {
await updateApiToken(entity, sourceId);
}
else {
throw new Error(`Unrecognized entity type ${sync}`);
throw new Error(`Unrecognized entity type ${entityName}`);
}
}
@@ -57,7 +60,6 @@ async function updateNote(entity, sourceId) {
await sql.replace("notes", entity);
await syncTableService.addNoteSync(entity.noteId, sourceId);
await eventLogService.addNoteEvent(entity.noteId, "Synced note <note>");
});
log.info("Update/sync note " + entity.noteId);
@@ -69,7 +71,11 @@ async function updateBranch(entity, sourceId) {
await sql.transactional(async () => {
if (orig === null || orig.dateModified < entity.dateModified) {
delete entity.isExpanded;
// isExpanded is not synced unless it's a new branch instance
// otherwise in case of full new sync we'll get all branches (even root) collapsed.
if (orig) {
delete entity.isExpanded;
}
await sql.replace('branches', entity);
@@ -109,7 +115,7 @@ async function updateNoteReordering(entityId, entity, sourceId) {
async function updateOptions(entity, sourceId) {
const orig = await sql.getRowOrNull("SELECT * FROM options WHERE name = ?", [entity.name]);
if (!orig.isSynced) {
if (orig && !orig.isSynced) {
return;
}
@@ -182,6 +188,20 @@ async function updateLabel(entity, sourceId) {
}
}
async function updateRelation(entity, sourceId) {
const origRelation = await sql.getRow("SELECT * FROM relations WHERE relationId = ?", [entity.relationId]);
if (!origRelation || origRelation.dateModified <= entity.dateModified) {
await sql.transactional(async () => {
await sql.replace("relations", entity);
await syncTableService.addRelationSync(entity.relationId, sourceId);
});
log.info("Update/sync relation " + entity.relationId);
}
}
async function updateApiToken(entity, sourceId) {
const apiTokenId = await sql.getRow("SELECT * FROM api_tokens WHERE apiTokenId = ?", [entity.apiTokenId]);

View File

@@ -28,7 +28,7 @@ async function setUserNamePassword() {
await passwordEncryptionService.setDataKey(password, utils.randomSecureToken(16));
sqlInit.setDbReadyAsResolved();
await sqlInit.initDbConnection();
}
const noteCount = parseInt(process.argv[2]);
@@ -71,4 +71,4 @@ async function start() {
process.exit(0);
}
sqlInit.schemaReady.then(cls.wrap(start));
sqlInit.dbReady.then(cls.wrap(start));

View File

@@ -19,7 +19,6 @@
<div style="flex-grow: 100; display: flex;">
<button class="btn btn-xs" id="jump-to-note-dialog-button" title="CTRL+J">Jump to note</button>
<button class="btn btn-xs" id="recent-notes-button" title="CTRL+E">Recent notes</button>
<button class="btn btn-xs" id="recent-changes-button">Recent changes</button>
<div>
<span style="font-size: smaller">Protected session:</span>
@@ -38,7 +37,7 @@
<button class="btn btn-xs" id="sync-now-button" title="Number of outstanding changes to be pushed to server">
<span class="ui-icon ui-icon-refresh"></span>
Sync now (<span id="changes-to-push-count">0</span>)
Sync now (<span id="outstanding-syncs-count">0</span>)
</button>
<button class="btn btn-xs" id="options-button">
@@ -171,6 +170,7 @@
<ul class="dropdown-menu dropdown-menu-right">
<li><a id="show-note-revisions-button">Note revisions</a></li>
<li><a class="show-labels-button"><kbd>Alt+L</kbd> Labels</a></li>
<li><a class="show-relations-button"><kbd>Alt+R</kbd> Relations</a></li>
<li><a id="show-source-button">HTML source</a></li>
<li><a id="upload-file-button">Upload file</a></li>
</ul>
@@ -180,6 +180,8 @@
</div>
<div id="note-detail-wrapper">
<div id="note-detail-script-area"></div>
<div id="note-detail-component-wrapper">
<div id="note-detail-text" class="note-detail-component" tabindex="2"></div>
@@ -254,18 +256,22 @@
<div id="children-overview"></div>
<div id="label-list">
<button class="btn btn-sm show-labels-button">Labels:</button>
<div id="labels-and-relations">
<span id="label-list">
<button class="btn btn-sm show-labels-button">Labels:</button>
<span id="label-list-inner"></span>
<span id="label-list-inner"></span>
</span>
<span id="relation-list">
<button class="btn btn-sm show-relations-button">Relations:</button>
<span id="relation-list-inner"></span>
</span>
</div>
</div>
</div>
<div id="recent-notes-dialog" title="Recent notes" style="display: none;">
<input id="recent-notes-search-input" class="form-control"/>
</div>
<div id="add-link-dialog" title="Add note link" style="display: none;">
<form id="add-link-form">
<div id="add-link-type-div" class="radio">
@@ -284,36 +290,39 @@
<div class="form-group">
<label for="note-autocomplete">Note</label>
<input id="note-autocomplete" placeholder="search for note by its name" style="width: 100%;">
<div class="input-group">
<input id="note-autocomplete" class="form-control" placeholder="search for note by its name" style="width: 100%;">
<span class="input-group-addon" id="add-link-show-recent-notes" title="Show recent notes" style="background: url('/images/icons/clock-16.png') no-repeat center; cursor: pointer;"></span>
</div>
</div>
<div class="form-group" id="add-link-title-form-group">
<label for="link-title">Link title</label>
<input id="link-title" style="width: 100%;">
<input id="link-title" class="form-control" style="width: 100%;">
</div>
<div class="form-group" id="add-link-prefix-form-group" title="Cloned note will be shown in note tree with given prefix">
<label for="clone-prefix">Prefix (optional)</label>
<input id="clone-prefix" style="width: 100%;">
<input id="clone-prefix" class="form-control" style="width: 100%;">
</div>
<button class="btn btn-sm">Add note link</button>
<button class="btn btn-primary">Add note link <kbd>enter</kbd></button>
</form>
</div>
<div id="jump-to-note-dialog" title="Jump to note" style="display: none;">
<form id="jump-to-note-form">
<div class="form-group">
<label for="jump-to-note-autocomplete">Note</label>
<input id="jump-to-note-autocomplete" placeholder="search for note by its name" style="width: 100%;">
</div>
<div class="form-group">
<label for="jump-to-note-autocomplete">Note</label>
<div class="input-group">
<input id="jump-to-note-autocomplete" class="form-control" placeholder="search for note by its name" style="width: 100%;">
<div style="display: flex; justify-content: space-between;">
<button id="jump-to-note-button" class="btn btn-sm btn-primary">Jump <kbd>enter</kbd></button>
<button id="show-in-full-text-button" class="btn btn-sm">Search in full text <kbd>ctrl+enter</kbd></button>
<span class="input-group-addon" id="jump-to-note-show-recent-notes" title="Show recent notes" style="background: url('/images/icons/clock-16.png') no-repeat center; cursor: pointer;"></span>
</div>
</form>
</div>
<button id="show-in-full-text-button" class="btn btn-sm">Search in full text <kbd>ctrl+enter</kbd></button>
</div>
<div id="protected-session-password-dialog" title="Protected session" style="display: none;">
@@ -334,6 +343,7 @@
<li><a href="#change-password">Change password</a></li>
<li><a href="#protected-session-timeout">Protected session</a></li>
<li><a href="#note-revision-snapshot-time-interval">Note revisions</a></li>
<li><a href="#sync-setup">Sync</a></li>
<li><a href="#advanced">Advanced</a></li>
<li><a href="#about">About Trilium</a></li>
</ul>
@@ -404,6 +414,40 @@
<button class="btn btn-sm">Save</button>
</form>
</div>
<div id="sync-setup">
<h4 style="margin-top: 0px;">Sync configuration</h4>
<form id="sync-setup-form">
<div class="form-group">
<label for="sync-server-host">Server instance address</label>
<input class="form-control" id="sync-server-host" placeholder="https://<host>:<port>">
</div>
<div class="form-group">
<label for="sync-server-timeout">Sync timeout (milliseconds)</label>
<input class="form-control" id="sync-server-timeout" min="1" max="10000000" type="number">
</div>
<div class="form-group">
<label for="sync-proxy">Sync proxy server (optional)</label>
<input class="form-control" id="sync-proxy" placeholder="https://<host>:<port>">
</div>
<button class="btn btn-sm">Save</button>
</form>
<h4>Sync test</h4>
<p>This will test connection and handshake to the sync server.</p>
<button id="test-sync-button" class="btn btn-sm">Test sync</button>
<h4>Sync document to the server instance</h4>
<p>This is used when you want to sync your local document to the server instance configured above. This is a one time action after which the documents are synced automatically and transparently.</p>
<button id="sync-to-server-button" class="btn btn-sm">Sync local document to the server instance</button>
</div>
<div id="advanced">
<h4 style="margin-top: 0px;">Sync</h4>
<button id="force-full-sync-button" class="btn btn-sm">Force full sync</button>
@@ -426,14 +470,7 @@
This means that some images can disappear from note revisions.</p>
<button id="cleanup-unused-images-button" class="btn btn-warning btn-sm">Permanently cleanup unused images</button>
<h4>Soft-delete cleanup</h4>
<p>This deletes all soft deleted rows from the database. This change isn't synced and should be done manually on all instances.
<strong>Use this only if you really know what you're doing.</strong></p>
<button id="cleanup-soft-deleted-items-button" class="btn btn-danger btn-sm">Permanently cleanup soft-deleted items</button>
<button id="cleanup-unused-images-button" class="btn btn-sm">Permanently cleanup unused images</button>
<h4>Vacuum database</h4>
@@ -544,14 +581,14 @@
<td data-bind="text: labelId" style="width: 150px;"></td>
<td>
<!-- Change to valueUpdate: blur is necessary because jQuery UI autocomplete hijacks change event -->
<input type="text" class="label-name" data-bind="value: name, valueUpdate: 'blur', event: { blur: $parent.labelChanged }"/>
<input type="text" class="label-name form-control" data-bind="value: name, valueUpdate: 'blur', event: { blur: $parent.labelChanged }"/>
<div style="color: yellowgreen" data-bind="if: $parent.isNotUnique($index())"><span class="glyphicon glyphicon-info-sign"></span> Duplicate label.</div>
<div style="color: red" data-bind="if: $parent.isEmptyName($index())">Label name can't be empty.</div>
</td>
<td>
<input type="text" class="label-value" data-bind="value: value, valueUpdate: 'blur', event: { blur: $parent.labelChanged }" style="width: 300px"/>
<input type="text" class="label-value form-control" data-bind="value: value, valueUpdate: 'blur', event: { blur: $parent.labelChanged }" style="width: 300px"/>
</td>
<td title="Delete" style="padding: 13px;">
<td title="Delete" style="padding: 13px; cursor: pointer;">
<span class="glyphicon glyphicon-trash" data-bind="click: $parent.deleteLabel"></span>
</td>
</tr>
@@ -561,6 +598,61 @@
</form>
</div>
<div id="relations-dialog" title="Note relations" style="display: none; padding: 20px;">
<form data-bind="submit: save">
<div style="text-align: center">
<button class="btn btn-large" style="width: 200px;" id="save-relations-button" type="submit">Save changes <kbd>enter</kbd></button>
</div>
<div style="height: 97%; overflow: auto">
<table id="relations-table" class="table">
<thead>
<tr>
<th></th>
<th>ID</th>
<th>Relation name</th>
<th>Target note</th>
<th>Inheritable</th>
<th></th>
</tr>
</thead>
<tbody data-bind="foreach: relations">
<tr data-bind="if: isDeleted == 0">
<td class="handle">
<span class="glyphicon glyphicon-resize-vertical"></span>
<input type="hidden" name="position" data-bind="value: position"/>
</td>
<!-- ID column has specific width because if it's empty its size can be deformed when dragging -->
<td data-bind="text: relationId" style="width: 150px;"></td>
<td>
<!-- Change to valueUpdate: blur is necessary because jQuery UI autocomplete hijacks change event -->
<input type="text" class="relation-name form-control" data-bind="value: name, valueUpdate: 'blur', event: { blur: $parent.relationChanged }"/>
<div style="color: yellowgreen" data-bind="if: $parent.isNotUnique($index())"><span class="glyphicon glyphicon-info-sign"></span> Duplicate relation.</div>
<div style="color: red" data-bind="if: $parent.isEmptyName($index())">Relation name can't be empty.</div>
</td>
<td>
<div class="input-group">
<input class="form-control relation-target-note-id"
placeholder="search for note by its name"
data-bind="value: targetNoteId, valueUpdate: 'blur', event: { blur: $parent.relationChanged }"
style="width: 300px;">
<span class="input-group-addon relations-show-recent-notes" title="Show recent notes" style="background: url('/images/icons/clock-16.png') no-repeat center; cursor: pointer;"></span>
</div>
</td>
<td title="Inheritable relations are automatically inherited to the child notes">
<input type="checkbox" value="1" data-bind="checked: isInheritable" />
</td>
<td title="Delete" style="padding: 13px; cursor: pointer;">
<span class="glyphicon glyphicon-trash" data-bind="click: $parent.deleteRelation"></span>
</td>
</tr>
</tbody>
</table>
</div>
</form>
</div>
<div id="tooltip" style="display: none;"></div>
<script type="text/javascript">

View File

@@ -5,31 +5,110 @@
<title>Setup</title>
</head>
<body>
<div style="width: 500px; margin: auto;">
<div id="setup-dialog" style="width: 500px; margin: auto; display:none;">
<h1>Trilium Notes setup</h1>
<div class="alert alert-warning" id="alert" style="display: none;">
</div>
<p>You're almost done with the setup. That last thing is to choose username and password using which you'll login to the application.
This password is also used for generating encryption key which encrypts protected notes.</p>
<div id="setup-type" data-bind="visible: step() == 'setup-type'">
<div class="radio">
<label><input type="radio" name="setup-type" value="new-document" data-bind="checked: setupNewDocument">
I'm a new user and I want to create new Trilium document for my notes</label>
</div>
<div class="radio" data-bind="if: instanceType == 'server'">
<label><input type="radio" name="setup-type" value="sync-from-desktop" data-bind="checked: setupSyncFromDesktop">
I have desktop instance already and I want to setup sync with it</label>
</div>
<div class="radio" data-bind="if: instanceType == 'desktop'">
<label><input type="radio" name="setup-type" value="sync-from-server" data-bind="checked: setupSyncFromServer">
I have server instance up and I want to setup sync with it</label>
</div>
<button type="button" data-bind="disable: !setupTypeSelected(), click: selectSetupType" class="btn btn-primary">Next</button>
</div>
<div data-bind="visible: step() == 'new-document'">
<h2>New document</h2>
<p>You're almost done with the setup. The last thing is to choose username and password using which you'll login to the application.
This password is also used for generating encryption key which encrypts protected notes.</p>
<form id="setup-form">
<div class="form-group">
<label for="username">Username</label>
<input type="text" class="form-control" id="username" placeholder="Arbitrary string">
<input type="text" class="form-control" data-bind="value: username" placeholder="Arbitrary string">
</div>
<div class="form-group">
<label for="password1">Password</label>
<input type="password" class="form-control" id="password1" placeholder="Password">
<input type="password" class="form-control" data-bind="value: password1" placeholder="Password">
</div>
<div class="form-group">
<label for="password2">Repeat password</label>
<input type="password" class="form-control" id="password2" placeholder="Password">
<input type="password" class="form-control" data-bind="value: password2" placeholder="Password">
</div>
<button type="submit" class="btn btn-default">Save</button>
</form>
<button type="button" data-bind="click: back" class="btn btn-default">Back</button>
&nbsp;
<button type="button" data-bind="click: finish" class="btn btn-primary">Finish setup</button>
</div>
<div data-bind="visible: step() == 'sync-from-desktop'">
<h2>Sync from Desktop</h2>
<p>This setup needs to be initiated from the desktop instance:</p>
<ol>
<li>please open your desktop instance of Trilium Notes</li>
<li>click on Options button in the top right</li>
<li>click on Sync tab</li>
<li>configure server instance address to the: <span id="current-host"></span> and click save.</li>
<li>click on "Sync document to the server instance" button</li>
<li>once you've done all this, click <a href="/">here</a></li>
</ol>
<button type="button" data-bind="click: back" class="btn btn-default">Back</button>
</div>
<div data-bind="visible: step() == 'sync-from-server'">
<h2>Sync from Server</h2>
<p>Please enter Trilium server address and credentials below. This will download the whole Trilium document from server and setup sync to it. Depending on the document size and your connection speed, this may take a while.</p>
<div class="form-group">
<label for="sync-server-host">Trilium server address</label>
<input type="text" id="syncServerHost" class="form-control" data-bind="value: syncServerHost" placeholder="https://<hostname>:<port>">
</div>
<div class="form-group">
<label for="sync-proxy">Proxy server (optional)</label>
<input type="text" id="sync-proxy" class="form-control" data-bind="value: syncProxy" placeholder="https://<hostname>:<port>">
</div>
<div class="form-group">
<label for="username">Username</label>
<input type="text" id="username" class="form-control" data-bind="value: username" placeholder="Username">
</div>
<div class="form-group">
<label for="password1">Password</label>
<input type="password" id="password1" class="form-control" data-bind="value: password1" placeholder="Password">
</div>
<button type="button" data-bind="click: back" class="btn btn-default">Back</button>
&nbsp;
<button type="button" data-bind="click: finish" class="btn btn-primary">Finish setup</button>
</div>
<div data-bind="visible: step() == 'sync-in-progress'">
<h2>Sync in progress</h2>
<div class="alert alert-success">Sync has been correctly set up. It will take some time for the initial sync to finish. Once it's done, you'll be redirected to the login page.</div>
<div data-bind="if: instanceType == 'desktop'">
Outstanding sync items: <strong id="outstanding-syncs">N/A</strong>
</div>
</div>
</div>
<script type="text/javascript">
@@ -37,6 +116,7 @@
const glob = {
sourceId: ''
};
const syncInProgress = <%= syncInProgress ? 'true' : 'false' %>;
</script>
<!-- Required for correct loading of scripts in Electron -->
@@ -47,6 +127,8 @@
<link href="libraries/bootstrap/css/bootstrap.css" rel="stylesheet">
<script src="libraries/bootstrap/js/bootstrap.js"></script>
<script src="/libraries/knockout.min.js"></script>
<script src="javascripts/setup.js" type="module"></script>
</body>
</html>