Compare commits
	
		
			184 Commits
		
	
	
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
|  | 2d1bc46c04 | ||
|  | 4bc44605fb | ||
|  | b868990fba | ||
|  | 26c06c9826 | ||
|  | f5b89432a6 | ||
|  | 0e7372adbf | ||
|  | d4fbe28517 | ||
|  | 668528d5eb | ||
|  | 17348a9cfe | ||
|  | 09b610701d | ||
|  | 71e687ad8e | ||
|  | 171877ce08 | ||
|  | 4f1e6ec70f | ||
|  | 1938c317c3 | ||
|  | 99d81059d0 | ||
|  | 59d5a86110 | ||
|  | a5e56ea839 | ||
|  | 44f85224e7 | ||
|  | 0aa08b1c1e | ||
|  | 406d74c4d7 | ||
|  | 7f9a8a55ca | ||
|  | a42bbba0e5 | ||
|  | 145efe67c3 | ||
|  | 513748836e | ||
|  | 427ce3972e | ||
|  | 02c0f9a6cd | ||
|  | 208771216e | ||
|  | 385d97a9b3 | ||
|  | e39d1d08ac | ||
|  | 0f106fb96f | ||
|  | df9acd0504 | ||
|  | dbe0eb3f3a | ||
|  | 4513651e12 | ||
|  | 3204291463 | ||
|  | 510704a074 | ||
|  | f440493e45 | ||
|  | b897c6de13 | ||
|  | acbd18e8fc | ||
|  | ff5b84db10 | ||
|  | 16535f6a73 | ||
|  | 5b657ad961 | ||
|  | bbbc3e9dc4 | ||
|  | f43f0e10a1 | ||
|  | 6d842a65a2 | ||
|  | 50c4de021c | ||
|  | 936d8449f6 | ||
|  | 462bc0edd5 | ||
|  | 35ef3c8470 | ||
|  | 5117d43e29 | ||
|  | 7c9ac488e8 | ||
|  | fec1574447 | ||
|  | f7587de452 | ||
|  | 41a6e777ea | ||
|  | 8fb0de900b | ||
|  | a40bf71fd4 | ||
|  | 2a53bb03ae | ||
|  | a684879b91 | ||
|  | ddbd4f73c8 | ||
|  | b0ed790edf | ||
|  | 3424406ff1 | ||
|  | ce5c385c15 | ||
|  | cd9eef32b0 | ||
|  | 12d82e3b33 | ||
|  | f071d3f651 | ||
|  | 297b536ebc | ||
|  | 7cca2d9247 | ||
|  | 36dc802d16 | ||
|  | c78ddb70cb | ||
|  | 9fb0599c45 | ||
|  | 13f524fb39 | ||
|  | 27be3b4c90 | ||
|  | af4ea66742 | ||
|  | 0f42c396f3 | ||
|  | 9e96272eb3 | ||
|  | 965dbcbc9a | ||
|  | 7ac109e7f7 | ||
|  | ac25770c0e | ||
|  | 5b15424498 | ||
|  | f1240c26bf | ||
|  | 1c0fd243d1 | ||
|  | 3491235533 | ||
|  | 5f36856571 | ||
|  | d3e44b37e9 | ||
|  | 90e9297ec5 | ||
|  | c568ef2f8a | ||
|  | fcf6141cde | ||
|  | 21551d7b77 | ||
|  | 12031d369f | ||
|  | b44c523845 | ||
|  | 49989695ff | ||
|  | a55d3530e9 | ||
|  | 2aab3ad281 | ||
|  | 194ce4f10f | ||
|  | 2089c32839 | ||
|  | f437be7af0 | ||
|  | 96dc56098d | ||
|  | 61987e46f7 | ||
|  | 509093b755 | ||
|  | 097114c0f2 | ||
|  | 040f9185f8 | ||
|  | 6dc934abbe | ||
|  | 2d24bf81dd | ||
|  | 9452fc236b | ||
|  | 365c37604b | ||
|  | 01c7e58d47 | ||
|  | d3d49923b1 | ||
|  | 263ac299d0 | ||
|  | 3d185a5178 | ||
|  | 2ff7a890bc | ||
|  | 2eb1a9705f | ||
|  | ed1381103a | ||
|  | 170d317589 | ||
|  | ededc063df | ||
|  | 986eace1be | ||
|  | 29086d8dfe | ||
|  | 9b3f3fde05 | ||
|  | 6a50afd952 | ||
|  | 697eee2706 | ||
|  | 8a95afd756 | ||
|  | 4d6eda8fe6 | ||
|  | e4f459fa2b | ||
|  | f578e001b0 | ||
|  | 2a08aef885 | ||
|  | 7564bf388c | ||
|  | 7e4d70259f | ||
|  | 5b98c1c0f3 | ||
|  | 02dc7b199b | ||
|  | d39cdbfada | ||
|  | 50bb4a47ee | ||
|  | a4627f2ddb | ||
|  | c8253caae9 | ||
|  | 0ece9bd1be | ||
|  | b6935abcc9 | ||
|  | 37ab7b4641 | ||
|  | 013714cb5c | ||
|  | 1fe7c62f5a | ||
|  | a06618d851 | ||
|  | e7460ca3a9 | ||
|  | 073300bbcd | ||
|  | a201661ce5 | ||
|  | 6235a3c886 | ||
|  | 3972c27e7a | ||
|  | 14cffbbe62 | ||
|  | 599c3c04af | ||
|  | f1412b631d | ||
|  | 41908050bb | ||
|  | f07033423c | ||
|  | daf96fcbf2 | ||
|  | 2bca94529e | ||
|  | b2c9a0da21 | ||
|  | 7a9542b4fc | ||
|  | 3a95c9e1bc | ||
|  | 3d2ef6be01 | ||
|  | d67246699a | ||
|  | 14c704d6db | ||
|  | 4c8eeb2e6f | ||
|  | c1b245c8b1 | ||
|  | 74202d67bb | ||
|  | 26066f39b1 | ||
|  | b255cf190c | ||
|  | bc77b143b0 | ||
|  | 9f0ff6ae7a | ||
|  | 736704c7d6 | ||
|  | 654c116c58 | ||
|  | 89a5cab98f | ||
|  | c39d0be8cd | ||
|  | e75b4cd848 | ||
|  | 378e8f35e5 | ||
|  | bdb5e2f13f | ||
|  | 8211bed449 | ||
|  | b243632483 | ||
|  | e4d2513451 | ||
|  | 385144451b | ||
|  | c8c533844e | ||
|  | 0e69f0c079 | ||
|  | aee60c444f | ||
|  | e7a504c66b | ||
|  | 45d9c7164c | ||
|  | bd913a63a8 | ||
|  | 5a1938c078 | ||
|  | 015cd68756 | ||
|  | 76c0e5b2b8 | ||
|  | 0f8f707acd | ||
|  | 083cccea28 | 
							
								
								
									
										4
									
								
								.dockerignore
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,4 @@ | ||||
| node_modules | ||||
| npm-debug.log | ||||
| dist | ||||
| .idea | ||||
							
								
								
									
										3
									
								
								.gitignore
									
									
									
									
										vendored
									
									
								
							
							
						
						| @@ -6,4 +6,5 @@ yarn-error.log | ||||
| *.db | ||||
| config.ini | ||||
| cert.key | ||||
| cert.crt | ||||
| cert.crt | ||||
| docs/ | ||||
| @@ -1,6 +1,6 @@ | ||||
| <?xml version="1.0" encoding="UTF-8"?> | ||||
| <dataSource name="document.db"> | ||||
|   <database-model serializer="dbm" rdbms="SQLITE" format-version="4.9"> | ||||
|   <database-model serializer="dbm" rdbms="SQLITE" format-version="4.11"> | ||||
|     <root id="1"> | ||||
|       <ServerVersion>3.16.1</ServerVersion> | ||||
|     </root> | ||||
| @@ -12,10 +12,10 @@ | ||||
|     <collation id="4" parent="1" name="NOCASE"/> | ||||
|     <collation id="5" parent="1" name="RTRIM"/> | ||||
|     <table id="6" parent="2" name="api_tokens"/> | ||||
|     <table id="7" parent="2" name="branches"/> | ||||
|     <table id="8" parent="2" name="event_log"/> | ||||
|     <table id="9" parent="2" name="images"/> | ||||
|     <table id="10" parent="2" name="labels"/> | ||||
|     <table id="7" parent="2" name="attributes"/> | ||||
|     <table id="8" parent="2" name="branches"/> | ||||
|     <table id="9" parent="2" name="event_log"/> | ||||
|     <table id="10" parent="2" name="images"/> | ||||
|     <table id="11" parent="2" name="note_images"/> | ||||
|     <table id="12" parent="2" name="note_revisions"/> | ||||
|     <table id="13" parent="2" name="notes"/> | ||||
| @@ -59,7 +59,6 @@ | ||||
|     <index id="25" 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"> | ||||
| @@ -67,7 +66,7 @@ | ||||
|       <Primary>1</Primary> | ||||
|       <UnderlyingIndexName>sqlite_autoindex_api_tokens_1</UnderlyingIndexName> | ||||
|     </key> | ||||
|     <column id="27" parent="7" name="branchId"> | ||||
|     <column id="27" parent="7" name="attributeId"> | ||||
|       <Position>1</Position> | ||||
|       <DataType>TEXT|0s</DataType> | ||||
|       <NotNull>1</NotNull> | ||||
| @@ -77,589 +76,574 @@ | ||||
|       <DataType>TEXT|0s</DataType> | ||||
|       <NotNull>1</NotNull> | ||||
|     </column> | ||||
|     <column id="29" parent="7" name="parentNoteId"> | ||||
|     <column id="29" parent="7" name="type"> | ||||
|       <Position>3</Position> | ||||
|       <DataType>TEXT|0s</DataType> | ||||
|       <NotNull>1</NotNull> | ||||
|     </column> | ||||
|     <column id="30" parent="7" name="notePosition"> | ||||
|     <column id="30" parent="7" name="name"> | ||||
|       <Position>4</Position> | ||||
|       <DataType>INTEGER|0s</DataType> | ||||
|       <DataType>TEXT|0s</DataType> | ||||
|       <NotNull>1</NotNull> | ||||
|     </column> | ||||
|     <column id="31" parent="7" name="prefix"> | ||||
|     <column id="31" parent="7" name="value"> | ||||
|       <Position>5</Position> | ||||
|       <DataType>TEXT|0s</DataType> | ||||
|       <NotNull>1</NotNull> | ||||
|       <DefaultExpression>''</DefaultExpression> | ||||
|     </column> | ||||
|     <column id="32" parent="7" name="isExpanded"> | ||||
|     <column id="32" parent="7" name="position"> | ||||
|       <Position>6</Position> | ||||
|       <DataType>BOOLEAN|0s</DataType> | ||||
|     </column> | ||||
|     <column id="33" parent="7" name="isDeleted"> | ||||
|       <Position>7</Position> | ||||
|       <DataType>INTEGER|0s</DataType> | ||||
|       <DataType>INT|0s</DataType> | ||||
|       <NotNull>1</NotNull> | ||||
|       <DefaultExpression>0</DefaultExpression> | ||||
|     </column> | ||||
|     <column id="33" parent="7" name="dateCreated"> | ||||
|       <Position>7</Position> | ||||
|       <DataType>TEXT|0s</DataType> | ||||
|       <NotNull>1</NotNull> | ||||
|     </column> | ||||
|     <column id="34" parent="7" name="dateModified"> | ||||
|       <Position>8</Position> | ||||
|       <DataType>TEXT|0s</DataType> | ||||
|       <NotNull>1</NotNull> | ||||
|     </column> | ||||
|     <column id="35" parent="7" name="hash"> | ||||
|     <column id="35" parent="7" name="isDeleted"> | ||||
|       <Position>9</Position> | ||||
|       <DataType>INT|0s</DataType> | ||||
|       <NotNull>1</NotNull> | ||||
|     </column> | ||||
|     <column id="36" parent="7" name="hash"> | ||||
|       <Position>10</Position> | ||||
|       <DataType>TEXT|0s</DataType> | ||||
|       <NotNull>1</NotNull> | ||||
|       <DefaultExpression>""</DefaultExpression> | ||||
|     </column> | ||||
|     <column id="37" parent="7" name="isInheritable"> | ||||
|       <Position>11</Position> | ||||
|       <DataType>int|0s</DataType> | ||||
|       <DefaultExpression>0</DefaultExpression> | ||||
|     </column> | ||||
|     <index id="38" parent="7" name="sqlite_autoindex_attributes_1"> | ||||
|       <NameSurrogate>1</NameSurrogate> | ||||
|       <ColNames>attributeId</ColNames> | ||||
|       <Unique>1</Unique> | ||||
|     </index> | ||||
|     <key id="39" parent="7"> | ||||
|       <ColNames>attributeId</ColNames> | ||||
|       <Primary>1</Primary> | ||||
|       <UnderlyingIndexName>sqlite_autoindex_attributes_1</UnderlyingIndexName> | ||||
|     </key> | ||||
|     <column id="40" parent="8" name="branchId"> | ||||
|       <Position>1</Position> | ||||
|       <DataType>TEXT|0s</DataType> | ||||
|       <NotNull>1</NotNull> | ||||
|     </column> | ||||
|     <column id="41" parent="8" name="noteId"> | ||||
|       <Position>2</Position> | ||||
|       <DataType>TEXT|0s</DataType> | ||||
|       <NotNull>1</NotNull> | ||||
|     </column> | ||||
|     <column id="42" parent="8" name="parentNoteId"> | ||||
|       <Position>3</Position> | ||||
|       <DataType>TEXT|0s</DataType> | ||||
|       <NotNull>1</NotNull> | ||||
|     </column> | ||||
|     <column id="43" parent="8" name="notePosition"> | ||||
|       <Position>4</Position> | ||||
|       <DataType>INTEGER|0s</DataType> | ||||
|       <NotNull>1</NotNull> | ||||
|     </column> | ||||
|     <column id="44" parent="8" name="prefix"> | ||||
|       <Position>5</Position> | ||||
|       <DataType>TEXT|0s</DataType> | ||||
|     </column> | ||||
|     <column id="45" parent="8" name="isExpanded"> | ||||
|       <Position>6</Position> | ||||
|       <DataType>BOOLEAN|0s</DataType> | ||||
|     </column> | ||||
|     <column id="46" parent="8" name="isDeleted"> | ||||
|       <Position>7</Position> | ||||
|       <DataType>INTEGER|0s</DataType> | ||||
|       <NotNull>1</NotNull> | ||||
|       <DefaultExpression>0</DefaultExpression> | ||||
|     </column> | ||||
|     <column id="47" parent="8" name="dateModified"> | ||||
|       <Position>8</Position> | ||||
|       <DataType>TEXT|0s</DataType> | ||||
|       <NotNull>1</NotNull> | ||||
|     </column> | ||||
|     <column id="48" parent="8" name="hash"> | ||||
|       <Position>9</Position> | ||||
|       <DataType>TEXT|0s</DataType> | ||||
|       <NotNull>1</NotNull> | ||||
|       <DefaultExpression>""</DefaultExpression> | ||||
|     </column> | ||||
|     <column id="36" parent="7" name="dateCreated"> | ||||
|     <column id="49" parent="8" name="dateCreated"> | ||||
|       <Position>10</Position> | ||||
|       <DataType>TEXT|0s</DataType> | ||||
|       <NotNull>1</NotNull> | ||||
|       <DefaultExpression>'1970-01-01T00:00:00.000Z'</DefaultExpression> | ||||
|     </column> | ||||
|     <index id="37" parent="7" name="sqlite_autoindex_branches_1"> | ||||
|     <index id="50" parent="8" 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="51" parent="8" name="IDX_branches_noteId_parentNoteId"> | ||||
|       <ColNames>noteId | ||||
| parentNoteId</ColNames> | ||||
|       <ColumnCollations></ColumnCollations> | ||||
|     </index> | ||||
|     <index id="39" parent="7" name="IDX_branches_noteId"> | ||||
|     <index id="52" parent="8" name="IDX_branches_noteId"> | ||||
|       <ColNames>noteId</ColNames> | ||||
|       <ColumnCollations></ColumnCollations> | ||||
|     </index> | ||||
|     <index id="40" parent="7" name="IDX_branches_parentNoteId"> | ||||
|     <index id="53" parent="8" name="IDX_branches_parentNoteId"> | ||||
|       <ColNames>parentNoteId</ColNames> | ||||
|       <ColumnCollations></ColumnCollations> | ||||
|     </index> | ||||
|     <key id="41" parent="7"> | ||||
|     <key id="54" parent="8"> | ||||
|       <ColNames>branchId</ColNames> | ||||
|       <Primary>1</Primary> | ||||
|       <UnderlyingIndexName>sqlite_autoindex_branches_1</UnderlyingIndexName> | ||||
|     </key> | ||||
|     <column id="42" parent="8" name="id"> | ||||
|     <column id="55" parent="9" name="eventId"> | ||||
|       <Position>1</Position> | ||||
|       <DataType>INTEGER|0s</DataType> | ||||
|       <DataType>TEXT|0s</DataType> | ||||
|       <NotNull>1</NotNull> | ||||
|       <SequenceIdentity>1</SequenceIdentity> | ||||
|     </column> | ||||
|     <column id="43" parent="8" name="noteId"> | ||||
|     <column id="56" parent="9" name="noteId"> | ||||
|       <Position>2</Position> | ||||
|       <DataType>TEXT|0s</DataType> | ||||
|     </column> | ||||
|     <column id="44" parent="8" name="comment"> | ||||
|     <column id="57" parent="9" name="comment"> | ||||
|       <Position>3</Position> | ||||
|       <DataType>TEXT|0s</DataType> | ||||
|     </column> | ||||
|     <column id="45" parent="8" name="dateCreated"> | ||||
|     <column id="58" parent="9" name="dateCreated"> | ||||
|       <Position>4</Position> | ||||
|       <DataType>TEXT|0s</DataType> | ||||
|       <NotNull>1</NotNull> | ||||
|     </column> | ||||
|     <key id="46" parent="8"> | ||||
|       <ColNames>id</ColNames> | ||||
|     <index id="59" parent="9" name="sqlite_autoindex_event_log_1"> | ||||
|       <NameSurrogate>1</NameSurrogate> | ||||
|       <ColNames>eventId</ColNames> | ||||
|       <Unique>1</Unique> | ||||
|     </index> | ||||
|     <key id="60" parent="9"> | ||||
|       <ColNames>eventId</ColNames> | ||||
|       <Primary>1</Primary> | ||||
|       <UnderlyingIndexName>sqlite_autoindex_event_log_1</UnderlyingIndexName> | ||||
|     </key> | ||||
|     <column id="47" parent="9" name="imageId"> | ||||
|     <column id="61" parent="10" name="imageId"> | ||||
|       <Position>1</Position> | ||||
|       <DataType>TEXT|0s</DataType> | ||||
|       <NotNull>1</NotNull> | ||||
|     </column> | ||||
|     <column id="48" parent="9" name="format"> | ||||
|     <column id="62" parent="10" name="format"> | ||||
|       <Position>2</Position> | ||||
|       <DataType>TEXT|0s</DataType> | ||||
|       <NotNull>1</NotNull> | ||||
|     </column> | ||||
|     <column id="49" parent="9" name="checksum"> | ||||
|     <column id="63" parent="10" name="checksum"> | ||||
|       <Position>3</Position> | ||||
|       <DataType>TEXT|0s</DataType> | ||||
|       <NotNull>1</NotNull> | ||||
|     </column> | ||||
|     <column id="50" parent="9" name="name"> | ||||
|     <column id="64" parent="10" name="name"> | ||||
|       <Position>4</Position> | ||||
|       <DataType>TEXT|0s</DataType> | ||||
|       <NotNull>1</NotNull> | ||||
|     </column> | ||||
|     <column id="51" parent="9" name="data"> | ||||
|     <column id="65" parent="10" name="data"> | ||||
|       <Position>5</Position> | ||||
|       <DataType>BLOB|0s</DataType> | ||||
|     </column> | ||||
|     <column id="52" parent="9" name="isDeleted"> | ||||
|     <column id="66" parent="10" name="isDeleted"> | ||||
|       <Position>6</Position> | ||||
|       <DataType>INT|0s</DataType> | ||||
|       <NotNull>1</NotNull> | ||||
|       <DefaultExpression>0</DefaultExpression> | ||||
|     </column> | ||||
|     <column id="53" parent="9" name="dateModified"> | ||||
|     <column id="67" parent="10" name="dateModified"> | ||||
|       <Position>7</Position> | ||||
|       <DataType>TEXT|0s</DataType> | ||||
|       <NotNull>1</NotNull> | ||||
|     </column> | ||||
|     <column id="54" parent="9" name="dateCreated"> | ||||
|     <column id="68" parent="10" name="dateCreated"> | ||||
|       <Position>8</Position> | ||||
|       <DataType>TEXT|0s</DataType> | ||||
|       <NotNull>1</NotNull> | ||||
|     </column> | ||||
|     <column id="55" parent="9" name="hash"> | ||||
|     <column id="69" parent="10" name="hash"> | ||||
|       <Position>9</Position> | ||||
|       <DataType>TEXT|0s</DataType> | ||||
|       <NotNull>1</NotNull> | ||||
|       <DefaultExpression>""</DefaultExpression> | ||||
|     </column> | ||||
|     <index id="56" parent="9" name="sqlite_autoindex_images_1"> | ||||
|     <index id="70" parent="10" name="sqlite_autoindex_images_1"> | ||||
|       <NameSurrogate>1</NameSurrogate> | ||||
|       <ColNames>imageId</ColNames> | ||||
|       <ColumnCollations></ColumnCollations> | ||||
|       <Unique>1</Unique> | ||||
|     </index> | ||||
|     <key id="57" parent="9"> | ||||
|     <key id="71" parent="10"> | ||||
|       <ColNames>imageId</ColNames> | ||||
|       <Primary>1</Primary> | ||||
|       <UnderlyingIndexName>sqlite_autoindex_images_1</UnderlyingIndexName> | ||||
|     </key> | ||||
|     <column id="58" parent="10" name="labelId"> | ||||
|     <column id="72" parent="11" name="noteImageId"> | ||||
|       <Position>1</Position> | ||||
|       <DataType>TEXT|0s</DataType> | ||||
|       <NotNull>1</NotNull> | ||||
|     </column> | ||||
|     <column id="59" parent="10" name="noteId"> | ||||
|     <column id="73" parent="11" name="noteId"> | ||||
|       <Position>2</Position> | ||||
|       <DataType>TEXT|0s</DataType> | ||||
|       <NotNull>1</NotNull> | ||||
|     </column> | ||||
|     <column id="60" parent="10" name="name"> | ||||
|     <column id="74" parent="11" name="imageId"> | ||||
|       <Position>3</Position> | ||||
|       <DataType>TEXT|0s</DataType> | ||||
|       <NotNull>1</NotNull> | ||||
|     </column> | ||||
|     <column id="61" parent="10" name="value"> | ||||
|       <Position>4</Position> | ||||
|       <DataType>TEXT|0s</DataType> | ||||
|       <NotNull>1</NotNull> | ||||
|       <DefaultExpression>''</DefaultExpression> | ||||
|     </column> | ||||
|     <column id="62" parent="10" name="position"> | ||||
|       <Position>5</Position> | ||||
|       <DataType>INT|0s</DataType> | ||||
|       <NotNull>1</NotNull> | ||||
|       <DefaultExpression>0</DefaultExpression> | ||||
|     </column> | ||||
|     <column id="63" parent="10" name="dateCreated"> | ||||
|       <Position>6</Position> | ||||
|       <DataType>TEXT|0s</DataType> | ||||
|       <NotNull>1</NotNull> | ||||
|     </column> | ||||
|     <column id="64" parent="10" name="dateModified"> | ||||
|       <Position>7</Position> | ||||
|       <DataType>TEXT|0s</DataType> | ||||
|       <NotNull>1</NotNull> | ||||
|     </column> | ||||
|     <column id="65" parent="10" name="isDeleted"> | ||||
|       <Position>8</Position> | ||||
|       <DataType>INT|0s</DataType> | ||||
|       <NotNull>1</NotNull> | ||||
|     </column> | ||||
|     <column id="66" parent="10" name="hash"> | ||||
|       <Position>9</Position> | ||||
|       <DataType>TEXT|0s</DataType> | ||||
|       <NotNull>1</NotNull> | ||||
|       <DefaultExpression>""</DefaultExpression> | ||||
|     </column> | ||||
|     <index id="67" parent="10" name="sqlite_autoindex_labels_1"> | ||||
|       <NameSurrogate>1</NameSurrogate> | ||||
|       <ColNames>labelId</ColNames> | ||||
|       <ColumnCollations></ColumnCollations> | ||||
|       <Unique>1</Unique> | ||||
|     </index> | ||||
|     <index id="68" parent="10" name="IDX_labels_noteId"> | ||||
|       <ColNames>noteId</ColNames> | ||||
|       <ColumnCollations></ColumnCollations> | ||||
|     </index> | ||||
|     <index id="69" parent="10" name="IDX_labels_name_value"> | ||||
|       <ColNames>name | ||||
| value</ColNames> | ||||
|       <ColumnCollations></ColumnCollations> | ||||
|     </index> | ||||
|     <key id="70" parent="10"> | ||||
|       <ColNames>labelId</ColNames> | ||||
|       <Primary>1</Primary> | ||||
|       <UnderlyingIndexName>sqlite_autoindex_labels_1</UnderlyingIndexName> | ||||
|     </key> | ||||
|     <column id="71" parent="11" name="noteImageId"> | ||||
|       <Position>1</Position> | ||||
|       <DataType>TEXT|0s</DataType> | ||||
|       <NotNull>1</NotNull> | ||||
|     </column> | ||||
|     <column id="72" parent="11" name="noteId"> | ||||
|       <Position>2</Position> | ||||
|       <DataType>TEXT|0s</DataType> | ||||
|       <NotNull>1</NotNull> | ||||
|     </column> | ||||
|     <column id="73" parent="11" name="imageId"> | ||||
|       <Position>3</Position> | ||||
|       <DataType>TEXT|0s</DataType> | ||||
|       <NotNull>1</NotNull> | ||||
|     </column> | ||||
|     <column id="74" parent="11" name="isDeleted"> | ||||
|     <column id="75" parent="11" name="isDeleted"> | ||||
|       <Position>4</Position> | ||||
|       <DataType>INT|0s</DataType> | ||||
|       <NotNull>1</NotNull> | ||||
|       <DefaultExpression>0</DefaultExpression> | ||||
|     </column> | ||||
|     <column id="75" parent="11" name="dateModified"> | ||||
|     <column id="76" parent="11" name="dateModified"> | ||||
|       <Position>5</Position> | ||||
|       <DataType>TEXT|0s</DataType> | ||||
|       <NotNull>1</NotNull> | ||||
|     </column> | ||||
|     <column id="76" parent="11" name="dateCreated"> | ||||
|     <column id="77" parent="11" name="dateCreated"> | ||||
|       <Position>6</Position> | ||||
|       <DataType>TEXT|0s</DataType> | ||||
|       <NotNull>1</NotNull> | ||||
|     </column> | ||||
|     <column id="77" parent="11" name="hash"> | ||||
|     <column id="78" parent="11" name="hash"> | ||||
|       <Position>7</Position> | ||||
|       <DataType>TEXT|0s</DataType> | ||||
|       <NotNull>1</NotNull> | ||||
|       <DefaultExpression>""</DefaultExpression> | ||||
|     </column> | ||||
|     <index id="78" parent="11" name="sqlite_autoindex_note_images_1"> | ||||
|     <index id="79" parent="11" name="sqlite_autoindex_note_images_1"> | ||||
|       <NameSurrogate>1</NameSurrogate> | ||||
|       <ColNames>noteImageId</ColNames> | ||||
|       <ColumnCollations></ColumnCollations> | ||||
|       <Unique>1</Unique> | ||||
|     </index> | ||||
|     <index id="79" parent="11" name="IDX_note_images_noteId_imageId"> | ||||
|     <index id="80" parent="11" name="IDX_note_images_noteId_imageId"> | ||||
|       <ColNames>noteId | ||||
| imageId</ColNames> | ||||
|       <ColumnCollations></ColumnCollations> | ||||
|     </index> | ||||
|     <index id="80" parent="11" name="IDX_note_images_noteId"> | ||||
|     <index id="81" parent="11" name="IDX_note_images_noteId"> | ||||
|       <ColNames>noteId</ColNames> | ||||
|       <ColumnCollations></ColumnCollations> | ||||
|     </index> | ||||
|     <index id="81" parent="11" name="IDX_note_images_imageId"> | ||||
|     <index id="82" parent="11" name="IDX_note_images_imageId"> | ||||
|       <ColNames>imageId</ColNames> | ||||
|       <ColumnCollations></ColumnCollations> | ||||
|     </index> | ||||
|     <key id="82" parent="11"> | ||||
|     <key id="83" parent="11"> | ||||
|       <ColNames>noteImageId</ColNames> | ||||
|       <Primary>1</Primary> | ||||
|       <UnderlyingIndexName>sqlite_autoindex_note_images_1</UnderlyingIndexName> | ||||
|     </key> | ||||
|     <column id="83" parent="12" name="noteRevisionId"> | ||||
|     <column id="84" parent="12" name="noteRevisionId"> | ||||
|       <Position>1</Position> | ||||
|       <DataType>TEXT|0s</DataType> | ||||
|       <NotNull>1</NotNull> | ||||
|     </column> | ||||
|     <column id="84" parent="12" name="noteId"> | ||||
|     <column id="85" parent="12" name="noteId"> | ||||
|       <Position>2</Position> | ||||
|       <DataType>TEXT|0s</DataType> | ||||
|       <NotNull>1</NotNull> | ||||
|     </column> | ||||
|     <column id="85" parent="12" name="title"> | ||||
|     <column id="86" parent="12" name="title"> | ||||
|       <Position>3</Position> | ||||
|       <DataType>TEXT|0s</DataType> | ||||
|     </column> | ||||
|     <column id="86" parent="12" name="content"> | ||||
|     <column id="87" parent="12" name="content"> | ||||
|       <Position>4</Position> | ||||
|       <DataType>TEXT|0s</DataType> | ||||
|     </column> | ||||
|     <column id="87" parent="12" name="isProtected"> | ||||
|     <column id="88" parent="12" name="isProtected"> | ||||
|       <Position>5</Position> | ||||
|       <DataType>INT|0s</DataType> | ||||
|       <NotNull>1</NotNull> | ||||
|       <DefaultExpression>0</DefaultExpression> | ||||
|     </column> | ||||
|     <column id="88" parent="12" name="dateModifiedFrom"> | ||||
|     <column id="89" parent="12" name="dateModifiedFrom"> | ||||
|       <Position>6</Position> | ||||
|       <DataType>TEXT|0s</DataType> | ||||
|       <NotNull>1</NotNull> | ||||
|     </column> | ||||
|     <column id="89" parent="12" name="dateModifiedTo"> | ||||
|     <column id="90" parent="12" name="dateModifiedTo"> | ||||
|       <Position>7</Position> | ||||
|       <DataType>TEXT|0s</DataType> | ||||
|       <NotNull>1</NotNull> | ||||
|     </column> | ||||
|     <column id="90" parent="12" name="type"> | ||||
|     <column id="91" parent="12" name="type"> | ||||
|       <Position>8</Position> | ||||
|       <DataType>TEXT|0s</DataType> | ||||
|       <NotNull>1</NotNull> | ||||
|       <DefaultExpression>''</DefaultExpression> | ||||
|     </column> | ||||
|     <column id="91" parent="12" name="mime"> | ||||
|     <column id="92" parent="12" name="mime"> | ||||
|       <Position>9</Position> | ||||
|       <DataType>TEXT|0s</DataType> | ||||
|       <NotNull>1</NotNull> | ||||
|       <DefaultExpression>''</DefaultExpression> | ||||
|     </column> | ||||
|     <column id="92" parent="12" name="hash"> | ||||
|     <column id="93" parent="12" name="hash"> | ||||
|       <Position>10</Position> | ||||
|       <DataType>TEXT|0s</DataType> | ||||
|       <NotNull>1</NotNull> | ||||
|       <DefaultExpression>""</DefaultExpression> | ||||
|     </column> | ||||
|     <index id="93" parent="12" name="sqlite_autoindex_note_revisions_1"> | ||||
|     <index id="94" parent="12" name="sqlite_autoindex_note_revisions_1"> | ||||
|       <NameSurrogate>1</NameSurrogate> | ||||
|       <ColNames>noteRevisionId</ColNames> | ||||
|       <ColumnCollations></ColumnCollations> | ||||
|       <Unique>1</Unique> | ||||
|     </index> | ||||
|     <index id="94" parent="12" name="IDX_note_revisions_noteId"> | ||||
|     <index id="95" parent="12" name="IDX_note_revisions_noteId"> | ||||
|       <ColNames>noteId</ColNames> | ||||
|       <ColumnCollations></ColumnCollations> | ||||
|     </index> | ||||
|     <index id="95" parent="12" name="IDX_note_revisions_dateModifiedFrom"> | ||||
|     <index id="96" parent="12" name="IDX_note_revisions_dateModifiedFrom"> | ||||
|       <ColNames>dateModifiedFrom</ColNames> | ||||
|       <ColumnCollations></ColumnCollations> | ||||
|     </index> | ||||
|     <index id="96" parent="12" name="IDX_note_revisions_dateModifiedTo"> | ||||
|     <index id="97" parent="12" name="IDX_note_revisions_dateModifiedTo"> | ||||
|       <ColNames>dateModifiedTo</ColNames> | ||||
|       <ColumnCollations></ColumnCollations> | ||||
|     </index> | ||||
|     <key id="97" parent="12"> | ||||
|     <key id="98" parent="12"> | ||||
|       <ColNames>noteRevisionId</ColNames> | ||||
|       <Primary>1</Primary> | ||||
|       <UnderlyingIndexName>sqlite_autoindex_note_revisions_1</UnderlyingIndexName> | ||||
|     </key> | ||||
|     <column id="98" parent="13" name="noteId"> | ||||
|     <column id="99" parent="13" name="noteId"> | ||||
|       <Position>1</Position> | ||||
|       <DataType>TEXT|0s</DataType> | ||||
|       <NotNull>1</NotNull> | ||||
|     </column> | ||||
|     <column id="99" parent="13" name="title"> | ||||
|     <column id="100" parent="13" name="title"> | ||||
|       <Position>2</Position> | ||||
|       <DataType>TEXT|0s</DataType> | ||||
|       <NotNull>1</NotNull> | ||||
|       <DefaultExpression>"unnamed"</DefaultExpression> | ||||
|     </column> | ||||
|     <column id="100" parent="13" name="content"> | ||||
|     <column id="101" parent="13" name="content"> | ||||
|       <Position>3</Position> | ||||
|       <DataType>TEXT|0s</DataType> | ||||
|       <NotNull>1</NotNull> | ||||
|       <DefaultExpression>""</DefaultExpression> | ||||
|     </column> | ||||
|     <column id="101" parent="13" name="isProtected"> | ||||
|     <column id="102" parent="13" name="isProtected"> | ||||
|       <Position>4</Position> | ||||
|       <DataType>INT|0s</DataType> | ||||
|       <NotNull>1</NotNull> | ||||
|       <DefaultExpression>0</DefaultExpression> | ||||
|     </column> | ||||
|     <column id="102" parent="13" name="isDeleted"> | ||||
|     <column id="103" parent="13" name="isDeleted"> | ||||
|       <Position>5</Position> | ||||
|       <DataType>INT|0s</DataType> | ||||
|       <NotNull>1</NotNull> | ||||
|       <DefaultExpression>0</DefaultExpression> | ||||
|     </column> | ||||
|     <column id="103" parent="13" name="dateCreated"> | ||||
|     <column id="104" parent="13" name="dateCreated"> | ||||
|       <Position>6</Position> | ||||
|       <DataType>TEXT|0s</DataType> | ||||
|       <NotNull>1</NotNull> | ||||
|     </column> | ||||
|     <column id="104" parent="13" name="dateModified"> | ||||
|     <column id="105" parent="13" name="dateModified"> | ||||
|       <Position>7</Position> | ||||
|       <DataType>TEXT|0s</DataType> | ||||
|       <NotNull>1</NotNull> | ||||
|     </column> | ||||
|     <column id="105" parent="13" name="type"> | ||||
|     <column id="106" parent="13" name="type"> | ||||
|       <Position>8</Position> | ||||
|       <DataType>TEXT|0s</DataType> | ||||
|       <NotNull>1</NotNull> | ||||
|       <DefaultExpression>'text'</DefaultExpression> | ||||
|     </column> | ||||
|     <column id="106" parent="13" name="mime"> | ||||
|     <column id="107" parent="13" name="mime"> | ||||
|       <Position>9</Position> | ||||
|       <DataType>TEXT|0s</DataType> | ||||
|       <NotNull>1</NotNull> | ||||
|       <DefaultExpression>'text/html'</DefaultExpression> | ||||
|     </column> | ||||
|     <column id="107" parent="13" name="hash"> | ||||
|     <column id="108" parent="13" name="hash"> | ||||
|       <Position>10</Position> | ||||
|       <DataType>TEXT|0s</DataType> | ||||
|       <NotNull>1</NotNull> | ||||
|       <DefaultExpression>""</DefaultExpression> | ||||
|     </column> | ||||
|     <index id="108" parent="13" name="sqlite_autoindex_notes_1"> | ||||
|     <index id="109" parent="13" name="sqlite_autoindex_notes_1"> | ||||
|       <NameSurrogate>1</NameSurrogate> | ||||
|       <ColNames>noteId</ColNames> | ||||
|       <ColumnCollations></ColumnCollations> | ||||
|       <Unique>1</Unique> | ||||
|     </index> | ||||
|     <index id="109" parent="13" name="IDX_notes_type"> | ||||
|     <index id="110" parent="13" name="IDX_notes_type"> | ||||
|       <ColNames>type</ColNames> | ||||
|       <ColumnCollations></ColumnCollations> | ||||
|     </index> | ||||
|     <key id="110" parent="13"> | ||||
|     <key id="111" parent="13"> | ||||
|       <ColNames>noteId</ColNames> | ||||
|       <Primary>1</Primary> | ||||
|       <UnderlyingIndexName>sqlite_autoindex_notes_1</UnderlyingIndexName> | ||||
|     </key> | ||||
|     <column id="111" parent="14" name="name"> | ||||
|     <column id="112" parent="14" name="name"> | ||||
|       <Position>1</Position> | ||||
|       <DataType>TEXT|0s</DataType> | ||||
|       <NotNull>1</NotNull> | ||||
|     </column> | ||||
|     <column id="112" parent="14" name="value"> | ||||
|     <column id="113" parent="14" name="value"> | ||||
|       <Position>2</Position> | ||||
|       <DataType>TEXT|0s</DataType> | ||||
|     </column> | ||||
|     <column id="113" parent="14" name="dateModified"> | ||||
|     <column id="114" parent="14" name="dateModified"> | ||||
|       <Position>3</Position> | ||||
|       <DataType>INT|0s</DataType> | ||||
|     </column> | ||||
|     <column id="114" parent="14" name="isSynced"> | ||||
|     <column id="115" parent="14" name="isSynced"> | ||||
|       <Position>4</Position> | ||||
|       <DataType>INTEGER|0s</DataType> | ||||
|       <NotNull>1</NotNull> | ||||
|       <DefaultExpression>0</DefaultExpression> | ||||
|     </column> | ||||
|     <column id="115" parent="14" name="hash"> | ||||
|     <column id="116" parent="14" name="hash"> | ||||
|       <Position>5</Position> | ||||
|       <DataType>TEXT|0s</DataType> | ||||
|       <NotNull>1</NotNull> | ||||
|       <DefaultExpression>""</DefaultExpression> | ||||
|     </column> | ||||
|     <column id="116" parent="14" name="dateCreated"> | ||||
|     <column id="117" parent="14" name="dateCreated"> | ||||
|       <Position>6</Position> | ||||
|       <DataType>TEXT|0s</DataType> | ||||
|       <NotNull>1</NotNull> | ||||
|       <DefaultExpression>'1970-01-01T00:00:00.000Z'</DefaultExpression> | ||||
|     </column> | ||||
|     <index id="117" parent="14" name="sqlite_autoindex_options_1"> | ||||
|     <index id="118" parent="14" name="sqlite_autoindex_options_1"> | ||||
|       <NameSurrogate>1</NameSurrogate> | ||||
|       <ColNames>name</ColNames> | ||||
|       <ColumnCollations></ColumnCollations> | ||||
|       <Unique>1</Unique> | ||||
|     </index> | ||||
|     <key id="118" parent="14"> | ||||
|     <key id="119" parent="14"> | ||||
|       <ColNames>name</ColNames> | ||||
|       <Primary>1</Primary> | ||||
|       <UnderlyingIndexName>sqlite_autoindex_options_1</UnderlyingIndexName> | ||||
|     </key> | ||||
|     <column id="119" parent="15" name="branchId"> | ||||
|     <column id="120" parent="15" name="branchId"> | ||||
|       <Position>1</Position> | ||||
|       <DataType>TEXT|0s</DataType> | ||||
|       <NotNull>1</NotNull> | ||||
|     </column> | ||||
|     <column id="120" parent="15" name="notePath"> | ||||
|     <column id="121" parent="15" name="notePath"> | ||||
|       <Position>2</Position> | ||||
|       <DataType>TEXT|0s</DataType> | ||||
|       <NotNull>1</NotNull> | ||||
|     </column> | ||||
|     <column id="121" parent="15" name="dateCreated"> | ||||
|     <column id="122" parent="15" name="hash"> | ||||
|       <Position>3</Position> | ||||
|       <DataType>TEXT|0s</DataType> | ||||
|       <NotNull>1</NotNull> | ||||
|     </column> | ||||
|     <column id="122" parent="15" name="isDeleted"> | ||||
|       <Position>4</Position> | ||||
|       <DataType>INT|0s</DataType> | ||||
|     </column> | ||||
|     <column id="123" parent="15" name="hash"> | ||||
|       <Position>5</Position> | ||||
|       <DataType>TEXT|0s</DataType> | ||||
|       <NotNull>1</NotNull> | ||||
|       <DefaultExpression>""</DefaultExpression> | ||||
|     </column> | ||||
|     <index id="124" parent="15" name="sqlite_autoindex_recent_notes_1"> | ||||
|     <column id="123" parent="15" name="dateCreated"> | ||||
|       <Position>4</Position> | ||||
|       <DataType>TEXT|0s</DataType> | ||||
|       <NotNull>1</NotNull> | ||||
|     </column> | ||||
|     <column id="124" parent="15" name="isDeleted"> | ||||
|       <Position>5</Position> | ||||
|       <DataType>INT|0s</DataType> | ||||
|     </column> | ||||
|     <index id="125" parent="15" name="sqlite_autoindex_recent_notes_1"> | ||||
|       <NameSurrogate>1</NameSurrogate> | ||||
|       <ColNames>branchId</ColNames> | ||||
|       <ColumnCollations></ColumnCollations> | ||||
|       <Unique>1</Unique> | ||||
|     </index> | ||||
|     <key id="125" parent="15"> | ||||
|     <key id="126" parent="15"> | ||||
|       <ColNames>branchId</ColNames> | ||||
|       <Primary>1</Primary> | ||||
|       <UnderlyingIndexName>sqlite_autoindex_recent_notes_1</UnderlyingIndexName> | ||||
|     </key> | ||||
|     <column id="126" parent="16" name="sourceId"> | ||||
|     <column id="127" parent="16" name="sourceId"> | ||||
|       <Position>1</Position> | ||||
|       <DataType>TEXT|0s</DataType> | ||||
|       <NotNull>1</NotNull> | ||||
|     </column> | ||||
|     <column id="127" parent="16" name="dateCreated"> | ||||
|     <column id="128" parent="16" name="dateCreated"> | ||||
|       <Position>2</Position> | ||||
|       <DataType>TEXT|0s</DataType> | ||||
|       <NotNull>1</NotNull> | ||||
|     </column> | ||||
|     <index id="128" parent="16" name="sqlite_autoindex_source_ids_1"> | ||||
|     <index id="129" parent="16" name="sqlite_autoindex_source_ids_1"> | ||||
|       <NameSurrogate>1</NameSurrogate> | ||||
|       <ColNames>sourceId</ColNames> | ||||
|       <ColumnCollations></ColumnCollations> | ||||
|       <Unique>1</Unique> | ||||
|     </index> | ||||
|     <key id="129" parent="16"> | ||||
|     <key id="130" parent="16"> | ||||
|       <ColNames>sourceId</ColNames> | ||||
|       <Primary>1</Primary> | ||||
|       <UnderlyingIndexName>sqlite_autoindex_source_ids_1</UnderlyingIndexName> | ||||
|     </key> | ||||
|     <column id="130" parent="17" name="type"> | ||||
|     <column id="131" parent="17" name="type"> | ||||
|       <Position>1</Position> | ||||
|       <DataType>text|0s</DataType> | ||||
|     </column> | ||||
|     <column id="131" parent="17" name="name"> | ||||
|     <column id="132" parent="17" name="name"> | ||||
|       <Position>2</Position> | ||||
|       <DataType>text|0s</DataType> | ||||
|     </column> | ||||
|     <column id="132" parent="17" name="tbl_name"> | ||||
|     <column id="133" parent="17" name="tbl_name"> | ||||
|       <Position>3</Position> | ||||
|       <DataType>text|0s</DataType> | ||||
|     </column> | ||||
|     <column id="133" parent="17" name="rootpage"> | ||||
|     <column id="134" parent="17" name="rootpage"> | ||||
|       <Position>4</Position> | ||||
|       <DataType>integer|0s</DataType> | ||||
|     </column> | ||||
|     <column id="134" parent="17" name="sql"> | ||||
|     <column id="135" parent="17" name="sql"> | ||||
|       <Position>5</Position> | ||||
|       <DataType>text|0s</DataType> | ||||
|     </column> | ||||
|     <column id="135" parent="18" name="name"> | ||||
|     <column id="136" parent="18" name="name"> | ||||
|       <Position>1</Position> | ||||
|     </column> | ||||
|     <column id="136" parent="18" name="seq"> | ||||
|     <column id="137" parent="18" name="seq"> | ||||
|       <Position>2</Position> | ||||
|     </column> | ||||
|     <column id="137" parent="19" name="id"> | ||||
|     <column id="138" parent="19" name="id"> | ||||
|       <Position>1</Position> | ||||
|       <DataType>INTEGER|0s</DataType> | ||||
|       <NotNull>1</NotNull> | ||||
|       <SequenceIdentity>1</SequenceIdentity> | ||||
|     </column> | ||||
|     <column id="138" parent="19" name="entityName"> | ||||
|     <column id="139" parent="19" name="entityName"> | ||||
|       <Position>2</Position> | ||||
|       <DataType>TEXT|0s</DataType> | ||||
|       <NotNull>1</NotNull> | ||||
|     </column> | ||||
|     <column id="139" parent="19" name="entityId"> | ||||
|     <column id="140" parent="19" name="entityId"> | ||||
|       <Position>3</Position> | ||||
|       <DataType>TEXT|0s</DataType> | ||||
|       <NotNull>1</NotNull> | ||||
|     </column> | ||||
|     <column id="140" parent="19" name="sourceId"> | ||||
|     <column id="141" parent="19" name="sourceId"> | ||||
|       <Position>4</Position> | ||||
|       <DataType>TEXT|0s</DataType> | ||||
|       <NotNull>1</NotNull> | ||||
|     </column> | ||||
|     <column id="141" parent="19" name="syncDate"> | ||||
|     <column id="142" parent="19" name="syncDate"> | ||||
|       <Position>5</Position> | ||||
|       <DataType>TEXT|0s</DataType> | ||||
|       <NotNull>1</NotNull> | ||||
|     </column> | ||||
|     <index id="142" parent="19" name="IDX_sync_entityName_entityId"> | ||||
|     <index id="143" parent="19" name="IDX_sync_entityName_entityId"> | ||||
|       <ColNames>entityName | ||||
| entityId</ColNames> | ||||
|       <ColumnCollations></ColumnCollations> | ||||
|       <Unique>1</Unique> | ||||
|     </index> | ||||
|     <index id="143" parent="19" name="IDX_sync_syncDate"> | ||||
|     <index id="144" parent="19" name="IDX_sync_syncDate"> | ||||
|       <ColNames>syncDate</ColNames> | ||||
|       <ColumnCollations></ColumnCollations> | ||||
|     </index> | ||||
|     <key id="144" parent="19"> | ||||
|     <key id="145" parent="19"> | ||||
|       <ColNames>id</ColNames> | ||||
|       <Primary>1</Primary> | ||||
|     </key> | ||||
|   | ||||
							
								
								
									
										6
									
								
								.idea/sqldialects.xml
									
									
									
										generated
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,6 @@ | ||||
| <?xml version="1.0" encoding="UTF-8"?> | ||||
| <project version="4"> | ||||
|   <component name="SqlDialectMappings"> | ||||
|     <file url="PROJECT" dialect="SQLite" /> | ||||
|   </component> | ||||
| </project> | ||||
							
								
								
									
										21
									
								
								Dockerfile
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,21 @@ | ||||
| FROM node:8.11.2 | ||||
|  | ||||
| RUN apt-get update && apt-get install -y nasm | ||||
|  | ||||
| # Create app directory | ||||
| WORKDIR /usr/src/app | ||||
|  | ||||
| # Install app dependencies | ||||
| # A wildcard is used to ensure both package.json AND package-lock.json are copied | ||||
| # where available (npm@5+) | ||||
| COPY package*.json ./ | ||||
|  | ||||
| RUN npm install --production | ||||
| # If you are building your code for production | ||||
| # RUN npm install --only=production | ||||
|  | ||||
| # Bundle app source | ||||
| COPY . . | ||||
|  | ||||
| EXPOSE 8080 | ||||
| CMD [ "node", "src/www" ] | ||||
							
								
								
									
										8
									
								
								bin/build-docker.sh
									
									
									
									
									
										Executable file
									
								
							
							
						
						| @@ -0,0 +1,8 @@ | ||||
| #!/usr/bin/env bash | ||||
|  | ||||
| if [[ $# -eq 0 ]] ; then | ||||
|     echo "Missing argument of new version" | ||||
|     exit 1 | ||||
| fi | ||||
|  | ||||
| sudo docker build -t zadam/trilium:latest -t zadam/trilium:$1 . | ||||
							
								
								
									
										23
									
								
								bin/build-pkg.sh
									
									
									
									
									
										Executable file
									
								
							
							
						
						| @@ -0,0 +1,23 @@ | ||||
| #!/usr/bin/env bash | ||||
|  | ||||
| if [[ $# -eq 0 ]] ; then | ||||
|     echo "Missing argument of new version" | ||||
|     exit 1 | ||||
| fi | ||||
|  | ||||
| VERSION=$1 | ||||
|  | ||||
| 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-${VERSION}-server.7z trilium-linux-x64-server | ||||
							
								
								
									
										9
									
								
								bin/push-docker-image.sh
									
									
									
									
									
										Executable file
									
								
							
							
						
						| @@ -0,0 +1,9 @@ | ||||
| #!/usr/bin/env bash | ||||
|  | ||||
| if [[ $# -eq 0 ]] ; then | ||||
|     echo "Missing argument of new version" | ||||
|     exit 1 | ||||
| fi | ||||
|  | ||||
| sudo docker push zadam/trilium:latest | ||||
| sudo docker push zadam/trilium:$1 | ||||
| @@ -47,6 +47,7 @@ bin/package.sh | ||||
| LINUX_X64_BUILD=trilium-linux-x64-$VERSION.7z | ||||
| LINUX_IA32_BUILD=trilium-linux-ia32-$VERSION.7z | ||||
| WINDOWS_X64_BUILD=trilium-windows-x64-$VERSION.7z | ||||
| SERVER_BUILD=trilium-linux-x64-server-$VERSION.7z | ||||
|  | ||||
| echo "Creating release in GitHub" | ||||
|  | ||||
| @@ -75,4 +76,21 @@ github-release upload \ | ||||
|     --name "$WINDOWS_X64_BUILD" \ | ||||
|     --file "dist/$WINDOWS_X64_BUILD" | ||||
|  | ||||
| echo "Packaging server version" | ||||
|  | ||||
| bin/build-pkg.sh $VERSION | ||||
|  | ||||
| github-release upload \ | ||||
|     --tag $TAG \ | ||||
|     --name "$SERVER_BUILD" \ | ||||
|     --file "dist/$SERVER_BUILD" | ||||
|  | ||||
| echo "Building docker image" | ||||
|  | ||||
| bin/build-docker.sh $VERSION | ||||
|  | ||||
| echo "Pushing docker image to dockerhub" | ||||
|  | ||||
| bin/push-docker-image.sh $VERSION | ||||
|  | ||||
| echo "Release finished!" | ||||
| @@ -3,15 +3,10 @@ | ||||
| instanceName= | ||||
|  | ||||
| [Network] | ||||
| # port setting is relevant only for web deployments, desktop builds run on random free port | ||||
| port=8080 | ||||
| # true for TLS/SSL/HTTPS (secure), false for HTTP (unsecure). | ||||
| https=false | ||||
| # path to certificate (run "bash generate-cert.sh" to generate self-signed certificate). Relevant only if https=true | ||||
| # path to certificate (run "bash bin/generate-cert.sh" to generate self-signed certificate). Relevant only if https=true | ||||
| certPath= | ||||
| keyPath= | ||||
|  | ||||
| [Sync] | ||||
| syncServerHost= | ||||
| syncServerTimeout=10000 | ||||
| syncProxy= | ||||
| syncServerCertificate= | ||||
							
								
								
									
										2
									
								
								db/migrations/0097__add_zoomFactor.sql
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,2 @@ | ||||
| INSERT INTO options (optionId, name, value, dateCreated, dateModified, isSynced) | ||||
| VALUES ('zoomFactor_key', 'zoomFactor', '1.0', '2018-06-01T03:35:55.041Z', '2018-06-01T03:35:55.041Z', 0); | ||||
							
								
								
									
										1
									
								
								db/migrations/0098__rename_hideInAutocomplete.sql
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1 @@ | ||||
| UPDATE labels SET name = 'archived' WHERE name = 'hideInAutocomplete' | ||||
							
								
								
									
										2
									
								
								db/migrations/0099__add_theme_option.sql
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,2 @@ | ||||
| INSERT INTO options (optionId, name, value, dateCreated, dateModified, isSynced) | ||||
| VALUES ('theme_key', 'theme', 'white', '2018-06-01T03:35:55.041Z', '2018-06-01T03:35:55.041Z', 0); | ||||
							
								
								
									
										15
									
								
								db/migrations/0100__remove_optionId.sql
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,15 @@ | ||||
| create table options_mig | ||||
| ( | ||||
|   name TEXT not null PRIMARY KEY, | ||||
|   value TEXT, | ||||
|   dateModified INT, | ||||
|   isSynced INTEGER default 0 not null, | ||||
|   hash TEXT default "" not null, | ||||
|   dateCreated TEXT default '1970-01-01T00:00:00.000Z' not null | ||||
| ); | ||||
|  | ||||
| INSERT INTO options_mig (name, value, dateModified, isSynced, hash, dateCreated) | ||||
| SELECT name, value, dateModified, isSynced, hash, dateCreated FROM options; | ||||
|  | ||||
| DROP TABLE options; | ||||
| ALTER TABLE options_mig RENAME TO options; | ||||
							
								
								
									
										8
									
								
								db/migrations/0101__add_sync_options.sql
									
									
									
									
									
										Normal 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); | ||||
							
								
								
									
										2
									
								
								db/migrations/0102__fix_sync_entityIds.sql
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,2 @@ | ||||
| DELETE FROM sync WHERE entityName = 'note_tree'; | ||||
| DELETE FROM sync WHERE entityName = 'attributes'; | ||||
							
								
								
									
										2
									
								
								db/migrations/0103__add_initialized_option.sql
									
									
									
									
									
										Normal 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); | ||||
							
								
								
									
										4
									
								
								db/migrations/0104__fill_sync_rows_for_options.js
									
									
									
									
									
										Normal 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(); | ||||
| @@ -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; | ||||
							
								
								
									
										15
									
								
								db/migrations/0106__add_relations_table.sql
									
									
									
									
									
										Normal 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); | ||||
| @@ -0,0 +1 @@ | ||||
| ALTER TABLE relations ADD isInheritable int DEFAULT 0 NULL; | ||||
							
								
								
									
										9
									
								
								db/migrations/0108__new_backup_options.sql
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,9 @@ | ||||
| UPDATE options SET name = 'lastDailyBackupDate' WHERE name = 'lastBackupDate'; | ||||
|  | ||||
| INSERT INTO options (name, value, dateCreated, dateModified, isSynced) | ||||
| VALUES ('lastWeeklyBackupDate', '2018-07-29T18:31:00.874Z', '2018-07-29T18:31:00.874Z', '2018-07-29T18:31:00.874Z', 0); | ||||
|  | ||||
| INSERT INTO options (name, value, dateCreated, dateModified, isSynced) | ||||
| VALUES ('lastMonthlyBackupDate', '2018-07-29T18:31:00.874Z', '2018-07-29T18:31:00.874Z', '2018-07-29T18:31:00.874Z', 0); | ||||
|  | ||||
| -- these options are not synced so no need to fix sync rows | ||||
							
								
								
									
										27
									
								
								db/migrations/0109__create_attributes.sql
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,27 @@ | ||||
| create table attributes | ||||
| ( | ||||
|   attributeId      TEXT not null primary key, | ||||
|   noteId       TEXT not null, | ||||
|   type         TEXT not null, | ||||
|   name         TEXT not null, | ||||
|   value        TEXT default '' 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_attributes_name_value | ||||
|   on attributes (name, value); | ||||
|  | ||||
| create index IDX_attributes_value | ||||
|   on attributes (value); | ||||
|  | ||||
| create index IDX_attributes_noteId | ||||
|   on attributes (noteId); | ||||
|  | ||||
| INSERT INTO attributes (attributeId, noteId, type, name, value, position, dateCreated, dateModified, isDeleted, hash) | ||||
| SELECT labelId, noteId, 'label', name, value, position, dateCreated, dateModified, isDeleted, hash FROM labels; | ||||
|  | ||||
| INSERT INTO attributes (attributeId, noteId, type, name, value, position, dateCreated, dateModified, isDeleted, hash) | ||||
| SELECT relationId, sourceNoteId, 'relation', name, targetNoteId, position, dateCreated, dateModified, isDeleted, hash FROM relations; | ||||
							
								
								
									
										1
									
								
								db/migrations/0110__add_isInheritable_to_attributes.sql
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1 @@ | ||||
| ALTER TABLE attributes ADD isInheritable int DEFAULT 0 NULL; | ||||
							
								
								
									
										4
									
								
								db/migrations/0111__cleanup_labels_and_relations.sql
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,4 @@ | ||||
| DROP TABLE relations; | ||||
| DROP TABLE labels; | ||||
|  | ||||
| DELETE FROM sync WHERE entityName = 'relations' OR entityName = 'labels'; | ||||
							
								
								
									
										1
									
								
								db/migrations/0112__rename_inheritAttributes.sql
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1 @@ | ||||
| UPDATE attributes SET name = 'template' WHERE name = 'inheritAttributes'; | ||||
| @@ -82,21 +82,6 @@ CREATE INDEX `IDX_branches_noteId_parentNoteId` ON `branches` ( | ||||
|   `noteId`, | ||||
|   `parentNoteId` | ||||
| ); | ||||
| CREATE TABLE labels | ||||
| ( | ||||
|   labelId  TEXT not null primary key, | ||||
|   noteId       TEXT not null, | ||||
|   name         TEXT not null, | ||||
|   value        TEXT default '' 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_labels_name_value | ||||
|   on labels (name, value); | ||||
| CREATE INDEX IDX_labels_noteId | ||||
|   on labels (noteId); | ||||
| CREATE TABLE IF NOT EXISTS "notes" ( | ||||
|   `noteId`	TEXT NOT NULL, | ||||
|   `title`	TEXT NOT NULL DEFAULT "unnamed", | ||||
| @@ -115,9 +100,10 @@ CREATE INDEX IDX_notes_type | ||||
| CREATE TABLE IF NOT EXISTS "recent_notes" ( | ||||
|   `branchId` TEXT NOT NULL PRIMARY KEY, | ||||
|   `notePath` TEXT NOT NULL, | ||||
|   hash TEXT DEFAULT "" NOT NULL, | ||||
|   `dateCreated` TEXT NOT NULL, | ||||
|   isDeleted INT | ||||
| , hash TEXT DEFAULT "" NOT NULL); | ||||
| ); | ||||
| CREATE TABLE IF NOT EXISTS "event_log" ( | ||||
|   `eventId`	TEXT NOT NULL PRIMARY KEY, | ||||
|   `noteId`	TEXT, | ||||
| @@ -126,11 +112,22 @@ CREATE TABLE IF NOT EXISTS "event_log" ( | ||||
| ); | ||||
| CREATE TABLE IF NOT EXISTS "options" | ||||
| ( | ||||
|   optionId TEXT NOT NULL PRIMARY KEY, | ||||
|   name TEXT not null, | ||||
|   name TEXT not null PRIMARY KEY, | ||||
|   value TEXT, | ||||
|   dateModified INT, | ||||
|   isSynced INTEGER default 0 not null, | ||||
|   hash TEXT default "" not null, | ||||
|   dateCreated TEXT default '1970-01-01T00:00:00.000Z' not null | ||||
| ); | ||||
| CREATE TABLE attributes | ||||
| ( | ||||
|   attributeId      TEXT not null primary key, | ||||
|   noteId       TEXT not null, | ||||
|   type         TEXT not null, | ||||
|   name         TEXT not null, | ||||
|   value        TEXT default '' 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, isInheritable int DEFAULT 0 NULL); | ||||
|   | ||||
							
								
								
									
										10
									
								
								electron.js
									
									
									
									
									
								
							
							
						
						| @@ -2,9 +2,9 @@ | ||||
|  | ||||
| const electron = require('electron'); | ||||
| const path = require('path'); | ||||
| const config = require('./src/services/config'); | ||||
| const log = require('./src/services/log'); | ||||
| const url = require("url"); | ||||
| const port = require('./src/services/port'); | ||||
|  | ||||
| const app = electron.app; | ||||
| const globalShortcut = electron.globalShortcut; | ||||
| @@ -23,7 +23,7 @@ function onClosed() { | ||||
|     mainWindow = null; | ||||
| } | ||||
|  | ||||
| function createMainWindow() { | ||||
| async function createMainWindow() { | ||||
|     const win = new electron.BrowserWindow({ | ||||
|         width: 1200, | ||||
|         height: 900, | ||||
| @@ -31,14 +31,12 @@ function createMainWindow() { | ||||
|         icon: path.join(__dirname, 'src/public/images/app-icons/png/256x256.png') | ||||
|     }); | ||||
|  | ||||
|     const port = config['Network']['port'] || '3000'; | ||||
|  | ||||
|     win.setMenu(null); | ||||
|     win.loadURL('http://localhost:' + port); | ||||
|     win.loadURL('http://localhost:' + await port); | ||||
|     win.on('closed', onClosed); | ||||
|  | ||||
|     win.webContents.on('new-window', (e, url) => { | ||||
|         if (url !== mainWindow.webContents.getURL()) { | ||||
|         if (url !== win.webContents.getURL()) { | ||||
|             e.preventDefault(); | ||||
|             require('electron').shell.openExternal(url); | ||||
|         } | ||||
|   | ||||
							
								
								
									
										8422
									
								
								package-lock.json
									
									
									
										generated
									
									
									
								
							
							
						
						
							
								
								
									
										118
									
								
								package.json
									
									
									
									
									
								
							
							
						
						| @@ -1,9 +1,12 @@ | ||||
| { | ||||
|   "name": "trilium", | ||||
|   "description": "Trilium Notes", | ||||
|   "version": "0.14.1", | ||||
|   "version": "0.20.0", | ||||
|   "license": "AGPL-3.0-only", | ||||
|   "main": "electron.js", | ||||
|   "bin": { | ||||
|     "trilium": "./src/www" | ||||
|   }, | ||||
|   "repository": { | ||||
|     "type": "git", | ||||
|     "url": "https://github.com/zadam/trilium.git" | ||||
| @@ -17,60 +20,65 @@ | ||||
|     "start-forge": "electron-forge start", | ||||
|     "package-forge": "electron-forge package", | ||||
|     "make-forge": "electron-forge make", | ||||
|     "publish-forge": "electron-forge publish" | ||||
|     "publish-forge": "electron-forge publish", | ||||
|     "build-backend-docs": "jsdoc -d ./docs/backend_api src/entities/*.js src/services/backend_script_api.js", | ||||
|     "build-frontend-docs": "jsdoc -d ./docs/frontend_api src/public/javascripts/entities/*.js src/public/javascripts/services/frontend_script_api.js", | ||||
|     "build-docs": "npm run build-backend-docs && npm run build-frontend-docs" | ||||
|   }, | ||||
|   "dependencies": { | ||||
|     "async-mutex": "^0.1.3", | ||||
|     "axios": "^0.18", | ||||
|     "body-parser": "^1.18.3", | ||||
|     "cls-hooked": "^4.2.2", | ||||
|     "cookie-parser": "~1.4.3", | ||||
|     "debug": "~3.1.0", | ||||
|     "devtron": "^1.4.0", | ||||
|     "ejs": "~2.6.1", | ||||
|     "electron-debug": "^1.5.0", | ||||
|     "electron-dl": "^1.12.0", | ||||
|     "electron-in-page-search": "^1.3.2", | ||||
|     "express": "~4.16.3", | ||||
|     "express-session": "^1.15.6", | ||||
|     "fs-extra": "^6.0.1", | ||||
|     "helmet": "^3.12.1", | ||||
|     "html": "^1.0.0", | ||||
|     "image-type": "^3.0.0", | ||||
|     "imagemin": "^5.3.1", | ||||
|     "imagemin-giflossy": "^5.1.10", | ||||
|     "imagemin-mozjpeg": "^7.0.0", | ||||
|     "imagemin-pngquant": "^5.1.0", | ||||
|     "ini": "^1.3.5", | ||||
|     "jimp": "^0.2.28", | ||||
|     "moment": "^2.22.1", | ||||
|     "multer": "^1.3.0", | ||||
|     "async-mutex": "0.1.3", | ||||
|     "axios": "0.18", | ||||
|     "body-parser": "1.18.3", | ||||
|     "cls-hooked": "4.2.2", | ||||
|     "cookie-parser": "1.4.3", | ||||
|     "debug": "3.1.0", | ||||
|     "devtron": "1.4.0", | ||||
|     "ejs": "2.6.1", | ||||
|     "electron-debug": "2.0.0", | ||||
|     "electron-dl": "1.12.0", | ||||
|     "electron-in-page-search": "1.3.2", | ||||
|     "express": "4.16.3", | ||||
|     "express-session": "1.15.6", | ||||
|     "fs-extra": "7.0.0", | ||||
|     "get-port": "4.0.0", | ||||
|     "helmet": "3.13.0", | ||||
|     "html": "1.0.0", | ||||
|     "image-type": "3.0.0", | ||||
|     "imagemin": "6.0.0", | ||||
|     "imagemin-giflossy": "5.1.10", | ||||
|     "imagemin-mozjpeg": "7.0.0", | ||||
|     "imagemin-pngquant": "6.0.0", | ||||
|     "ini": "1.3.5", | ||||
|     "jimp": "0.3.5", | ||||
|     "moment": "2.22.2", | ||||
|     "multer": "1.3.1", | ||||
|     "open": "0.0.5", | ||||
|     "rand-token": "^0.4.0", | ||||
|     "rcedit": "^1.1.0", | ||||
|     "request": "^2.87.0", | ||||
|     "request-promise": "^4.2.2", | ||||
|     "rimraf": "^2.6.2", | ||||
|     "sanitize-filename": "^1.6.1", | ||||
|     "scrypt": "^6.0.3", | ||||
|     "serve-favicon": "~2.5.0", | ||||
|     "session-file-store": "^1.2.0", | ||||
|     "simple-node-logger": "^0.93.37", | ||||
|     "sqlite": "^2.9.2", | ||||
|     "tar-stream": "^1.6.1", | ||||
|     "unescape": "^1.0.1", | ||||
|     "ws": "^5.2.0", | ||||
|     "xml2js": "^0.4.19" | ||||
|     "rand-token": "0.4.0", | ||||
|     "rcedit": "1.1.0", | ||||
|     "request": "2.88.0", | ||||
|     "request-promise": "4.2.2", | ||||
|     "rimraf": "2.6.2", | ||||
|     "sanitize-filename": "1.6.1", | ||||
|     "scrypt": "6.0.3", | ||||
|     "serve-favicon": "2.5.0", | ||||
|     "session-file-store": "1.2.0", | ||||
|     "simple-node-logger": "0.93.37", | ||||
|     "sqlite": "3.0.0", | ||||
|     "tar-stream": "1.6.1", | ||||
|     "unescape": "1.0.1", | ||||
|     "ws": "6.0.0", | ||||
|     "xml2js": "0.4.19" | ||||
|   }, | ||||
|   "devDependencies": { | ||||
|     "electron": "^2.0.1", | ||||
|     "electron-compile": "^6.4.2", | ||||
|     "electron-packager": "^12.1.0", | ||||
|     "electron-prebuilt-compile": "2.0.0", | ||||
|     "electron-rebuild": "^1.7.3", | ||||
|     "lorem-ipsum": "^1.0.4", | ||||
|     "tape": "^4.9.0", | ||||
|     "xo": "^0.21.1" | ||||
|     "electron": "2.0.7", | ||||
|     "electron-compile": "6.4.3", | ||||
|     "electron-packager": "12.1.1", | ||||
|     "electron-prebuilt-compile": "2.0.7", | ||||
|     "electron-rebuild": "1.8.2", | ||||
|     "lorem-ipsum": "1.0.5", | ||||
|     "tape": "4.9.1", | ||||
|     "xo": "0.22.0", | ||||
|     "pkg": "4.3.4" | ||||
|   }, | ||||
|   "config": { | ||||
|     "forge": { | ||||
| @@ -109,5 +117,15 @@ | ||||
|       "node", | ||||
|       "browser" | ||||
|     ] | ||||
|   }, | ||||
|   "pkg": { | ||||
|     "assets": [ | ||||
|       "./db/**/*", | ||||
|       "./src/public/**/*", | ||||
|       "./src/views/**/*", | ||||
|       "./node_modules/mozjpeg/vendor/*", | ||||
|       "./node_modules/pngquant-bin/vendor/*", | ||||
|       "./node_modules/giflossy/vendor/*" | ||||
|     ] | ||||
|   } | ||||
| } | ||||
|   | ||||
| @@ -11,6 +11,7 @@ const os = require('os'); | ||||
| const sessionSecret = require('./services/session_secret'); | ||||
| const cls = require('./services/cls'); | ||||
| require('./entities/entity_constructor'); | ||||
| require('./services/handlers'); | ||||
|  | ||||
| const app = express(); | ||||
|  | ||||
| @@ -47,7 +48,7 @@ const sessionParser = session({ | ||||
|     cookie: { | ||||
|         //    path: "/", | ||||
|         httpOnly: true, | ||||
|         maxAge:  1800000 | ||||
|         maxAge:  24 * 60 * 60 * 1000 // in milliseconds | ||||
|     }, | ||||
|     store: new FileStore({ | ||||
|         ttl: 30 * 24 * 3600, | ||||
|   | ||||
| @@ -3,14 +3,22 @@ | ||||
| const Entity = require('./entity'); | ||||
| const dateUtils = require('../services/date_utils'); | ||||
|  | ||||
| /** | ||||
|  * ApiToken is an entity representing token used to authenticate against Trilium API from client applications. Currently used only by Trilium Sender. | ||||
|  * | ||||
|  * @param {string} apiTokenId - primary key | ||||
|  * @param {string} token | ||||
|  * @param {boolean} isDeleted - true if API token is deleted | ||||
|  * @param {string} dateCreated | ||||
|  * | ||||
|  * @extends Entity | ||||
|  */ | ||||
| class ApiToken extends Entity { | ||||
|     static get tableName() { return "api_tokens"; } | ||||
|     static get entityName() { return "api_tokens"; } | ||||
|     static get primaryKeyName() { return "apiTokenId"; } | ||||
|     static get hashedProperties() { return ["apiTokenId", "token", "dateCreated", "isDeleted"]; } | ||||
|  | ||||
|     beforeSaving() { | ||||
|         super.beforeSaving(); | ||||
|  | ||||
|         if (!this.isDeleted) { | ||||
|             this.isDeleted = false; | ||||
|         } | ||||
| @@ -18,6 +26,8 @@ class ApiToken extends Entity { | ||||
|         if (!this.dateCreated) { | ||||
|             this.dateCreated = dateUtils.nowDate(); | ||||
|         } | ||||
|  | ||||
|         super.beforeSaving(); | ||||
|     } | ||||
| } | ||||
|  | ||||
|   | ||||
							
								
								
									
										93
									
								
								src/entities/attribute.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,93 @@ | ||||
| "use strict"; | ||||
|  | ||||
| const Entity = require('./entity'); | ||||
| const repository = require('../services/repository'); | ||||
| const dateUtils = require('../services/date_utils'); | ||||
| const sql = require('../services/sql'); | ||||
|  | ||||
| /** | ||||
|  * Attribute is key value pair owned by a note. | ||||
|  * | ||||
|  * @param {string} attributeId | ||||
|  * @param {string} noteId | ||||
|  * @param {string} type | ||||
|  * @param {string} name | ||||
|  * @param {string} value | ||||
|  * @param {int} position | ||||
|  * @param {boolean} isInheritable | ||||
|  * @param {boolean} isDeleted | ||||
|  * @param {string} dateCreated | ||||
|  * @param {string} dateModified | ||||
|  * | ||||
|  * @extends Entity | ||||
|  */ | ||||
| class Attribute extends Entity { | ||||
|     static get entityName() { return "attributes"; } | ||||
|     static get primaryKeyName() { return "attributeId"; } | ||||
|     static get hashedProperties() { return ["attributeId", "noteId", "type", "name", "value", "isInheritable", "isDeleted", "dateCreated"]; } | ||||
|  | ||||
|     constructor(row) { | ||||
|         super(row); | ||||
|  | ||||
|         this.isInheritable = !!this.isInheritable; | ||||
|  | ||||
|         if (this.isDefinition()) { | ||||
|             try { | ||||
|                 this.value = JSON.parse(this.value); | ||||
|             } | ||||
|             catch (e) { | ||||
|             } | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     async getNote() { | ||||
|         return await repository.getEntity("SELECT * FROM notes WHERE noteId = ?", [this.noteId]); | ||||
|     } | ||||
|  | ||||
|     async getTargetNote() { | ||||
|         if (this.type !== 'relation') { | ||||
|             throw new Error(`Attribute ${this.attributeId} is not relation`); | ||||
|         } | ||||
|  | ||||
|         if (!this.value) { | ||||
|             return null; | ||||
|         } | ||||
|  | ||||
|         return await repository.getEntity("SELECT * FROM notes WHERE noteId = ?", [this.value]); | ||||
|     } | ||||
|  | ||||
|     isDefinition() { | ||||
|         return this.type === 'label-definition' || this.type === 'relation-definition'; | ||||
|     } | ||||
|  | ||||
|     async beforeSaving() { | ||||
|         if (!this.value) { | ||||
|             // null value isn't allowed | ||||
|             this.value = ""; | ||||
|         } | ||||
|  | ||||
|         if (this.position === undefined) { | ||||
|             this.position = 1 + await sql.getValue(`SELECT COALESCE(MAX(position), 0) FROM attributes WHERE noteId = ?`, [this.noteId]); | ||||
|         } | ||||
|  | ||||
|         if (!this.isInheritable) { | ||||
|             this.isInheritable = false; | ||||
|         } | ||||
|  | ||||
|         if (!this.isDeleted) { | ||||
|             this.isDeleted = false; | ||||
|         } | ||||
|  | ||||
|         if (!this.dateCreated) { | ||||
|             this.dateCreated = dateUtils.nowDate(); | ||||
|         } | ||||
|  | ||||
|         super.beforeSaving(); | ||||
|  | ||||
|         if (this.isChanged) { | ||||
|             this.dateModified = dateUtils.nowDate(); | ||||
|         } | ||||
|     } | ||||
| } | ||||
|  | ||||
| module.exports = Attribute; | ||||
| @@ -5,19 +5,33 @@ const dateUtils = require('../services/date_utils'); | ||||
| const repository = require('../services/repository'); | ||||
| const sql = require('../services/sql'); | ||||
|  | ||||
| /** | ||||
|  * Branch represents note's placement in the tree - it's essentially pair of noteId and parentNoteId. | ||||
|  * Each note can have multiple (at least one) branches, meaning it can be placed into multiple places in the tree. | ||||
|  * | ||||
|  * @param {string} branchId - primary key | ||||
|  * @param {string} noteId | ||||
|  * @param {string} parentNoteId | ||||
|  * @param {int} notePosition | ||||
|  * @param {string} prefix | ||||
|  * @param {boolean} isExpanded | ||||
|  * @param {boolean} isDeleted | ||||
|  * @param {string} dateModified | ||||
|  * @param {string} dateCreated | ||||
|  * | ||||
|  * @extends Entity | ||||
|  */ | ||||
| class Branch extends Entity { | ||||
|     static get tableName() { return "branches"; } | ||||
|     static get entityName() { return "branches"; } | ||||
|     static get primaryKeyName() { return "branchId"; } | ||||
|     // notePosition is not part of hash because it would produce a lot of updates in case of reordering | ||||
|     static get hashedProperties() { return ["branchId", "noteId", "parentNoteId", "dateModified", "isDeleted", "prefix"]; } | ||||
|     static get hashedProperties() { return ["branchId", "noteId", "parentNoteId", "isDeleted", "prefix"]; } | ||||
|  | ||||
|     async getNote() { | ||||
|         return await repository.getEntity("SELECT * FROM notes WHERE noteId = ?", [this.noteId]); | ||||
|     } | ||||
|  | ||||
|     async beforeSaving() { | ||||
|         super.beforeSaving(); | ||||
|  | ||||
|         if (this.notePosition === undefined) { | ||||
|             const maxNotePos = await sql.getValue('SELECT MAX(notePosition) FROM branches WHERE parentNoteId = ? AND isDeleted = 0', [this.parentNoteId]); | ||||
|             this.notePosition = maxNotePos === null ? 0 : maxNotePos + 1; | ||||
| @@ -31,7 +45,11 @@ class Branch extends Entity { | ||||
|             this.dateCreated = dateUtils.nowDate(); | ||||
|         } | ||||
|  | ||||
|         this.dateModified = dateUtils.nowDate(); | ||||
|         super.beforeSaving(); | ||||
|  | ||||
|         if (this.isChanged) { | ||||
|             this.dateModified = dateUtils.nowDate(); | ||||
|         } | ||||
|     } | ||||
| } | ||||
|  | ||||
|   | ||||
| @@ -1,13 +1,19 @@ | ||||
| "use strict"; | ||||
|  | ||||
| const utils = require('../services/utils'); | ||||
| const repository = require('../services/repository'); | ||||
|  | ||||
| class Entity { | ||||
|     /** | ||||
|      * @param {object} [row] - database row representing given entity | ||||
|      */ | ||||
|     constructor(row = {}) { | ||||
|         for (const key in row) { | ||||
|             this[key] = row[key]; | ||||
|         } | ||||
|  | ||||
|         if ('isDeleted' in this) { | ||||
|             this.isDeleted = !!this.isDeleted; | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     beforeSaving() { | ||||
| @@ -15,20 +21,25 @@ class Entity { | ||||
|             this[this.constructor.primaryKeyName] = utils.newEntityId(); | ||||
|         } | ||||
|  | ||||
|         const origHash = this.hash; | ||||
|  | ||||
|         this.hash = this.generateHash(); | ||||
|  | ||||
|         this.isChanged = origHash !== this.hash; | ||||
|     } | ||||
|  | ||||
|     generateHash() { | ||||
|         let contentToHash = ""; | ||||
|  | ||||
|         for (const propertyName of this.constructor.hashedProperties) { | ||||
|             contentToHash += "|" + this[propertyName]; | ||||
|         } | ||||
|  | ||||
|         // this IF is to ease the migration from before hashed options, can be later removed | ||||
|         if (this.constructor.tableName !== 'options' || this.isSynced) { | ||||
|             this["hash"] = utils.hash(contentToHash).substr(0, 10); | ||||
|         } | ||||
|         return utils.hash(contentToHash).substr(0, 10); | ||||
|     } | ||||
|  | ||||
|     async save() { | ||||
|         await repository.updateEntity(this); | ||||
|         await require('../services/repository').updateEntity(this); | ||||
|  | ||||
|         return this; | ||||
|     } | ||||
|   | ||||
| @@ -3,17 +3,37 @@ const NoteRevision = require('../entities/note_revision'); | ||||
| const Image = require('../entities/image'); | ||||
| const NoteImage = require('../entities/note_image'); | ||||
| const Branch = require('../entities/branch'); | ||||
| const Label = require('../entities/label'); | ||||
| const Attribute = require('../entities/attribute'); | ||||
| const RecentNote = require('../entities/recent_note'); | ||||
| const ApiToken = require('../entities/api_token'); | ||||
| const Option = require('../entities/option'); | ||||
| const repository = require('../services/repository'); | ||||
|  | ||||
| const ENTITY_NAME_TO_ENTITY = { | ||||
|     "attributes": Attribute, | ||||
|     "images": Image, | ||||
|     "note_images": NoteImage, | ||||
|     "branches": Branch, | ||||
|     "notes": Note, | ||||
|     "note_revisions": NoteRevision, | ||||
|     "recent_notes": RecentNote, | ||||
|     "options": Option, | ||||
|     "api_tokens": ApiToken | ||||
| }; | ||||
|  | ||||
| function getEntityFromEntityName(entityName) { | ||||
|     if (!(entityName in ENTITY_NAME_TO_ENTITY)) { | ||||
|         throw new Error(`Entity for table ${entityName} not found!`); | ||||
|     } | ||||
|  | ||||
|     return ENTITY_NAME_TO_ENTITY[entityName]; | ||||
| } | ||||
|  | ||||
| function createEntityFromRow(row) { | ||||
|     let entity; | ||||
|  | ||||
|     if (row.labelId) { | ||||
|         entity = new Label(row); | ||||
|     if (row.attributeId) { | ||||
|         entity = new Attribute(row); | ||||
|     } | ||||
|     else if (row.noteRevisionId) { | ||||
|         entity = new NoteRevision(row); | ||||
| @@ -46,8 +66,9 @@ function createEntityFromRow(row) { | ||||
|     return entity; | ||||
| } | ||||
|  | ||||
| repository.setEntityConstructor(createEntityFromRow); | ||||
|  | ||||
| module.exports = { | ||||
|     createEntityFromRow | ||||
| }; | ||||
|     createEntityFromRow, | ||||
|     getEntityFromEntityName | ||||
| }; | ||||
|  | ||||
| repository.setEntityConstructor(module.exports); | ||||
|   | ||||
| @@ -3,14 +3,26 @@ | ||||
| const Entity = require('./entity'); | ||||
| const dateUtils = require('../services/date_utils'); | ||||
|  | ||||
| /** | ||||
|  * This class represents image data. | ||||
|  * | ||||
|  * @param {string} imageId | ||||
|  * @param {string} format | ||||
|  * @param {string} checksum | ||||
|  * @param {string} name | ||||
|  * @param {blob} data | ||||
|  * @param {boolean} isDeleted | ||||
|  * @param {string} dateModified | ||||
|  * @param {string} dateCreated | ||||
|  * | ||||
|  * @extends Entity | ||||
|  */ | ||||
| class Image extends Entity { | ||||
|     static get tableName() { return "images"; } | ||||
|     static get entityName() { return "images"; } | ||||
|     static get primaryKeyName() { return "imageId"; } | ||||
|     static get hashedProperties() { return ["imageId", "format", "checksum", "name", "isDeleted", "dateModified", "dateCreated"]; } | ||||
|     static get hashedProperties() { return ["imageId", "format", "checksum", "name", "isDeleted", "dateCreated"]; } | ||||
|  | ||||
|     beforeSaving() { | ||||
|         super.beforeSaving(); | ||||
|  | ||||
|         if (!this.isDeleted) { | ||||
|             this.isDeleted = false; | ||||
|         } | ||||
| @@ -19,7 +31,11 @@ class Image extends Entity { | ||||
|             this.dateCreated = dateUtils.nowDate(); | ||||
|         } | ||||
|  | ||||
|         this.dateModified = dateUtils.nowDate(); | ||||
|         super.beforeSaving(); | ||||
|  | ||||
|         if (this.isChanged) { | ||||
|             this.dateModified = dateUtils.nowDate(); | ||||
|         } | ||||
|     } | ||||
| } | ||||
|  | ||||
|   | ||||
| @@ -1,41 +0,0 @@ | ||||
| "use strict"; | ||||
|  | ||||
| const Entity = require('./entity'); | ||||
| const repository = require('../services/repository'); | ||||
| const dateUtils = require('../services/date_utils'); | ||||
| const sql = require('../services/sql'); | ||||
|  | ||||
| class Label extends Entity { | ||||
|     static get tableName() { return "labels"; } | ||||
|     static get primaryKeyName() { return "labelId"; } | ||||
|     static get hashedProperties() { return ["labelId", "noteId", "name", "value", "dateModified", "dateCreated"]; } | ||||
|  | ||||
|     async getNote() { | ||||
|         return await repository.getEntity("SELECT * FROM notes WHERE noteId = ?", [this.noteId]); | ||||
|     } | ||||
|  | ||||
|     async beforeSaving() { | ||||
|         super.beforeSaving(); | ||||
|  | ||||
|         if (!this.value) { | ||||
|             // null value isn't allowed | ||||
|             this.value = ""; | ||||
|         } | ||||
|  | ||||
|         if (this.position === undefined) { | ||||
|             this.position = 1 + await sql.getValue(`SELECT COALESCE(MAX(position), 0) FROM labels WHERE noteId = ?`, [this.noteId]); | ||||
|         } | ||||
|  | ||||
|         if (!this.isDeleted) { | ||||
|             this.isDeleted = false; | ||||
|         } | ||||
|  | ||||
|         if (!this.dateCreated) { | ||||
|             this.dateCreated = dateUtils.nowDate(); | ||||
|         } | ||||
|  | ||||
|         this.dateModified = dateUtils.nowDate(); | ||||
|     } | ||||
| } | ||||
|  | ||||
| module.exports = Label; | ||||
| @@ -1,18 +1,42 @@ | ||||
| "use strict"; | ||||
|  | ||||
| const Entity = require('./entity'); | ||||
| const Attribute = require('./attribute'); | ||||
| const protectedSessionService = require('../services/protected_session'); | ||||
| const repository = require('../services/repository'); | ||||
| const dateUtils = require('../services/date_utils'); | ||||
|  | ||||
| class Note extends Entity { | ||||
|     static get tableName() { return "notes"; } | ||||
|     static get primaryKeyName() { return "noteId"; } | ||||
|     static get hashedProperties() { return ["noteId", "title", "content", "type", "dateModified", "isProtected", "isDeleted"]; } | ||||
| const LABEL = 'label'; | ||||
| const RELATION = 'relation'; | ||||
|  | ||||
| /** | ||||
|  * This represents a Note which is a central object in the Trilium Notes project. | ||||
|  * | ||||
|  * @property {string} noteId - primary key | ||||
|  * @property {string} type - one of "text", "code", "file" or "render" | ||||
|  * @property {string} mime - MIME type, e.g. "text/html" | ||||
|  * @property {string} title - note title | ||||
|  * @property {string} content - note content - e.g. HTML text for text notes, file payload for files | ||||
|  * @property {boolean} isProtected - true if note is protected | ||||
|  * @property {boolean} isDeleted - true if note is deleted | ||||
|  * @property {string} dateCreated | ||||
|  * @property {string} dateModified | ||||
|  * | ||||
|  * @extends Entity | ||||
|  */ | ||||
| class Note extends Entity { | ||||
|     static get entityName() { return "notes"; } | ||||
|     static get primaryKeyName() { return "noteId"; } | ||||
|     static get hashedProperties() { return ["noteId", "title", "content", "type", "isProtected", "isDeleted"]; } | ||||
|  | ||||
|     /** | ||||
|      * @param row - object containing database row from "notes" table | ||||
|      */ | ||||
|     constructor(row) { | ||||
|         super(row); | ||||
|  | ||||
|         this.isProtected = !!this.isProtected; | ||||
|  | ||||
|         // check if there's noteId, otherwise this is a new entity which wasn't encrypted yet | ||||
|         if (this.isProtected && this.noteId) { | ||||
|             protectedSessionService.decryptNote(this); | ||||
| @@ -30,19 +54,28 @@ class Note extends Entity { | ||||
|         catch(e) {} | ||||
|     } | ||||
|  | ||||
|     /** @returns {boolean} true if this note is the root of the note tree. Root note has "root" noteId */ | ||||
|     isRoot() { | ||||
|         return this.noteId === 'root'; | ||||
|     } | ||||
|  | ||||
|     /** @returns {boolean} true if this note is of application/json content type */ | ||||
|     isJson() { | ||||
|         return this.mime === "application/json"; | ||||
|     } | ||||
|  | ||||
|     /** @returns {boolean} true if this note is JavaScript (code or attachment) */ | ||||
|     isJavaScript() { | ||||
|         return (this.type === "code" || this.type === "file") | ||||
|             && (this.mime.startsWith("application/javascript") || this.mime === "application/x-javascript"); | ||||
|     } | ||||
|  | ||||
|     /** @returns {boolean} true if this note is HTML */ | ||||
|     isHtml() { | ||||
|         return (this.type === "code" || this.type === "file" || this.type === "render") && this.mime === "text/html"; | ||||
|     } | ||||
|  | ||||
|     /** @returns {string} JS script environment - either "frontend" or "backend" */ | ||||
|     getScriptEnv() { | ||||
|         if (this.isHtml() || (this.isJavaScript() && this.mime.endsWith('env=frontend'))) { | ||||
|             return "frontend"; | ||||
| @@ -59,55 +92,385 @@ class Note extends Entity { | ||||
|         return null; | ||||
|     } | ||||
|  | ||||
|     async getLabels() { | ||||
|         return await repository.getEntities("SELECT * FROM labels WHERE noteId = ? AND isDeleted = 0", [this.noteId]); | ||||
|     /** | ||||
|      * @returns {Promise<Attribute[]>} attributes belonging to this specific note (excludes inherited attributes) | ||||
|      */ | ||||
|     async getOwnedAttributes() { | ||||
|         return await repository.getEntities(`SELECT * FROM attributes WHERE isDeleted = 0 AND noteId = ?`, [this.noteId]); | ||||
|     } | ||||
|  | ||||
|     // WARNING: this doesn't take into account the possibility to have multi-valued labels! | ||||
|     async getLabelMap() { | ||||
|         const map = {}; | ||||
|  | ||||
|         for (const label of await this.getLabels()) { | ||||
|             map[label.name] = label.value; | ||||
|     /** @returns {Promise<Attribute[]>} all note's attributes, including inherited ones */ | ||||
|     async getAttributes() { | ||||
|         if (!this.__attributeCache) { | ||||
|             await this.loadAttributesToCache(); | ||||
|         } | ||||
|  | ||||
|         return map; | ||||
|         return this.__attributeCache; | ||||
|     } | ||||
|  | ||||
|     async hasLabel(name) { | ||||
|         const map = await this.getLabelMap(); | ||||
|  | ||||
|         return map.hasOwnProperty(name); | ||||
|     /** @returns {Promise<Attribute[]>} all note's labels (attributes with type label), including inherited ones */ | ||||
|     async getLabels() { | ||||
|         return (await this.getAttributes()).filter(attr => attr.type === LABEL); | ||||
|     } | ||||
|  | ||||
|     // WARNING: this doesn't take into account the possibility to have multi-valued labels! | ||||
|     async getLabel(name) { | ||||
|         return await repository.getEntity("SELECT * FROM labels WHERE noteId = ? AND name = ?", [this.noteId, name]); | ||||
|     /** @returns {Promise<Attribute[]>} all note's relations (attributes with type relation), including inherited ones */ | ||||
|     async getRelations() { | ||||
|         return (await this.getAttributes()).filter(attr => attr.type === RELATION); | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Clear note's attributes cache to force fresh reload for next attribute request. | ||||
|      * Cache is note instance scoped. | ||||
|      */ | ||||
|     invalidateAttributeCache() { | ||||
|         this.__attributeCache = null; | ||||
|     } | ||||
|  | ||||
|     /** @returns {Promise<void>} */ | ||||
|     async loadAttributesToCache() { | ||||
|         const attributes = await repository.getEntities(` | ||||
|             WITH RECURSIVE | ||||
|             tree(noteId, level) AS ( | ||||
|                 SELECT ?, 0 | ||||
|                 UNION | ||||
|                 SELECT branches.parentNoteId, tree.level + 1 FROM branches | ||||
|                     JOIN tree ON branches.noteId = tree.noteId | ||||
|                     JOIN notes ON notes.noteId = branches.parentNoteId | ||||
|                 WHERE notes.isDeleted = 0 | ||||
|                   AND branches.isDeleted = 0 | ||||
|             ), | ||||
|             treeWithAttrs(noteId, level) AS ( | ||||
|                 SELECT * FROM tree | ||||
|                 UNION | ||||
|                 SELECT attributes.value, treeWithAttrs.level + 1 FROM attributes | ||||
|                      JOIN treeWithAttrs ON treeWithAttrs.noteId = attributes.noteId | ||||
|                 WHERE attributes.isDeleted = 0 | ||||
|                   AND attributes.type = 'relation' | ||||
|                   AND attributes.name = 'template' | ||||
|                   AND (attributes.noteId = ? OR attributes.isInheritable = 1) | ||||
|                 ) | ||||
|             SELECT attributes.* FROM attributes JOIN treeWithAttrs ON attributes.noteId = treeWithAttrs.noteId | ||||
|             WHERE attributes.isDeleted = 0 AND (attributes.isInheritable = 1 OR attributes.noteId = ?) | ||||
|             ORDER BY level, noteId, position`, [this.noteId, this.noteId, this.noteId]); | ||||
|         // attributes are ordered so that "closest" attributes are first | ||||
|         // we order by noteId so that attributes from same note stay together. Actual noteId ordering doesn't matter. | ||||
|  | ||||
|         const filteredAttributes = attributes.filter((attr, index) => { | ||||
|             if (attr.isDefinition()) { | ||||
|                 const firstDefinitionIndex = attributes.findIndex(el => el.type === attr.type && el.name === attr.name); | ||||
|  | ||||
|                 // keep only if this element is the first definition for this type & name | ||||
|                 return firstDefinitionIndex === index; | ||||
|             } | ||||
|             else { | ||||
|                 const definitionAttr = attributes.find(el => el.type === attr.type + '-definition' && el.name === attr.name); | ||||
|  | ||||
|                 if (!definitionAttr) { | ||||
|                     return true; | ||||
|                 } | ||||
|  | ||||
|                 const definition = definitionAttr.value; | ||||
|  | ||||
|                 if (definition.multiplicityType === 'multivalue') { | ||||
|                     return true; | ||||
|                 } | ||||
|                 else { | ||||
|                     const firstAttrIndex = attributes.findIndex(el => el.type === attr.type && el.name === attr.name); | ||||
|  | ||||
|                     // in case of single-valued attribute we'll keep it only if it's first (closest) | ||||
|                     return firstAttrIndex === index; | ||||
|                 } | ||||
|             } | ||||
|         }); | ||||
|  | ||||
|         for (const attr of filteredAttributes) { | ||||
|             attr.isOwned = attr.noteId === this.noteId; | ||||
|         } | ||||
|  | ||||
|         this.__attributeCache = filteredAttributes; | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * @param {string} type - attribute type (label, relation, etc.) | ||||
|      * @param {string} name - attribute name | ||||
|      * @returns {Promise<boolean>} true if note has an attribute with given type and name (including inherited) | ||||
|      */ | ||||
|     async hasAttribute(type, name) { | ||||
|         return !!await this.getAttribute(type, name); | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * @param {string} type - attribute type (label, relation, etc.) | ||||
|      * @param {string} name - attribute name | ||||
|      * @returns {Promise<Attribute>} attribute of given type and name. If there's more such attributes, first is  returned. Returns null if there's no such attribute belonging to this note. | ||||
|      */ | ||||
|     async getAttribute(type, name) { | ||||
|         const attributes = await this.getAttributes(); | ||||
|  | ||||
|         return attributes.find(attr => attr.type === type && attr.name === name); | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * @param {string} type - attribute type (label, relation, etc.) | ||||
|      * @param {string} name - attribute name | ||||
|      * @returns {Promise<string>} attribute value of given type and name or null if no such attribute exists. | ||||
|      */ | ||||
|     async getAttributeValue(type, name) { | ||||
|         const attr = await this.getAttribute(type, name); | ||||
|  | ||||
|         return attr ? attr.value : null; | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Based on enabled, attribute is either set or removed. | ||||
|      * | ||||
|      * @param {string} type - attribute type ('relation', 'label' etc.) | ||||
|      * @param {boolean} enabled - toggle On or Off | ||||
|      * @param {string} name - attribute name | ||||
|      * @param {string} [value] - attribute value (optional) | ||||
|      * @returns {Promise<void>} | ||||
|      */ | ||||
|     async toggleAttribute(type, enabled, name, value) { | ||||
|         if (enabled) { | ||||
|             await this.setAttribute(type, name, value); | ||||
|         } | ||||
|         else { | ||||
|             await this.removeAttribute(type, name, value); | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Creates given attribute name-value pair if it doesn't exist. | ||||
|      * | ||||
|      * @param {string} type - attribute type (label, relation, etc.) | ||||
|      * @param {string} name - attribute name | ||||
|      * @param {string} [value] - attribute value (optional) | ||||
|      * @returns {Promise<void>} | ||||
|      */ | ||||
|     async setAttribute(type, name, value) { | ||||
|         const attributes = await this.getOwnedAttributes(); | ||||
|         let attr = attributes.find(attr => attr.type === type && (value === undefined || attr.value === value)); | ||||
|  | ||||
|         if (!attr) { | ||||
|             attr = new Attribute({ | ||||
|                 noteId: this.noteId, | ||||
|                 type: type, | ||||
|                 name: name, | ||||
|                 value: value !== undefined ? value : "" | ||||
|             }); | ||||
|  | ||||
|             await attr.save(); | ||||
|  | ||||
|             this.invalidateAttributeCache(); | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Removes given attribute name-value pair if it exists. | ||||
|      * | ||||
|      * @param {string} type - attribute type (label, relation, etc.) | ||||
|      * @param {string} name - attribute name | ||||
|      * @param {string} [value] - attribute value (optional) | ||||
|      * @returns {Promise<void>} | ||||
|      */ | ||||
|     async removeAttribute(type, name, value) { | ||||
|         const attributes = await this.getOwnedAttributes(); | ||||
|  | ||||
|         for (const attribute of attributes) { | ||||
|             if (attribute.type === type && (value === undefined || value === attribute.value)) { | ||||
|                 attribute.isDeleted = true; | ||||
|                 await attribute.save(); | ||||
|  | ||||
|                 this.invalidateAttributeCache(); | ||||
|             } | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * @param {string} name - label name | ||||
|      * @returns {Promise<boolean>} true if label exists (including inherited) | ||||
|      */ | ||||
|     async hasLabel(name) { return await this.hasAttribute(LABEL, name); } | ||||
|  | ||||
|     /** | ||||
|      * @param {string} name - relation name | ||||
|      * @returns {Promise<boolean>} true if relation exists (including inherited) | ||||
|      */ | ||||
|     async hasRelation(name) { return await this.hasAttribute(RELATION, name); } | ||||
|  | ||||
|     /** | ||||
|      * @param {string} name - label name | ||||
|      * @returns {Promise<Attribute>} label if it exists, null otherwise | ||||
|      */ | ||||
|     async getLabel(name) { return await this.getAttribute(LABEL, name); } | ||||
|  | ||||
|     /** | ||||
|      * @param {string} name - relation name | ||||
|      * @returns {Promise<Attribute>} relation if it exists, null otherwise | ||||
|      */ | ||||
|     async getRelation(name) { return await this.getAttribute(RELATION, name); } | ||||
|  | ||||
|     /** | ||||
|      * @param {string} name - label name | ||||
|      * @returns {Promise<string>} label value if label exists, null otherwise | ||||
|      */ | ||||
|     async getLabelValue(name) { return await this.getAttributeValue(LABEL, name); } | ||||
|  | ||||
|     /** | ||||
|      * @param {string} name - relation name | ||||
|      * @returns {Promise<string>} relation value if relation exists, null otherwise | ||||
|      */ | ||||
|     async getRelationValue(name) { return await this.getAttributeValue(RELATION, name); } | ||||
|  | ||||
|     /** | ||||
|      * Based on enabled, label is either set or removed. | ||||
|      * | ||||
|      * @param {boolean} enabled - toggle On or Off | ||||
|      * @param {string} name - label name | ||||
|      * @param {string} [value] - label value (optional) | ||||
|      * @returns {Promise<void>} | ||||
|      */ | ||||
|     async toggleLabel(enabled, name, value) { return await this.toggleAttribute(LABEL, enabled, name, value); } | ||||
|  | ||||
|     /** | ||||
|      * Based on enabled, relation is either set or removed. | ||||
|      * | ||||
|      * @param {boolean} enabled - toggle On or Off | ||||
|      * @param {string} name - relation name | ||||
|      * @param {string} [value] - relation value (noteId) | ||||
|      * @returns {Promise<void>} | ||||
|      */ | ||||
|     async toggleRelation(enabled, name, value) { return await this.toggleAttribute(RELATION, enabled, name, value); } | ||||
|  | ||||
|     /** | ||||
|      * Create label name-value pair if it doesn't exist yet. | ||||
|      * | ||||
|      * @param {string} name - label name | ||||
|      * @param {string} [value] - label value | ||||
|      * @returns {Promise<void>} | ||||
|      */ | ||||
|     async setLabel(name, value) { return await this.setAttribute(LABEL, name, value); } | ||||
|  | ||||
|     /** | ||||
|      * Create relation name-value pair if it doesn't exist yet. | ||||
|      * | ||||
|      * @param {string} name - relation name | ||||
|      * @param {string} [value] - relation value (noteId) | ||||
|      * @returns {Promise<void>} | ||||
|      */ | ||||
|     async setRelation(name, value) { return await this.setAttribute(RELATION, name, value); } | ||||
|  | ||||
|     /** | ||||
|      * Remove label name-value pair, if it exists. | ||||
|      * | ||||
|      * @param {string} name - label name | ||||
|      * @param {string} [value] - label value | ||||
|      * @returns {Promise<void>} | ||||
|      */ | ||||
|     async removeLabel(name, value) { return await this.removeAttribute(LABEL, name, value); } | ||||
|  | ||||
|     /** | ||||
|      * Remove relation name-value pair, if it exists. | ||||
|      * | ||||
|      * @param {string} name - relation name | ||||
|      * @param {string} [value] - relation value (noteId) | ||||
|      * @returns {Promise<void>} | ||||
|      */ | ||||
|     async removeRelation(name, value) { return await this.removeAttribute(RELATION, name, value); } | ||||
|  | ||||
|     /** | ||||
|      * @param {string} name | ||||
|      * @returns {Promise<Note>|null} target note of the relation or null (if target is empty or note was not found) | ||||
|      */ | ||||
|     async getRelationTarget(name) { | ||||
|         const relation = await this.getRelation(name); | ||||
|  | ||||
|         return relation ? await repository.getNote(relation.value) : null; | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Finds notes with given attribute name and value. Only own attributes are considered, not inherited ones | ||||
|      * | ||||
|      * @param {string} type - attribute type (label, relation, etc.) | ||||
|      * @param {string} name - attribute name | ||||
|      * @param {string} [value] - attribute value | ||||
|      * @returns {Promise<Note[]>} | ||||
|      */ | ||||
|     async findNotesWithAttribute(type, name, value) { | ||||
|         const params = [this.noteId, name]; | ||||
|         let valueCondition = ""; | ||||
|  | ||||
|         if (value !== undefined) { | ||||
|             params.push(value); | ||||
|             valueCondition = " AND attributes.value = ?"; | ||||
|         } | ||||
|  | ||||
|         const notes = await repository.getEntities(` | ||||
|             WITH RECURSIVE | ||||
|             tree(noteId) AS ( | ||||
|                 SELECT ? | ||||
|                 UNION | ||||
|                 SELECT branches.noteId FROM branches | ||||
|                     JOIN tree ON branches.parentNoteId = tree.noteId | ||||
|                     JOIN notes ON notes.noteId = branches.noteId | ||||
|                 WHERE notes.isDeleted = 0 | ||||
|                   AND branches.isDeleted = 0 | ||||
|             ) | ||||
|             SELECT notes.* FROM notes  | ||||
|             JOIN tree ON tree.noteId = notes.noteId | ||||
|             JOIN attributes ON attributes.noteId = notes.noteId | ||||
|             WHERE attributes.isDeleted = 0  | ||||
|               AND attributes.name = ? | ||||
|               ${valueCondition}  | ||||
|             ORDER BY noteId, position`, params); | ||||
|  | ||||
|         return notes; | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Finds notes with given label name and value. Only own labels are considered, not inherited ones | ||||
|      * | ||||
|      * @param {string} name - label name | ||||
|      * @param {string} [value] - label value | ||||
|      * @returns {Promise<Note[]>} | ||||
|      */ | ||||
|     async findNotesWithLabel(name, value) { return await this.findNotesWithAttribute(LABEL, name, value); } | ||||
|  | ||||
|     /** | ||||
|      * Finds notes with given relation name and value. Only own relations are considered, not inherited ones | ||||
|      * | ||||
|      * @param {string} name - relation name | ||||
|      * @param {string} [value] - relation value | ||||
|      * @returns {Promise<Note[]>} | ||||
|      */ | ||||
|     async findNotesWithRelation(name, value) { return await this.findNotesWithAttribute(RELATION, name, value); } | ||||
|  | ||||
|     /** | ||||
|      * Returns note revisions of this note. | ||||
|      * | ||||
|      * @returns {Promise<NoteRevision[]>} | ||||
|      */ | ||||
|     async getRevisions() { | ||||
|         return await repository.getEntities("SELECT * FROM note_revisions WHERE noteId = ?", [this.noteId]); | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * @returns {Promise<NoteImage[]>} | ||||
|      */ | ||||
|     async getNoteImages() { | ||||
|         return await repository.getEntities("SELECT * FROM note_images WHERE noteId = ? AND isDeleted = 0", [this.noteId]); | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * @returns {Promise<Branch[]>} | ||||
|      */ | ||||
|     async getBranches() { | ||||
|         return await repository.getEntities("SELECT * FROM branches WHERE isDeleted = 0 AND noteId = ?", [this.noteId]); | ||||
|     } | ||||
|  | ||||
|     async getChildNote(name) { | ||||
|         return await repository.getEntity(` | ||||
|           SELECT notes.*  | ||||
|           FROM branches  | ||||
|             JOIN notes USING(noteId)  | ||||
|           WHERE notes.isDeleted = 0 | ||||
|                 AND branches.isDeleted = 0 | ||||
|                 AND branches.parentNoteId = ? | ||||
|                 AND notes.title = ?`, [this.noteId, name]); | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * @returns {Promise<Note[]>} child notes of this note | ||||
|      */ | ||||
|     async getChildNotes() { | ||||
|         return await repository.getEntities(` | ||||
|           SELECT notes.*  | ||||
| @@ -119,6 +482,9 @@ class Note extends Entity { | ||||
|           ORDER BY branches.notePosition`, [this.noteId]); | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * @returns {Promise<Branch[]>} child branches of this note | ||||
|      */ | ||||
|     async getChildBranches() { | ||||
|         return await repository.getEntities(` | ||||
|           SELECT branches.*  | ||||
| @@ -128,6 +494,9 @@ class Note extends Entity { | ||||
|           ORDER BY branches.notePosition`, [this.noteId]); | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * @returns {Promise<Note[]>} parent notes of this note (note can have multiple parents because of cloning) | ||||
|      */ | ||||
|     async getParentNotes() { | ||||
|         return await repository.getEntities(` | ||||
|           SELECT parent_notes.*  | ||||
| @@ -140,8 +509,6 @@ class Note extends Entity { | ||||
|     } | ||||
|  | ||||
|     beforeSaving() { | ||||
|         super.beforeSaving(); | ||||
|  | ||||
|         if (this.isJson() && this.jsonContent) { | ||||
|             this.content = JSON.stringify(this.jsonContent, null, '\t'); | ||||
|         } | ||||
| @@ -158,7 +525,11 @@ class Note extends Entity { | ||||
|             this.dateCreated = dateUtils.nowDate(); | ||||
|         } | ||||
|  | ||||
|         this.dateModified = dateUtils.nowDate(); | ||||
|         super.beforeSaving(); | ||||
|  | ||||
|         if (this.isChanged) { | ||||
|             this.dateModified = dateUtils.nowDate(); | ||||
|         } | ||||
|     } | ||||
| } | ||||
|  | ||||
|   | ||||
| @@ -4,10 +4,22 @@ const Entity = require('./entity'); | ||||
| const repository = require('../services/repository'); | ||||
| const dateUtils = require('../services/date_utils'); | ||||
|  | ||||
| /** | ||||
|  * This class represents image's placement in the note(s). One image may be placed into several notes. | ||||
|  * | ||||
|  * @param {string} noteImageId | ||||
|  * @param {string} noteId | ||||
|  * @param {string} imageId | ||||
|  * @param {boolean} isDeleted | ||||
|  * @param {string} dateModified | ||||
|  * @param {string} dateCreated | ||||
|  * | ||||
|  * @extends Entity | ||||
|  */ | ||||
| class NoteImage extends Entity { | ||||
|     static get tableName() { return "note_images"; } | ||||
|     static get entityName() { return "note_images"; } | ||||
|     static get primaryKeyName() { return "noteImageId"; } | ||||
|     static get hashedProperties() { return ["noteImageId", "noteId", "imageId", "isDeleted", "dateModified", "dateCreated"]; } | ||||
|     static get hashedProperties() { return ["noteImageId", "noteId", "imageId", "isDeleted", "dateCreated"]; } | ||||
|  | ||||
|     async getNote() { | ||||
|         return await repository.getEntity("SELECT * FROM notes WHERE noteId = ?", [this.noteId]); | ||||
| @@ -18,8 +30,6 @@ class NoteImage extends Entity { | ||||
|     } | ||||
|  | ||||
|     beforeSaving() { | ||||
|         super.beforeSaving(); | ||||
|  | ||||
|         if (!this.isDeleted) { | ||||
|             this.isDeleted = false; | ||||
|         } | ||||
| @@ -28,7 +38,11 @@ class NoteImage extends Entity { | ||||
|             this.dateCreated = dateUtils.nowDate(); | ||||
|         } | ||||
|  | ||||
|         this.dateModified = dateUtils.nowDate(); | ||||
|         super.beforeSaving(); | ||||
|  | ||||
|         if (this.isChanged) { | ||||
|             this.dateModified = dateUtils.nowDate(); | ||||
|         } | ||||
|     } | ||||
| } | ||||
|  | ||||
|   | ||||
| @@ -4,14 +4,31 @@ const Entity = require('./entity'); | ||||
| const protectedSessionService = require('../services/protected_session'); | ||||
| const repository = require('../services/repository'); | ||||
|  | ||||
| /** | ||||
|  * NoteRevision represents snapshot of note's title and content at some point in the past. It's used for seamless note versioning. | ||||
|  * | ||||
|  * @param {string} noteRevisionId | ||||
|  * @param {string} noteId | ||||
|  * @param {string} type | ||||
|  * @param {string} mime | ||||
|  * @param {string} title | ||||
|  * @param {string} content | ||||
|  * @param {string} isProtected | ||||
|  * @param {string} dateModifiedFrom | ||||
|  * @param {string} dateModifiedTo | ||||
|  * | ||||
|  * @extends Entity | ||||
|  */ | ||||
| class NoteRevision extends Entity { | ||||
|     static get tableName() { return "note_revisions"; } | ||||
|     static get entityName() { return "note_revisions"; } | ||||
|     static get primaryKeyName() { return "noteRevisionId"; } | ||||
|     static get hashedProperties() { return ["noteRevisionId", "noteId", "title", "content", "dateModifiedFrom", "dateModifiedTo"]; } | ||||
|     static get hashedProperties() { return ["noteRevisionId", "noteId", "title", "content", "isProtected", "dateModifiedFrom", "dateModifiedTo"]; } | ||||
|  | ||||
|     constructor(row) { | ||||
|         super(row); | ||||
|  | ||||
|         this.isProtected = !!this.isProtected; | ||||
|  | ||||
|         if (this.isProtected) { | ||||
|             protectedSessionService.decryptNoteRevision(this); | ||||
|         } | ||||
| @@ -22,11 +39,11 @@ class NoteRevision extends Entity { | ||||
|     } | ||||
|  | ||||
|     beforeSaving() { | ||||
|         super.beforeSaving(); | ||||
|  | ||||
|         if (this.isProtected) { | ||||
|             protectedSessionService.encryptNoteRevision(this); | ||||
|         } | ||||
|  | ||||
|         super.beforeSaving(); | ||||
|     } | ||||
| } | ||||
|  | ||||
|   | ||||
| @@ -3,15 +3,34 @@ | ||||
| const Entity = require('./entity'); | ||||
| const dateUtils = require('../services/date_utils'); | ||||
|  | ||||
| /** | ||||
|  * Option represents name-value pair, either directly configurable by the user or some system property. | ||||
|  * | ||||
|  * @param {string} name | ||||
|  * @param {string} value | ||||
|  * @param {boolean} isSynced | ||||
|  * @param {string} dateModified | ||||
|  * @param {string} dateCreated | ||||
|  * | ||||
|  * @extends Entity | ||||
|  */ | ||||
| class Option extends Entity { | ||||
|     static get tableName() { return "options"; } | ||||
|     static get primaryKeyName() { return "optionId"; } | ||||
|     static get hashedProperties() { return ["optionId", "name", "value"]; } | ||||
|     static get entityName() { return "options"; } | ||||
|     static get primaryKeyName() { return "name"; } | ||||
|     static get hashedProperties() { return ["name", "value"]; } | ||||
|  | ||||
|     constructor(row) { | ||||
|         super(row); | ||||
|  | ||||
|         this.isSynced = !!this.isSynced; | ||||
|     } | ||||
|  | ||||
|     beforeSaving() { | ||||
|         super.beforeSaving(); | ||||
|  | ||||
|         this.dateModified = dateUtils.nowDate(); | ||||
|         if (this.isChanged) { | ||||
|             this.dateModified = dateUtils.nowDate(); | ||||
|         } | ||||
|     } | ||||
| } | ||||
|  | ||||
|   | ||||
| @@ -3,14 +3,22 @@ | ||||
| const Entity = require('./entity'); | ||||
| const dateUtils = require('../services/date_utils'); | ||||
|  | ||||
| /** | ||||
|  * RecentNote represents recently visited note. | ||||
|  * | ||||
|  * @param {string} branchId | ||||
|  * @param {string} notePath | ||||
|  * @param {boolean} isDeleted | ||||
|  * @param {string} dateModified | ||||
|  * | ||||
|  * @extends Entity | ||||
|  */ | ||||
| class RecentNote extends Entity { | ||||
|     static get tableName() { return "recent_notes"; } | ||||
|     static get entityName() { return "recent_notes"; } | ||||
|     static get primaryKeyName() { return "branchId"; } | ||||
|     static get hashedProperties() { return ["branchId", "notePath", "dateCreated", "isDeleted"]; } | ||||
|  | ||||
|     beforeSaving() { | ||||
|         super.beforeSaving(); | ||||
|  | ||||
|         if (!this.isDeleted) { | ||||
|             this.isDeleted = false; | ||||
|         } | ||||
| @@ -18,6 +26,8 @@ class RecentNote extends Entity { | ||||
|         if (!this.dateCreated) { | ||||
|             this.dateCreated = dateUtils.nowDate(); | ||||
|         } | ||||
|  | ||||
|         super.beforeSaving(); | ||||
|     } | ||||
| } | ||||
|  | ||||
|   | ||||
| Before Width: | Height: | Size: 511 B After Width: | Height: | Size: 511 B | 
							
								
								
									
										
											BIN
										
									
								
								src/public/images/icons/clock-16.png
									
									
									
									
									
										Normal file
									
								
							
							
						
						| After Width: | Height: | Size: 381 B | 
| Before Width: | Height: | Size: 245 B After Width: | Height: | Size: 245 B | 
| Before Width: | Height: | Size: 339 B After Width: | Height: | Size: 339 B | 
| Before Width: | Height: | Size: 463 B After Width: | Height: | Size: 463 B | 
							
								
								
									
										
											BIN
										
									
								
								src/public/images/icons/edit-20.png
									
									
									
									
									
										Normal file
									
								
							
							
						
						| After Width: | Height: | Size: 312 B | 
| Before Width: | Height: | Size: 288 B After Width: | Height: | Size: 288 B | 
| Before Width: | Height: | Size: 284 B After Width: | Height: | Size: 284 B | 
| Before Width: | Height: | Size: 292 B After Width: | Height: | Size: 292 B | 
| Before Width: | Height: | Size: 511 B After Width: | Height: | Size: 511 B | 
| Before Width: | Height: | Size: 155 B After Width: | Height: | Size: 155 B | 
| Before Width: | Height: | Size: 323 B | 
| Before Width: | Height: | Size: 358 B After Width: | Height: | Size: 358 B | 
| Before Width: | Height: | Size: 252 B After Width: | Height: | Size: 252 B | 
							
								
								
									
										
											BIN
										
									
								
								src/public/images/icons/play-20.png
									
									
									
									
									
										Normal file
									
								
							
							
						
						| After Width: | Height: | Size: 288 B | 
							
								
								
									
										
											BIN
										
									
								
								src/public/images/icons/save-20.png
									
									
									
									
									
										Normal file
									
								
							
							
						
						| After Width: | Height: | Size: 388 B | 
							
								
								
									
										
											BIN
										
									
								
								src/public/images/icons/search-20.png
									
									
									
									
									
										Normal file
									
								
							
							
						
						| After Width: | Height: | Size: 431 B | 
| Before Width: | Height: | Size: 419 B After Width: | Height: | Size: 419 B | 
| Before Width: | Height: | Size: 354 B After Width: | Height: | Size: 354 B | 
							
								
								
									
										
											BIN
										
									
								
								src/public/images/icons/shield-20.png
									
									
									
									
									
										Normal file
									
								
							
							
						
						| After Width: | Height: | Size: 388 B | 
							
								
								
									
										
											BIN
										
									
								
								src/public/images/icons/shield-off-20.png
									
									
									
									
									
										Normal file
									
								
							
							
						
						| After Width: | Height: | Size: 462 B | 
| Before Width: | Height: | Size: 240 B After Width: | Height: | Size: 240 B | 
| Before Width: | Height: | Size: 337 B | 
							
								
								
									
										
											BIN
										
									
								
								src/public/images/icons/x-20.png
									
									
									
									
									
										Normal file
									
								
							
							
						
						| After Width: | Height: | Size: 259 B | 
							
								
								
									
										1
									
								
								src/public/images/shield.svg
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1 @@ | ||||
| <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#fafafa" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="feather feather-shield"><path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"></path></svg> | ||||
| After Width: | Height: | Size: 274 B | 
| @@ -2,8 +2,8 @@ import cloningService from '../services/cloning.js'; | ||||
| import linkService from '../services/link.js'; | ||||
| import noteDetailService from '../services/note_detail.js'; | ||||
| import treeUtils from '../services/tree_utils.js'; | ||||
| import server from "../services/server.js"; | ||||
| import noteDetailText from "../services/note_detail_text.js"; | ||||
| import noteAutocompleteService from "../services/note_autocomplete.js"; | ||||
|  | ||||
| const $dialog = $("#add-link-dialog"); | ||||
| const $form = $("#add-link-form"); | ||||
| @@ -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 = $dialog.find(".show-recent-notes-button"); | ||||
|  | ||||
| function setLinkType(linkType) { | ||||
|     $linkTypes.each(function () { | ||||
| @@ -53,16 +54,16 @@ async function showDialog() { | ||||
|         $linkTitle.val(noteTitle); | ||||
|     } | ||||
|  | ||||
|     $autoComplete.autocomplete({ | ||||
|         source: async function(request, response) { | ||||
|             const result = await server.get('autocomplete?query=' + encodeURIComponent(request.term)); | ||||
|     await $autoComplete.autocomplete({ | ||||
|         source: noteAutocompleteService.autocompleteSource, | ||||
|         minLength: 0, | ||||
|         change: async (event, ui) => { | ||||
|             if (!ui.item) { | ||||
|                 return; | ||||
|             } | ||||
|  | ||||
|             const notePath = linkService.getNotePathFromLabel(ui.item.value); | ||||
|  | ||||
|             response(result); | ||||
|         }, | ||||
|         minLength: 2, | ||||
|         change: async () => { | ||||
|             const val = $autoComplete.val(); | ||||
|             const notePath = linkService.getNodePathFromLabel(val); | ||||
|             if (!notePath) { | ||||
|                 return; | ||||
|             } | ||||
| @@ -73,21 +74,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) { | ||||
| @@ -145,8 +155,14 @@ function linkTypeChanged() { | ||||
|     $linkTypeDiv.toggle(!hasSelection()); | ||||
| } | ||||
|  | ||||
| function showRecentNotes() { | ||||
|     $autoComplete.autocomplete("search", ""); | ||||
| } | ||||
|  | ||||
| $linkTypes.change(linkTypeChanged); | ||||
|  | ||||
| $showRecentNotesButton.click(showRecentNotes); | ||||
|  | ||||
| export default { | ||||
|     showDialog | ||||
| }; | ||||
							
								
								
									
										302
									
								
								src/public/javascripts/dialogs/attributes.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,302 @@ | ||||
| import noteDetailService from '../services/note_detail.js'; | ||||
| import server from '../services/server.js'; | ||||
| import infoService from "../services/info.js"; | ||||
| import treeUtils from "../services/tree_utils.js"; | ||||
| import linkService from "../services/link.js"; | ||||
|  | ||||
| const $dialog = $("#attributes-dialog"); | ||||
| const $saveAttributesButton = $("#save-attributes-button"); | ||||
| const $ownedAttributesBody = $('#owned-attributes-table tbody'); | ||||
|  | ||||
| const attributesModel = new AttributesModel(); | ||||
|  | ||||
| function AttributesModel() { | ||||
|     const self = this; | ||||
|  | ||||
|     this.ownedAttributes = ko.observableArray(); | ||||
|     this.inheritedAttributes = ko.observableArray(); | ||||
|  | ||||
|     this.availableTypes = [ | ||||
|         { text: "Label", value: "label" }, | ||||
|         { text: "Label definition", value: "label-definition" }, | ||||
|         { text: "Relation", value: "relation" }, | ||||
|         { text: "Relation definition", value: "relation-definition" } | ||||
|     ]; | ||||
|  | ||||
|     this.availableLabelTypes = [ | ||||
|         { text: "Text", value: "text" }, | ||||
|         { text: "Number", value: "number" }, | ||||
|         { text: "Boolean", value: "boolean" }, | ||||
|         { text: "Date", value: "date" }, | ||||
|         { text: "URL", value: "url"} | ||||
|     ]; | ||||
|  | ||||
|     this.multiplicityTypes = [ | ||||
|         { text: "Single value", value: "singlevalue" }, | ||||
|         { text: "Multi value", value: "multivalue" } | ||||
|     ]; | ||||
|  | ||||
|     this.typeChanged = function(data, event) { | ||||
|         self.getTargetAttribute(event.target).valueHasMutated(); | ||||
|     }; | ||||
|  | ||||
|     this.labelTypeChanged = function(data, event) { | ||||
|         self.getTargetAttribute(event.target).valueHasMutated(); | ||||
|     }; | ||||
|  | ||||
|     this.updateAttributePositions = function() { | ||||
|         let position = 0; | ||||
|  | ||||
|         // we need to update positions by searching in the DOM, because order of the | ||||
|         // attributes in the viewmodel (self.ownedAttributes()) stays the same | ||||
|         $ownedAttributesBody.find('input[name="position"]').each(function() { | ||||
|             const attribute = self.getTargetAttribute(this); | ||||
|  | ||||
|             attribute().position = position++; | ||||
|         }); | ||||
|     }; | ||||
|  | ||||
|     async function showAttributes(attributes) { | ||||
|         const ownedAttributes = attributes.filter(attr => attr.isOwned); | ||||
|  | ||||
|         for (const attr of ownedAttributes) { | ||||
|             attr.labelValue = attr.type === 'label' ? attr.value : ''; | ||||
|             attr.relationValue = attr.type === 'relation' ? (await treeUtils.getNoteTitle(attr.value) + " (" + attr.value + ")") : ''; | ||||
|             attr.labelDefinition = (attr.type === 'label-definition' && attr.value) ? attr.value : { | ||||
|                 labelType: "text", | ||||
|                 multiplicityType: "singlevalue", | ||||
|                 isPromoted: true | ||||
|             }; | ||||
|  | ||||
|             attr.relationDefinition = (attr.type === 'relation-definition' && attr.value) ? attr.value : { | ||||
|                 multiplicityType: "singlevalue", | ||||
|                 isPromoted: true | ||||
|             }; | ||||
|  | ||||
|             delete attr.value; | ||||
|         } | ||||
|  | ||||
|         self.ownedAttributes(ownedAttributes.map(ko.observable)); | ||||
|  | ||||
|         addLastEmptyRow(); | ||||
|  | ||||
|         const inheritedAttributes = attributes.filter(attr => !attr.isOwned); | ||||
|  | ||||
|         self.inheritedAttributes(inheritedAttributes); | ||||
|     } | ||||
|  | ||||
|     this.loadAttributes = async function() { | ||||
|         const noteId = noteDetailService.getCurrentNoteId(); | ||||
|  | ||||
|         const attributes = await server.get('notes/' + noteId + '/attributes'); | ||||
|  | ||||
|         await showAttributes(attributes); | ||||
|  | ||||
|         // attribute might not be rendered immediatelly so could not focus | ||||
|         setTimeout(() => $(".attribute-type-select:last").focus(), 100); | ||||
|  | ||||
|         $ownedAttributesBody.sortable({ | ||||
|             handle: '.handle', | ||||
|             containment: $ownedAttributesBody, | ||||
|             update: this.updateAttributePositions | ||||
|         }); | ||||
|     }; | ||||
|  | ||||
|     this.deleteAttribute = function(data, event) { | ||||
|         const attribute = self.getTargetAttribute(event.target); | ||||
|         const attributeData = attribute(); | ||||
|  | ||||
|         if (attributeData) { | ||||
|             attributeData.isDeleted = true; | ||||
|  | ||||
|             attribute(attributeData); | ||||
|  | ||||
|             addLastEmptyRow(); | ||||
|         } | ||||
|     }; | ||||
|  | ||||
|     function isValid() { | ||||
|         for (let attributes = self.ownedAttributes(), i = 0; i < attributes.length; i++) { | ||||
|             if (self.isEmptyName(i)) { | ||||
|                 return false; | ||||
|             } | ||||
|         } | ||||
|  | ||||
|         return true; | ||||
|     } | ||||
|  | ||||
|     this.save = async function() { | ||||
|         // we need to defocus from input (in case of enter-triggered save) because value is updated | ||||
|         // on blur event (because of conflict with jQuery UI Autocomplete). Without this, input would | ||||
|         // stay in focus, blur wouldn't be triggered and change wouldn't be updated in the viewmodel. | ||||
|         $saveAttributesButton.focus(); | ||||
|  | ||||
|         if (!isValid()) { | ||||
|             alert("Please fix all validation errors and try saving again."); | ||||
|             return; | ||||
|         } | ||||
|  | ||||
|         self.updateAttributePositions(); | ||||
|  | ||||
|         const noteId = noteDetailService.getCurrentNoteId(); | ||||
|  | ||||
|         const attributesToSave = self.ownedAttributes() | ||||
|             .map(attribute => attribute()) | ||||
|             .filter(attribute => attribute.attributeId !== "" || attribute.name !== ""); | ||||
|  | ||||
|         for (const attr of attributesToSave) { | ||||
|             if (attr.type === 'label') { | ||||
|                 attr.value = attr.labelValue; | ||||
|             } | ||||
|             else if (attr.type === 'relation') { | ||||
|                 attr.value = treeUtils.getNoteIdFromNotePath(linkService.getNotePathFromLabel(attr.relationValue)); | ||||
|             } | ||||
|             else if (attr.type === 'label-definition') { | ||||
|                 attr.value = attr.labelDefinition; | ||||
|             } | ||||
|             else if (attr.type === 'relation-definition') { | ||||
|                 attr.value = attr.relationDefinition; | ||||
|             } | ||||
|  | ||||
|             delete attr.labelValue; | ||||
|             delete attr.relationValue; | ||||
|             delete attr.labelDefinition; | ||||
|             delete attr.relationDefinition; | ||||
|         } | ||||
|  | ||||
|         const attributes = await server.put('notes/' + noteId + '/attributes', attributesToSave); | ||||
|  | ||||
|         await showAttributes(attributes); | ||||
|  | ||||
|         infoService.showMessage("Attributes have been saved."); | ||||
|  | ||||
|         noteDetailService.loadAttributes(); | ||||
|     }; | ||||
|  | ||||
|     function addLastEmptyRow() { | ||||
|         const attributes = self.ownedAttributes().filter(attr => !attr().isDeleted); | ||||
|         const last = attributes.length === 0 ? null : attributes[attributes.length - 1](); | ||||
|  | ||||
|         if (!last || last.name.trim() !== "") { | ||||
|             self.ownedAttributes.push(ko.observable({ | ||||
|                 attributeId: '', | ||||
|                 type: 'label', | ||||
|                 name: '', | ||||
|                 labelValue: '', | ||||
|                 relationValue: '', | ||||
|                 isInheritable: false, | ||||
|                 isDeleted: false, | ||||
|                 position: 0, | ||||
|                 labelDefinition: { | ||||
|                     labelType: "text", | ||||
|                     multiplicityType: "singlevalue", | ||||
|                     isPromoted: true | ||||
|                 }, | ||||
|                 relationDefinition: { | ||||
|                     multiplicityType: "singlevalue", | ||||
|                     isPromoted: true | ||||
|                 } | ||||
|             })); | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     this.attributeChanged = function (data, event) { | ||||
|         addLastEmptyRow(); | ||||
|  | ||||
|         const attribute = self.getTargetAttribute(event.target); | ||||
|  | ||||
|         attribute.valueHasMutated(); | ||||
|     }; | ||||
|  | ||||
|     this.isEmptyName = function(index) { | ||||
|         const cur = self.ownedAttributes()[index](); | ||||
|  | ||||
|         return cur.name.trim() === "" && !cur.isDeleted && (cur.attributeId !== "" || cur.labelValue !== "" || cur.relationValue); | ||||
|     }; | ||||
|  | ||||
|     this.getTargetAttribute = function(target) { | ||||
|         const context = ko.contextFor(target); | ||||
|         const index = context.$index(); | ||||
|  | ||||
|         return self.ownedAttributes()[index]; | ||||
|     } | ||||
| } | ||||
|  | ||||
| async function showDialog() { | ||||
|     glob.activeDialog = $dialog; | ||||
|  | ||||
|     await attributesModel.loadAttributes(); | ||||
|  | ||||
|     $dialog.dialog({ | ||||
|         modal: true, | ||||
|         width: 950, | ||||
|         height: 700 | ||||
|     }); | ||||
| } | ||||
|  | ||||
| ko.applyBindings(attributesModel, $dialog[0]); | ||||
|  | ||||
| $dialog.on('focus', '.attribute-name', function (e) { | ||||
|     if (!$(this).hasClass("ui-autocomplete-input")) { | ||||
|         $(this).autocomplete({ | ||||
|             source: async (request, response) => { | ||||
|                 const attribute = attributesModel.getTargetAttribute(this); | ||||
|                 const type = (attribute().type === 'relation' || attribute().type === 'relation-definition') ? 'relation' : 'label'; | ||||
|                 const names = await server.get('attributes/names/?type=' + type + '&query=' + encodeURIComponent(request.term)); | ||||
|                 const result = names.map(name => { | ||||
|                     return { | ||||
|                         label: name, | ||||
|                         value: name | ||||
|                     } | ||||
|                 }); | ||||
|  | ||||
|                 if (result.length > 0) { | ||||
|                     response(result); | ||||
|                 } | ||||
|                 else { | ||||
|                     response([{ | ||||
|                         label: "No results", | ||||
|                         value: "No results" | ||||
|                     }]); | ||||
|                 } | ||||
|             }, | ||||
|             minLength: 0 | ||||
|         }); | ||||
|     } | ||||
|  | ||||
|     $(this).autocomplete("search", $(this).val()); | ||||
| }); | ||||
|  | ||||
| $dialog.on('focus', '.label-value', async function (e) { | ||||
|     if (!$(this).hasClass("ui-autocomplete-input")) { | ||||
|         const attributeName = $(this).parent().parent().find('.attribute-name').val(); | ||||
|  | ||||
|         if (attributeName.trim() === "") { | ||||
|             return; | ||||
|         } | ||||
|  | ||||
|         const attributeValues = await server.get('attributes/values/' + encodeURIComponent(attributeName)); | ||||
|  | ||||
|         if (attributeValues.length === 0) { | ||||
|             return; | ||||
|         } | ||||
|  | ||||
|         $(this).autocomplete({ | ||||
|             // shouldn't be required and autocomplete should just accept array of strings, but that fails | ||||
|             // because we have overriden filter() function in autocomplete.js | ||||
|             source: attributeValues.map(attribute => { | ||||
|                 return { | ||||
|                     attribute: attribute, | ||||
|                     value: attribute | ||||
|                 } | ||||
|             }), | ||||
|             minLength: 0 | ||||
|         }); | ||||
|     } | ||||
|  | ||||
|     $(this).autocomplete("search", $(this).val()); | ||||
| }); | ||||
|  | ||||
| export default { | ||||
|     showDialog | ||||
| }; | ||||
| @@ -15,7 +15,7 @@ async function showDialog() { | ||||
|  | ||||
|     await $dialog.dialog({ | ||||
|         modal: true, | ||||
|         width: 500 | ||||
|         width: 600 | ||||
|     }); | ||||
|  | ||||
|     const currentNode = treeService.getCurrentNode(); | ||||
|   | ||||
| @@ -1,10 +1,12 @@ | ||||
| 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'; | ||||
| import noteautocompleteService from '../services/note_autocomplete.js'; | ||||
| import linkService from "../services/link.js"; | ||||
|  | ||||
| const $dialog = $("#jump-to-note-dialog"); | ||||
| const $autoComplete = $("#jump-to-note-autocomplete"); | ||||
| const $form = $("#jump-to-note-form"); | ||||
| const $showInFullTextButton = $("#show-in-full-text-button"); | ||||
| const $showRecentNotesButton = $dialog.find(".show-recent-notes-button"); | ||||
|  | ||||
| async function showDialog() { | ||||
|     glob.activeDialog = $dialog; | ||||
| @@ -13,39 +15,54 @@ async function showDialog() { | ||||
|  | ||||
|     $dialog.dialog({ | ||||
|         modal: true, | ||||
|         width: 800 | ||||
|         width: 800, | ||||
|         position: { my: "center top+100", at: "top", of: window } | ||||
|     }); | ||||
|  | ||||
|     await $autoComplete.autocomplete({ | ||||
|         source: async function(request, response) { | ||||
|             const result = await server.get('autocomplete?query=' + encodeURIComponent(request.term)); | ||||
|         source: noteautocompleteService.autocompleteSource, | ||||
|         focus: event => event.preventDefault(), | ||||
|         minLength: 0, | ||||
|         autoFocus: true, | ||||
|         select: function (event, ui) { | ||||
|             if (ui.item.value === 'No results') { | ||||
|                 return false; | ||||
|             } | ||||
|  | ||||
|             response(result); | ||||
|         }, | ||||
|         minLength: 2 | ||||
|             const notePath = linkService.getNotePathFromLabel(ui.item.value); | ||||
|  | ||||
|             treeService.activateNote(notePath); | ||||
|  | ||||
|             $dialog.dialog('close'); | ||||
|         } | ||||
|     }); | ||||
|  | ||||
|     showRecentNotes(); | ||||
| } | ||||
|  | ||||
| function getSelectedNotePath() { | ||||
|     const val = $autoComplete.val(); | ||||
|     return linkService.getNodePathFromLabel(val); | ||||
| function showInFullText(e) { | ||||
|     // stop from propagating upwards (dangerous especially with ctrl+enter executable javascript notes) | ||||
|     e.preventDefault(); | ||||
|     e.stopPropagation(); | ||||
|  | ||||
|     const searchText = $autoComplete.val(); | ||||
|  | ||||
|     searchNotesService.resetSearch(); | ||||
|     searchNotesService.showSearch(); | ||||
|     searchNotesService.doSearch(searchText); | ||||
|  | ||||
|     $dialog.dialog('close'); | ||||
| } | ||||
|  | ||||
| function goToNote() { | ||||
|     const notePath = getSelectedNotePath(); | ||||
|  | ||||
|     if (notePath) { | ||||
|         treeService.activateNode(notePath); | ||||
|  | ||||
|         $dialog.dialog('close'); | ||||
|     } | ||||
| function showRecentNotes() { | ||||
|     $autoComplete.autocomplete("search", ""); | ||||
| } | ||||
|  | ||||
| $form.submit(() => { | ||||
|     goToNote(); | ||||
| $showInFullTextButton.click(showInFullText); | ||||
|  | ||||
|     return false; | ||||
| }); | ||||
| $showRecentNotesButton.click(showRecentNotes); | ||||
|  | ||||
| $dialog.bind('keydown', 'ctrl+return', showInFullText); | ||||
|  | ||||
| export default { | ||||
|     showDialog | ||||
|   | ||||
| @@ -1,223 +0,0 @@ | ||||
| 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"; | ||||
|  | ||||
| const $dialog = $("#labels-dialog"); | ||||
| const $saveLabelsButton = $("#save-labels-button"); | ||||
| const $labelsBody = $('#labels-table tbody'); | ||||
|  | ||||
| const labelsModel = new LabelsModel(); | ||||
| let labelNames = []; | ||||
|  | ||||
| function LabelsModel() { | ||||
|     const self = this; | ||||
|  | ||||
|     this.labels = ko.observableArray(); | ||||
|  | ||||
|     this.updateLabelPositions = function() { | ||||
|         let position = 0; | ||||
|  | ||||
|         // we need to update positions by searching in the DOM, because order of the | ||||
|         // labels in the viewmodel (self.labels()) stays the same | ||||
|         $labelsBody.find('input[name="position"]').each(function() { | ||||
|             const label = self.getTargetLabel(this); | ||||
|  | ||||
|             label().position = position++; | ||||
|         }); | ||||
|     }; | ||||
|  | ||||
|     this.loadLabels = async function() { | ||||
|         const noteId = noteDetailService.getCurrentNoteId(); | ||||
|  | ||||
|         const labels = await server.get('notes/' + noteId + '/labels'); | ||||
|  | ||||
|         self.labels(labels.map(ko.observable)); | ||||
|  | ||||
|         addLastEmptyRow(); | ||||
|  | ||||
|         labelNames = await server.get('labels/names'); | ||||
|  | ||||
|         // label might not be rendered immediatelly so could not focus | ||||
|         setTimeout(() => $(".label-name:last").focus(), 100); | ||||
|  | ||||
|         $labelsBody.sortable({ | ||||
|             handle: '.handle', | ||||
|             containment: $labelsBody, | ||||
|             update: this.updateLabelPositions | ||||
|         }); | ||||
|     }; | ||||
|  | ||||
|     this.deleteLabel = function(data, event) { | ||||
|         const label = self.getTargetLabel(event.target); | ||||
|         const labelData = label(); | ||||
|  | ||||
|         if (labelData) { | ||||
|             labelData.isDeleted = 1; | ||||
|  | ||||
|             label(labelData); | ||||
|  | ||||
|             addLastEmptyRow(); | ||||
|         } | ||||
|     }; | ||||
|  | ||||
|     function isValid() { | ||||
|         for (let labels = self.labels(), i = 0; i < labels.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. | ||||
|         $saveLabelsButton.focus(); | ||||
|  | ||||
|         if (!isValid()) { | ||||
|             alert("Please fix all validation errors and try saving again."); | ||||
|             return; | ||||
|         } | ||||
|  | ||||
|         self.updateLabelPositions(); | ||||
|  | ||||
|         const noteId = noteDetailService.getCurrentNoteId(); | ||||
|  | ||||
|         const labelsToSave = self.labels() | ||||
|             .map(label => label()) | ||||
|             .filter(label => label.labelId !== "" || label.name !== ""); | ||||
|  | ||||
|         const labels = await server.put('notes/' + noteId + '/labels', labelsToSave); | ||||
|  | ||||
|         self.labels(labels.map(ko.observable)); | ||||
|  | ||||
|         addLastEmptyRow(); | ||||
|  | ||||
|         infoService.showMessage("Labels have been saved."); | ||||
|  | ||||
|         noteDetailService.loadLabelList(); | ||||
|     }; | ||||
|  | ||||
|     function addLastEmptyRow() { | ||||
|         const labels = self.labels().filter(attr => attr().isDeleted === 0); | ||||
|         const last = labels.length === 0 ? null : labels[labels.length - 1](); | ||||
|  | ||||
|         if (!last || last.name.trim() !== "" || last.value !== "") { | ||||
|             self.labels.push(ko.observable({ | ||||
|                 labelId: '', | ||||
|                 name: '', | ||||
|                 value: '', | ||||
|                 isDeleted: 0, | ||||
|                 position: 0 | ||||
|             })); | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     this.labelChanged = function (data, event) { | ||||
|         addLastEmptyRow(); | ||||
|  | ||||
|         const label = self.getTargetLabel(event.target); | ||||
|  | ||||
|         label.valueHasMutated(); | ||||
|     }; | ||||
|  | ||||
|     this.isNotUnique = function(index) { | ||||
|         const cur = self.labels()[index](); | ||||
|  | ||||
|         if (cur.name.trim() === "") { | ||||
|             return false; | ||||
|         } | ||||
|  | ||||
|         for (let labels = self.labels(), i = 0; i < labels.length; i++) { | ||||
|             const label = labels[i](); | ||||
|  | ||||
|             if (index !== i && cur.name === label.name) { | ||||
|                 return true; | ||||
|             } | ||||
|         } | ||||
|  | ||||
|         return false; | ||||
|     }; | ||||
|  | ||||
|     this.isEmptyName = function(index) { | ||||
|         const cur = self.labels()[index](); | ||||
|  | ||||
|         return cur.name.trim() === "" && (cur.labelId !== "" || cur.value !== ""); | ||||
|     }; | ||||
|  | ||||
|     this.getTargetLabel = function(target) { | ||||
|         const context = ko.contextFor(target); | ||||
|         const index = context.$index(); | ||||
|  | ||||
|         return self.labels()[index]; | ||||
|     } | ||||
| } | ||||
|  | ||||
| async function showDialog() { | ||||
|     glob.activeDialog = $dialog; | ||||
|  | ||||
|     await labelsModel.loadLabels(); | ||||
|  | ||||
|     $dialog.dialog({ | ||||
|         modal: true, | ||||
|         width: 800, | ||||
|         height: 500 | ||||
|     }); | ||||
| } | ||||
|  | ||||
| ko.applyBindings(labelsModel, document.getElementById('labels-dialog')); | ||||
|  | ||||
| $(document).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 | ||||
|             // because we have overriden filter() function in autocomplete.js | ||||
|             source: labelNames.map(label => { | ||||
|                 return { | ||||
|                     label: label, | ||||
|                     value: label | ||||
|                 } | ||||
|             }), | ||||
|             minLength: 0 | ||||
|         }); | ||||
|     } | ||||
|  | ||||
|     $(this).autocomplete("search", $(this).val()); | ||||
| }); | ||||
|  | ||||
| $(document).on('focus', '.label-value', async function (e) { | ||||
|     if (!$(this).hasClass("ui-autocomplete-input")) { | ||||
|         const labelName = $(this).parent().parent().find('.label-name').val(); | ||||
|  | ||||
|         if (labelName.trim() === "") { | ||||
|             return; | ||||
|         } | ||||
|  | ||||
|         const labelValues = await server.get('labels/values/' + encodeURIComponent(labelName)); | ||||
|  | ||||
|         if (labelValues.length === 0) { | ||||
|             return; | ||||
|         } | ||||
|  | ||||
|         $(this).autocomplete({ | ||||
|             // shouldn't be required and autocomplete should just accept array of strings, but that fails | ||||
|             // because we have overriden filter() function in autocomplete.js | ||||
|             source: labelValues.map(label => { | ||||
|                 return { | ||||
|                     label: label, | ||||
|                     value: label | ||||
|                 } | ||||
|             }), | ||||
|             minLength: 0 | ||||
|         }); | ||||
|     } | ||||
|  | ||||
|     $(this).autocomplete("search", $(this).val()); | ||||
| }); | ||||
|  | ||||
| export default { | ||||
|     showDialog | ||||
| }; | ||||
| @@ -63,10 +63,10 @@ $list.on('change', () => { | ||||
|     } | ||||
| }); | ||||
|  | ||||
| $(document).on('click', "a[action='note-revision']", event => { | ||||
| $(document).on('click', "a[data-action='note-revision']", event => { | ||||
|     const linkEl = $(event.target); | ||||
|     const noteId = linkEl.attr('note-path'); | ||||
|     const noteRevisionId = linkEl.attr('note-revision-id'); | ||||
|     const noteId = linkEl.attr('data-note-path'); | ||||
|     const noteRevisionId = linkEl.attr('data-note-revision-id'); | ||||
|  | ||||
|     showNoteRevisionsDialog(noteId, noteRevisionId); | ||||
|  | ||||
|   | ||||
| @@ -29,7 +29,7 @@ function formatNode(node, level) { | ||||
|     const indentAfter  = new Array(level - 1).join('  '); | ||||
|     let textNode; | ||||
|  | ||||
|     for (const i = 0; i < node.children.length; i++) { | ||||
|     for (let i = 0; i < node.children.length; i++) { | ||||
|         textNode = document.createTextNode('\n' + indentBefore); | ||||
|         node.insertBefore(textNode, node.children[i]); | ||||
|  | ||||
|   | ||||
| @@ -1,9 +1,10 @@ | ||||
| "use strict"; | ||||
|  | ||||
| import protectedSessionHolder from '../services/protected_session_holder.js'; | ||||
| import utils from '../services/utils.js'; | ||||
| import server from '../services/server.js'; | ||||
| import infoService from "../services/info.js"; | ||||
| import zoomService from "../services/zoom.js"; | ||||
| import utils from "../services/utils.js"; | ||||
|  | ||||
| const $dialog = $("#options-dialog"); | ||||
| const $tabs = $("#options-tabs"); | ||||
| @@ -33,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."); | ||||
| } | ||||
| @@ -44,6 +45,41 @@ export default { | ||||
|     saveOptions | ||||
| }; | ||||
|  | ||||
| addTabHandler((function() { | ||||
|     const $themeSelect = $("#theme-select"); | ||||
|     const $zoomFactorSelect = $("#zoom-factor-select"); | ||||
|     const $html = $("html"); | ||||
|  | ||||
|     function optionsLoaded(options) { | ||||
|         $themeSelect.val(options.theme); | ||||
|  | ||||
|         if (utils.isElectron()) { | ||||
|             $zoomFactorSelect.val(options.zoomFactor); | ||||
|         } | ||||
|         else { | ||||
|             $zoomFactorSelect.prop('disabled', true); | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     $themeSelect.change(function() { | ||||
|         const newTheme = $(this).val(); | ||||
|  | ||||
|         $html.attr("class", "theme-" + newTheme); | ||||
|  | ||||
|         server.put('options/theme/' + newTheme); | ||||
|     }); | ||||
|  | ||||
|     $zoomFactorSelect.change(function() { | ||||
|         const newZoomFactor = $(this).val(); | ||||
|  | ||||
|         zoomService.setZoomFactorAndSave(newZoomFactor); | ||||
|     }); | ||||
|  | ||||
|     return { | ||||
|         optionsLoaded | ||||
|     }; | ||||
| })()); | ||||
|  | ||||
| addTabHandler((function() { | ||||
|     const $form = $("#change-password-form"); | ||||
|     const $oldPassword = $("#old-password"); | ||||
| @@ -93,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); | ||||
|         }); | ||||
|  | ||||
| @@ -117,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; | ||||
|     }); | ||||
| @@ -137,6 +171,7 @@ addTabHandler((function () { | ||||
| addTabHandler((async function () { | ||||
|     const $appVersion = $("#app-version"); | ||||
|     const $dbVersion = $("#db-version"); | ||||
|     const $syncVersion = $("#sync-version"); | ||||
|     const $buildDate = $("#build-date"); | ||||
|     const $buildRevision = $("#build-revision"); | ||||
|  | ||||
| @@ -144,6 +179,7 @@ addTabHandler((async function () { | ||||
|  | ||||
|     $appVersion.html(appInfo.appVersion); | ||||
|     $dbVersion.html(appInfo.dbVersion); | ||||
|     $syncVersion.html(appInfo.syncVersion); | ||||
|     $buildDate.html(appInfo.buildDate); | ||||
|     $buildRevision.html(appInfo.buildRevision); | ||||
|     $buildRevision.attr('href', 'https://github.com/zadam/trilium/commit/' + appInfo.buildRevision); | ||||
| @@ -151,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"); | ||||
|   | ||||
| @@ -15,7 +15,11 @@ async function showDialog() { | ||||
|  | ||||
|     const result = await server.get('recent-changes/'); | ||||
|  | ||||
|     $dialog.html(''); | ||||
|     $dialog.empty(); | ||||
|  | ||||
|     if (result.length === 0) { | ||||
|         $dialog.append("No changes yet ..."); | ||||
|     } | ||||
|  | ||||
|     const groupedByDate = groupByDate(result); | ||||
|  | ||||
| @@ -30,9 +34,9 @@ async function showDialog() { | ||||
|             const revLink = $("<a>", { | ||||
|                 href: 'javascript:', | ||||
|                 text: 'rev' | ||||
|             }).attr('action', 'note-revision') | ||||
|                 .attr('note-path', change.noteId) | ||||
|                 .attr('note-revision-id', change.noteRevisionId); | ||||
|             }).attr('data-action', 'note-revision') | ||||
|                 .attr('data-note-path', change.noteId) | ||||
|                 .attr('data-note-revision-id', change.noteRevisionId); | ||||
|  | ||||
|             let noteLink; | ||||
|  | ||||
|   | ||||
| @@ -1,113 +0,0 @@ | ||||
| import treeService from '../services/tree.js'; | ||||
| import messagingService from '../services/messaging.js'; | ||||
| import server from '../services/server.js'; | ||||
| import utils from "../services/utils.js"; | ||||
| import treeUtils from "../services/tree_utils.js"; | ||||
|  | ||||
| const $dialog = $("#recent-notes-dialog"); | ||||
| const $searchInput = $('#recent-notes-search-input'); | ||||
|  | ||||
| // list of recent note paths | ||||
| let list = []; | ||||
|  | ||||
| async function reload() { | ||||
|     const result = await server.get('recent-notes'); | ||||
|  | ||||
|     list = result.map(r => r.notePath); | ||||
| } | ||||
|  | ||||
| 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)); | ||||
|  | ||||
|             list = result.map(r => r.notePath); | ||||
|         } | ||||
|     }, 1500); | ||||
| } | ||||
|  | ||||
| async function getNoteTitle(notePath) { | ||||
|     let noteTitle; | ||||
|  | ||||
|     try { | ||||
|         noteTitle = await treeUtils.getNotePathTitle(notePath); | ||||
|     } | ||||
|     catch (e) { | ||||
|         noteTitle = "[error - can't find note title]"; | ||||
|  | ||||
|         messagingService.logError("Could not find title for notePath=" + notePath + ", stack=" + e.stack); | ||||
|     } | ||||
|  | ||||
|     return noteTitle; | ||||
| } | ||||
|  | ||||
| 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(''); | ||||
|  | ||||
|     // remove the current note | ||||
|     const recNotes = list.filter(note => note !== treeService.getCurrentNotePath()); | ||||
|     const items = []; | ||||
|  | ||||
|     for (const notePath of recNotes) { | ||||
|         items.push({ | ||||
|             label: await getNoteTitle(notePath), | ||||
|             value: 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" | ||||
|         } | ||||
|     }); | ||||
| } | ||||
|  | ||||
| setTimeout(reload, 100); | ||||
|  | ||||
| messagingService.subscribeToMessages(syncData => { | ||||
|     if (syncData.some(sync => sync.entityName === 'recent_notes')) { | ||||
|         console.log(utils.now(), "Reloading recent notes because of background changes"); | ||||
|  | ||||
|         reload(); | ||||
|     } | ||||
| }); | ||||
|  | ||||
| export default { | ||||
|     showDialog, | ||||
|     addRecentNote, | ||||
|     reload | ||||
| }; | ||||
| @@ -1,19 +1,28 @@ | ||||
| /** Represents mapping between note and parent note */ | ||||
| class Branch { | ||||
|     constructor(treeCache, row) { | ||||
|         this.treeCache = treeCache; | ||||
|         /** @param {string} primary key */ | ||||
|         this.branchId = row.branchId; | ||||
|         /** @param {string} */ | ||||
|         this.noteId = row.noteId; | ||||
|         this.note = null; | ||||
|         /** @param {string} */ | ||||
|         this.parentNoteId = row.parentNoteId; | ||||
|         /** @param {int} */ | ||||
|         this.notePosition = row.notePosition; | ||||
|         /** @param {string} */ | ||||
|         this.prefix = row.prefix; | ||||
|         /** @param {boolean} */ | ||||
|         this.isExpanded = row.isExpanded; | ||||
|     } | ||||
|  | ||||
|     /** @returns {NoteShort} */ | ||||
|     async getNote() { | ||||
|         return await this.treeCache.getNote(this.noteId); | ||||
|     } | ||||
|  | ||||
|     /** @returns {boolean} true if it's top level, meaning its parent is root note */ | ||||
|     isTopLevel() { | ||||
|         return this.parentNoteId === 'root'; | ||||
|     } | ||||
|   | ||||
| @@ -1,13 +1,18 @@ | ||||
| import NoteShort from './note_short.js'; | ||||
|  | ||||
| /** | ||||
|  * Represents full note, specifically including note's content. | ||||
|  */ | ||||
| class NoteFull extends NoteShort { | ||||
|     constructor(treeCache, row) { | ||||
|         super(treeCache, row); | ||||
|  | ||||
|         /** @param {string} */ | ||||
|         this.content = row.content; | ||||
|  | ||||
|         if (this.content !== "" && this.isJson()) { | ||||
|             try { | ||||
|                 /** @param {object} */ | ||||
|                 this.jsonContent = JSON.parse(this.content); | ||||
|             } | ||||
|             catch(e) {} | ||||
|   | ||||
| @@ -1,18 +1,31 @@ | ||||
| /** | ||||
|  * This note's representation is used in note tree and is kept in TreeCache. | ||||
|  * Its notable omission is the note content. | ||||
|  */ | ||||
| class NoteShort { | ||||
|     constructor(treeCache, row) { | ||||
|         this.treeCache = treeCache; | ||||
|         /** @param {string} */ | ||||
|         this.noteId = row.noteId; | ||||
|         /** @param {string} */ | ||||
|         this.title = row.title; | ||||
|         /** @param {boolean} */ | ||||
|         this.isProtected = row.isProtected; | ||||
|         /** @param {string} one of 'text', 'code', 'file' or 'render' */ | ||||
|         this.type = row.type; | ||||
|         /** @param {string} content-type, e.g. "application/json" */ | ||||
|         this.mime = row.mime; | ||||
|         this.hideInAutocomplete = row.hideInAutocomplete; | ||||
|         /** @param {boolean} */ | ||||
|         this.archived = row.archived; | ||||
|         this.cssClass = row.cssClass; | ||||
|     } | ||||
|  | ||||
|     /** @returns {boolean} */ | ||||
|     isJson() { | ||||
|         return this.mime === "application/json"; | ||||
|     } | ||||
|  | ||||
|     /** @returns {Promise<Branch[]>} */ | ||||
|     async getBranches() { | ||||
|         const branchIds = this.treeCache.parents[this.noteId].map( | ||||
|             parentNoteId => this.treeCache.getBranchIdByChildParent(this.noteId, parentNoteId)); | ||||
| @@ -20,11 +33,13 @@ class NoteShort { | ||||
|         return this.treeCache.getBranches(branchIds); | ||||
|     } | ||||
|  | ||||
|     /** @returns {boolean} */ | ||||
|     hasChildren() { | ||||
|         return this.treeCache.children[this.noteId] | ||||
|             && this.treeCache.children[this.noteId].length > 0; | ||||
|     } | ||||
|  | ||||
|     /** @returns {Promise<Branch[]>} */ | ||||
|     async getChildBranches() { | ||||
|         if (!this.treeCache.children[this.noteId]) { | ||||
|             return []; | ||||
| @@ -36,18 +51,22 @@ class NoteShort { | ||||
|         return await this.treeCache.getBranches(branchIds); | ||||
|     } | ||||
|  | ||||
|     /** @returns {string[]} */ | ||||
|     getParentNoteIds() { | ||||
|         return this.treeCache.parents[this.noteId] || []; | ||||
|     } | ||||
|  | ||||
|     /** @returns {Promise<NoteShort[]>} */ | ||||
|     async getParentNotes() { | ||||
|         return await this.treeCache.getNotes(this.getParentNoteIds()); | ||||
|     } | ||||
|  | ||||
|     /** @returns {string[]} */ | ||||
|     getChildNoteIds() { | ||||
|         return this.treeCache.children[this.noteId] || []; | ||||
|     } | ||||
|  | ||||
|     /** @returns {Promise<NoteShort[]>} */ | ||||
|     async getChildNotes() { | ||||
|         return await this.treeCache.getNotes(this.getChildNoteIds()); | ||||
|     } | ||||
| @@ -59,7 +78,7 @@ class NoteShort { | ||||
|     get dto() { | ||||
|         const dto = Object.assign({}, this); | ||||
|         delete dto.treeCache; | ||||
|         delete dto.hideInAutocomplete; | ||||
|         delete dto.archived; | ||||
|  | ||||
|         return dto; | ||||
|     } | ||||
|   | ||||
| @@ -1,46 +0,0 @@ | ||||
| import server from './services/server.js'; | ||||
|  | ||||
| $(document).ready(async () => { | ||||
|     const {appDbVersion, dbVersion} = await server.get('migration'); | ||||
|  | ||||
|     console.log("HI", {appDbVersion, dbVersion}); | ||||
|  | ||||
|     if (appDbVersion === dbVersion) { | ||||
|         $("#up-to-date").show(); | ||||
|     } | ||||
|     else { | ||||
|         $("#need-to-migrate").show(); | ||||
|  | ||||
|         $("#app-db-version").html(appDbVersion); | ||||
|         $("#db-version").html(dbVersion); | ||||
|     } | ||||
| }); | ||||
|  | ||||
| $("#run-migration").click(async () => { | ||||
|     $("#run-migration").prop("disabled", true); | ||||
|  | ||||
|     $("#migration-result").show(); | ||||
|  | ||||
|     const result = await server.post('migration'); | ||||
|  | ||||
|     for (const migration of result.migrations) { | ||||
|         const row = $('<tr>') | ||||
|             .append($('<td>').html(migration.dbVersion)) | ||||
|             .append($('<td>').html(migration.name)) | ||||
|             .append($('<td>').html(migration.success ? 'Yes' : 'No')) | ||||
|             .append($('<td>').html(migration.success ? 'N/A' : migration.error)); | ||||
|  | ||||
|         if (!migration.success) { | ||||
|             row.addClass("danger"); | ||||
|         } | ||||
|  | ||||
|         $("#migration-table").append(row); | ||||
|     } | ||||
| }); | ||||
|  | ||||
| // copy of this shortcut to be able to debug migration problems | ||||
| $(document).bind('keydown', 'ctrl+shift+i', () => { | ||||
|     require('electron').remote.getCurrentWindow().toggleDevTools(); | ||||
|  | ||||
|     return false; | ||||
| }); | ||||
							
								
								
									
										26
									
								
								src/public/javascripts/services/bootstrap.js
									
									
									
									
										vendored
									
									
								
							
							
						
						| @@ -1,10 +1,9 @@ | ||||
| import addLinkDialog from '../dialogs/add_link.js'; | ||||
| import jumpToNoteDialog from '../dialogs/jump_to_note.js'; | ||||
| import labelsDialog from '../dialogs/labels.js'; | ||||
| import attributesDialog from '../dialogs/attributes.js'; | ||||
| import noteRevisionsDialog from '../dialogs/note_revisions.js'; | ||||
| import noteSourceDialog from '../dialogs/note_source.js'; | ||||
| import recentChangesDialog from '../dialogs/recent_changes.js'; | ||||
| import recentNotesDialog from '../dialogs/recent_notes.js'; | ||||
| import optionsDialog from '../dialogs/options.js'; | ||||
| import sqlConsoleDialog from '../dialogs/sql_console.js'; | ||||
|  | ||||
| @@ -17,8 +16,8 @@ import messagingService from './messaging.js'; | ||||
| import noteDetailService from './note_detail.js'; | ||||
| import noteType from './note_type.js'; | ||||
| import protected_session from './protected_session.js'; | ||||
| import searchTreeService from './search_tree.js'; | ||||
| import ScriptApi from './script_api.js'; | ||||
| import searchNotesService from './search_notes.js'; | ||||
| import FrontendScriptApi from './frontend_script_api.js'; | ||||
| import ScriptContext from './script_context.js'; | ||||
| import sync from './sync.js'; | ||||
| import treeService from './tree.js'; | ||||
| @@ -36,6 +35,8 @@ import libraryLoader from "./library_loader.js"; | ||||
| window.glob.getCurrentNode = treeService.getCurrentNode; | ||||
| window.glob.getHeaders = server.getHeaders; | ||||
| window.glob.showAddLinkDialog = addLinkDialog.showDialog; | ||||
| // this is required by CKEditor when uploading images | ||||
| window.glob.noteChanged = noteDetailService.noteChanged; | ||||
|  | ||||
| // required for ESLint plugin | ||||
| window.glob.getCurrentNote = noteDetailService.getCurrentNote; | ||||
| @@ -47,7 +48,12 @@ window.onerror = function (msg, url, lineNo, columnNo, error) { | ||||
|  | ||||
|     let message = "Uncaught error: "; | ||||
|  | ||||
|     if (string.indexOf("script error") > -1){ | ||||
|     if (string.includes("Cannot read property 'defaultView' of undefined")) { | ||||
|         // ignore this specific error which is very common but we don't know where it comes from | ||||
|         // and it seems to be harmless | ||||
|         return true; | ||||
|     } | ||||
|     else if (string.includes("script error")) { | ||||
|         message += 'No details available'; | ||||
|     } | ||||
|     else { | ||||
| @@ -65,6 +71,14 @@ window.onerror = function (msg, url, lineNo, columnNo, error) { | ||||
|     return false; | ||||
| }; | ||||
|  | ||||
| const wikiBaseUrl = "https://github.com/zadam/trilium/wiki/"; | ||||
|  | ||||
| $(document).on("click", "button[data-help-page]", e => { | ||||
|     const $button = $(e.target); | ||||
|  | ||||
|     window.open(wikiBaseUrl + $button.attr("data-help-page"), '_blank'); | ||||
| }); | ||||
|  | ||||
| $("#logout-button").toggle(!utils.isElectron()); | ||||
|  | ||||
| if (utils.isElectron()) { | ||||
| @@ -74,7 +88,7 @@ if (utils.isElectron()) { | ||||
|             await treeService.reload(); | ||||
|         } | ||||
|  | ||||
|         await treeService.activateNode(parentNoteId); | ||||
|         await treeService.activateNote(parentNoteId); | ||||
|  | ||||
|         setTimeout(() => { | ||||
|             const node = treeService.getCurrentNode(); | ||||
|   | ||||
| @@ -1,12 +1,24 @@ | ||||
| import ScriptContext from "./script_context.js"; | ||||
| import server from "./server.js"; | ||||
| import infoService from "./info.js"; | ||||
|  | ||||
| async function executeBundle(bundle) { | ||||
|     const apiContext = ScriptContext(bundle.note, bundle.allNotes); | ||||
| async function getAndExecuteBundle(noteId, originEntity = null) { | ||||
|     const bundle = await server.get('script/bundle/' + noteId); | ||||
|  | ||||
|     return await (function () { | ||||
|         return eval(`const apiContext = this; (async function() { ${bundle.script}\r\n})()`); | ||||
|     }.call(apiContext)); | ||||
|     await executeBundle(bundle, originEntity); | ||||
| } | ||||
|  | ||||
| async function executeBundle(bundle, originEntity) { | ||||
|     const apiContext = ScriptContext(bundle.note, bundle.allNotes, originEntity); | ||||
|  | ||||
|     try { | ||||
|         return await (function () { | ||||
|             return eval(`const apiContext = this; (async function() { ${bundle.script}\r\n})()`); | ||||
|         }.call(apiContext)); | ||||
|     } | ||||
|     catch (e) { | ||||
|         infoService.showAndLogError(`Execution of script "${bundle.note.title}" (${bundle.note.noteId}) failed with error: ${e.message}`); | ||||
|     } | ||||
| } | ||||
|  | ||||
| async function executeStartupBundles() { | ||||
| @@ -17,7 +29,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 | ||||
| } | ||||
| @@ -114,13 +114,14 @@ const contextMenuOptions = { | ||||
|         // Modify menu entries depending on node status | ||||
|         $tree.contextmenu("enableEntry", "insertNoteHere", isNotRoot && parentNote.type !== 'search'); | ||||
|         $tree.contextmenu("enableEntry", "insertChildNote", note.type !== 'search'); | ||||
|         $tree.contextmenu("enableEntry", "delete", isNotRoot); | ||||
|         $tree.contextmenu("enableEntry", "delete", isNotRoot && parentNote.type !== 'search'); | ||||
|         $tree.contextmenu("enableEntry", "copy", isNotRoot); | ||||
|         $tree.contextmenu("enableEntry", "cut", isNotRoot); | ||||
|         $tree.contextmenu("enableEntry", "pasteAfter", clipboardIds.length > 0 && isNotRoot && parentNote.type !== 'search'); | ||||
|         $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", isNotRoot && parentNote.type !== 'search'); | ||||
|  | ||||
|         // Activate node on right-click | ||||
|         node.setActive(); | ||||
|   | ||||
| @@ -2,16 +2,16 @@ import utils from "./utils.js"; | ||||
| import treeService from "./tree.js"; | ||||
| import linkService from "./link.js"; | ||||
| import fileService from "./file.js"; | ||||
| 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 searchTreeService from "./search_tree.js"; | ||||
| import labelsDialog from "../dialogs/labels.js"; | ||||
| import searchNotesService from "./search_notes.js"; | ||||
| import attributesDialog from "../dialogs/attributes.js"; | ||||
| import protectedSessionService from "./protected_session.js"; | ||||
|  | ||||
| function registerEntrypoints() { | ||||
| @@ -22,27 +22,23 @@ function registerEntrypoints() { | ||||
|  | ||||
|     utils.bindShortcut('ctrl+l', addLinkDialog.showDialog); | ||||
|  | ||||
|     $("#jump-to-note-button").click(jumpToNoteDialog.showDialog); | ||||
|     $("#jump-to-note-dialog-button").click(jumpToNoteDialog.showDialog); | ||||
|     utils.bindShortcut('ctrl+j', jumpToNoteDialog.showDialog); | ||||
|  | ||||
|     $("#show-note-revisions-button").click(noteRevisionsDialog.showCurrentNoteRevisions); | ||||
|  | ||||
|     $("#show-source-button").click(noteSourceDialog.showDialog); | ||||
|     utils.bindShortcut('ctrl+u', noteSourceDialog.showDialog); | ||||
|  | ||||
|     $("#recent-changes-button").click(recentChangesDialog.showDialog); | ||||
|  | ||||
|     $("#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); | ||||
|  | ||||
|     $("#toggle-search-button").click(searchTreeService.toggleSearch); | ||||
|     utils.bindShortcut('ctrl+s', searchTreeService.toggleSearch); | ||||
|  | ||||
|     $(".show-labels-button").click(labelsDialog.showDialog); | ||||
|     utils.bindShortcut('alt+l', labelsDialog.showDialog); | ||||
|     $(".show-attributes-button").click(attributesDialog.showDialog); | ||||
|     utils.bindShortcut('alt+a', attributesDialog.showDialog); | ||||
|  | ||||
|     $("#options-button").click(optionsDialog.showDialog); | ||||
|  | ||||
| @@ -57,7 +53,13 @@ function registerEntrypoints() { | ||||
|         utils.bindShortcut('alt+right', window.history.forward); | ||||
|     } | ||||
|  | ||||
|     utils.bindShortcut('alt+m', e => $(".hide-toggle").toggleClass("suppressed")); | ||||
|     utils.bindShortcut('alt+m', e => { | ||||
|         $(".hide-toggle").toggle(); | ||||
|  | ||||
|         // when hiding switch display to block, otherwise grid still tries to display columns which shows | ||||
|         // left empty column | ||||
|         $("#container").css("display", $("#container").css("display") === "grid" ? "block" : "grid"); | ||||
|     }); | ||||
|  | ||||
|     // hide (toggle) everything except for the note content for distraction free writing | ||||
|     utils.bindShortcut('alt+t', e => { | ||||
| @@ -109,27 +111,10 @@ function registerEntrypoints() { | ||||
|         $("#note-detail-text").focus(); | ||||
|     }); | ||||
|  | ||||
|     $(document).bind('keydown', 'ctrl+-', () => { | ||||
|         if (utils.isElectron()) { | ||||
|             const webFrame = require('electron').webFrame; | ||||
|  | ||||
|             if (webFrame.getZoomFactor() > 0.2) { | ||||
|                 webFrame.setZoomFactor(webFrame.getZoomFactor() - 0.1); | ||||
|             } | ||||
|  | ||||
|             return false; | ||||
|         } | ||||
|     }); | ||||
|  | ||||
|     $(document).bind('keydown', 'ctrl+=', () => { | ||||
|         if (utils.isElectron()) { | ||||
|             const webFrame = require('electron').webFrame; | ||||
|  | ||||
|             webFrame.setZoomFactor(webFrame.getZoomFactor() + 0.1); | ||||
|  | ||||
|             return false; | ||||
|         } | ||||
|     }); | ||||
|     if (utils.isElectron()) { | ||||
|         $(document).bind('keydown', 'ctrl+-', zoomService.decreaseZoomFactor); | ||||
|         $(document).bind('keydown', 'ctrl+=', zoomService.increaseZoomFactor); | ||||
|     } | ||||
|  | ||||
|     $("#note-title").bind('keydown', 'return', () => $("#note-detail-text").focus()); | ||||
|  | ||||
|   | ||||
| @@ -21,7 +21,7 @@ $("#file-upload").change(async function() { | ||||
|  | ||||
|     await treeService.reload(); | ||||
|  | ||||
|     await treeService.activateNode(resp.noteId); | ||||
|     await treeService.activateNote(resp.noteId); | ||||
| }); | ||||
|  | ||||
| export default { | ||||
|   | ||||
							
								
								
									
										196
									
								
								src/public/javascripts/services/frontend_script_api.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,196 @@ | ||||
| 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'; | ||||
| import treeCache from './tree_cache.js'; | ||||
|  | ||||
| /** | ||||
|  * This is the main frontend API interface for scripts. It's published in the local "api" object. | ||||
|  * | ||||
|  * @constructor | ||||
|  * @hideconstructor | ||||
|  */ | ||||
| function FrontendScriptApi(startNote, currentNote, originEntity = null) { | ||||
|     const $pluginButtons = $("#plugin-buttons"); | ||||
|  | ||||
|     /** @property {object} note where script started executing */ | ||||
|     this.startNote = startNote; | ||||
|     /** @property {object} note where script is currently executing */ | ||||
|     this.currentNote = currentNote; | ||||
|     /** @property {object|null} entity whose event triggered this execution */ | ||||
|     this.originEntity = originEntity; | ||||
|  | ||||
|     /** | ||||
|      * Activates note in the tree and in the note detail. | ||||
|      * | ||||
|      * @method | ||||
|      * @param {string} notePath (or noteId) | ||||
|      * @returns {Promise<void>} | ||||
|      */ | ||||
|     this.activateNote = treeService.activateNote; | ||||
|  | ||||
|     /** | ||||
|      * Activates newly created note. Compared to this.activateNote() also refreshes tree. | ||||
|      * | ||||
|      * @param {string} notePath (or noteId) | ||||
|      * @return {Promise<void>} | ||||
|      */ | ||||
|     this.activateNewNote = async notePath => { | ||||
|         await treeService.reload(); | ||||
|  | ||||
|         await treeService.activateNote(notePath, true); | ||||
|     }; | ||||
|  | ||||
|     /** | ||||
|      * @typedef {Object} ToolbarButtonOptions | ||||
|      * @property {string} title | ||||
|      * @property {string} [icon] - name of the jQuery UI icon to be used (e.g. "clock" for "ui-icon-clock" icon) | ||||
|      * @property {function} action - callback handling the click on the button | ||||
|      * @property {string} [shortcut] - keyboard shortcut for the button, e.g. "alt+t" | ||||
|      */ | ||||
|  | ||||
|     /** | ||||
|      * Adds new button the the plugin area. | ||||
|      * | ||||
|      * @param {ToolbarButtonOptions} opts | ||||
|      */ | ||||
|     this.addButtonToToolbar = opts => { | ||||
|         const buttonId = "toolbar-button-" + opts.title.replace(/[^a-zA-Z0-9]/g, "-"); | ||||
|  | ||||
|         $("#" + buttonId).remove(); | ||||
|  | ||||
|         const icon = $("<span>") | ||||
|             .addClass("ui-icon ui-icon-" + opts.icon); | ||||
|  | ||||
|         const button = $('<button>') | ||||
|             .addClass("btn btn-xs") | ||||
|             .click(opts.action) | ||||
|             .append(icon) | ||||
|             .append($("<span>").text(opts.title)); | ||||
|  | ||||
|         button.attr('id', buttonId); | ||||
|  | ||||
|         $pluginButtons.append(button); | ||||
|  | ||||
|         if (opts.shortcut) { | ||||
|             $(document).bind('keydown', opts.shortcut, opts.action); | ||||
|  | ||||
|             button.attr("title", "Shortcut " + opts.shortcut); | ||||
|         } | ||||
|     }; | ||||
|  | ||||
|     function prepareParams(params) { | ||||
|         if (!params) { | ||||
|             return params; | ||||
|         } | ||||
|  | ||||
|         return params.map(p => { | ||||
|             if (typeof p === "function") { | ||||
|                 return "!@#Function: " + p.toString(); | ||||
|             } | ||||
|             else { | ||||
|                 return p; | ||||
|             } | ||||
|         }); | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Executes given anonymous function on the server. | ||||
|      * Internally this serializes the anonymous function into string and sends it to backend via AJAX. | ||||
|      * | ||||
|      * @param {string} script - script to be executed on the backend | ||||
|      * @param {Array.<?>} params - list of parameters to the anonymous function to be send to backend | ||||
|      * @return {Promise<*>} return value of the executed function on the backend | ||||
|      */ | ||||
|     this.runOnServer = async (script, params = []) => { | ||||
|         if (typeof script === "function") { | ||||
|             script = script.toString(); | ||||
|         } | ||||
|  | ||||
|         const ret = await server.post('script/exec', { | ||||
|             script: script, | ||||
|             params: prepareParams(params), | ||||
|             startNoteId: startNote.noteId, | ||||
|             currentNoteId: currentNote.noteId, | ||||
|             originEntityName: "notes", // currently there's no other entity on frontend which can trigger event | ||||
|             originEntityId: originEntity ? originEntity.noteId : null | ||||
|         }); | ||||
|  | ||||
|         if (ret.success) { | ||||
|             return ret.executionResult; | ||||
|         } | ||||
|         else { | ||||
|             throw new Error("server error: " + ret.error); | ||||
|         } | ||||
|     }; | ||||
|  | ||||
|     /** | ||||
|      * Returns list of notes. If note is missing from cache, it's loaded. | ||||
|      * | ||||
|      * This is often used to bulk-fill the cache with notes which would have to be picked one by one | ||||
|      * otherwise (by e.g. createNoteLink()) | ||||
|      * | ||||
|      * @param {string[]} noteIds | ||||
|      * @param {boolean} [silentNotFoundError] - don't report error if the note is not found | ||||
|      * @return {Promise<NoteShort[]>} | ||||
|      */ | ||||
|     this.getNotes = async (noteIds, silentNotFoundError = false) => await treeCache.getNotes(noteIds, silentNotFoundError); | ||||
|  | ||||
|     /** | ||||
|      * Instance name identifies particular Trilium instance. It can be useful for scripts | ||||
|      * if some action needs to happen on only one specific instance. | ||||
|      * | ||||
|      * @return {string} | ||||
|      */ | ||||
|     this.getInstanceName = () => window.glob.instanceName; | ||||
|  | ||||
|     /** | ||||
|      * @method | ||||
|      * @param {Date} date | ||||
|      * @returns {string} date in YYYY-MM-DD format | ||||
|      */ | ||||
|     this.formatDateISO = utils.formatDateISO; | ||||
|  | ||||
|     /** | ||||
|      * @method | ||||
|      * @param {string} str | ||||
|      * @returns {Date} parsed object | ||||
|      */ | ||||
|     this.parseDate = utils.parseDate; | ||||
|  | ||||
|     /** | ||||
|      * Show info message to the user. | ||||
|      * | ||||
|      * @method | ||||
|      * @param {string} message | ||||
|      */ | ||||
|     this.showMessage = infoService.showMessage; | ||||
|  | ||||
|     /** | ||||
|      * Show error message to the user. | ||||
|      * | ||||
|      * @method | ||||
|      * @param {string} message | ||||
|      */ | ||||
|     this.showError = infoService.showError; | ||||
|  | ||||
|     /** | ||||
|      * Refresh tree | ||||
|      * | ||||
|      * @method | ||||
|      * @returns {Promise<void>} | ||||
|      */ | ||||
|     this.refreshTree = treeService.reload; | ||||
|  | ||||
|     /** | ||||
|      * Create note link (jQuery object) for given note. | ||||
|      * | ||||
|      * @method | ||||
|      * @param {string} notePath (or noteId) | ||||
|      * @param {string} [noteTitle] - if not present we'll use note title | ||||
|      */ | ||||
|     this.createNoteLink = linkService.createNoteLink; | ||||
| } | ||||
|  | ||||
| export default FrontendScriptApi; | ||||
| @@ -14,6 +14,12 @@ function showMessage(message) { | ||||
|     }); | ||||
| } | ||||
|  | ||||
| function showAndLogError(message, delay = 10000) { | ||||
|     showError(message, delay); | ||||
|  | ||||
|     messagingService.logError(message); | ||||
| } | ||||
|  | ||||
| function showError(message, delay = 10000) { | ||||
|     console.log(utils.now(), "error: ", message); | ||||
|  | ||||
| @@ -36,5 +42,6 @@ function throwError(message) { | ||||
| export default { | ||||
|     showMessage, | ||||
|     showError, | ||||
|     showAndLogError, | ||||
|     throwError | ||||
| } | ||||
| @@ -3,7 +3,7 @@ import noteDetailText from './note_detail_text.js'; | ||||
| import treeUtils from './tree_utils.js'; | ||||
|  | ||||
| function getNotePathFromLink(url) { | ||||
|     const notePathMatch = /#([A-Za-z0-9/]+)$/.exec(url); | ||||
|     const notePathMatch = /#root([A-Za-z0-9/]*)$/.exec(url); | ||||
|  | ||||
|     if (notePathMatch === null) { | ||||
|         return null; | ||||
| @@ -13,8 +13,8 @@ function getNotePathFromLink(url) { | ||||
|     } | ||||
| } | ||||
|  | ||||
| function getNodePathFromLabel(label) { | ||||
|     const notePathMatch = / \(([A-Za-z0-9/]+)\)/.exec(label); | ||||
| function getNotePathFromLabel(label) { | ||||
|     const notePathMatch = / \(([#A-Za-z0-9/]+)\)/.exec(label); | ||||
|  | ||||
|     if (notePathMatch !== null) { | ||||
|         return notePathMatch[1]; | ||||
| @@ -23,7 +23,7 @@ function getNodePathFromLabel(label) { | ||||
|     return null; | ||||
| } | ||||
|  | ||||
| async function createNoteLink(notePath, noteTitle) { | ||||
| async function createNoteLink(notePath, noteTitle = null) { | ||||
|     if (!noteTitle) { | ||||
|         const noteId = treeUtils.getNoteIdFromNotePath(notePath); | ||||
|  | ||||
| @@ -33,8 +33,8 @@ async function createNoteLink(notePath, noteTitle) { | ||||
|     const noteLink = $("<a>", { | ||||
|         href: 'javascript:', | ||||
|         text: noteTitle | ||||
|     }).attr('action', 'note') | ||||
|         .attr('note-path', notePath); | ||||
|     }).attr('data-action', 'note') | ||||
|         .attr('data-note-path', notePath); | ||||
|  | ||||
|     return noteLink; | ||||
| } | ||||
| @@ -43,10 +43,10 @@ function goToLink(e) { | ||||
|     e.preventDefault(); | ||||
|  | ||||
|     const $link = $(e.target); | ||||
|     let notePath = $link.attr("note-path"); | ||||
|     let notePath = $link.attr("data-note-path"); | ||||
|  | ||||
|     if (!notePath) { | ||||
|         const address = $link.attr("note-path") ? $link.attr("note-path") : $link.attr('href'); | ||||
|         const address = $link.attr("data-note-path") ? $link.attr("data-note-path") : $link.attr('href'); | ||||
|  | ||||
|         if (!address) { | ||||
|             return; | ||||
| @@ -61,7 +61,7 @@ function goToLink(e) { | ||||
|         notePath = getNotePathFromLink(address); | ||||
|     } | ||||
|  | ||||
|     treeService.activateNode(notePath); | ||||
|     treeService.activateNote(notePath); | ||||
|  | ||||
|     // this is quite ugly hack, but it seems like we can't close the tooltip otherwise | ||||
|     $("[role='tooltip']").remove(); | ||||
| @@ -90,14 +90,26 @@ function addTextToEditor(text) { | ||||
|     doc.enqueueChanges(() => editor.data.insertText(text), doc.selection); | ||||
| } | ||||
|  | ||||
| ko.bindingHandlers.noteLink = { | ||||
|     init: async function(element, valueAccessor, allBindings, viewModel, bindingContext) { | ||||
|         const noteId = ko.unwrap(valueAccessor()); | ||||
|  | ||||
|         if (noteId) { | ||||
|             const link = await createNoteLink(noteId); | ||||
|  | ||||
|             $(element).append(link); | ||||
|         } | ||||
|     } | ||||
| }; | ||||
|  | ||||
| // when click on link popup, in case of internal link, just go the the referenced note instead of default behavior | ||||
| // of opening the link in new window/tab | ||||
| $(document).on('click', "a[action='note']", goToLink); | ||||
| $(document).on('click', "a[data-action='note']", goToLink); | ||||
| $(document).on('click', 'div.popover-content a, div.ui-tooltip-content a', goToLink); | ||||
| $(document).on('dblclick', '#note-detail-text a', goToLink); | ||||
|  | ||||
| export default { | ||||
|     getNodePathFromLabel, | ||||
|     getNotePathFromLabel, | ||||
|     getNotePathFromLink, | ||||
|     createNoteLink, | ||||
|     addLinkToEditor, | ||||
|   | ||||
| @@ -1,8 +1,9 @@ | ||||
| import utils from './utils.js'; | ||||
| import infoService from "./info.js"; | ||||
|  | ||||
| const $changesToPushCount = $("#changes-to-push-count"); | ||||
| const $outstandingSyncsCount = $("#outstanding-syncs-count"); | ||||
|  | ||||
| const syncMessageHandlers = []; | ||||
| const messageHandlers = []; | ||||
|  | ||||
| let ws; | ||||
| @@ -25,9 +26,17 @@ function subscribeToMessages(messageHandler) { | ||||
|     messageHandlers.push(messageHandler); | ||||
| } | ||||
|  | ||||
| function subscribeToSyncMessages(messageHandler) { | ||||
|     syncMessageHandlers.push(messageHandler); | ||||
| } | ||||
|  | ||||
| function handleMessage(event) { | ||||
|     const message = JSON.parse(event.data); | ||||
|  | ||||
|     for (const messageHandler of messageHandlers) { | ||||
|         messageHandler(message); | ||||
|     } | ||||
|  | ||||
|     if (message.type === 'sync') { | ||||
|         lastPingTs = new Date().getTime(); | ||||
|  | ||||
| @@ -39,11 +48,11 @@ function handleMessage(event) { | ||||
|  | ||||
|         const syncData = message.data.filter(sync => sync.sourceId !== glob.sourceId); | ||||
|  | ||||
|         for (const messageHandler of messageHandlers) { | ||||
|             messageHandler(syncData); | ||||
|         for (const syncMessageHandler of syncMessageHandlers) { | ||||
|             syncMessageHandler(syncData); | ||||
|         } | ||||
|  | ||||
|         $changesToPushCount.html(message.changesToPushCount); | ||||
|         $outstandingSyncsCount.html(message.outstandingSyncs); | ||||
|     } | ||||
|     else if (message.type === 'sync-hash-check-failed') { | ||||
|         infoService.showError("Sync check failed!", 60000); | ||||
| @@ -73,26 +82,10 @@ setTimeout(() => { | ||||
|  | ||||
|     lastSyncId = glob.maxSyncIdAtLoad; | ||||
|     lastPingTs = new Date().getTime(); | ||||
|     let connectionBrokenNotification = null; | ||||
|  | ||||
|     setInterval(async () => { | ||||
|         if (new Date().getTime() - lastPingTs > 30000) { | ||||
|             if (!connectionBrokenNotification) { | ||||
|                 connectionBrokenNotification = $.notify({ | ||||
|                     // options | ||||
|                     message: "Lost connection to server" | ||||
|                 },{ | ||||
|                     // options | ||||
|                     type: 'danger', | ||||
|                     delay: 100000000 // keep it until we explicitly close it | ||||
|                 }); | ||||
|             } | ||||
|         } | ||||
|         else if (connectionBrokenNotification) { | ||||
|             await connectionBrokenNotification.close(); | ||||
|             connectionBrokenNotification = null; | ||||
|  | ||||
|             infoService.showMessage("Re-connected to server"); | ||||
|             console.log("Lost connection to server"); | ||||
|         } | ||||
|  | ||||
|         ws.send(JSON.stringify({ | ||||
| @@ -104,5 +97,6 @@ setTimeout(() => { | ||||
|  | ||||
| export default { | ||||
|     logError, | ||||
|     subscribeToMessages | ||||
|     subscribeToMessages, | ||||
|     subscribeToSyncMessages | ||||
| }; | ||||
							
								
								
									
										60
									
								
								src/public/javascripts/services/note_autocomplete.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,60 @@ | ||||
| import server from "./server.js"; | ||||
| import noteDetailService from "./note_detail.js"; | ||||
|  | ||||
| async function autocompleteSource(request, response) { | ||||
|     const result = await server.get('autocomplete' | ||||
|         + '?query=' + encodeURIComponent(request.term) | ||||
|         + '¤tNoteId=' + noteDetailService.getCurrentNoteId()); | ||||
|  | ||||
|     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" | ||||
|         }]); | ||||
|     } | ||||
| } | ||||
|  | ||||
| async function initNoteAutocomplete($el) { | ||||
|     if (!$el.hasClass("ui-autocomplete-input")) { | ||||
|         const $showRecentNotesButton = $("<span>") | ||||
|             .addClass("input-group-addon show-recent-notes-button") | ||||
|             .prop("title", "Show recent notes"); | ||||
|  | ||||
|         $el.after($showRecentNotesButton); | ||||
|  | ||||
|         $showRecentNotesButton.click(() => $el.autocomplete("search", "")); | ||||
|  | ||||
|         await $el.autocomplete({ | ||||
|             appendTo: $el.parent().parent(), | ||||
|             source: autocompleteSource, | ||||
|             minLength: 0, | ||||
|             change: function (event, ui) { | ||||
|                 $el.trigger("change"); | ||||
|             }, | ||||
|             select: function (event, ui) { | ||||
|                 if (ui.item.value === 'No results') { | ||||
|                     return false; | ||||
|                 } | ||||
|             } | ||||
|         }); | ||||
|     } | ||||
| } | ||||
|  | ||||
| ko.bindingHandlers.noteAutocomplete = { | ||||
|     init: function(element, valueAccessor, allBindings, viewModel, bindingContext) { | ||||
|         initNoteAutocomplete($(element)); | ||||
|     } | ||||
| }; | ||||
|  | ||||
| export default { | ||||
|     initNoteAutocomplete, | ||||
|     autocompleteSource | ||||
| } | ||||
| @@ -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,8 @@ 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"; | ||||
| import noteAutocompleteService from "./note_autocomplete.js"; | ||||
|  | ||||
| const $noteTitle = $("#note-title"); | ||||
|  | ||||
| @@ -23,9 +26,11 @@ const $protectButton = $("#protect-button"); | ||||
| const $unprotectButton = $("#unprotect-button"); | ||||
| const $noteDetailWrapper = $("#note-detail-wrapper"); | ||||
| const $noteIdDisplay = $("#note-id-display"); | ||||
| const $labelList = $("#label-list"); | ||||
| const $labelListInner = $("#label-list-inner"); | ||||
| const $attributeList = $("#attribute-list"); | ||||
| const $attributeListInner = $("#attribute-list-inner"); | ||||
| const $childrenOverview = $("#children-overview"); | ||||
| const $scriptArea = $("#note-detail-script-area"); | ||||
| const $promotedAttributesContainer = $("#note-detail-promoted-attributes"); | ||||
|  | ||||
| let currentNote = null; | ||||
|  | ||||
| @@ -114,11 +119,12 @@ async function saveNoteIfChanged() { | ||||
| } | ||||
|  | ||||
| function setNoteBackgroundIfProtected(note) { | ||||
|     const isProtected = !!note.isProtected; | ||||
|     const isProtected = note.isProtected; | ||||
|  | ||||
|     $noteDetailWrapper.toggleClass("protected", isProtected); | ||||
|     $protectButton.toggle(!isProtected); | ||||
|     $unprotectButton.toggle(isProtected); | ||||
|     $protectButton.toggleClass("active", isProtected); | ||||
|     $unprotectButton.toggleClass("active", !isProtected); | ||||
|     $unprotectButton.prop("disabled", !protectedSessionHolder.isProtectedSessionAvailable()); | ||||
| } | ||||
|  | ||||
| let isNewNoteCreated = false; | ||||
| @@ -150,7 +156,7 @@ async function loadNoteDetail(noteId) { | ||||
|  | ||||
|     $noteIdDisplay.html(noteId); | ||||
|  | ||||
|     await handleProtectedSession(); | ||||
|     setNoteBackgroundIfProtected(currentNote); | ||||
|  | ||||
|     $noteDetailWrapper.show(); | ||||
|  | ||||
| @@ -164,21 +170,26 @@ async function loadNoteDetail(noteId) { | ||||
|  | ||||
|         $noteDetailComponents.hide(); | ||||
|  | ||||
|         await handleProtectedSession(); | ||||
|  | ||||
|         await getComponent(currentNote.type).show(); | ||||
|     } | ||||
|     finally { | ||||
|         noteChangeDisabled = false; | ||||
|     } | ||||
|  | ||||
|     setNoteBackgroundIfProtected(currentNote); | ||||
|     treeService.setBranchBackgroundBasedOnProtectedStatus(noteId); | ||||
|  | ||||
|     // after loading new note make sure editor is scrolled to the top | ||||
|     $noteDetailWrapper.scrollTop(0); | ||||
|  | ||||
|     const labels = await loadLabelList(); | ||||
|     $scriptArea.html(''); | ||||
|  | ||||
|     const hideChildrenOverview = labels.some(label => label.name === 'hideChildrenOverview'); | ||||
|     await bundleService.executeRelationBundles(getCurrentNote(), 'runOnNoteView'); | ||||
|  | ||||
|     const attributes = await loadAttributes(); | ||||
|  | ||||
|     const hideChildrenOverview = attributes.some(attr => attr.type === 'label' && attr.name === 'hideChildrenOverview'); | ||||
|     await showChildrenOverview(hideChildrenOverview); | ||||
| } | ||||
|  | ||||
| @@ -198,7 +209,7 @@ async function showChildrenOverview(hideChildrenOverview) { | ||||
|         const link = $('<a>', { | ||||
|             href: 'javascript:', | ||||
|             text: await treeUtils.getNoteTitle(childBranch.noteId, childBranch.parentNoteId) | ||||
|         }).attr('action', 'note').attr('note-path', notePath + '/' + childBranch.noteId); | ||||
|         }).attr('data-action', 'note').attr('data-note-path', notePath + '/' + childBranch.noteId); | ||||
|  | ||||
|         const childEl = $('<div class="child-overview">').html(link); | ||||
|         $childrenOverview.append(childEl); | ||||
| @@ -207,25 +218,230 @@ async function showChildrenOverview(hideChildrenOverview) { | ||||
|     $childrenOverview.show(); | ||||
| } | ||||
|  | ||||
| async function loadLabelList() { | ||||
| async function loadAttributes() { | ||||
|     $promotedAttributesContainer.empty(); | ||||
|     $attributeList.hide(); | ||||
|  | ||||
|     const noteId = getCurrentNoteId(); | ||||
|  | ||||
|     const labels = await server.get('notes/' + noteId + '/labels'); | ||||
|     const attributes = await server.get('notes/' + noteId + '/attributes'); | ||||
|  | ||||
|     $labelListInner.html(''); | ||||
|     const promoted = attributes.filter(attr => | ||||
|         (attr.type === 'label-definition' || attr.type === 'relation-definition') | ||||
|         && !attr.name.startsWith("child:") | ||||
|         && attr.value.isPromoted); | ||||
|  | ||||
|     if (labels.length > 0) { | ||||
|         for (const label of labels) { | ||||
|             $labelListInner.append(utils.formatLabel(label) + " "); | ||||
|     let idx = 1; | ||||
|  | ||||
|     async function createRow(definitionAttr, valueAttr) { | ||||
|         const definition = definitionAttr.value; | ||||
|         const inputId = "promoted-input-" + idx; | ||||
|         const $tr = $("<tr>"); | ||||
|         const $labelCell = $("<th>").append(valueAttr.name); | ||||
|         const $input = $("<input>") | ||||
|             .prop("id", inputId) | ||||
|             .prop("tabindex", definitionAttr.position) | ||||
|             .prop("attribute-id", valueAttr.isOwned ? valueAttr.attributeId : '') // if not owned, we'll force creation of a new attribute instead of updating the inherited one | ||||
|             .prop("attribute-type", valueAttr.type) | ||||
|             .prop("attribute-name", valueAttr.name) | ||||
|             .prop("value", valueAttr.value) | ||||
|             .addClass("form-control") | ||||
|             .addClass("promoted-attribute-input"); | ||||
|  | ||||
|         idx++; | ||||
|  | ||||
|         const $inputCell = $("<td>").append($("<div>").addClass("input-group").append($input)); | ||||
|  | ||||
|         const $actionCell = $("<td>"); | ||||
|         const $multiplicityCell = $("<td>"); | ||||
|  | ||||
|         $tr | ||||
|             .append($labelCell) | ||||
|             .append($inputCell) | ||||
|             .append($actionCell) | ||||
|             .append($multiplicityCell); | ||||
|  | ||||
|         if (valueAttr.type === 'label') { | ||||
|             if (definition.labelType === 'text') { | ||||
|                 $input.prop("type", "text"); | ||||
|  | ||||
|                 // no need to await for this, can be done asynchronously | ||||
|                 server.get('attributes/values/' + encodeURIComponent(valueAttr.name)).then(attributeValues => { | ||||
|                     if (attributeValues.length === 0) { | ||||
|                         return; | ||||
|                     } | ||||
|  | ||||
|                     $input.autocomplete({ | ||||
|                         // shouldn't be required and autocomplete should just accept array of strings, but that fails | ||||
|                         // because we have overriden filter() function in autocomplete.js | ||||
|                         source: attributeValues.map(attribute => { | ||||
|                             return { | ||||
|                                 attribute: attribute, | ||||
|                                 value: attribute | ||||
|                             } | ||||
|                         }), | ||||
|                         minLength: 0 | ||||
|                     }); | ||||
|  | ||||
|                     $input.focus(() => $input.autocomplete("search", "")); | ||||
|                 }); | ||||
|             } | ||||
|             else if (definition.labelType === 'number') { | ||||
|                 $input.prop("type", "number"); | ||||
|             } | ||||
|             else if (definition.labelType === 'boolean') { | ||||
|                 $input.prop("type", "checkbox"); | ||||
|  | ||||
|                 if (valueAttr.value === "true") { | ||||
|                     $input.prop("checked", "checked"); | ||||
|                 } | ||||
|             } | ||||
|             else if (definition.labelType === 'date') { | ||||
|                 $input.prop("type", "text"); | ||||
|  | ||||
|                 $input.datepicker({ | ||||
|                     changeMonth: true, | ||||
|                     changeYear: true, | ||||
|                     yearRange: "c-200:c+10", | ||||
|                     dateFormat: "yy-mm-dd" | ||||
|                 }); | ||||
|  | ||||
|                 const $todayButton = $("<button>").addClass("btn btn-small").text("Today").click(() => { | ||||
|                     $input.val(utils.formatDateISO(new Date())); | ||||
|                     $input.trigger("change"); | ||||
|                 }); | ||||
|  | ||||
|                 $actionCell.append($todayButton); | ||||
|             } | ||||
|             else if (definition.labelType === 'url') { | ||||
|                 $input.prop("placeholder", "http://website..."); | ||||
|  | ||||
|                 const $openButton = $("<button>").addClass("btn btn-small").text("Open").click(() => { | ||||
|                     window.open($input.val(), '_blank'); | ||||
|                 }); | ||||
|  | ||||
|                 $actionCell.append($openButton); | ||||
|             } | ||||
|             else { | ||||
|                 messagingService.logError("Unknown labelType=" + definitionAttr.labelType); | ||||
|             } | ||||
|         } | ||||
|         else if (valueAttr.type === 'relation') { | ||||
|             if (valueAttr.value) { | ||||
|                 $input.val((await treeUtils.getNoteTitle(valueAttr.value) + " (" + valueAttr.value + ")")); | ||||
|             } | ||||
|  | ||||
|             // no need to wait for this | ||||
|             noteAutocompleteService.initNoteAutocomplete($input); | ||||
|  | ||||
|             // ideally we'd use link instead of button which would allow tooltip preview, but | ||||
|             // we can't guarantee updating the link in the a element | ||||
|             const $openButton = $("<button>").addClass("btn btn-small").text("Open").click(() => { | ||||
|                 const notePath = linkService.getNotePathFromLabel($input.val()); | ||||
|  | ||||
|                 treeService.activateNote(notePath); | ||||
|             }); | ||||
|  | ||||
|             $actionCell.append($openButton); | ||||
|         } | ||||
|         else { | ||||
|             messagingService.logError("Unknown attribute type=" + valueAttr.type); | ||||
|             return; | ||||
|         } | ||||
|  | ||||
|         $labelList.show(); | ||||
|     } | ||||
|     else { | ||||
|         $labelList.hide(); | ||||
|         if (definition.multiplicityType === "multivalue") { | ||||
|             const addButton = $("<span>") | ||||
|                 .addClass("glyphicon glyphicon-plus pointer") | ||||
|                 .prop("title", "Add new attribute") | ||||
|                 .click(async () => { | ||||
|                 const $new = await createRow(definitionAttr, { | ||||
|                     attributeId: "", | ||||
|                     type: valueAttr.type, | ||||
|                     name: definitionAttr.name, | ||||
|                     value: "" | ||||
|                 }); | ||||
|  | ||||
|                 $tr.after($new); | ||||
|  | ||||
|                 $new.find('input').focus(); | ||||
|             }); | ||||
|  | ||||
|             const removeButton = $("<span>") | ||||
|                 .addClass("glyphicon glyphicon-trash pointer") | ||||
|                 .prop("title", "Remove this attribute") | ||||
|                 .click(async () => { | ||||
|                 if (valueAttr.attributeId) { | ||||
|                     await server.remove("notes/" + noteId + "/attributes/" + valueAttr.attributeId); | ||||
|                 } | ||||
|  | ||||
|                 $tr.remove(); | ||||
|             }); | ||||
|  | ||||
|             $multiplicityCell.append(addButton).append("   ").append(removeButton); | ||||
|         } | ||||
|  | ||||
|         return $tr; | ||||
|     } | ||||
|  | ||||
|     return labels; | ||||
|     if (promoted.length > 0) { | ||||
|         const $tbody = $("<tbody>"); | ||||
|  | ||||
|         for (const definitionAttr of promoted) { | ||||
|             const definitionType = definitionAttr.type; | ||||
|             const valueType = definitionType.substr(0, definitionType.length - 11); | ||||
|  | ||||
|             let valueAttrs = attributes.filter(el => el.name === definitionAttr.name && el.type === valueType); | ||||
|  | ||||
|             if (valueAttrs.length === 0) { | ||||
|                 valueAttrs.push({ | ||||
|                     attributeId: "", | ||||
|                     type: valueType, | ||||
|                     name: definitionAttr.name, | ||||
|                     value: "" | ||||
|                 }); | ||||
|             } | ||||
|  | ||||
|             if (definitionAttr.value.multiplicityType === 'singlevalue') { | ||||
|                 valueAttrs = valueAttrs.slice(0, 1); | ||||
|             } | ||||
|  | ||||
|             for (const valueAttr of valueAttrs) { | ||||
|                 const $tr = await createRow(definitionAttr, valueAttr); | ||||
|  | ||||
|                 $tbody.append($tr); | ||||
|             } | ||||
|         } | ||||
|  | ||||
|         // we replace the whole content in one step so there can't be any race conditions | ||||
|         // (previously we saw promoted attributes doubling) | ||||
|         $promotedAttributesContainer.empty().append($tbody); | ||||
|     } | ||||
|     else { | ||||
|         $attributeListInner.html(''); | ||||
|  | ||||
|         if (attributes.length > 0) { | ||||
|             for (const attribute of attributes) { | ||||
|                 if (attribute.type === 'label') { | ||||
|                     $attributeListInner.append(utils.formatLabel(attribute) + " "); | ||||
|                 } | ||||
|                 else if (attribute.type === 'relation') { | ||||
|                     $attributeListInner.append(attribute.name + "="); | ||||
|                     $attributeListInner.append(await linkService.createNoteLink(attribute.value)); | ||||
|                     $attributeListInner.append(" "); | ||||
|                 } | ||||
|                 else if (attribute.type === 'label-definition' || attribute.type === 'relation-definition') { | ||||
|                     $attributeListInner.append(attribute.name + " definition "); | ||||
|                 } | ||||
|                 else { | ||||
|                     messagingService.logError("Unknown attr type: " + attribute.type); | ||||
|                 } | ||||
|             } | ||||
|  | ||||
|             $attributeList.show(); | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     return attributes; | ||||
| } | ||||
|  | ||||
| async function loadNote(noteId) { | ||||
| @@ -240,7 +456,7 @@ function focus() { | ||||
|     getComponent(note.type).focus(); | ||||
| } | ||||
|  | ||||
| messagingService.subscribeToMessages(syncData => { | ||||
| messagingService.subscribeToSyncMessages(syncData => { | ||||
|     if (syncData.some(sync => sync.entityName === 'notes' && sync.entityId === getCurrentNoteId())) { | ||||
|         infoService.showMessage('Reloading note because of background changes'); | ||||
|  | ||||
| @@ -248,6 +464,35 @@ messagingService.subscribeToMessages(syncData => { | ||||
|     } | ||||
| }); | ||||
|  | ||||
| $promotedAttributesContainer.on('change', '.promoted-attribute-input', async event => { | ||||
|     const $attr = $(event.target); | ||||
|  | ||||
|     let value; | ||||
|  | ||||
|     if ($attr.prop("type") === "checkbox") { | ||||
|         value = $attr.is(':checked') ? "true" : "false"; | ||||
|     } | ||||
|     else if ($attr.prop("attribute-type") === "relation") { | ||||
|         if ($attr.val()) { | ||||
|             value = treeUtils.getNoteIdFromNotePath(linkService.getNotePathFromLabel($attr.val())); | ||||
|         } | ||||
|     } | ||||
|     else { | ||||
|         value = $attr.val(); | ||||
|     } | ||||
|  | ||||
|     const result = await server.put("notes/" + getCurrentNoteId() + "/attribute", { | ||||
|         attributeId: $attr.prop("attribute-id"), | ||||
|         type: $attr.prop("attribute-type"), | ||||
|         name: $attr.prop("attribute-name"), | ||||
|         value: value | ||||
|     }); | ||||
|  | ||||
|     $attr.prop("attribute-id", result.attributeId); | ||||
|  | ||||
|     infoService.showMessage("Attribute has been saved."); | ||||
| }); | ||||
|  | ||||
| $(document).ready(() => { | ||||
|     $noteTitle.on('input', () => { | ||||
|         noteChanged(); | ||||
| @@ -276,7 +521,7 @@ export default { | ||||
|     getCurrentNoteId, | ||||
|     newNoteCreated, | ||||
|     focus, | ||||
|     loadLabelList, | ||||
|     loadAttributes, | ||||
|     saveNote, | ||||
|     saveNoteIfChanged, | ||||
|     noteChanged | ||||
|   | ||||
| @@ -32,7 +32,7 @@ async function show() { | ||||
|             lint: true, | ||||
|             gutters: ["CodeMirror-lint-markers"], | ||||
|             lineNumbers: true, | ||||
|             tabindex: 2 // so that tab from title will lead to code editor focus | ||||
|             tabindex: 100 | ||||
|         }); | ||||
|  | ||||
|         codeEditor.on('change', noteDetailService.noteChanged); | ||||
| @@ -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")) { | ||||
|   | ||||
| @@ -14,13 +14,13 @@ const $fileOpen = $("#file-open"); | ||||
| async function show() { | ||||
|     const currentNote = noteDetailService.getCurrentNote(); | ||||
|  | ||||
|     const labels = await server.get('notes/' + currentNote.noteId + '/labels'); | ||||
|     const labelMap = utils.toObject(labels, l => [l.name, l.value]); | ||||
|     const attributes = await server.get('notes/' + currentNote.noteId + '/attributes'); | ||||
|     const attributeMap = utils.toObject(attributes, l => [l.name, l.value]); | ||||
|  | ||||
|     $noteDetailFile.show(); | ||||
|  | ||||
|     $fileFileName.text(labelMap.original_file_name); | ||||
|     $fileFileSize.text(labelMap.file_size + " bytes"); | ||||
|     $fileFileName.text(attributeMap.originalFileName); | ||||
|     $fileFileSize.text(attributeMap.fileSize + " bytes"); | ||||
|     $fileFileType.text(currentNote.mime); | ||||
| } | ||||
|  | ||||
|   | ||||
| @@ -13,7 +13,7 @@ let codeEditorInitialized; | ||||
| async function show() { | ||||
|     codeEditorInitialized = false; | ||||
|  | ||||
|     $noteDetailRender.show(); | ||||
|     $noteDetailRender.empty().show(); | ||||
|  | ||||
|     await render(); | ||||
| } | ||||
|   | ||||
| @@ -11,7 +11,13 @@ async function show() { | ||||
|  | ||||
|         textEditor = await BalloonEditor.create($noteDetailText[0], {}); | ||||
|  | ||||
|         textEditor.model.document.on('change', noteDetailService.noteChanged); | ||||
|         textEditor.model.document.on('change', () => { | ||||
|                 // change is triggered on just marker/selection changes which is not interesting for us | ||||
|                 if (textEditor.model.document.differ.getChanges().length > 0) { | ||||
|                     noteDetailService.noteChanged(); | ||||
|                 } | ||||
|             } | ||||
|         ); | ||||
|     } | ||||
|  | ||||
|     textEditor.setData(noteDetailService.getCurrentNote().content); | ||||
|   | ||||
							
								
								
									
										9
									
								
								src/public/javascripts/services/options_init.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,9 @@ | ||||
| import server from "./server.js"; | ||||
|  | ||||
| const optionsReady = new Promise((resolve, reject) => { | ||||
|     $(document).ready(() => server.get('options').then(resolve)); | ||||
| }); | ||||
|  | ||||
| export default { | ||||
|     optionsReady | ||||
| } | ||||
| @@ -32,6 +32,7 @@ function ensureProtectedSession(requireProtectedSession, modal) { | ||||
|     const dfd = $.Deferred(); | ||||
|  | ||||
|     if (requireProtectedSession && !protectedSessionHolder.isProtectedSessionAvailable()) { | ||||
|         // using deferred instead of promise because it allows resolving from outside | ||||
|         protectedSessionDeferred = dfd; | ||||
|  | ||||
|         if (treeService.getCurrentNode().data.isProtected) { | ||||
| @@ -39,7 +40,9 @@ function ensureProtectedSession(requireProtectedSession, modal) { | ||||
|         } | ||||
|  | ||||
|         $dialog.dialog({ | ||||
|             modal: modal, | ||||
|             // everything is now non-modal, because modal dialog caused weird high CPU usage on opening | ||||
|             // and tearing of text input | ||||
|             modal: false, | ||||
|             width: 400, | ||||
|             open: () => { | ||||
|                 if (!modal) { | ||||
| @@ -80,11 +83,10 @@ async function setupProtectedSession() { | ||||
|         $noteDetailWrapper.show(); | ||||
|  | ||||
|         protectedSessionDeferred.resolve(); | ||||
|         protectedSessionDeferred = null; | ||||
|  | ||||
|         $protectedSessionOnButton.addClass('active'); | ||||
|         $protectedSessionOffButton.removeClass('active'); | ||||
|  | ||||
|         protectedSessionDeferred = null; | ||||
|     } | ||||
| } | ||||
|  | ||||
| @@ -105,6 +107,10 @@ async function enterProtectedSessionOnServer(password) { | ||||
| } | ||||
|  | ||||
| async function protectNoteAndSendToServer() { | ||||
|     if (noteDetailService.getCurrentNote().isProtected) { | ||||
|         return; | ||||
|     } | ||||
|  | ||||
|     await ensureProtectedSession(true, true); | ||||
|  | ||||
|     const note = noteDetailService.getCurrentNote(); | ||||
| @@ -118,7 +124,18 @@ async function protectNoteAndSendToServer() { | ||||
| } | ||||
|  | ||||
| async function unprotectNoteAndSendToServer() { | ||||
|     await ensureProtectedSession(true, true); | ||||
|     if (!noteDetailService.getCurrentNote().isProtected) { | ||||
|         return; | ||||
|     } | ||||
|  | ||||
|     if (!protectedSessionHolder.isProtectedSessionAvailable()) { | ||||
|         console.log("Unprotecting notes outside of protected session is not allowed."); | ||||
|         // the reason is that it's not easy to handle even with ensureProtectedSession, | ||||
|         // because we would first have to make sure the note is loaded and only then unprotect | ||||
|         // we used to have a bug where we would overwrite the previous note with unprotected content. | ||||
|  | ||||
|         return; | ||||
|     } | ||||
|  | ||||
|     const note = noteDetailService.getCurrentNote(); | ||||
|     note.isProtected = false; | ||||
|   | ||||
| @@ -1,13 +1,11 @@ | ||||
| import utils from "./utils.js"; | ||||
| import server from "./server.js"; | ||||
| import optionsInitService from './options_init.js'; | ||||
|  | ||||
| let lastProtectedSessionOperationDate = null; | ||||
| let protectedSessionTimeout = null; | ||||
| let protectedSessionId = null; | ||||
|  | ||||
| $(document).ready(() => { | ||||
|     server.get('options').then(options => protectedSessionTimeout = options.protectedSessionTimeout); | ||||
| }); | ||||
| optionsInitService.optionsReady.then(options => protectedSessionTimeout = options.protectedSessionTimeout); | ||||
|  | ||||
| setInterval(() => { | ||||
|     if (lastProtectedSessionOperationDate !== null && new Date().getTime() - lastProtectedSessionOperationDate.getTime() > protectedSessionTimeout * 1000) { | ||||
|   | ||||