mirror of
https://github.com/actualbudget/actual.git
synced 2026-03-10 20:23:07 -05:00
Compare commits
1081 Commits
v23.4.1
...
react-aria
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
58b57aefe1 | ||
|
|
e25683f130 | ||
|
|
496c76c7f9 | ||
|
|
b7d4964539 | ||
|
|
7479df359a | ||
|
|
b1b14d0813 | ||
|
|
b710b9675e | ||
|
|
f8fb4a9ba7 | ||
|
|
9f738956d7 | ||
|
|
dc9ce974a5 | ||
|
|
27974c63fd | ||
|
|
f81c452ba5 | ||
|
|
7072674111 | ||
|
|
16e887c917 | ||
|
|
572033debe | ||
|
|
b85f9102ce | ||
|
|
942aebedd0 | ||
|
|
32d830440a | ||
|
|
4575616961 | ||
|
|
4a0e2ea306 | ||
|
|
14ec9a9089 | ||
|
|
e91b4070aa | ||
|
|
6dd34b0c63 | ||
|
|
ab4639f48f | ||
|
|
aa3cbd881b | ||
|
|
8681c9c3e6 | ||
|
|
9ec9aef632 | ||
|
|
3be7dd753d | ||
|
|
259e84cea5 | ||
|
|
f9014f0e19 | ||
|
|
e59f5c9af8 | ||
|
|
771c01c8b4 | ||
|
|
9f72b43826 | ||
|
|
ec3475d834 | ||
|
|
5ea9c587a8 | ||
|
|
1e38055376 | ||
|
|
0ee9126820 | ||
|
|
9e455e4c1e | ||
|
|
d77b54f27b | ||
|
|
ff36d1efbe | ||
|
|
cbbbaf65cf | ||
|
|
f129b07dc9 | ||
|
|
f1caf21deb | ||
|
|
a28ea6be8f | ||
|
|
f36c5e002b | ||
|
|
803289ee1f | ||
|
|
76cdad4fe6 | ||
|
|
d03b30bc00 | ||
|
|
710d9ab8ac | ||
|
|
d008944022 | ||
|
|
f18bce6094 | ||
|
|
31eb00a155 | ||
|
|
a67c969189 | ||
|
|
58e6c6f23a | ||
|
|
f046d75b75 | ||
|
|
30bcfedc86 | ||
|
|
866b4d6cd4 | ||
|
|
a42938fa64 | ||
|
|
e02b0f9bc7 | ||
|
|
049a41f366 | ||
|
|
7f30680fb3 | ||
|
|
2d4256b239 | ||
|
|
247e3e8d93 | ||
|
|
5951b92668 | ||
|
|
a9ee670eb4 | ||
|
|
3990aaf38f | ||
|
|
48f5880f1d | ||
|
|
3332f58376 | ||
|
|
46ea8fbf72 | ||
|
|
6a21f8e3de | ||
|
|
f02ca4e3d2 | ||
|
|
7f658691bb | ||
|
|
5b1a730f11 | ||
|
|
0c14eb17c4 | ||
|
|
7bb0425c81 | ||
|
|
8832c2b234 | ||
|
|
437e202d27 | ||
|
|
d34f5eccb6 | ||
|
|
f1d3902e3e | ||
|
|
8b6ef7b325 | ||
|
|
6ad0b47c7c | ||
|
|
96964224f4 | ||
|
|
0ed5e3ebe6 | ||
|
|
64cd6ee3c9 | ||
|
|
abc4636662 | ||
|
|
ade25b3304 | ||
|
|
b192ad955e | ||
|
|
e9da476b51 | ||
|
|
12719e3049 | ||
|
|
e178d9914d | ||
|
|
6fd728aa2d | ||
|
|
bf26ca4eb9 | ||
|
|
f606d92c5c | ||
|
|
8b850f1410 | ||
|
|
c992e340ca | ||
|
|
06f9db06b0 | ||
|
|
2b96bb3d52 | ||
|
|
ebb9452b8f | ||
|
|
196f03b84e | ||
|
|
93e784a0fe | ||
|
|
026194e5e2 | ||
|
|
98341b440a | ||
|
|
417f5805a8 | ||
|
|
094f0b8a91 | ||
|
|
b89a32025a | ||
|
|
d62919a357 | ||
|
|
1c7d9bf141 | ||
|
|
6d117f44de | ||
|
|
64821e6a64 | ||
|
|
e7c6611c88 | ||
|
|
6220aadb2d | ||
|
|
2959054d0c | ||
|
|
7d960579f9 | ||
|
|
5fd1d05670 | ||
|
|
0e86dea544 | ||
|
|
c92266fd7f | ||
|
|
08aff05a06 | ||
|
|
1195af76a6 | ||
|
|
e4bc0caa51 | ||
|
|
c3783b6498 | ||
|
|
55ef74eabc | ||
|
|
9667e1c269 | ||
|
|
b803731bc5 | ||
|
|
8415e7cddc | ||
|
|
5f16349a19 | ||
|
|
aff5bba4ec | ||
|
|
d79b8c6cb2 | ||
|
|
bbc123c3b8 | ||
|
|
04273d8064 | ||
|
|
3f1fd55a7b | ||
|
|
bfda42a2be | ||
|
|
201f0fd336 | ||
|
|
99ab8db99f | ||
|
|
f604fdaf36 | ||
|
|
c311d4a8df | ||
|
|
1fc7c9974b | ||
|
|
e63fb46c8f | ||
|
|
2901b5e5d0 | ||
|
|
df7ad22377 | ||
|
|
b11a3dc267 | ||
|
|
5a77051a72 | ||
|
|
f8352808c4 | ||
|
|
e5933ad8cd | ||
|
|
e3b4c3f559 | ||
|
|
2d1abf477c | ||
|
|
392f8085c1 | ||
|
|
6021d4ab0b | ||
|
|
f1f1ba87e2 | ||
|
|
7cee3b86ce | ||
|
|
9acea32f3a | ||
|
|
44770a36c7 | ||
|
|
3e03718f13 | ||
|
|
9c3075f60f | ||
|
|
7e04226b34 | ||
|
|
b0f55fae38 | ||
|
|
7c658da15d | ||
|
|
ddddec029f | ||
|
|
98d4cf450e | ||
|
|
243703b2f7 | ||
|
|
e917c96407 | ||
|
|
8a70d2464d | ||
|
|
dcb25bb5e6 | ||
|
|
f20e9c6bdb | ||
|
|
f73f6c7f0d | ||
|
|
37457cd6cd | ||
|
|
c16f4c71c5 | ||
|
|
452ca82287 | ||
|
|
ffddd9e8a5 | ||
|
|
2178da0414 | ||
|
|
f8809df59c | ||
|
|
04bc0c3c64 | ||
|
|
0aa6a057b1 | ||
|
|
b34aaab5f5 | ||
|
|
688bfe59e8 | ||
|
|
8f543a29e0 | ||
|
|
ad73a404c4 | ||
|
|
4ed9f4fff4 | ||
|
|
176c8466a4 | ||
|
|
703c319f7f | ||
|
|
ca8a8174c4 | ||
|
|
f8af8f95ac | ||
|
|
98a7aac736 | ||
|
|
d49f907f53 | ||
|
|
164dd399b0 | ||
|
|
f41d3f22c7 | ||
|
|
469c789c14 | ||
|
|
6ec3728280 | ||
|
|
c8cc479358 | ||
|
|
51ad488ce2 | ||
|
|
985411d48f | ||
|
|
53b5f3a0fc | ||
|
|
3ec4ef71c6 | ||
|
|
5df02c19dc | ||
|
|
464c9de6fb | ||
|
|
aad609f427 | ||
|
|
7313cf722d | ||
|
|
978a658c1e | ||
|
|
cdad43ff32 | ||
|
|
4c29afd424 | ||
|
|
c98a21b030 | ||
|
|
f5fde34952 | ||
|
|
59f854c075 | ||
|
|
85affae70f | ||
|
|
57d4cc57cd | ||
|
|
7d892d8164 | ||
|
|
84fbc7e464 | ||
|
|
5f77b87b07 | ||
|
|
97ec8f8d3a | ||
|
|
407ad4deee | ||
|
|
1c04aeae39 | ||
|
|
02e7d036d5 | ||
|
|
16ef674910 | ||
|
|
19bcfbe6e9 | ||
|
|
a2c1c2dea6 | ||
|
|
e9b5bfcc53 | ||
|
|
672dd5ea4b | ||
|
|
b101f9989b | ||
|
|
cf9b8c357e | ||
|
|
c71e1d2e8a | ||
|
|
1e462714e4 | ||
|
|
d9f55460dd | ||
|
|
77a670bbc3 | ||
|
|
1a1fe9ac9d | ||
|
|
16df116d1d | ||
|
|
d7075ae551 | ||
|
|
e500cba7e9 | ||
|
|
2d188b3941 | ||
|
|
e88ea69801 | ||
|
|
9aeab0ff5b | ||
|
|
36c700d92d | ||
|
|
eed6105222 | ||
|
|
291e3a8d14 | ||
|
|
edd34b7903 | ||
|
|
345ea71eed | ||
|
|
d89a016ab1 | ||
|
|
b87951855b | ||
|
|
70e37c0119 | ||
|
|
f55bd860ba | ||
|
|
0f960df8cf | ||
|
|
915c562545 | ||
|
|
770d86258f | ||
|
|
a955fe2474 | ||
|
|
4e9130ac29 | ||
|
|
e94a5505d8 | ||
|
|
1ee2cbec1c | ||
|
|
da6b039f10 | ||
|
|
310cc04a2b | ||
|
|
6fce10aea0 | ||
|
|
97a664159b | ||
|
|
c2291d4b5d | ||
|
|
d6de90a296 | ||
|
|
90305965ba | ||
|
|
b655009477 | ||
|
|
2e600e52c7 | ||
|
|
b5f617dbe5 | ||
|
|
b3fc23201e | ||
|
|
6f251e6024 | ||
|
|
b356004f68 | ||
|
|
66dc593fa5 | ||
|
|
8b34ba3958 | ||
|
|
2df774d9f5 | ||
|
|
c96c957a34 | ||
|
|
f3d7641ba9 | ||
|
|
83fd85f059 | ||
|
|
308f8339ae | ||
|
|
9697795279 | ||
|
|
9b40610f26 | ||
|
|
2a1c452aac | ||
|
|
1dd1c188d6 | ||
|
|
8f634099e2 | ||
|
|
7ee48e4c1d | ||
|
|
270705b3cd | ||
|
|
9588a76109 | ||
|
|
ada9e7da31 | ||
|
|
38ffccb903 | ||
|
|
f5023a7c07 | ||
|
|
bc6a0f8e60 | ||
|
|
5ee7d336ef | ||
|
|
24e42daa51 | ||
|
|
4bafd13c55 | ||
|
|
afaee6bc16 | ||
|
|
e44fbb3847 | ||
|
|
ff70c654a2 | ||
|
|
586a26968c | ||
|
|
107acdb36b | ||
|
|
5343030800 | ||
|
|
d7635755f2 | ||
|
|
501c6a02cc | ||
|
|
6281cc751e | ||
|
|
dc7cab501e | ||
|
|
f51376a4a5 | ||
|
|
6f600a4fee | ||
|
|
c82a6dc5ef | ||
|
|
ab97a3fbcd | ||
|
|
8b15c8cc17 | ||
|
|
02ec279a64 | ||
|
|
ab8c3c018a | ||
|
|
8356640e45 | ||
|
|
1767a32b3d | ||
|
|
5bcfc71be6 | ||
|
|
998efb9447 | ||
|
|
dc159d71a2 | ||
|
|
7db7b5c400 | ||
|
|
823b426952 | ||
|
|
8827169bfa | ||
|
|
90eaf2ba17 | ||
|
|
a5fa0f3bb6 | ||
|
|
4570459d85 | ||
|
|
76cbd44c75 | ||
|
|
0e0d960cd4 | ||
|
|
98c17bd5e0 | ||
|
|
601c9aa7df | ||
|
|
3b77609159 | ||
|
|
66261641a0 | ||
|
|
4b034468e3 | ||
|
|
b700aee87d | ||
|
|
9fca85209f | ||
|
|
a9362cc6f9 | ||
|
|
40296dc876 | ||
|
|
ed1e0ceb30 | ||
|
|
55817b0e70 | ||
|
|
6d7d12138c | ||
|
|
52f1f79c01 | ||
|
|
991fc4f450 | ||
|
|
4a8c692d06 | ||
|
|
0609f47cc3 | ||
|
|
0d1e6f2ee7 | ||
|
|
8568aebdbb | ||
|
|
bfb7c1d213 | ||
|
|
e526555748 | ||
|
|
45c4b262a2 | ||
|
|
e1f805b9c9 | ||
|
|
d0c11cd3af | ||
|
|
0c5bce8baf | ||
|
|
e650e00cb8 | ||
|
|
422996f8a7 | ||
|
|
9cad57c607 | ||
|
|
2a0f8335ed | ||
|
|
08cbdab2a1 | ||
|
|
ec2de3b387 | ||
|
|
65372e86a5 | ||
|
|
53a61000a4 | ||
|
|
485902af6b | ||
|
|
5a67b7e822 | ||
|
|
b994a6a74a | ||
|
|
b1b266e83c | ||
|
|
58626c0026 | ||
|
|
45094daf2f | ||
|
|
2bb7b3c2ee | ||
|
|
d6f610a326 | ||
|
|
8a8113a648 | ||
|
|
029e2f09bf | ||
|
|
86007e392f | ||
|
|
4c4f2fd426 | ||
|
|
7a18827b1d | ||
|
|
77fd65b2e7 | ||
|
|
30f03e8079 | ||
|
|
6e16262b63 | ||
|
|
29a515f3fe | ||
|
|
a5ab1a8fae | ||
|
|
d37622162a | ||
|
|
e3a8366dd7 | ||
|
|
d5e49dde59 | ||
|
|
f5258e6ebe | ||
|
|
f4d80fad92 | ||
|
|
3324dd5fa0 | ||
|
|
14509d15df | ||
|
|
9b461c48c9 | ||
|
|
55f2d126b3 | ||
|
|
6ae2047ab8 | ||
|
|
9fdffcc8e9 | ||
|
|
3daff4381f | ||
|
|
5914469b11 | ||
|
|
39e7f2598b | ||
|
|
c8d326d24b | ||
|
|
d8639a2a71 | ||
|
|
734191424b | ||
|
|
5d4fcfde00 | ||
|
|
54d7e5460a | ||
|
|
43ebe9e0fd | ||
|
|
515bdf5a74 | ||
|
|
018714610a | ||
|
|
00ee165f8e | ||
|
|
68442ae9e6 | ||
|
|
b937bfae04 | ||
|
|
317e7f135e | ||
|
|
5adb083575 | ||
|
|
524bd4e9eb | ||
|
|
9dfd6ce34c | ||
|
|
5d28bc0e3b | ||
|
|
a4e97e0070 | ||
|
|
a6e38ad2ae | ||
|
|
bb0ae4ebc3 | ||
|
|
dd29e02c5c | ||
|
|
75186183eb | ||
|
|
83f13cbdc8 | ||
|
|
0045d9212e | ||
|
|
dd254c6c23 | ||
|
|
c66d6e00f5 | ||
|
|
ae3be13aa8 | ||
|
|
cd9d1fb674 | ||
|
|
edba670d3a | ||
|
|
fbb1a9647d | ||
|
|
06cf65497f | ||
|
|
62a0a0fedc | ||
|
|
106dce25dd | ||
|
|
ff11399180 | ||
|
|
363c9e4afb | ||
|
|
e745a4073b | ||
|
|
7d1a895b48 | ||
|
|
a8b42bcd50 | ||
|
|
f33bce41ea | ||
|
|
cdefe6133f | ||
|
|
44a5199a31 | ||
|
|
dccad902d6 | ||
|
|
b477f7c2f1 | ||
|
|
540c410957 | ||
|
|
e205344867 | ||
|
|
8b968579b1 | ||
|
|
6ce794ffcc | ||
|
|
d5359a96ca | ||
|
|
3eee0b11d2 | ||
|
|
e792afb1fd | ||
|
|
4fb55d0d70 | ||
|
|
b330991855 | ||
|
|
165ad45822 | ||
|
|
295917036b | ||
|
|
ef9a7cfe85 | ||
|
|
4ece4a7ff6 | ||
|
|
761b3c6a16 | ||
|
|
7ace8c52dd | ||
|
|
f6dd0ecdb9 | ||
|
|
97a4296d7c | ||
|
|
89698480a5 | ||
|
|
1a6db82cfb | ||
|
|
6ce502ea24 | ||
|
|
fd962a97b0 | ||
|
|
300ed824f0 | ||
|
|
caca2497ea | ||
|
|
33e778fe9f | ||
|
|
8c43c78fc7 | ||
|
|
e0d82fd4f9 | ||
|
|
b6462347a9 | ||
|
|
8bf0f8e5bf | ||
|
|
bc07235017 | ||
|
|
794476ac51 | ||
|
|
30bc216142 | ||
|
|
6f8d2574ab | ||
|
|
9262b46428 | ||
|
|
8e1c11e18e | ||
|
|
6b5ee3f774 | ||
|
|
02d3f96c20 | ||
|
|
829c83afb2 | ||
|
|
5d29585fb7 | ||
|
|
882fd9f5cd | ||
|
|
d203def230 | ||
|
|
4d7cfab8bc | ||
|
|
84a9269ae4 | ||
|
|
83d2472a55 | ||
|
|
458d556e51 | ||
|
|
d9aeb8db1e | ||
|
|
85c0352abb | ||
|
|
7a40a645e6 | ||
|
|
7e9921e9e5 | ||
|
|
02aff1acbe | ||
|
|
e0d66d3083 | ||
|
|
d999e466b7 | ||
|
|
e589163bb7 | ||
|
|
d2b86d100c | ||
|
|
d8996405c4 | ||
|
|
e548125907 | ||
|
|
edbcaba89b | ||
|
|
b39d106c04 | ||
|
|
4894118809 | ||
|
|
c6723da780 | ||
|
|
7a8c320560 | ||
|
|
6fbf0fdc10 | ||
|
|
efaefcfaa9 | ||
|
|
73d281b6ee | ||
|
|
03e943f383 | ||
|
|
5854dffd50 | ||
|
|
c727e3e980 | ||
|
|
b385c715ef | ||
|
|
77e550f028 | ||
|
|
9ea8e86bb3 | ||
|
|
273fa8cd59 | ||
|
|
d4c8b5fa16 | ||
|
|
e62e8ca24e | ||
|
|
4497fbcb10 | ||
|
|
8a15caf42d | ||
|
|
af9efa09f0 | ||
|
|
a5557ca032 | ||
|
|
0bf587bcf5 | ||
|
|
e11b65719e | ||
|
|
c09a85f340 | ||
|
|
f90fe04b2b | ||
|
|
ca55d9c85e | ||
|
|
4ada92071e | ||
|
|
e848946898 | ||
|
|
9da05543c3 | ||
|
|
8a721bf2e0 | ||
|
|
36914abcd4 | ||
|
|
cd2d186599 | ||
|
|
b3bcff094d | ||
|
|
7955fe7aed | ||
|
|
2741422c0a | ||
|
|
cde4551eb7 | ||
|
|
7174fccfe4 | ||
|
|
0303292a28 | ||
|
|
f95df06800 | ||
|
|
3680d45814 | ||
|
|
dc872647a9 | ||
|
|
7a45d467b7 | ||
|
|
79aa07ff1a | ||
|
|
c4ff099f26 | ||
|
|
ad2b577515 | ||
|
|
5f52801869 | ||
|
|
6bfd9586e0 | ||
|
|
f36713e0be | ||
|
|
8b20169b59 | ||
|
|
da03acc394 | ||
|
|
5d1f2d48ae | ||
|
|
c96782d7c1 | ||
|
|
5d61dfce68 | ||
|
|
5a81a25b7e | ||
|
|
29f323e721 | ||
|
|
c462604833 | ||
|
|
e48f36cd20 | ||
|
|
1ed84059d7 | ||
|
|
5d8b96a749 | ||
|
|
0920bdc3fa | ||
|
|
c3a9b4dda3 | ||
|
|
eab0068428 | ||
|
|
0d1b962b2f | ||
|
|
67b9143dfc | ||
|
|
e7f298e32a | ||
|
|
516f0c259f | ||
|
|
2c87c60328 | ||
|
|
b4fd6e86ed | ||
|
|
a3e71a8b49 | ||
|
|
aa9c992f2e | ||
|
|
ca498b19cc | ||
|
|
8be2389fd9 | ||
|
|
46988800ef | ||
|
|
881999bcfe | ||
|
|
9c124e8e44 | ||
|
|
3306963c54 | ||
|
|
baa474843a | ||
|
|
6ed1b58321 | ||
|
|
28266ed9a2 | ||
|
|
cd6bd9ece8 | ||
|
|
fb2f712c16 | ||
|
|
f58fae8d16 | ||
|
|
a10b10a87b | ||
|
|
47007232b8 | ||
|
|
4761e9ce2f | ||
|
|
849262c95e | ||
|
|
e6e184c412 | ||
|
|
9c6d9ecf0a | ||
|
|
65273127f5 | ||
|
|
b5530085bb | ||
|
|
9ec82d52c9 | ||
|
|
93922567fc | ||
|
|
7c0b7a2f17 | ||
|
|
e8055bbc35 | ||
|
|
7fdd9767ba | ||
|
|
1f105999af | ||
|
|
f970263c72 | ||
|
|
fecc9178b3 | ||
|
|
08c80b6f58 | ||
|
|
a3995582c4 | ||
|
|
5008eb022f | ||
|
|
353e7be31f | ||
|
|
30c7024663 | ||
|
|
22efe74ec8 | ||
|
|
5792b15cb5 | ||
|
|
168b79a178 | ||
|
|
7062f2a7d9 | ||
|
|
af666458d3 | ||
|
|
184b302273 | ||
|
|
3004f336da | ||
|
|
45bb7696c3 | ||
|
|
199a9f838d | ||
|
|
df5aa3186c | ||
|
|
1c68c3f964 | ||
|
|
a15f80e771 | ||
|
|
be18891957 | ||
|
|
a6dae398da | ||
|
|
d8c885bf4e | ||
|
|
f94d8aff9f | ||
|
|
a549cdf0e7 | ||
|
|
b7a2f16246 | ||
|
|
0ec88ecc24 | ||
|
|
39fa9f2097 | ||
|
|
9040cab600 | ||
|
|
e56cb4bc85 | ||
|
|
fc08baf7ae | ||
|
|
7325153da1 | ||
|
|
e12793e7eb | ||
|
|
5fe146ee0a | ||
|
|
0af2987cef | ||
|
|
e266093a4a | ||
|
|
19f0efb654 | ||
|
|
e0b2eab475 | ||
|
|
25f4e2a8b5 | ||
|
|
d338a73794 | ||
|
|
75f2bf8b1b | ||
|
|
eb54487d8e | ||
|
|
5cc6bad34b | ||
|
|
acf4456077 | ||
|
|
b45615e6fc | ||
|
|
38609ee25a | ||
|
|
cae2b9cd5a | ||
|
|
4cacc845c0 | ||
|
|
3dfbd23f96 | ||
|
|
21effa654d | ||
|
|
c33dc6fbad | ||
|
|
057caf127a | ||
|
|
767bc8ecb6 | ||
|
|
bdf5c45cda | ||
|
|
3dfe633428 | ||
|
|
efba86a72d | ||
|
|
316da144e1 | ||
|
|
d3ab8f9812 | ||
|
|
0ea7f1852b | ||
|
|
3c341fc583 | ||
|
|
de90504a6d | ||
|
|
f6e2d3b1f3 | ||
|
|
f1973d55c0 | ||
|
|
476070b0a7 | ||
|
|
510f635bbc | ||
|
|
d1e57340b8 | ||
|
|
9ca36f3eeb | ||
|
|
05f0df4917 | ||
|
|
5f347bbe40 | ||
|
|
3c4f62bd51 | ||
|
|
abd2d424a6 | ||
|
|
89c5f15c1f | ||
|
|
2a597fb3b8 | ||
|
|
2f97c3a1e2 | ||
|
|
ba5174ddfa | ||
|
|
9a900f9ff1 | ||
|
|
2081e25cf5 | ||
|
|
49ab358cf2 | ||
|
|
efb72af1b8 | ||
|
|
16334f67a5 | ||
|
|
ddb78af57d | ||
|
|
9e2226e384 | ||
|
|
a1dd6cf3dc | ||
|
|
2ee023ac22 | ||
|
|
3496ac28f3 | ||
|
|
f6f496f656 | ||
|
|
68147d654f | ||
|
|
3d29cfbed5 | ||
|
|
fbd1097a0c | ||
|
|
0cc34798fb | ||
|
|
b6100cfecd | ||
|
|
d835b113f8 | ||
|
|
4e4d20ad31 | ||
|
|
8167ea8a83 | ||
|
|
42e1b5ca7e | ||
|
|
55285f4c5f | ||
|
|
5565116c34 | ||
|
|
addd8e0f5f | ||
|
|
0e66ebfeef | ||
|
|
05f2e2af1a | ||
|
|
8ff89a5ab4 | ||
|
|
faa4a7c0e2 | ||
|
|
ba4885cb85 | ||
|
|
a8a0f777e2 | ||
|
|
852b5373bc | ||
|
|
bca09023e2 | ||
|
|
4d8efccc73 | ||
|
|
0e539d91fe | ||
|
|
2ff1e67e8a | ||
|
|
35c3d54688 | ||
|
|
50f60b18e7 | ||
|
|
bc5c2ce059 | ||
|
|
21d5f117ee | ||
|
|
319d196e93 | ||
|
|
96863b3196 | ||
|
|
9fde36dca1 | ||
|
|
108daedff5 | ||
|
|
bf786e8872 | ||
|
|
3eb09b66ec | ||
|
|
cb00826f51 | ||
|
|
15ab80a475 | ||
|
|
3e32db74cc | ||
|
|
db07d7a73d | ||
|
|
65899b0ef0 | ||
|
|
63c3d07cb9 | ||
|
|
246e0d76c1 | ||
|
|
1a15d2c039 | ||
|
|
90e32df3fb | ||
|
|
8ef2c4013a | ||
|
|
a460bc25d6 | ||
|
|
59de6b0035 | ||
|
|
3931625133 | ||
|
|
6a0b7d6b7d | ||
|
|
6817c45ddc | ||
|
|
ad4c383adf | ||
|
|
8864e79db1 | ||
|
|
dd7a7fa796 | ||
|
|
d4422f89aa | ||
|
|
cdff98b109 | ||
|
|
835e16e54d | ||
|
|
d46afab6dd | ||
|
|
05e582793d | ||
|
|
fc62d85c23 | ||
|
|
940af6d367 | ||
|
|
c19717e84c | ||
|
|
080951fb34 | ||
|
|
245c59e942 | ||
|
|
99dc87d715 | ||
|
|
e48924d987 | ||
|
|
2b06a42ac5 | ||
|
|
d8c99221ff | ||
|
|
7c48e53329 | ||
|
|
a0290609f9 | ||
|
|
821fa724e8 | ||
|
|
5adab1885b | ||
|
|
240dc46a23 | ||
|
|
3d621c68cb | ||
|
|
dd47b6c6ad | ||
|
|
37cec4c46f | ||
|
|
108c0a6176 | ||
|
|
ec24d0eaae | ||
|
|
6912c082b1 | ||
|
|
8a6c54c4d5 | ||
|
|
85f21550cb | ||
|
|
fe8ed4e346 | ||
|
|
2d0464c097 | ||
|
|
6dfc43abf1 | ||
|
|
c52900e713 | ||
|
|
4378489d80 | ||
|
|
ca5977db75 | ||
|
|
639720b6fd | ||
|
|
79f4d02350 | ||
|
|
f8afb396ed | ||
|
|
884ab8c783 | ||
|
|
ac055dc2e0 | ||
|
|
32cc86ec99 | ||
|
|
6fae79560e | ||
|
|
f8ce38f11e | ||
|
|
b114a8f8fc | ||
|
|
0228072c6f | ||
|
|
efec507bf8 | ||
|
|
1d65184241 | ||
|
|
d3f9a9c3a0 | ||
|
|
91217b6d5e | ||
|
|
af875ab035 | ||
|
|
8ada28775e | ||
|
|
6ebcbc8738 | ||
|
|
42af73cdff | ||
|
|
71f885b899 | ||
|
|
b325bd9b18 | ||
|
|
a0ecd65e70 | ||
|
|
2ef0fc9415 | ||
|
|
ba6eb26e6e | ||
|
|
b208294185 | ||
|
|
448c4546f2 | ||
|
|
b2738db441 | ||
|
|
c667118f10 | ||
|
|
218a4a761a | ||
|
|
e1dc58d456 | ||
|
|
e9137fccc7 | ||
|
|
facc3acf31 | ||
|
|
c2d5d475b9 | ||
|
|
e17d90ce5f | ||
|
|
9fed15f88b | ||
|
|
4a9b30d4d5 | ||
|
|
40d94141c4 | ||
|
|
023badc39c | ||
|
|
e78430db62 | ||
|
|
8ee4768f58 | ||
|
|
c581a8016c | ||
|
|
293692d5c5 | ||
|
|
7b7e6e4db0 | ||
|
|
cd54a2093e | ||
|
|
92099dc763 | ||
|
|
9d27379b25 | ||
|
|
b84546826f | ||
|
|
421aa65e6d | ||
|
|
fac3af6360 | ||
|
|
a9d34dfcc8 | ||
|
|
9bfbf229db | ||
|
|
2d11c0f61e | ||
|
|
fe033d68cf | ||
|
|
64ad07b9db | ||
|
|
e7f8288244 | ||
|
|
5f92920195 | ||
|
|
907571bd83 | ||
|
|
5bc37379fc | ||
|
|
0d943516a3 | ||
|
|
5f76067190 | ||
|
|
81446fb4ef | ||
|
|
edf2e32c98 | ||
|
|
46af4556a9 | ||
|
|
d349354c9d | ||
|
|
a3c59f1ec3 | ||
|
|
73289148df | ||
|
|
abd7cf090a | ||
|
|
30d035f8c6 | ||
|
|
e8b3419933 | ||
|
|
fd5ace58b4 | ||
|
|
60e5f1ae85 | ||
|
|
61d707482a | ||
|
|
26d0bda8b2 | ||
|
|
a99e88b46c | ||
|
|
9fd4e6c8f7 | ||
|
|
410dbbc8b1 | ||
|
|
9273a0abcf | ||
|
|
f68cb4ae13 | ||
|
|
e7d8fdf590 | ||
|
|
9ef5fd12e0 | ||
|
|
2c69af2149 | ||
|
|
5dd59c0053 | ||
|
|
ebc943bd70 | ||
|
|
ee3d995117 | ||
|
|
9c527e3fce | ||
|
|
b8c90aa8d5 | ||
|
|
f6c5769d47 | ||
|
|
c06b1badc4 | ||
|
|
94a6b53f4a | ||
|
|
1d60635e3b | ||
|
|
a84f009ad9 | ||
|
|
c6e480e89c | ||
|
|
af53f06eac | ||
|
|
93a6df362c | ||
|
|
9a80a006ce | ||
|
|
a3e9971379 | ||
|
|
ac0d17e57e | ||
|
|
b9f0b0d1d7 | ||
|
|
7340b48b70 | ||
|
|
6146d659cc | ||
|
|
d757ffd641 | ||
|
|
5cd6b6b7c5 | ||
|
|
ec42dad728 | ||
|
|
a97b36ebdd | ||
|
|
717de22f58 | ||
|
|
d91d062c96 | ||
|
|
63d48032c5 | ||
|
|
f329fe21af | ||
|
|
ba2de7ece5 | ||
|
|
77ef86413d | ||
|
|
2860837741 | ||
|
|
e6acf52638 | ||
|
|
4c9dbcb96d | ||
|
|
617892eb93 | ||
|
|
986dc6c1c0 | ||
|
|
a7e7ff61ef | ||
|
|
1031bbbce7 | ||
|
|
9d31120f1a | ||
|
|
4bceaf8a25 | ||
|
|
185daf470d | ||
|
|
723cbcf99c | ||
|
|
c2ebfc72ef | ||
|
|
92ddaa568a | ||
|
|
1845ec8469 | ||
|
|
9bccacea47 | ||
|
|
b06d87d2ee | ||
|
|
fc6eb4be33 | ||
|
|
a3e3c78c5c | ||
|
|
927c859456 | ||
|
|
954309d034 | ||
|
|
91474f1f0c | ||
|
|
d6cb8674f7 | ||
|
|
2a627ef988 | ||
|
|
c76f39c0fa | ||
|
|
cc497b29ab | ||
|
|
8d112d2e93 | ||
|
|
e9f6d6ba4d | ||
|
|
88452ea519 | ||
|
|
ff7be0d637 | ||
|
|
02b1e03611 | ||
|
|
6e0c84ccad | ||
|
|
bd125d2915 | ||
|
|
2cc40cbff9 | ||
|
|
20825d66fe | ||
|
|
28db6fb32e | ||
|
|
583eb10b83 | ||
|
|
c1b99958f4 | ||
|
|
050f48ac2a | ||
|
|
e4ec5b3eb1 | ||
|
|
953ff45085 | ||
|
|
2ebea847c1 | ||
|
|
4e89a95e35 | ||
|
|
ede51872e2 | ||
|
|
d36569d258 | ||
|
|
70f00b6bb4 | ||
|
|
f5617aca1c | ||
|
|
bdaa78b919 | ||
|
|
32ecd52f2b | ||
|
|
f1c21be4a0 | ||
|
|
7a5bf2ffc4 | ||
|
|
f5ea9d0fda | ||
|
|
168b38fc4b | ||
|
|
de41301a44 | ||
|
|
7c0c440df2 | ||
|
|
0d636aa04c | ||
|
|
f4940dceb1 | ||
|
|
447f7d6459 | ||
|
|
d325e6b060 | ||
|
|
0286fa4ed0 | ||
|
|
df74e6ddd1 | ||
|
|
3b3770d6b9 | ||
|
|
e6d931729c | ||
|
|
7165a2159d | ||
|
|
9e03a5f7bd | ||
|
|
1bc988d9d1 | ||
|
|
dafbfb4198 | ||
|
|
e07ff45ae6 | ||
|
|
f41763b0b9 | ||
|
|
59a1c38d34 | ||
|
|
09d624c24b | ||
|
|
81afe28901 | ||
|
|
c88038e95e | ||
|
|
962ebc9ef0 | ||
|
|
1733179bfb | ||
|
|
2108712f2d | ||
|
|
982d57c9ae | ||
|
|
38b8000d2a | ||
|
|
d5387c5d46 | ||
|
|
081c9d068f | ||
|
|
aa503d6a74 | ||
|
|
ed50e2b392 | ||
|
|
ea4a68e06c | ||
|
|
45247e6d59 | ||
|
|
6fd2ad21ec | ||
|
|
b7d5602cce | ||
|
|
53df1a03a3 | ||
|
|
0f81d877ef | ||
|
|
a3ca5a26ae | ||
|
|
dacaef6fa4 | ||
|
|
78e7468715 | ||
|
|
d48add55b6 | ||
|
|
bb3ed4cea4 | ||
|
|
fddcdec81f | ||
|
|
0e7de456f6 | ||
|
|
64408495ee | ||
|
|
228cff3cfd | ||
|
|
f3f2c8485a | ||
|
|
83459b4c78 | ||
|
|
9d041aaa7a | ||
|
|
0c0f9e6ccf | ||
|
|
d6ed860bc3 | ||
|
|
c6443f24b2 | ||
|
|
2b64a49359 | ||
|
|
287fb9b9d6 | ||
|
|
c286f1c5f3 | ||
|
|
977e0c9008 | ||
|
|
497a3104f0 | ||
|
|
adc5e324a7 | ||
|
|
2c6cca6bf6 | ||
|
|
610c42a1ae | ||
|
|
fcb1bba7fa | ||
|
|
764a20a36b | ||
|
|
b8dbec46bb | ||
|
|
a86fd9cf06 | ||
|
|
8f16e0167c | ||
|
|
60a8f72be8 | ||
|
|
61a5b1a337 | ||
|
|
f8dfa5a6e0 | ||
|
|
9aea091f53 | ||
|
|
4bee4584dc | ||
|
|
05754d3e42 | ||
|
|
4f5fd6c463 | ||
|
|
06b2a8757e | ||
|
|
0e2b317eb8 | ||
|
|
15bc3c45a0 | ||
|
|
ded6ee8a65 | ||
|
|
c1af40ff5c | ||
|
|
f06edd723d | ||
|
|
02f1fe48c6 | ||
|
|
87d269ba5c | ||
|
|
6e6d765699 | ||
|
|
a25327d370 | ||
|
|
ed285e9ac5 | ||
|
|
c42d17897c | ||
|
|
be81091698 | ||
|
|
3cba838412 | ||
|
|
38357f7efa | ||
|
|
2c7b814d37 | ||
|
|
e1f7262f2a | ||
|
|
400078dce5 | ||
|
|
6de6ad661d | ||
|
|
a0dfb8afbd | ||
|
|
1d301ac78d | ||
|
|
8875f6d487 | ||
|
|
66bfef28c0 | ||
|
|
f03b8a3a14 | ||
|
|
62ebd0627d | ||
|
|
bb1a4747f5 | ||
|
|
d640859940 | ||
|
|
e660e1e727 | ||
|
|
ad89aea45c | ||
|
|
c73416bdb8 | ||
|
|
6253aaa015 | ||
|
|
0d2d861896 | ||
|
|
0baf4a094a | ||
|
|
353474dacd | ||
|
|
5f38b579fe | ||
|
|
1e2bc29a60 | ||
|
|
fafd162db0 | ||
|
|
ec5e98b934 | ||
|
|
18e3a16299 | ||
|
|
efe4194f9c | ||
|
|
4249a0beb1 | ||
|
|
461132b95e | ||
|
|
19af0b36a2 | ||
|
|
6fc4fc294a | ||
|
|
a6b6295426 | ||
|
|
029dfd4688 | ||
|
|
2587350d1e | ||
|
|
d400ebfda0 | ||
|
|
34c8a73ee5 | ||
|
|
4ecb58cd5c | ||
|
|
1305335f0a | ||
|
|
15b51921b2 | ||
|
|
54f9b712e4 | ||
|
|
5afd76fb45 | ||
|
|
54c8d5b7b8 | ||
|
|
e4e9267c08 | ||
|
|
655b677961 | ||
|
|
8faa7bd68d | ||
|
|
f618055aab | ||
|
|
54fa4bccf6 | ||
|
|
24d4070667 | ||
|
|
7b1c3665d5 | ||
|
|
7d80c3eda6 | ||
|
|
f7aa313aea | ||
|
|
c9576d98e4 | ||
|
|
bcc2abf472 | ||
|
|
933ca3ecca | ||
|
|
acaff825c1 | ||
|
|
f913d99c9f | ||
|
|
539cb0e5cf | ||
|
|
d2185909c3 | ||
|
|
646d0d90a4 | ||
|
|
66f7336be8 | ||
|
|
4cebdb537c | ||
|
|
3d4b0c0e25 | ||
|
|
99cf51c159 | ||
|
|
e6b5782c64 | ||
|
|
977296361c | ||
|
|
2d26cf3ad1 | ||
|
|
759a018346 | ||
|
|
4aab3dec0c | ||
|
|
bd14b51e1c | ||
|
|
2d7e0c3f7a | ||
|
|
c4d3a1ce76 | ||
|
|
db65e83722 | ||
|
|
71908b6fb9 | ||
|
|
851fa8c7f5 | ||
|
|
30684a47d7 | ||
|
|
4b712699a8 | ||
|
|
abc4552a78 | ||
|
|
a69d858328 | ||
|
|
9695043206 | ||
|
|
e036397614 | ||
|
|
43cd6b6347 | ||
|
|
9ee93f74fe | ||
|
|
89c065e401 | ||
|
|
6325a36847 | ||
|
|
76c69a6e70 | ||
|
|
c7f6ca4302 | ||
|
|
36b2d7d090 | ||
|
|
7c80a200d7 | ||
|
|
e8a62f89a1 | ||
|
|
b0c5a9389c | ||
|
|
a1d321d65e | ||
|
|
3ceb2d92ad | ||
|
|
0bcf6ea6f9 | ||
|
|
f8b73355ab | ||
|
|
38d2e69858 | ||
|
|
944c7ff30f | ||
|
|
e9188813fd | ||
|
|
131bb86711 | ||
|
|
eed097d41e | ||
|
|
10559a68b3 | ||
|
|
6e7e98e139 |
14
.devcontainer/devcontainer.json
Normal file
14
.devcontainer/devcontainer.json
Normal file
@@ -0,0 +1,14 @@
|
||||
// For format details, see https://aka.ms/devcontainer.json. For config options, see the
|
||||
// README at: https://github.com/devcontainers/templates/tree/main/src/docker-existing-docker-compose
|
||||
{
|
||||
"name": "Actual development",
|
||||
"dockerComposeFile": [
|
||||
"../docker-compose.yml",
|
||||
"docker-compose.yml"
|
||||
],
|
||||
// Alternatively:
|
||||
// "image": "mcr.microsoft.com/devcontainers/typescript-node:0-16",
|
||||
"service": "actual-development",
|
||||
"workspaceFolder": "/workspaces/${localWorkspaceFolderBasename}",
|
||||
"postCreateCommand": "yarn install"
|
||||
}
|
||||
6
.devcontainer/docker-compose.yml
Normal file
6
.devcontainer/docker-compose.yml
Normal file
@@ -0,0 +1,6 @@
|
||||
version: '3.8'
|
||||
services:
|
||||
actual-development:
|
||||
volumes:
|
||||
- ..:/workspaces:cached
|
||||
command: /bin/sh -c "while sleep 1000; do :; done"
|
||||
27
.eslintignore
Normal file
27
.eslintignore
Normal file
@@ -0,0 +1,27 @@
|
||||
packages/api/app/bundle.api.js
|
||||
packages/api/dist
|
||||
packages/api/@types
|
||||
packages/api/migrations
|
||||
|
||||
packages/crdt/dist
|
||||
|
||||
packages/desktop-client/bundle.browser.js
|
||||
packages/desktop-client/build/
|
||||
packages/desktop-client/build-stats/
|
||||
packages/desktop-client/public/kcab/
|
||||
packages/desktop-client/public/data/
|
||||
packages/desktop-client/**/node_modules/*
|
||||
packages/desktop-client/node_modules/
|
||||
packages/desktop-client/src/icons/**/*
|
||||
packages/desktop-client/test-results/
|
||||
|
||||
packages/desktop-electron/client-build/
|
||||
packages/desktop-electron/dist/
|
||||
|
||||
packages/import-ynab4/**/node_modules/*
|
||||
|
||||
packages/import-ynab5/**/node_modules/*
|
||||
|
||||
packages/loot-core/**/node_modules/*
|
||||
packages/loot-core/**/lib-dist/*
|
||||
packages/loot-core/**/proto/*
|
||||
294
.eslintrc.js
294
.eslintrc.js
@@ -1,3 +1,4 @@
|
||||
/* eslint-disable rulesdir/typography */
|
||||
const path = require('path');
|
||||
|
||||
const rulesDirPlugin = require('eslint-plugin-rulesdir');
|
||||
@@ -9,37 +10,105 @@ rulesDirPlugin.RULES_DIR = path.join(
|
||||
'rules',
|
||||
);
|
||||
|
||||
const ruleFCMsg =
|
||||
'Type the props argument and let TS infer or use ComponentType for a component prop';
|
||||
|
||||
const restrictedImportPatterns = [
|
||||
{
|
||||
group: ['*.api', '*.web', '*.electron'],
|
||||
message: 'Don’t directly reference imports from other platforms',
|
||||
},
|
||||
{
|
||||
group: ['uuid'],
|
||||
importNames: ['*'],
|
||||
message: "Use `import { v4 as uuidv4 } from 'uuid'` instead",
|
||||
},
|
||||
];
|
||||
|
||||
const restrictedImportColors = [
|
||||
{
|
||||
group: ['**/style', '**/colors'],
|
||||
importNames: ['colors'],
|
||||
message: 'Please use themes instead of colors',
|
||||
},
|
||||
];
|
||||
|
||||
module.exports = {
|
||||
plugins: ['prettier', 'import', 'rulesdir', '@typescript-eslint'],
|
||||
extends: ['react-app', 'plugin:@typescript-eslint/recommended'],
|
||||
extends: [
|
||||
'react-app',
|
||||
'plugin:react/recommended',
|
||||
'plugin:react/jsx-runtime',
|
||||
'plugin:prettier/recommended',
|
||||
'plugin:@typescript-eslint/recommended',
|
||||
'plugin:import/typescript',
|
||||
],
|
||||
parser: '@typescript-eslint/parser',
|
||||
parserOptions: { project: [path.join(__dirname, './tsconfig.json')] },
|
||||
reportUnusedDisableDirectives: true,
|
||||
globals: {
|
||||
globalThis: false,
|
||||
vi: true,
|
||||
},
|
||||
rules: {
|
||||
'prettier/prettier': 'error',
|
||||
'no-unused-vars': [
|
||||
'error',
|
||||
'prettier/prettier': 'warn',
|
||||
|
||||
// Note: base rule explicitly disabled in favor of the TS one
|
||||
'no-unused-vars': 'off',
|
||||
'@typescript-eslint/no-unused-vars': [
|
||||
'warn',
|
||||
{
|
||||
args: 'none',
|
||||
varsIgnorePattern: '^_',
|
||||
varsIgnorePattern: '^(_|React)',
|
||||
ignoreRestSiblings: true,
|
||||
},
|
||||
],
|
||||
|
||||
'no-restricted-globals': ['error'].concat(
|
||||
curly: ['warn', 'multi-line', 'consistent'],
|
||||
|
||||
'no-restricted-globals': ['warn'].concat(
|
||||
require('confusing-browser-globals').filter(g => g !== 'self'),
|
||||
),
|
||||
|
||||
'rulesdir/typography': 'error',
|
||||
'react/jsx-filename-extension': [
|
||||
'warn',
|
||||
{ extensions: ['.jsx', '.tsx'], allow: 'as-needed' },
|
||||
],
|
||||
'react/jsx-no-useless-fragment': 'warn',
|
||||
'react/self-closing-comp': 'warn',
|
||||
'react/no-unstable-nested-components': [
|
||||
'warn',
|
||||
{ allowAsProps: true, customValidators: ['formatter'] },
|
||||
],
|
||||
|
||||
'rulesdir/typography': 'warn',
|
||||
'rulesdir/prefer-if-statement': 'warn',
|
||||
|
||||
// https://github.com/eslint/eslint/issues/16954
|
||||
// https://github.com/eslint/eslint/issues/16953
|
||||
'no-loop-func': 'off',
|
||||
|
||||
// TODO: re-enable these rules
|
||||
'react-hooks/exhaustive-deps': 'off',
|
||||
// Do don't need this as we're using TypeScript
|
||||
'react/prop-types': 'off',
|
||||
|
||||
'import/no-useless-path-segments': 'error',
|
||||
// TODO: re-enable these rules
|
||||
'react/react-in-jsx-scope': 'off',
|
||||
|
||||
'no-var': 'warn',
|
||||
'react/jsx-curly-brace-presence': 'warn',
|
||||
'object-shorthand': ['warn', 'properties'],
|
||||
|
||||
'import/extensions': [
|
||||
'warn',
|
||||
'never',
|
||||
{
|
||||
json: 'always',
|
||||
},
|
||||
],
|
||||
'import/no-useless-path-segments': 'warn',
|
||||
'import/no-duplicates': ['warn', { 'prefer-inline': true }],
|
||||
'import/no-unused-modules': ['warn', { unusedExports: true }],
|
||||
'import/order': [
|
||||
'error',
|
||||
'warn',
|
||||
{
|
||||
alphabetize: {
|
||||
caseInsensitive: true,
|
||||
@@ -67,11 +136,208 @@ module.exports = {
|
||||
},
|
||||
],
|
||||
|
||||
'no-restricted-syntax': [
|
||||
'warn',
|
||||
{
|
||||
// forbid React.* as they are legacy https://twitter.com/dan_abramov/status/1308739731551858689
|
||||
selector:
|
||||
":matches(MemberExpression[object.name='React'], TSQualifiedName[left.name='React'])",
|
||||
message:
|
||||
'Using default React import is discouraged, please use named exports directly instead.',
|
||||
},
|
||||
{
|
||||
// forbid <a> in favor of <Link>
|
||||
selector: 'JSXOpeningElement[name.name="a"]',
|
||||
message: 'Using <a> is discouraged, please use <Link> instead.',
|
||||
},
|
||||
],
|
||||
'no-restricted-imports': [
|
||||
'warn',
|
||||
{ patterns: [...restrictedImportPatterns, ...restrictedImportColors] },
|
||||
],
|
||||
|
||||
'@typescript-eslint/ban-ts-comment': [
|
||||
'error',
|
||||
{ 'ts-ignore': 'allow-with-description' },
|
||||
],
|
||||
|
||||
// Rules disable during TS migration
|
||||
'@typescript-eslint/no-var-requires': 'off',
|
||||
'prefer-const': 'off',
|
||||
'prefer-const': 'warn',
|
||||
'prefer-spread': 'off',
|
||||
'@typescript-eslint/no-empty-function': 'off',
|
||||
'@typescript-eslint/no-unused-vars': 'off',
|
||||
'import/no-default-export': 'warn',
|
||||
},
|
||||
overrides: [
|
||||
{
|
||||
files: ['.eslintrc.js', './**/.eslintrc.js'],
|
||||
parserOptions: { project: null },
|
||||
rules: {
|
||||
'@typescript-eslint/consistent-type-exports': 'off',
|
||||
},
|
||||
},
|
||||
{
|
||||
files: [
|
||||
'./packages/desktop-client/**/*.{ts,tsx}',
|
||||
'./packages/loot-core/src/client/**/*.{ts,tsx}',
|
||||
],
|
||||
rules: {
|
||||
// enforce type over interface
|
||||
'@typescript-eslint/consistent-type-definitions': ['warn', 'type'],
|
||||
// enforce import type
|
||||
'@typescript-eslint/consistent-type-imports': [
|
||||
'warn',
|
||||
{ prefer: 'type-imports', fixStyle: 'inline-type-imports' },
|
||||
],
|
||||
'@typescript-eslint/ban-types': [
|
||||
'warn',
|
||||
{
|
||||
types: {
|
||||
// forbid FC as superflous
|
||||
FunctionComponent: { message: ruleFCMsg },
|
||||
FC: { message: ruleFCMsg },
|
||||
},
|
||||
extendDefaults: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
files: ['./packages/desktop-client/**/*'],
|
||||
excludedFiles: [
|
||||
'./packages/desktop-client/src/hooks/useNavigate.{ts,tsx}',
|
||||
],
|
||||
rules: {
|
||||
'no-restricted-imports': [
|
||||
'warn',
|
||||
{
|
||||
patterns: [
|
||||
{
|
||||
group: ['react-router-dom'],
|
||||
importNames: ['useNavigate'],
|
||||
message: 'Please use Actual’s useNavigate() hook instead.',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
files: ['./packages/loot-core/src/**/*'],
|
||||
rules: {
|
||||
'no-restricted-imports': [
|
||||
'warn',
|
||||
{
|
||||
patterns: [
|
||||
...restrictedImportPatterns,
|
||||
{
|
||||
group: ['loot-core/**'],
|
||||
message:
|
||||
'Please use relative imports in loot-core instead of importing from `loot-core/*`',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
files: [
|
||||
'packages/loot-core/src/types/**/*',
|
||||
'packages/loot-core/src/client/state-types/**/*',
|
||||
'**/icons/**/*',
|
||||
'**/{mocks,__mocks__}/**/*',
|
||||
// can't correctly resolve usages
|
||||
'**/*.{testing,electron,browser,web,api}.ts',
|
||||
],
|
||||
rules: { 'import/no-unused-modules': 'off' },
|
||||
},
|
||||
{
|
||||
files: [
|
||||
'./packages/desktop-client/src/style/index.*',
|
||||
'./packages/desktop-client/src/style/palette.*',
|
||||
],
|
||||
rules: {
|
||||
'no-restricted-imports': ['off', { patterns: restrictedImportColors }],
|
||||
},
|
||||
},
|
||||
{
|
||||
files: [
|
||||
'./packages/api/migrations/*',
|
||||
'./packages/loot-core/migrations/*',
|
||||
],
|
||||
rules: {
|
||||
'import/no-default-export': 'off',
|
||||
},
|
||||
},
|
||||
{
|
||||
// TODO: fix the issues in these files
|
||||
files: [
|
||||
'./packages/desktop-client/src/components/accounts/Account.jsx',
|
||||
'./packages/desktop-client/src/components/accounts/MobileAccount.jsx',
|
||||
'./packages/desktop-client/src/components/accounts/MobileAccounts.jsx',
|
||||
'./packages/desktop-client/src/components/App.tsx',
|
||||
'./packages/desktop-client/src/components/budget/BudgetCategories.jsx',
|
||||
'./packages/desktop-client/src/components/budget/BudgetSummaries.tsx',
|
||||
'./packages/desktop-client/src/components/budget/DynamicBudgetTable.tsx',
|
||||
'./packages/desktop-client/src/components/budget/index.tsx',
|
||||
'./packages/desktop-client/src/components/budget/MobileBudget.tsx',
|
||||
'./packages/desktop-client/src/components/budget/rollover/HoldMenu.tsx',
|
||||
'./packages/desktop-client/src/components/budget/rollover/TransferMenu.tsx',
|
||||
'./packages/desktop-client/src/components/common/Menu.tsx',
|
||||
'./packages/desktop-client/src/components/FinancesApp.tsx',
|
||||
'./packages/desktop-client/src/components/GlobalKeys.ts',
|
||||
'./packages/desktop-client/src/components/LoggedInUser.tsx',
|
||||
'./packages/desktop-client/src/components/manager/ManagementApp.jsx',
|
||||
'./packages/desktop-client/src/components/manager/subscribe/common.tsx',
|
||||
'./packages/desktop-client/src/components/ManageRules.tsx',
|
||||
'./packages/desktop-client/src/components/mobile/MobileAmountInput.jsx',
|
||||
'./packages/desktop-client/src/components/mobile/MobileNavTabs.tsx',
|
||||
'./packages/desktop-client/src/components/Modals.tsx',
|
||||
'./packages/desktop-client/src/components/modals/EditRule.jsx',
|
||||
'./packages/desktop-client/src/components/modals/ImportTransactions.jsx',
|
||||
'./packages/desktop-client/src/components/modals/MergeUnusedPayees.jsx',
|
||||
'./packages/desktop-client/src/components/Notifications.tsx',
|
||||
'./packages/desktop-client/src/components/payees/ManagePayees.jsx',
|
||||
'./packages/desktop-client/src/components/payees/ManagePayeesWithData.jsx',
|
||||
'./packages/desktop-client/src/components/payees/PayeeTable.tsx',
|
||||
'./packages/desktop-client/src/components/reports/graphs/tableGraph/ReportTable.tsx',
|
||||
'./packages/desktop-client/src/components/reports/graphs/tableGraph/ReportTableTotals.tsx',
|
||||
'./packages/desktop-client/src/components/reports/reports/CashFlowCard.jsx',
|
||||
'./packages/desktop-client/src/components/reports/reports/CustomReport.jsx',
|
||||
'./packages/desktop-client/src/components/reports/reports/NetWorthCard.jsx',
|
||||
'./packages/desktop-client/src/components/reports/SaveReportName.tsx',
|
||||
'./packages/desktop-client/src/components/reports/useReport.ts',
|
||||
'./packages/desktop-client/src/components/schedules/ScheduleDetails.jsx',
|
||||
'./packages/desktop-client/src/components/schedules/SchedulesTable.tsx',
|
||||
'./packages/desktop-client/src/components/select/DateSelect.tsx',
|
||||
'./packages/desktop-client/src/components/sidebar/Tools.tsx',
|
||||
'./packages/desktop-client/src/components/sort.tsx',
|
||||
'./packages/desktop-client/src/components/spreadsheet/useSheetValue.ts',
|
||||
'./packages/desktop-client/src/components/table.tsx',
|
||||
'./packages/desktop-client/src/components/Titlebar.tsx',
|
||||
'./packages/desktop-client/src/components/transactions/MobileTransaction.jsx',
|
||||
'./packages/desktop-client/src/components/transactions/SelectedTransactions.jsx',
|
||||
'./packages/desktop-client/src/components/transactions/SimpleTransactionsTable.jsx',
|
||||
'./packages/desktop-client/src/components/transactions/TransactionList.jsx',
|
||||
'./packages/desktop-client/src/components/transactions/TransactionsTable.jsx',
|
||||
'./packages/desktop-client/src/components/transactions/TransactionsTable.test.jsx',
|
||||
'./packages/desktop-client/src/hooks/useAccounts.ts',
|
||||
'./packages/desktop-client/src/hooks/useCategories.ts',
|
||||
'./packages/desktop-client/src/hooks/usePayees.ts',
|
||||
'./packages/desktop-client/src/hooks/useProperFocus.tsx',
|
||||
'./packages/desktop-client/src/hooks/useSelected.tsx',
|
||||
'./packages/loot-core/src/client/query-hooks.tsx',
|
||||
],
|
||||
rules: {
|
||||
'react-hooks/exhaustive-deps': 'off',
|
||||
},
|
||||
},
|
||||
],
|
||||
settings: {
|
||||
'import/resolver': {
|
||||
typescript: {
|
||||
alwaysTryTypes: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
6
.gitattributes
vendored
6
.gitattributes
vendored
@@ -8,8 +8,12 @@
|
||||
|
||||
# Declare files that will always have LF line endings on checkout.
|
||||
*.js text eol=lf
|
||||
*.ts text eol=lf
|
||||
*.sh text eol=lf
|
||||
*.tsx text eol=lf
|
||||
|
||||
yarn.lock text eol=lf
|
||||
|
||||
# Denote all files that are truly binary and should not be modified.
|
||||
*.png binary
|
||||
*.jpg binary
|
||||
*.jpg binary
|
||||
|
||||
3
.github/FUNDING.yml
vendored
Normal file
3
.github/FUNDING.yml
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
# Funding policies: https://actualbudget.org/docs/contributing/leadership/funding
|
||||
open_collective: actual
|
||||
github: actualbudget
|
||||
21
.github/ISSUE_TEMPLATE/bug-report.yml
vendored
21
.github/ISSUE_TEMPLATE/bug-report.yml
vendored
@@ -1,13 +1,20 @@
|
||||
name: Bug Report
|
||||
description: File a bug report also known as an issue or problem.
|
||||
title: '[Bug]: '
|
||||
labels: ['bug', 'needs triage']
|
||||
labels: ['bug']
|
||||
body:
|
||||
- type: markdown
|
||||
id: intro-md
|
||||
attributes:
|
||||
value: |
|
||||
Thanks for taking the time to fill out this bug report! Please ensure you provide as much information as possible to better assist in confirming and identifying a fix for the bug.
|
||||
- type: markdown
|
||||
id: intro-md
|
||||
attributes:
|
||||
value: |
|
||||
**IMPORTANT:** we use Github Issues only for BUG REPORTS and FEATURE REQUESTS. If you are looking for help/support - please reach out to the [community on Discord](https://discord.gg/pRYNYr4W5A). All non-bug and non-feature-request issues will be closed.
|
||||
|
||||
**Bank-sync problems (SimpleFin / GoCardless)?** Reach out via the [community Discord](https://discord.gg/pRYNYr4W5A) first and open an issue only if the community deems the issue to be a legitimate bug in Actual.
|
||||
- type: checkboxes
|
||||
id: existing-issue
|
||||
attributes:
|
||||
@@ -16,6 +23,8 @@ body:
|
||||
options:
|
||||
- label: 'I have searched and found no existing issue'
|
||||
required: true
|
||||
- label: 'I will be providing steps how to reproduce the bug (in most cases this will also mean uploading a demo budget file)'
|
||||
required: true
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
@@ -27,13 +36,6 @@ body:
|
||||
value: 'A bug happened!'
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: errors-received
|
||||
attributes:
|
||||
label: 'What error did you receive?'
|
||||
description: 'If you received an error or a message on the screen, please provide that here.'
|
||||
validations:
|
||||
required: false
|
||||
- type: markdown
|
||||
id: env-info
|
||||
attributes:
|
||||
@@ -47,7 +49,9 @@ body:
|
||||
- Locally via Yarn
|
||||
- Docker
|
||||
- Fly.io
|
||||
- Pikapods
|
||||
- NAS
|
||||
- Desktop App (Electron)
|
||||
- Other
|
||||
validations:
|
||||
required: false
|
||||
@@ -61,6 +65,7 @@ body:
|
||||
- Chrome
|
||||
- Safari
|
||||
- Microsoft Edge
|
||||
- Desktop App (Electron)
|
||||
- Other
|
||||
- type: dropdown
|
||||
id: operating-system
|
||||
|
||||
7
.github/ISSUE_TEMPLATE/config.yml
vendored
7
.github/ISSUE_TEMPLATE/config.yml
vendored
@@ -1 +1,8 @@
|
||||
blank_issues_enabled: false
|
||||
contact_links:
|
||||
- name: Bank-sync issues
|
||||
url: https://discord.gg/pRYNYr4W5A
|
||||
about: Is bank-sync not working? Returning too much or too few information? Reach out to the community on Discord.
|
||||
- name: Support
|
||||
url: https://discord.gg/pRYNYr4W5A
|
||||
about: Need help with something? Having troubles setting up? Or perhaps issues using the API? Reach out to the community on Discord.
|
||||
|
||||
2
.github/ISSUE_TEMPLATE/feature-request.yml
vendored
2
.github/ISSUE_TEMPLATE/feature-request.yml
vendored
@@ -1,7 +1,7 @@
|
||||
name: Feature request
|
||||
description: Request a missing feature
|
||||
title: '[Feature] '
|
||||
labels: ['feature', 'needs triage']
|
||||
labels: ['feature']
|
||||
body:
|
||||
- type: markdown
|
||||
id: intro-md
|
||||
|
||||
60
.github/actions/check-migrations.js
vendored
Normal file
60
.github/actions/check-migrations.js
vendored
Normal file
@@ -0,0 +1,60 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
// overview:
|
||||
// 1. Identify the migrations in packages/loot-core/migrations/* on `master` and HEAD
|
||||
// 2. Make sure that any new migrations on HEAD are dated after the latest migration on `master`.
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const { spawnSync } = require('child_process');
|
||||
|
||||
const migrationsDir = path.join(
|
||||
__dirname,
|
||||
'..',
|
||||
'..',
|
||||
'packages',
|
||||
'loot-core',
|
||||
'migrations',
|
||||
);
|
||||
|
||||
function readMigrations(ref) {
|
||||
const { stdout } = spawnSync('git', [
|
||||
'ls-tree',
|
||||
'--name-only',
|
||||
ref,
|
||||
migrationsDir + '/',
|
||||
]);
|
||||
const files = stdout.toString().split('\n').filter(Boolean);
|
||||
console.log(`Found ${files.length} migrations on ${ref}.`);
|
||||
return files
|
||||
.map(file => path.basename(file))
|
||||
.filter(file => !file.startsWith('.'))
|
||||
.map(name => ({
|
||||
date: parseInt(name.split('_')[0]),
|
||||
name: name.match(/^\d+_(.+?)(\.sql)?$/)?.[1] ?? '***' + name,
|
||||
}));
|
||||
}
|
||||
|
||||
spawnSync('git', ['fetch', 'origin', 'master']);
|
||||
let masterMigrations = readMigrations('origin/master');
|
||||
let headMigrations = readMigrations('HEAD');
|
||||
|
||||
let latestMasterMigration = masterMigrations[masterMigrations.length - 1].date;
|
||||
let newMigrations = headMigrations.filter(
|
||||
migration => !masterMigrations.find(m => m.name === migration.name),
|
||||
);
|
||||
let badMigrations = newMigrations.filter(
|
||||
migration => migration.date <= latestMasterMigration,
|
||||
);
|
||||
|
||||
if (badMigrations.length) {
|
||||
console.error(
|
||||
`The following migrations are dated before the latest migration on master:`,
|
||||
);
|
||||
badMigrations.forEach(migration => {
|
||||
console.error(` ${migration.name}`);
|
||||
});
|
||||
process.exit(1);
|
||||
} else {
|
||||
console.log(`All migrations are dated after the latest migration on master.`);
|
||||
}
|
||||
184
.github/actions/handle-feature-requests.js
vendored
Normal file
184
.github/actions/handle-feature-requests.js
vendored
Normal file
@@ -0,0 +1,184 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
// overview:
|
||||
// 1. Fetch the issues that are linked to the PR
|
||||
// 2. Filter out the issues that are not feature requests
|
||||
// 3. For each feature request:
|
||||
// 1. Remove the 'help wanted' & 'needs votes' labels
|
||||
// 3. Find the automated comment, hide the comment as 'outdated'
|
||||
// 5. Post a new comment saying that the feature request has been implemented, and will be released in the next version. Link to the PR.
|
||||
|
||||
async function makeAPIRequest(query, variables) {
|
||||
const res = await fetch('https://api.github.com/graphql', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${process.env.GITHUB_TOKEN}`,
|
||||
'Content-Type': 'application/json',
|
||||
Accept: 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ query, variables }),
|
||||
});
|
||||
return res.json();
|
||||
}
|
||||
|
||||
function group(name, body) {
|
||||
console.log(`::group::${name}`);
|
||||
const result = body();
|
||||
if (result instanceof Promise) {
|
||||
return result.finally(() => console.log(`::endgroup::`));
|
||||
}
|
||||
console.log(`::endgroup::`);
|
||||
return result;
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const featureRequests = await group('Pull Request API Response', async () => {
|
||||
const res = await makeAPIRequest(
|
||||
/* GraphQL */ `
|
||||
query FetchLinkedIssues($pr: Int!) {
|
||||
repository(owner: "actualbudget", name: "actual") {
|
||||
pullRequest(number: $pr) {
|
||||
closingIssuesReferences(first: 10) {
|
||||
nodes {
|
||||
id
|
||||
number
|
||||
labels(first: 10) {
|
||||
nodes {
|
||||
id
|
||||
name
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`,
|
||||
{ pr: parseInt(process.env.PR_NUMBER) },
|
||||
);
|
||||
|
||||
console.log(JSON.stringify(res, null, 2));
|
||||
|
||||
return res.data.repository.pullRequest.closingIssuesReferences.nodes.filter(
|
||||
issue => issue.labels.nodes.some(label => label.name === 'feature'),
|
||||
);
|
||||
});
|
||||
|
||||
if (featureRequests.length === 0) {
|
||||
console.log('No linked feature requests found');
|
||||
return;
|
||||
}
|
||||
|
||||
for (const { id, number, labels } of featureRequests) {
|
||||
await group(`Issue #${number}: Remove labels`, async () => {
|
||||
const toRemove = labels.nodes
|
||||
.filter(
|
||||
label =>
|
||||
label.name === 'help wanted' ||
|
||||
label.name === 'needs votes' ||
|
||||
label.name === 'good first issue',
|
||||
)
|
||||
.map(label => label.id);
|
||||
const res = await makeAPIRequest(
|
||||
/* GraphQL */ `
|
||||
mutation RemoveLabels($issue: ID!, $labels: [ID!]!) {
|
||||
removeLabelsFromLabelable(
|
||||
input: {
|
||||
clientMutationId: "1"
|
||||
labelIds: $labels
|
||||
labelableId: $issue
|
||||
}
|
||||
) {
|
||||
clientMutationId
|
||||
}
|
||||
}
|
||||
`,
|
||||
{
|
||||
issue: id,
|
||||
labels: toRemove,
|
||||
},
|
||||
);
|
||||
console.log(JSON.stringify(res, null, 2));
|
||||
});
|
||||
|
||||
await group(`Issue #${number}: Collapse automatic comment`, async () => {
|
||||
const commentRes = await makeAPIRequest(
|
||||
/* GraphQL */ `
|
||||
query FetchComments($issue: Int!) {
|
||||
repository(owner: "actualbudget", name: "actual") {
|
||||
issue(number: $issue) {
|
||||
comments(first: 100) {
|
||||
nodes {
|
||||
id
|
||||
body
|
||||
author {
|
||||
login
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`,
|
||||
{ issue: number },
|
||||
);
|
||||
console.log(JSON.stringify(commentRes, null, 2));
|
||||
|
||||
const comments = commentRes.data.repository.issue.comments.nodes.filter(
|
||||
comment => comment.author.login === 'github-actions',
|
||||
);
|
||||
const commentToCollapse =
|
||||
comments.find(comment =>
|
||||
comment.body.includes('<!-- feature-auto-close-comment -->'),
|
||||
) ||
|
||||
comments.find(comment =>
|
||||
comment.body.includes(
|
||||
':sparkles: Thanks for sharing your idea! :sparkles:',
|
||||
),
|
||||
);
|
||||
|
||||
if (!commentToCollapse) {
|
||||
console.log('No comment to collapse found');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const res = await makeAPIRequest(
|
||||
/* GraphQL */ `
|
||||
mutation CollapseComment($comment: ID!) {
|
||||
minimizeComment(
|
||||
input: { classifier: OUTDATED, subjectId: $comment }
|
||||
) {
|
||||
clientMutationId
|
||||
}
|
||||
}
|
||||
`,
|
||||
{ comment: commentToCollapse.id },
|
||||
);
|
||||
console.log(JSON.stringify(res, null, 2));
|
||||
});
|
||||
|
||||
await group(`Issue #${number}: Post comment`, async () => {
|
||||
const res = await makeAPIRequest(
|
||||
/* GraphQL */ `
|
||||
mutation PostComment($issue: ID!, $body: String!) {
|
||||
addComment(
|
||||
input: { subjectId: $issue, body: $body, clientMutationId: "1" }
|
||||
) {
|
||||
clientMutationId
|
||||
}
|
||||
}
|
||||
`,
|
||||
{
|
||||
issue: id,
|
||||
body: `:tada: This feature has been implemented in #${process.env.PR_NUMBER} and will be released in the next version. Thanks for sharing your idea! :tada:\n\n<!-- feature-implemented-comment -->`,
|
||||
},
|
||||
);
|
||||
console.log(JSON.stringify(res, null, 2));
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
main().catch(err => {
|
||||
console.error(err);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -9,8 +9,8 @@ function get_status() {
|
||||
curl --header "Authorization: Bearer $GITHUB_TOKEN" "https://api.github.com/repos/actualbudget/actual/commits/$COMMIT_SHA/statuses" > /tmp/status.json
|
||||
cat /tmp/status.json
|
||||
echo "::endgroup::"
|
||||
netlify=$(jq '[.[] | select(.context == "netlify/actualbudget/deploy-preview")][0]' /tmp/status.json)
|
||||
state=$(jq -r '.state' <<< "$netlify")
|
||||
netlify=$(yarn jq '[.[] | select(.context == "netlify/actualbudget/deploy-preview")][0]' /tmp/status.json)
|
||||
state=$(yarn jq -r '.state' <<< "$netlify")
|
||||
echo "::group::Netlify Status"
|
||||
echo "$netlify"
|
||||
echo "::endgroup::"
|
||||
@@ -32,7 +32,7 @@ done
|
||||
|
||||
if [ "$state" == "success" ]; then
|
||||
echo -e "\033[0;32mNetlify build succeeded!\033[0m"
|
||||
jq -r '"url=" + .target_url' <<< "$netlify" > $GITHUB_OUTPUT
|
||||
yarn jq -r '"url=" + .target_url' <<< "$netlify" > $GITHUB_OUTPUT
|
||||
exit 0
|
||||
else
|
||||
echo -e "\033[0;31mNetlify build failed. Cancelling end-to-end tests.\033[0m"
|
||||
8
.github/actions/setup/action.yml
vendored
8
.github/actions/setup/action.yml
vendored
@@ -4,15 +4,15 @@ runs:
|
||||
using: composite
|
||||
steps:
|
||||
- name: Install node
|
||||
uses: actions/setup-node@v3
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 16.15.0
|
||||
node-version: 18.16.0
|
||||
- name: Cache
|
||||
uses: actions/cache@v3
|
||||
uses: actions/cache@v4
|
||||
id: cache
|
||||
with:
|
||||
path: '**/node_modules'
|
||||
key: yarn-v1-${{ runner.os }}-${{ hashFiles('**/yarn.lock') }}
|
||||
key: yarn-v1-${{ runner.os }}-${{ hashFiles('.nvmrc') }}-${{ hashFiles('**/yarn.lock') }}
|
||||
- name: Install
|
||||
run: yarn --immutable
|
||||
shell: bash
|
||||
|
||||
57
.github/workflows/build.yml
vendored
57
.github/workflows/build.yml
vendored
@@ -12,46 +12,59 @@ on:
|
||||
branches:
|
||||
- master
|
||||
pull_request:
|
||||
branches: '*'
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
|
||||
cancel-in-progress: ${{ github.ref != 'refs/heads/master' }}
|
||||
|
||||
jobs:
|
||||
api:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- uses: actions/checkout@v4
|
||||
- name: Set up environment
|
||||
uses: ./.github/actions/setup
|
||||
- name: Build API
|
||||
run: cd packages/api && yarn build
|
||||
- name: Create package tgz
|
||||
run: cd packages/api && yarn pack && mv package.tgz actual-api.tgz
|
||||
- name: Upload Build
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: actual-api
|
||||
path: packages/api/actual-api.tgz
|
||||
|
||||
crdt:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Set up environment
|
||||
uses: ./.github/actions/setup
|
||||
- name: Build CRDT
|
||||
run: cd packages/crdt && yarn build
|
||||
- name: Create package tgz
|
||||
run: cd packages/crdt && yarn pack && mv package.tgz actual-crdt.tgz
|
||||
- name: Upload Build
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: actual-crdt
|
||||
path: packages/crdt/actual-crdt.tgz
|
||||
|
||||
web:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- uses: actions/checkout@v4
|
||||
- name: Set up environment
|
||||
uses: ./.github/actions/setup
|
||||
- name: Build Web
|
||||
run: ./bin/package-browser
|
||||
- name: Upload Build
|
||||
uses: actions/upload-artifact@v3
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: actual-web
|
||||
path: packages/desktop-client/build
|
||||
|
||||
# TODO: re-enable after solving https://github.com/actualbudget/actual/issues/468
|
||||
# electron:
|
||||
# # As electron builds take longer, we only run them in master.
|
||||
# if: github.event_name != 'pull_request'
|
||||
# strategy:
|
||||
# matrix:
|
||||
# os:
|
||||
# - ubuntu-latest
|
||||
# - windows-latest
|
||||
# - macos-latest
|
||||
# runs-on: ${{ matrix.os }}
|
||||
# steps:
|
||||
# - uses: actions/checkout@v3
|
||||
# - name: Set up environment
|
||||
# uses: ./.github/actions/setup
|
||||
# - name: Build Electron
|
||||
# run: ./bin/package
|
||||
- name: Upload Build Stats
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: build-stats
|
||||
path: packages/desktop-client/build-stats
|
||||
|
||||
16
.github/workflows/check-release-notes.yml
vendored
16
.github/workflows/check-release-notes.yml
vendored
@@ -1,16 +0,0 @@
|
||||
name: Check release notes
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches:
|
||||
- '*'
|
||||
- '!release/*'
|
||||
|
||||
jobs:
|
||||
check:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v3
|
||||
- name: Check release notes
|
||||
uses: actualbudget/actions/release-notes/check@main
|
||||
48
.github/workflows/check.yml
vendored
Normal file
48
.github/workflows/check.yml
vendored
Normal file
@@ -0,0 +1,48 @@
|
||||
name: Test
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- master
|
||||
pull_request:
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
|
||||
cancel-in-progress: ${{ github.ref != 'refs/heads/master' }}
|
||||
|
||||
jobs:
|
||||
lint:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Set up environment
|
||||
uses: ./.github/actions/setup
|
||||
- name: Lint
|
||||
run: yarn lint
|
||||
typecheck:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Set up environment
|
||||
uses: ./.github/actions/setup
|
||||
- name: Typecheck
|
||||
run: yarn typecheck
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Set up environment
|
||||
uses: ./.github/actions/setup
|
||||
- name: Test
|
||||
run: yarn test
|
||||
|
||||
migrations:
|
||||
if: github.event_name == 'pull_request'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '19'
|
||||
- name: Check migrations
|
||||
run: node ./.github/actions/check-migrations.js
|
||||
11
.github/workflows/codeql.yml
vendored
11
.github/workflows/codeql.yml
vendored
@@ -4,10 +4,13 @@ on:
|
||||
push:
|
||||
branches: [master]
|
||||
pull_request:
|
||||
branches: [master]
|
||||
schedule:
|
||||
- cron: '23 11 * * 6'
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
|
||||
cancel-in-progress: ${{ github.ref != 'refs/heads/master' }}
|
||||
|
||||
jobs:
|
||||
analyze:
|
||||
name: Analyze
|
||||
@@ -19,14 +22,14 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v3
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@v2
|
||||
uses: github/codeql-action/init@v3
|
||||
with:
|
||||
languages: javascript
|
||||
|
||||
- name: Perform CodeQL Analysis
|
||||
uses: github/codeql-action/analyze@v2
|
||||
uses: github/codeql-action/analyze@v3
|
||||
with:
|
||||
category: '/language:javascript'
|
||||
|
||||
53
.github/workflows/e2e-test.yml
vendored
53
.github/workflows/e2e-test.yml
vendored
@@ -5,29 +5,66 @@ on: [pull_request]
|
||||
env:
|
||||
GITHUB_PR_NUMBER: ${{github.event.pull_request.number}}
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
test:
|
||||
name: Run end-to-end tests on Netlify PR preview
|
||||
netlify:
|
||||
name: Wait for Netlify build to finish
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
netlify_url: ${{ steps.netlify.outputs.url }}
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- uses: actions/checkout@v4
|
||||
- name: Set up environment
|
||||
uses: ./.github/actions/setup
|
||||
- name: Setup Playwright
|
||||
run: npx playwright install chromium --with-deps
|
||||
- name: Wait for Netlify build to finish
|
||||
id: netlify
|
||||
env:
|
||||
COMMIT_SHA: ${{ github.event.pull_request.head.sha }}
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: ./bin/netlify-wait-for-build
|
||||
run: ./.github/actions/netlify-wait-for-build
|
||||
|
||||
functional:
|
||||
name: Functional
|
||||
needs: netlify
|
||||
runs-on: ubuntu-latest
|
||||
container:
|
||||
image: mcr.microsoft.com/playwright:v1.41.1-jammy
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Set up environment
|
||||
uses: ./.github/actions/setup
|
||||
- name: Run E2E Tests on Netlify URL
|
||||
run: yarn e2e
|
||||
env:
|
||||
E2E_START_URL: ${{ steps.netlify.outputs.url }}
|
||||
- uses: actions/upload-artifact@v3
|
||||
E2E_START_URL: ${{ needs.netlify.outputs.netlify_url }}
|
||||
- uses: actions/upload-artifact@v4
|
||||
if: always()
|
||||
with:
|
||||
name: desktop-client-test-results
|
||||
path: packages/desktop-client/test-results/
|
||||
retention-days: 30
|
||||
overwrite: true
|
||||
vrt:
|
||||
name: Visual regression
|
||||
needs: netlify
|
||||
runs-on: ubuntu-latest
|
||||
container:
|
||||
image: mcr.microsoft.com/playwright:v1.41.1-jammy
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Set up environment
|
||||
uses: ./.github/actions/setup
|
||||
- name: Run VRT Tests on Netlify URL
|
||||
run: yarn vrt
|
||||
env:
|
||||
E2E_START_URL: ${{ needs.netlify.outputs.netlify_url }}
|
||||
- uses: actions/upload-artifact@v4
|
||||
if: always()
|
||||
with:
|
||||
name: desktop-client-test-results
|
||||
path: packages/desktop-client/test-results/
|
||||
retention-days: 30
|
||||
overwrite: true
|
||||
|
||||
74
.github/workflows/electron-master.yml
vendored
Normal file
74
.github/workflows/electron-master.yml
vendored
Normal file
@@ -0,0 +1,74 @@
|
||||
name: Electron Master
|
||||
|
||||
defaults:
|
||||
run:
|
||||
shell: bash
|
||||
|
||||
env:
|
||||
CI: true
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- v**
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
build:
|
||||
# this is so the assets can be added to the release
|
||||
permissions:
|
||||
contents: write
|
||||
strategy:
|
||||
matrix:
|
||||
os:
|
||||
- ubuntu-latest
|
||||
- windows-latest
|
||||
- macos-latest
|
||||
runs-on: ${{ matrix.os }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- if: ${{ startsWith(matrix.os, 'windows') }}
|
||||
run: pip.exe install setuptools
|
||||
- if: ${{ ! startsWith(matrix.os, 'windows') }}
|
||||
run: |
|
||||
mkdir .venv
|
||||
python3 -m venv .venv
|
||||
source .venv/bin/activate
|
||||
python3 -m pip install setuptools
|
||||
- if: ${{ startsWith(matrix.os, 'ubuntu') }}
|
||||
run: |
|
||||
sudo apt-get install flatpak -y
|
||||
sudo apt-get install flatpak-builder -y
|
||||
sudo flatpak remote-add --if-not-exists flathub https://flathub.org/repo/flathub.flatpakrepo
|
||||
sudo flatpak install org.freedesktop.Sdk/x86_64/23.08 -y
|
||||
sudo flatpak install org.freedesktop.Platform/x86_64/23.08 -y
|
||||
sudo flatpak install org.electronjs.Electron2.BaseApp/x86_64/23.08 -y
|
||||
- name: Set up environment
|
||||
uses: ./.github/actions/setup
|
||||
- name: Build Electron
|
||||
run: ./bin/package-electron
|
||||
env:
|
||||
# CSC_KEY_PASSWORD: ${{ secrets.CSC_KEY_PASSWORD }}
|
||||
# CSC_LINK: ${{ secrets.CSC_LINK }}
|
||||
APPLE_ID: ${{ secrets.APPLE_ID }}
|
||||
APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }}
|
||||
- name: Upload Build
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: actual-electron-${{ matrix.os }}
|
||||
path: |
|
||||
packages/desktop-electron/dist/*.dmg
|
||||
packages/desktop-electron/dist/*.exe
|
||||
packages/desktop-electron/dist/*.AppImage
|
||||
packages/desktop-electron/dist/*.flatpak
|
||||
- name: Add to Release
|
||||
uses: softprops/action-gh-release@v2
|
||||
with:
|
||||
files: |
|
||||
packages/desktop-electron/dist/*.dmg
|
||||
packages/desktop-electron/dist/*.exe
|
||||
packages/desktop-electron/dist/*.AppImage
|
||||
packages/desktop-electron/dist/*.flatpak
|
||||
56
.github/workflows/electron-pr.yml
vendored
Normal file
56
.github/workflows/electron-pr.yml
vendored
Normal file
@@ -0,0 +1,56 @@
|
||||
name: Electron
|
||||
|
||||
defaults:
|
||||
run:
|
||||
shell: bash
|
||||
|
||||
env:
|
||||
CI: true
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.event.pull_request.number }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
build:
|
||||
strategy:
|
||||
matrix:
|
||||
os:
|
||||
- ubuntu-latest
|
||||
- windows-latest
|
||||
- macos-latest
|
||||
runs-on: ${{ matrix.os }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- if: ${{ startsWith(matrix.os, 'windows') }}
|
||||
run: pip.exe install setuptools
|
||||
- if: ${{ ! startsWith(matrix.os, 'windows') }}
|
||||
run: |
|
||||
mkdir .venv
|
||||
python3 -m venv .venv
|
||||
source .venv/bin/activate
|
||||
python3 -m pip install setuptools
|
||||
- if: ${{ startsWith(matrix.os, 'ubuntu') }}
|
||||
run: |
|
||||
sudo apt-get install flatpak -y
|
||||
sudo apt-get install flatpak-builder -y
|
||||
sudo flatpak remote-add --if-not-exists flathub https://flathub.org/repo/flathub.flatpakrepo
|
||||
sudo flatpak install org.freedesktop.Sdk/x86_64/23.08 -y
|
||||
sudo flatpak install org.freedesktop.Platform/x86_64/23.08 -y
|
||||
sudo flatpak install org.electronjs.Electron2.BaseApp/x86_64/23.08 -y
|
||||
- name: Set up environment
|
||||
uses: ./.github/actions/setup
|
||||
- name: Build Electron
|
||||
run: ./bin/package-electron
|
||||
- name: Upload Build
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: actual-electron-${{ matrix.os }}
|
||||
path: |
|
||||
packages/desktop-electron/dist/*.dmg
|
||||
packages/desktop-electron/dist/*.exe
|
||||
packages/desktop-electron/dist/*.AppImage
|
||||
packages/desktop-electron/dist/*.flatpak
|
||||
17
.github/workflows/generate-release-notes.yml
vendored
17
.github/workflows/generate-release-notes.yml
vendored
@@ -1,17 +0,0 @@
|
||||
name: Generate Release Notes
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- release/*
|
||||
|
||||
jobs:
|
||||
generate:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v3
|
||||
- name: Generate release notes
|
||||
uses: actualbudget/actions/release-notes/generate@main
|
||||
with:
|
||||
github_token: ${{ secrets.GITHUB_TOKEN }}
|
||||
37
.github/workflows/issues-close-feature-requests.yml
vendored
Normal file
37
.github/workflows/issues-close-feature-requests.yml
vendored
Normal file
@@ -0,0 +1,37 @@
|
||||
name: Close feature requests with automated message
|
||||
|
||||
on:
|
||||
issues:
|
||||
types: [labeled]
|
||||
|
||||
jobs:
|
||||
needs-votes:
|
||||
if: ${{ github.event.label.name == 'feature' }}
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions-ecosystem/action-add-labels@v1
|
||||
with:
|
||||
labels: needs votes
|
||||
- name: Add reactions
|
||||
uses: aidan-mundy/react-to-issue@v1.1.1
|
||||
with:
|
||||
issue-number: ${{ github.event.issue.number }}
|
||||
reactions: '+1'
|
||||
- name: Create comment
|
||||
uses: peter-evans/create-or-update-comment@v3
|
||||
with:
|
||||
issue-number: ${{ github.event.issue.number }}
|
||||
body: |
|
||||
:sparkles: Thanks for sharing your idea! :sparkles:
|
||||
|
||||
This repository uses lodash style issue management for enhancements. That means enhancement issues are automatically closed. This doesn’t mean we don’t accept feature requests, though! We will consider implementing ones that receive many upvotes, and we welcome contributions for any feature requests marked as needing votes (just post a comment first so we can help you make a successful contribution).
|
||||
|
||||
The enhancement backlog can be found here: https://github.com/actualbudget/actual/issues?q=label%3A%22needs+votes%22+sort%3Areactions-%2B1-desc+
|
||||
|
||||
Don’t forget to upvote the top comment with 👍!
|
||||
|
||||
<!-- feature-auto-close-comment -->
|
||||
- name: Close Issue
|
||||
run: gh issue close "https://github.com/actualbudget/actual/issues/${{ github.event.issue.number }}"
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
35
.github/workflows/issues-feature-implemented.yml
vendored
Normal file
35
.github/workflows/issues-feature-implemented.yml
vendored
Normal file
@@ -0,0 +1,35 @@
|
||||
name: Handle completed feature requests
|
||||
|
||||
##########################################################################################
|
||||
# WARNING! This workflow uses the 'pull_request_target' event. That mans that it will #
|
||||
# always run in the context of the main actualbudget/actual repo, even if the PR is from #
|
||||
# a fork. This is necessary to get access to a GitHub token that can post a comment on #
|
||||
# the PR. Be VERY CAREFUL about adding things to this workflow, since forks can inject #
|
||||
# arbitrary code into their branch, and can pollute the artifacts we download. Arbitrary #
|
||||
# code execution in this workflow could lead to a compromise of the main repo. #
|
||||
##########################################################################################
|
||||
# See: https://securitylab.github.com/research/github-actions-preventing-pwn-requests #
|
||||
##########################################################################################
|
||||
|
||||
on:
|
||||
pull_request_target:
|
||||
types: [closed]
|
||||
|
||||
permissions:
|
||||
issues: write
|
||||
|
||||
jobs:
|
||||
handle-feature-requests:
|
||||
if: github.event.pull_request.merged == true
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
# This is not a security concern because we have approved & merged the PR
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '19'
|
||||
- name: Handle feature requests
|
||||
run: node .github/actions/handle-feature-requests.js
|
||||
env:
|
||||
PR_NUMBER: ${{ github.event.pull_request.number }}
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
14
.github/workflows/issues-remove-help-wanted.yml
vendored
Normal file
14
.github/workflows/issues-remove-help-wanted.yml
vendored
Normal file
@@ -0,0 +1,14 @@
|
||||
name: Remove 'help wanted' label from closed issues
|
||||
|
||||
on:
|
||||
issues:
|
||||
types: [closed]
|
||||
|
||||
jobs:
|
||||
remove-help-wanted:
|
||||
if: ${{ !contains(github.event.issue.labels.*.name, 'feature') && contains(github.event.issue.labels.*.name, 'help wanted') }}
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions-ecosystem/action-remove-labels@v1
|
||||
with:
|
||||
labels: help wanted
|
||||
18
.github/workflows/lint.yml
vendored
18
.github/workflows/lint.yml
vendored
@@ -1,18 +0,0 @@
|
||||
name: Linter
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- master
|
||||
pull_request:
|
||||
branches: '*'
|
||||
|
||||
jobs:
|
||||
lint:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- name: Set up environment
|
||||
uses: ./.github/actions/setup
|
||||
- name: Lint
|
||||
run: yarn lint
|
||||
43
.github/workflows/netlify-release.yml
vendored
Normal file
43
.github/workflows/netlify-release.yml
vendored
Normal file
@@ -0,0 +1,43 @@
|
||||
name: Deploy Netlify Release
|
||||
|
||||
defaults:
|
||||
run:
|
||||
shell: bash
|
||||
|
||||
env:
|
||||
CI: true
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- v**
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
build-and-deploy:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Repository Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up environment
|
||||
uses: ./.github/actions/setup
|
||||
|
||||
- name: Install Netlify
|
||||
run: npm install netlify-cli@17.10.1 -g
|
||||
|
||||
- name: Build Actual
|
||||
run: ./bin/package-browser
|
||||
|
||||
- name: Deploy to Netlify
|
||||
id: netlify_deploy
|
||||
run: |
|
||||
netlify deploy \
|
||||
--dir packages/desktop-client/build \
|
||||
--site ${{ secrets.NETLIFY_SITE_ID }} \
|
||||
--auth ${{ secrets.NETLIFY_API_TOKEN }} \
|
||||
--filter @actual-app/web \
|
||||
--prod
|
||||
14
.github/workflows/opened-issues-triage.yml
vendored
14
.github/workflows/opened-issues-triage.yml
vendored
@@ -1,14 +0,0 @@
|
||||
name: Mark new issue for triage
|
||||
|
||||
on:
|
||||
issues:
|
||||
types: [opened]
|
||||
|
||||
jobs:
|
||||
needs-triage:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- uses: actions-ecosystem/action-add-labels@v1
|
||||
with:
|
||||
labels: needs triage
|
||||
21
.github/workflows/release-notes.yml
vendored
Normal file
21
.github/workflows/release-notes.yml
vendored
Normal file
@@ -0,0 +1,21 @@
|
||||
name: Release notes
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
release-notes:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
- name: Check release notes
|
||||
if: startsWith(github.head_ref, 'release/') == false
|
||||
uses: actualbudget/actions/release-notes/check@main
|
||||
- name: Generate release notes
|
||||
if: startsWith(github.head_ref, 'release/') == true
|
||||
uses: actualbudget/actions/release-notes/generate@main
|
||||
85
.github/workflows/size-compare.yml
vendored
Normal file
85
.github/workflows/size-compare.yml
vendored
Normal file
@@ -0,0 +1,85 @@
|
||||
name: Compare Sizes
|
||||
|
||||
##########################################################################################
|
||||
# WARNING! This workflow uses the 'pull_request_target' event. That mans that it will #
|
||||
# always run in the context of the main actualbudget/actual repo, even if the PR is from #
|
||||
# a fork. This is necessary to get access to a GitHub token that can post a comment on #
|
||||
# the PR. Be VERY CAREFUL about adding things to this workflow, since forks can inject #
|
||||
# arbitrary code into their branch, and can pollute the artifacts we download. Arbitrary #
|
||||
# code execution in this workflow could lead to a compromise of the main repo. #
|
||||
##########################################################################################
|
||||
# See: https://securitylab.github.com/research/github-actions-preventing-pwn-requests #
|
||||
##########################################################################################
|
||||
|
||||
on:
|
||||
pull_request_target:
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
compare:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
pull-requests: write
|
||||
steps:
|
||||
- name: Wait for ${{github.base_ref}} build to succeed
|
||||
uses: fountainhead/action-wait-for-check@v1.1.0
|
||||
id: master-build
|
||||
with:
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
checkName: web
|
||||
ref: ${{github.base_ref}}
|
||||
|
||||
- name: Wait for PR build to succeed
|
||||
uses: fountainhead/action-wait-for-check@v1.1.0
|
||||
id: wait-for-build
|
||||
with:
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
checkName: web
|
||||
ref: ${{github.event.pull_request.head.sha}}
|
||||
|
||||
- name: Report build failure
|
||||
if: steps.wait-for-build.outputs.conclusion == 'failure'
|
||||
run: |
|
||||
echo "Build failed on PR branch or ${{github.base_ref}}"
|
||||
exit 1
|
||||
- name: Download build artifact from ${{github.base_ref}}
|
||||
uses: dawidd6/action-download-artifact@v3
|
||||
id: pr-build
|
||||
with:
|
||||
branch: ${{github.base_ref}}
|
||||
workflow: build.yml
|
||||
name: build-stats
|
||||
path: base
|
||||
|
||||
- name: Download build artifact from PR
|
||||
uses: dawidd6/action-download-artifact@v3
|
||||
with:
|
||||
pr: ${{github.event.pull_request.number}}
|
||||
workflow: build.yml
|
||||
name: build-stats
|
||||
path: head
|
||||
|
||||
- name: Strip content hashes from stats files
|
||||
run: |
|
||||
sed -i -E 's/index\.[0-9a-zA-Z_-]{8,}\./index./g' ./head/web-stats.json
|
||||
sed -i -E 's/\.[0-9a-zA-Z_-]{8,}\.chunk\././g' ./head/web-stats.json
|
||||
sed -i -E 's/\.[0-9a-f]{8,}\././g' ./head/*.json
|
||||
sed -i -E 's/index\.[0-9a-zA-Z_-]{8,}\./index./g' ./base/web-stats.json
|
||||
sed -i -E 's/\.[0-9a-zA-Z_-]{8,}\.chunk\././g' ./base/web-stats.json
|
||||
sed -i -E 's/\.[0-9a-f]{8,}\././g' ./base/*.json
|
||||
- uses: twk3/rollup-size-compare-action@v1.0.0
|
||||
with:
|
||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
current-stats-json-path: ./head/web-stats.json
|
||||
base-stats-json-path: ./base/web-stats.json
|
||||
title: desktop-client
|
||||
|
||||
- uses: github/webpack-bundlesize-compare-action@v1.8.2
|
||||
with:
|
||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
current-stats-json-path: ./head/loot-core-stats.json
|
||||
base-stats-json-path: ./base/loot-core-stats.json
|
||||
title: loot-core
|
||||
22
.github/workflows/stale.yml
vendored
22
.github/workflows/stale.yml
vendored
@@ -1,20 +1,16 @@
|
||||
name: Close inactive issues
|
||||
name: 'Close stale PRs'
|
||||
on:
|
||||
schedule:
|
||||
- cron: "30 1 * * *"
|
||||
- cron: '30 1 * * *'
|
||||
|
||||
jobs:
|
||||
close-issues:
|
||||
stale:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
issues: write
|
||||
pull-requests: write
|
||||
steps:
|
||||
- uses: actions/stale@v7
|
||||
- uses: actions/stale@v8
|
||||
with:
|
||||
days-before-issue-stale: 90
|
||||
days-before-issue-close: -1
|
||||
stale-issue-label: "stale"
|
||||
stale-issue-message: "🚧🚨 This issue is being marked as stale due to 90 days of inactivity. 🚧🚨"
|
||||
only-labels: 'needs triage'
|
||||
repo-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
stale-pr-message: 'This PR is stale because it has been open 30 days with no activity. Remove stale label or comment or this will be closed in 5 days.'
|
||||
close-pr-message: 'This PR was closed because it has been stalled for 5 days with no activity.'
|
||||
days-before-stale: 30
|
||||
days-before-close: 5
|
||||
days-before-issue-stale: -1
|
||||
|
||||
18
.github/workflows/test.yml
vendored
18
.github/workflows/test.yml
vendored
@@ -1,18 +0,0 @@
|
||||
name: Test
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- master
|
||||
pull_request:
|
||||
branches: '*'
|
||||
|
||||
jobs:
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- name: Set up environment
|
||||
uses: ./.github/actions/setup
|
||||
- name: Test
|
||||
run: yarn test
|
||||
18
.github/workflows/typecheck.yml
vendored
18
.github/workflows/typecheck.yml
vendored
@@ -1,18 +0,0 @@
|
||||
name: Typecheck
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- master
|
||||
pull_request:
|
||||
branches: '*'
|
||||
|
||||
jobs:
|
||||
typecheck:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- name: Set up environment
|
||||
uses: ./.github/actions/setup
|
||||
- name: Typecheck
|
||||
run: yarn typecheck
|
||||
42
.gitignore
vendored
42
.gitignore
vendored
@@ -1,26 +1,34 @@
|
||||
# Sample Data
|
||||
/data/*
|
||||
!data/.gitkeep
|
||||
/data2
|
||||
Actual-*
|
||||
**/xcuserdata/*
|
||||
export-2020-01-10.csv
|
||||
|
||||
# Secrets
|
||||
.secret-tokens
|
||||
|
||||
# MacOS
|
||||
.DS_Store
|
||||
|
||||
# Logs
|
||||
**/*.log
|
||||
|
||||
# JavaScript
|
||||
node_modules
|
||||
packages/api/dist
|
||||
packages/api/@types
|
||||
packages/crdt/dist
|
||||
packages/desktop-electron/client-build
|
||||
packages/desktop-electron/build
|
||||
packages/desktop-electron/.electron-symbols
|
||||
packages/desktop-electron/dist
|
||||
packages/desktop-electron/loot-core
|
||||
node_modules
|
||||
.DS_Store
|
||||
lerna-debug.log
|
||||
Actual-*
|
||||
.#*
|
||||
**/xcuserdata/*
|
||||
.secret-tokens
|
||||
bundle.desktop.js
|
||||
bundle.desktop.js.map
|
||||
bundle.mobile.js
|
||||
bundle.mobile.js.map
|
||||
export-2020-01-10.csv
|
||||
.idea
|
||||
|
||||
**/*.log
|
||||
|
||||
# Yarn
|
||||
.pnp.*
|
||||
@@ -30,3 +38,15 @@ export-2020-01-10.csv
|
||||
!.yarn/releases
|
||||
!.yarn/sdks
|
||||
!.yarn/versions
|
||||
|
||||
# VSCode
|
||||
.vscode
|
||||
|
||||
# IntelliJ IDEA
|
||||
.idea
|
||||
|
||||
# Misc
|
||||
.#*
|
||||
|
||||
# Local Netlify folder
|
||||
.netlify
|
||||
|
||||
1
.prettierignore
Normal file
1
.prettierignore
Normal file
@@ -0,0 +1 @@
|
||||
sync_pb.*
|
||||
2
.secret-tokens.example
Normal file
2
.secret-tokens.example
Normal file
@@ -0,0 +1,2 @@
|
||||
export APPLE_ID=example@email.com
|
||||
export APPLE_APP_SPECIFIC_PASSWORD=password
|
||||
541
.yarn/plugins/@yarnpkg/plugin-interactive-tools.cjs
vendored
541
.yarn/plugins/@yarnpkg/plugin-interactive-tools.cjs
vendored
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
873
.yarn/releases/yarn-3.4.1.cjs
vendored
873
.yarn/releases/yarn-3.4.1.cjs
vendored
File diff suppressed because one or more lines are too long
893
.yarn/releases/yarn-4.0.2.cjs
vendored
Executable file
893
.yarn/releases/yarn-4.0.2.cjs
vendored
Executable file
File diff suppressed because one or more lines are too long
12
.yarnrc.yml
12
.yarnrc.yml
@@ -1,9 +1,7 @@
|
||||
compressionLevel: mixed
|
||||
|
||||
enableGlobalCache: false
|
||||
|
||||
nodeLinker: node-modules
|
||||
|
||||
plugins:
|
||||
- path: .yarn/plugins/@yarnpkg/plugin-workspace-tools.cjs
|
||||
spec: "@yarnpkg/plugin-workspace-tools"
|
||||
- path: .yarn/plugins/@yarnpkg/plugin-interactive-tools.cjs
|
||||
spec: "@yarnpkg/plugin-interactive-tools"
|
||||
|
||||
yarnPath: .yarn/releases/yarn-3.4.1.cjs
|
||||
yarnPath: .yarn/releases/yarn-4.0.2.cjs
|
||||
|
||||
@@ -1,38 +1 @@
|
||||
## Expectations
|
||||
|
||||
For smaller improvements or features - feel free to submit a PR or an issue if you don't have the necessary skills to build it yourself. For larger features we would recommend first opening an issue to discuss it with the team.
|
||||
|
||||
We aren't going to take every single little change. Don't be offended if we close your PR. In order for the project to stay healthy, we need to guard our bandwidth and also only take changes that align with Actual.
|
||||
|
||||
Here are some initial guidelines for how contributions will be treated:
|
||||
|
||||
- The mental health of the maintainers will be prioritized above all else. If this means some things get lost and PRs are unreviewed because maintainers are spending time with family or on themselves, we celebrate that.
|
||||
|
||||
- Multiple maintainers are key to this being a healthy project. Currently a few people have maintainer rights (see list below). We are actively looking for more people to come on as maintainers. If nobody steps up, expect less activity on this project.
|
||||
|
||||
- An open PR does not automatically deserve time for a full review and acceptance. It's up to the PR author to convince the maintainers that the change is good and worth reviewing. This involves a clear description for why the the change is being made, detailing the tradeoffs.
|
||||
|
||||
- We especially welcome improvements in automation: creating github actions to automatically generate builds, making the release process easier, etc.
|
||||
|
||||
## Main contributors
|
||||
|
||||
(sorted alphabetically)
|
||||
|
||||
- @j-f1
|
||||
- @jlongster
|
||||
- @MatissJanis
|
||||
- @trevdor
|
||||
|
||||
## Alumni
|
||||
|
||||
(sorted alphabetically)
|
||||
|
||||
- @rich-howell
|
||||
|
||||
## Project ideas
|
||||
|
||||
We welcome all contributions from the community. If you have an idea for a feature you want to build - please go ahead and submit a PR with the implementation or if it's a larger feature - open a new issue so we can discuss it.
|
||||
|
||||
If you do not have ideas what to build: the issue list is always a good starting point. Look for issues labeled with "[help wanted](https://github.com/actualbudget/actual/issues?q=is%3Aissue+is%3Aopen+label%3A%22help+wanted%22)".
|
||||
|
||||
For first time contributions you can also filter the issues labeled with "[good first issue](https://github.com/actualbudget/actual/issues?q=is%3Aissue+is%3Aopen+label%3A%22good+first+issue%22)".
|
||||
Please review the contributing documentation on our website: https://actualbudget.org/docs/contributing/
|
||||
|
||||
11
Dockerfile
Normal file
11
Dockerfile
Normal file
@@ -0,0 +1,11 @@
|
||||
###################################################
|
||||
# This Dockerfile is used by the docker-compose.yml
|
||||
# file to build the development container.
|
||||
# Do not make any changes here unless you know what
|
||||
# you are doing.
|
||||
###################################################
|
||||
|
||||
FROM node:18-bullseye as dev
|
||||
RUN apt-get update -y && apt-get upgrade -y && apt-get install -y openssl
|
||||
WORKDIR /app
|
||||
CMD ["sh", "./bin/docker-start"]
|
||||
18
README.md
18
README.md
@@ -6,14 +6,14 @@
|
||||
|
||||
Actual is a local-first personal finance tool. It is 100% free and open-source, written in NodeJS, it has a synchronization element so that all your changes can move between devices without any heavy lifting.
|
||||
|
||||
If you are interested in contributing, or want to know how development works, see [CONTRIBUTING.md](https://github.com/actualbudget/actual/blob/master/CONTRIBUTING.md) we would love to have you.
|
||||
If you are interested in contributing, or want to know how development works, see our [contributing](https://actualbudget.org/docs/contributing/) document we would love to have you.
|
||||
|
||||
Want to say thanks? Click the ⭐ at the top of the page.
|
||||
|
||||
## Key Links
|
||||
|
||||
- Actual [discord](https://discord.gg/pRYNYr4W5A) community.
|
||||
- Actual [Community Documentation](https://actualbudget.github.io/docs)
|
||||
- Actual [Community Documentation](https://actualbudget.org/docs)
|
||||
|
||||
## Installation
|
||||
|
||||
@@ -23,22 +23,28 @@ If you are only interested in running the latest version and not contributing to
|
||||
|
||||
The easiest way to get Actual running is to use the [actual-server](https://github.com/actualbudget/actual-server) project. That is the server for syncing changes across devices, and it comes with the latest version of Actual. The server will provide both the web project and a server for syncing.
|
||||
|
||||
You can get up and running quickly and easily by following our [Running Actual Locally Guide](https://actualbudget.github.io/docs/Installing/Local/your-own-machine)
|
||||
You can get up and running quickly and easily by following our [Running Actual Locally Guide](https://actualbudget.org/docs/install/local)
|
||||
|
||||
## Documentation
|
||||
|
||||
We have a wide range of documentation on how to use Actual, this is all available in our [Community Documentation](https://actualbudget.github.io/docs), this includes topics on Budgeting, Account Management, Tips & Tricks and some documentation for developers.
|
||||
We have a wide range of documentation on how to use Actual, this is all available in our [Community Documentation](https://actualbudget.org/docs), this includes topics on Budgeting, Account Management, Tips & Tricks and some documentation for developers.
|
||||
|
||||
## Code structure
|
||||
|
||||
The Actual app is split up into a few packages:
|
||||
|
||||
- loot-core - The core application that runs on any platform
|
||||
- loot-design - The generic design components that make up the UI
|
||||
- desktop-client - The desktop UI
|
||||
- desktop-electron - The desktop app
|
||||
|
||||
More information on the project structure is available in our [community documentation](https://actualbudget.github.io/docs/Developers/project-layout).
|
||||
More information on the project structure is available in our [community documentation](https://actualbudget.org/docs/contributing/project-details).
|
||||
|
||||
## Feature Requests
|
||||
|
||||
Current feature requests can be seen [here](https://github.com/actualbudget/actual/issues?q=is%3Aissue+label%3A%22needs+votes%22+sort%3Areactions-%2B1-desc).
|
||||
Vote for your favorite requests by reacting :+1: to the top comment of the request.
|
||||
|
||||
To add new feature requests, open a new Issue of the "Feature Request" type.
|
||||
|
||||
## Sponsors
|
||||
|
||||
|
||||
13
bin/docker-start
Normal file
13
bin/docker-start
Normal file
@@ -0,0 +1,13 @@
|
||||
#!/bin/sh
|
||||
|
||||
#####################################################
|
||||
# This startup script is used by the docker container
|
||||
# to check if the node_modules folder is empty and
|
||||
# if so, run yarn to install the dependencies.
|
||||
#####################################################
|
||||
|
||||
if [ ! -d "node_modules" ] || [ "$(ls -A node_modules)" = "" ]; then
|
||||
yarn
|
||||
fi
|
||||
|
||||
BROWSER=0 yarn start:browser
|
||||
@@ -1,11 +0,0 @@
|
||||
#!/bin/sh
|
||||
FILES=$(git diff --cached --name-only --diff-filter=ACMR "*.js" "*.jsx" | sed 's| |\\ |g')
|
||||
[ -z "$FILES" ] && exit 0
|
||||
|
||||
# Prettify all selected files
|
||||
echo "$FILES" | xargs ./node_modules/.bin/prettier --write
|
||||
|
||||
# Add back the modified/prettified files to staging
|
||||
echo "$FILES" | xargs git add
|
||||
|
||||
exit 0
|
||||
@@ -1,27 +0,0 @@
|
||||
#!/bin/sh -e
|
||||
|
||||
ROOT=$(cd "`dirname $0`"; pwd)
|
||||
NPM_NAME="$1"
|
||||
NAME="$2"
|
||||
PACKAGE_DIR="`dirname "$ROOT"`/packages/$NAME"
|
||||
|
||||
if [ -z "$NAME" ] || [ -z "$NPM_NAME" ]; then
|
||||
echo "Usage: `basename $0` <npm-name> <local-name>"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ -d "$PACKAGE_DIR" ]; then
|
||||
read -p "Package exists, remove $PACKAGE_DIR? [y/N] " -r
|
||||
if [ -z "$REPLY" ] || [ "$REPLY" != "y" ]; then
|
||||
exit 2
|
||||
fi
|
||||
fi
|
||||
|
||||
rm -rf "$PACKAGE_DIR"
|
||||
URL="`npm view "$NPM_NAME" dist.tarball`"
|
||||
TMPDIR="`mktemp -d`"
|
||||
|
||||
cd "$TMPDIR"
|
||||
wget -O tar.tgz "$URL"
|
||||
tar xvzf tar.tgz
|
||||
mv package "$PACKAGE_DIR"
|
||||
@@ -1,50 +0,0 @@
|
||||
#!/bin/bash -e
|
||||
|
||||
VERSION=""
|
||||
|
||||
POSITIONAL=()
|
||||
while [[ $# -gt 0 ]]; do
|
||||
key="$1"
|
||||
|
||||
case $key in
|
||||
--version)
|
||||
VERSION="$2"
|
||||
shift
|
||||
shift
|
||||
;;
|
||||
*)
|
||||
POSITIONAL+=("$1")
|
||||
shift
|
||||
;;
|
||||
esac
|
||||
done
|
||||
set -- "${POSITIONAL[@]}"
|
||||
|
||||
NOTES="$@"
|
||||
|
||||
if [ -z "$VERSION" ]; then
|
||||
echo "--version is required";
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Version: $VERSION"
|
||||
echo "Notes: $NOTES"
|
||||
read -p "Make release? [y/N] " -r
|
||||
if [ -z "$REPLY" ] || [ "$REPLY" != "y" ]; then
|
||||
exit 2
|
||||
fi
|
||||
|
||||
source ./.secret-tokens
|
||||
|
||||
# Tag and push to make windows and linux versions
|
||||
git push origin master
|
||||
git tag -a "$VERSION" -m "$NOTES"
|
||||
git push origin "$VERSION"
|
||||
|
||||
# Make a macOS version
|
||||
./bin/package --release --version "$VERSION"
|
||||
|
||||
# TODO: browser version
|
||||
|
||||
# Finally, update github issues
|
||||
curl -X POST -H "x-release-token: $RELEASE_TOKEN" https://actual-automoto.fly.dev/release/"$VERSION"
|
||||
111
bin/package
111
bin/package
@@ -1,111 +0,0 @@
|
||||
#!/bin/bash -e
|
||||
|
||||
|
||||
ROOT=`dirname $0`
|
||||
VERSION=""
|
||||
BETA=""
|
||||
RELEASE=""
|
||||
RELEASE_NOTES=""
|
||||
CI=${CI:-false}
|
||||
|
||||
cd "$ROOT/.."
|
||||
|
||||
POSITIONAL=()
|
||||
while [[ $# -gt 0 ]]; do
|
||||
key="$1"
|
||||
|
||||
case $key in
|
||||
--version)
|
||||
VERSION="$2"
|
||||
shift
|
||||
shift
|
||||
;;
|
||||
--beta)
|
||||
RELEASE="beta"
|
||||
shift
|
||||
;;
|
||||
--release)
|
||||
RELEASE="production"
|
||||
shift
|
||||
;;
|
||||
*)
|
||||
POSITIONAL+=("$1")
|
||||
shift
|
||||
;;
|
||||
esac
|
||||
done
|
||||
set -- "${POSITIONAL[@]}"
|
||||
|
||||
if [ -z "$VERSION" ] && [ -n "$RELEASE" ]; then
|
||||
echo "Version is required if making a release"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ -n "$RELEASE" ]; then
|
||||
if [ -z "$CIRCLE_TAG" ]; then
|
||||
read -p "Make release: $RELEASE v$VERSION? [y/N] " -r
|
||||
if [ -z "$REPLY" ] || [ "$REPLY" != "y" ]; then
|
||||
exit 2
|
||||
fi
|
||||
fi
|
||||
|
||||
if [ "$RELEASE" == "production" ]; then
|
||||
if [ -z "$CIRCLE_TAG" ]; then
|
||||
RELEASE_NOTES=`git tag -l --format="%(contents:subject)" "$VERSION"`
|
||||
else
|
||||
RELEASE_NOTES=`git tag -l --format="%(contents:subject)" "$CIRCLE_TAG"`
|
||||
fi
|
||||
fi
|
||||
|
||||
PACKAGE_VERSION=`node -p -e "require('./packages/desktop-electron/package.json').version"`
|
||||
if [ "$VERSION" != "$PACKAGE_VERSION" ]; then
|
||||
echo "Version in desktop-electron/package.json does not match given version! ($PACKAGE_VERSION)"
|
||||
exit 4
|
||||
fi
|
||||
fi
|
||||
|
||||
if [ "$OSTYPE" == "msys" ]; then
|
||||
if [ $CI != true ]; then
|
||||
read -s -p "Windows certificate password: " -r CSC_KEY_PASSWORD
|
||||
export CSC_KEY_PASSWORD
|
||||
elif [ -n "$CIRCLE_TAG" ]; then
|
||||
# We only want to run this on CircleCI as Github doesn't have the CSC_KEY_PASSWORD secret set.
|
||||
certutil -f -p ${CSC_KEY_PASSWORD} -importPfx ~/windows-shift-reset-llc.p12
|
||||
fi
|
||||
fi
|
||||
|
||||
# We only need to run linting once (and this doesn't seem to work on
|
||||
# Windows for some reason)
|
||||
if [[ $CI != true && "$OSTYPE" == "darwin"* ]]; then
|
||||
yarn lint
|
||||
fi
|
||||
|
||||
yarn patch-package
|
||||
|
||||
yarn workspace loot-core build:node
|
||||
|
||||
yarn workspace @actual-app/web build
|
||||
|
||||
yarn workspace Actual update-client
|
||||
|
||||
(
|
||||
cd packages/desktop-electron;
|
||||
yarn clean;
|
||||
|
||||
export npm_config_better_sqlite3_binary_host="https://static.actualbudget.com/prebuild/better-sqlite3"
|
||||
|
||||
if [ "$RELEASE" == "production" ]; then
|
||||
if [ -f ../../.secret-tokens ]; then
|
||||
source ../../.secret-tokens
|
||||
fi
|
||||
yarn build --publish always -c.releaseInfo.releaseNotes="$RELEASE_NOTES" --arm64 --x64
|
||||
|
||||
echo "\nCreated release $VERSION with release notes \"$RELEASE_NOTES\""
|
||||
elif [ "$RELEASE" == "beta" ]; then
|
||||
yarn build --publish never --arm64 --x64
|
||||
|
||||
echo "\nCreated beta release $VERSION"
|
||||
else
|
||||
SKIP_NOTARIZATION=true yarn build --publish never --x64
|
||||
fi
|
||||
)
|
||||
@@ -1,63 +1,10 @@
|
||||
#!/bin/bash -e
|
||||
|
||||
ROOT=`dirname $0`
|
||||
VERSION=""
|
||||
RELEASE=""
|
||||
CI=${CI:-false}
|
||||
|
||||
cd "$ROOT/.."
|
||||
|
||||
POSITIONAL=()
|
||||
while [[ $# -gt 0 ]]; do
|
||||
key="$1"
|
||||
|
||||
case $key in
|
||||
--version)
|
||||
VERSION="$2"
|
||||
shift
|
||||
shift
|
||||
;;
|
||||
--beta)
|
||||
RELEASE="beta"
|
||||
shift
|
||||
;;
|
||||
--release)
|
||||
RELEASE="production"
|
||||
shift
|
||||
;;
|
||||
*)
|
||||
POSITIONAL+=("$1")
|
||||
shift
|
||||
;;
|
||||
esac
|
||||
done
|
||||
set -- "${POSITIONAL[@]}"
|
||||
|
||||
if [ -z "$VERSION" ] && [ -n "$RELEASE" ]; then
|
||||
echo "Version is required if making a release"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ -n "$RELEASE" ]; then
|
||||
read -p "Deploy release for browser: $RELEASE v$VERSION? [y/N] " -r
|
||||
if [ -z "$REPLY" ] || [ "$REPLY" != "y" ]; then
|
||||
exit 2
|
||||
fi
|
||||
|
||||
PACKAGE_VERSION=`node -p -e "require('./packages/desktop-electron/package.json').version"`
|
||||
if [ "$VERSION" != "$PACKAGE_VERSION" ] && [ "$VERSION-next" != "$PACKAGE_VERSION" ]; then
|
||||
echo "Version in desktop-electron/package.json does not match given version! ($PACKAGE_VERSION)"
|
||||
exit 4
|
||||
fi
|
||||
fi
|
||||
|
||||
# There's no need to check linting in CI as it'll be done in a different step.
|
||||
if [ $CI != true ]; then
|
||||
yarn lint
|
||||
fi
|
||||
|
||||
ACTUAL_RELEASE_TYPE=$RELEASE yarn workspace loot-core build:browser
|
||||
|
||||
REACT_APP_RELEASE_TYPE=$RELEASE yarn workspace @actual-app/web build:browser
|
||||
yarn workspace loot-core build:browser
|
||||
yarn workspace @actual-app/web build:browser
|
||||
|
||||
echo "packages/desktop-client/build"
|
||||
|
||||
59
bin/package-electron
Executable file
59
bin/package-electron
Executable file
@@ -0,0 +1,59 @@
|
||||
#!/bin/bash -e
|
||||
|
||||
|
||||
ROOT=`dirname $0`
|
||||
RELEASE=""
|
||||
CI=${CI:-false}
|
||||
|
||||
cd "$ROOT/.."
|
||||
|
||||
POSITIONAL=()
|
||||
while [[ $# -gt 0 ]]; do
|
||||
key="$1"
|
||||
|
||||
case $key in
|
||||
--release)
|
||||
RELEASE="production"
|
||||
shift
|
||||
;;
|
||||
*)
|
||||
POSITIONAL+=("$1")
|
||||
shift
|
||||
;;
|
||||
esac
|
||||
done
|
||||
set -- "${POSITIONAL[@]}"
|
||||
|
||||
if [ "$OSTYPE" == "msys" ]; then
|
||||
if [ $CI != true ]; then
|
||||
read -s -p "Windows certificate password: " -r CSC_KEY_PASSWORD
|
||||
export CSC_KEY_PASSWORD
|
||||
elif [ -n "$CIRCLE_TAG" ]; then
|
||||
# We only want to run this on CircleCI as Github doesn't have the CSC_KEY_PASSWORD secret set.
|
||||
certutil -f -p ${CSC_KEY_PASSWORD} -importPfx ~/windows-shift-reset-llc.p12
|
||||
fi
|
||||
fi
|
||||
|
||||
yarn rebuild-electron
|
||||
|
||||
yarn workspace loot-core build:node
|
||||
|
||||
yarn workspace @actual-app/web build --mode=desktop
|
||||
|
||||
yarn workspace desktop-electron update-client
|
||||
|
||||
(
|
||||
cd packages/desktop-electron;
|
||||
yarn clean;
|
||||
|
||||
if [ "$RELEASE" == "production" ]; then
|
||||
if [ -f ../../.secret-tokens ]; then
|
||||
source ../../.secret-tokens
|
||||
fi
|
||||
yarn build --publish never --arm64 --x64
|
||||
|
||||
echo "\nCreated release"
|
||||
else
|
||||
SKIP_NOTARIZATION=true yarn build --publish never --x64
|
||||
fi
|
||||
)
|
||||
32
bin/run-vrt
Executable file
32
bin/run-vrt
Executable file
@@ -0,0 +1,32 @@
|
||||
#!/bin/sh
|
||||
|
||||
# See here for more information: https://github.com/actualbudget/actual/tree/master/packages/desktop-client#visual-regression
|
||||
|
||||
if [ ! -d "node_modules" ] || [ "$(ls -A node_modules)" = "" ]; then
|
||||
yarn
|
||||
fi
|
||||
|
||||
E2E_START_URL="${E2E_START_URL:-https://localhost:3001}"
|
||||
VRT_ARGS=""
|
||||
|
||||
# Loop through all arguments
|
||||
while [ $# -gt 0 ]; do
|
||||
key="$1"
|
||||
case $key in
|
||||
--e2e-start-url)
|
||||
E2E_START_URL="$2"
|
||||
shift
|
||||
;;
|
||||
*)
|
||||
VRT_ARGS="$VRT_ARGS $1"
|
||||
;;
|
||||
esac
|
||||
shift
|
||||
done
|
||||
|
||||
echo "Running VRT tests with the following parameters:"
|
||||
echo "E2E_START_URL: $E2E_START_URL"
|
||||
echo "VRT_ARGS: $VRT_ARGS"
|
||||
|
||||
docker run --rm --network host -v "$(pwd)":/work/ -w /work/ -it mcr.microsoft.com/playwright:v1.41.1-jammy /bin/bash \
|
||||
-c "E2E_START_URL=$E2E_START_URL yarn vrt $VRT_ARGS"
|
||||
BIN
demo.png
BIN
demo.png
Binary file not shown.
|
Before Width: | Height: | Size: 157 KiB After Width: | Height: | Size: 107 KiB |
18
docker-compose.yml
Normal file
18
docker-compose.yml
Normal file
@@ -0,0 +1,18 @@
|
||||
###################################################
|
||||
# This creates and stands up the development
|
||||
# docker container. Depends on the Dockerfile and
|
||||
# docker-start.sh files.
|
||||
###################################################
|
||||
|
||||
services:
|
||||
actual-development:
|
||||
build: .
|
||||
image: actual-development
|
||||
environment:
|
||||
- HTTPS
|
||||
ports:
|
||||
- '3001:3001'
|
||||
volumes:
|
||||
- '.:/app'
|
||||
restart: 'no'
|
||||
|
||||
50
package.json
50
package.json
@@ -18,42 +18,56 @@
|
||||
]
|
||||
},
|
||||
"scripts": {
|
||||
"start": "npm-run-all --parallel 'start:desktop-*'",
|
||||
"start": "yarn start:browser",
|
||||
"start:desktop": "yarn rebuild-electron && npm-run-all --parallel 'start:desktop-*'",
|
||||
"start:desktop-node": "yarn workspace loot-core watch:node",
|
||||
"start:desktop-client": "yarn workspace @actual-app/web watch",
|
||||
"start:desktop-electron": "yarn workspace Actual watch",
|
||||
"start:desktop-electron": "yarn workspace desktop-electron watch",
|
||||
"start:electron": "yarn start:desktop",
|
||||
"start:browser": "npm-run-all --parallel 'start:browser-*'",
|
||||
"start:browser-backend": "yarn workspace loot-core watch:browser",
|
||||
"start:browser-frontend": "yarn workspace @actual-app/web start:browser",
|
||||
"build:browser": "./bin/package-browser",
|
||||
"test": "yarn workspaces foreach --parallel --verbose run test",
|
||||
"test:debug": "yarn workspaces foreach --verbose run test",
|
||||
"e2e": "yarn workspaces foreach --parallel --verbose run e2e",
|
||||
"build:desktop": "./bin/package-electron",
|
||||
"build:api": "yarn workspace @actual-app/api build",
|
||||
"test": "yarn workspaces foreach --all --parallel --verbose run test",
|
||||
"test:debug": "yarn workspaces foreach --all --verbose run test",
|
||||
"e2e": "yarn workspaces foreach --all --parallel --verbose run e2e",
|
||||
"vrt": "yarn workspaces foreach --all --parallel --verbose run vrt",
|
||||
"vrt:docker": "./bin/run-vrt",
|
||||
"rebuild-electron": "./node_modules/.bin/electron-rebuild -f -m ./packages/loot-core",
|
||||
"rebuild-node": "yarn workspace loot-core rebuild",
|
||||
"lint": "cross-env NODE_ENV=development yarn workspaces foreach --verbose run lint --max-warnings 0",
|
||||
"typecheck": "yarn tsc",
|
||||
"postinstall": "patch-package"
|
||||
"lint": "eslint . --max-warnings 0 --ext .js,.jsx,.ts,.tsx",
|
||||
"lint:verbose": "DEBUG=eslint:cli-engine eslint . --max-warnings 0",
|
||||
"typecheck": "yarn tsc && tsc-strict",
|
||||
"jq": "./node_modules/node-jq/bin/jq"
|
||||
},
|
||||
"devDependencies": {
|
||||
"cross-env": "^5.1.5",
|
||||
"cross-env": "^7.0.3",
|
||||
"eslint": "^8.37.0",
|
||||
"eslint-config-prettier": "^9.1.0",
|
||||
"eslint-config-react-app": "7.0.1",
|
||||
"eslint-plugin-prettier": "4.2.1",
|
||||
"eslint-import-resolver-typescript": "3.5.5",
|
||||
"eslint-plugin-import": "2.27.5",
|
||||
"eslint-plugin-prettier": "5.1.3",
|
||||
"eslint-plugin-react": "7.32.2",
|
||||
"eslint-plugin-rulesdir": "^0.2.2",
|
||||
"npm-run-all": "^4.1.3",
|
||||
"patch-package": "^6.1.2",
|
||||
"prettier": "2.8.2",
|
||||
"react-refresh": "^0.14.0",
|
||||
"node-jq": "^4.0.1",
|
||||
"npm-run-all": "^4.1.5",
|
||||
"prettier": "3.2.4",
|
||||
"source-map-support": "^0.5.21",
|
||||
"typescript": "^5.0.2"
|
||||
"typescript": "^5.0.2",
|
||||
"typescript-strict-plugin": "^2.2.2-beta.2"
|
||||
},
|
||||
"resolutions": {
|
||||
"react-error-overlay": "6.0.9"
|
||||
"rollup": "4.9.4"
|
||||
},
|
||||
"packageManager": "yarn@3.4.1",
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
},
|
||||
"packageManager": "yarn@4.0.2",
|
||||
"browserslist": [
|
||||
"electron 12.0",
|
||||
"electron 24.0",
|
||||
"defaults"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -1,2 +0,0 @@
|
||||
app/bundle.api.js
|
||||
dist
|
||||
2
packages/api/.gitignore
vendored
2
packages/api/.gitignore
vendored
@@ -1,3 +1,5 @@
|
||||
app/bundle.api.js*
|
||||
app/stats.json
|
||||
migrations
|
||||
default-db.sqlite
|
||||
mocks/budgets/**/*
|
||||
|
||||
@@ -2,4 +2,4 @@
|
||||
npm install @actual-app/api
|
||||
```
|
||||
|
||||
View docs here: https://actualbudget.github.io/docs/Developers/using-the-API
|
||||
View docs here: https://actualbudget.org/docs/api/
|
||||
|
||||
21
packages/api/__snapshots__/methods.test.ts.snap
Normal file
21
packages/api/__snapshots__/methods.test.ts.snap
Normal file
@@ -0,0 +1,21 @@
|
||||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`API setup and teardown successfully loads budget 1`] = `
|
||||
Array [
|
||||
"2016-10",
|
||||
"2016-11",
|
||||
"2016-12",
|
||||
"2017-01",
|
||||
"2017-02",
|
||||
"2017-03",
|
||||
"2017-04",
|
||||
"2017-05",
|
||||
"2017-06",
|
||||
"2017-07",
|
||||
"2017-08",
|
||||
"2017-09",
|
||||
"2017-10",
|
||||
"2017-11",
|
||||
"2017-12",
|
||||
]
|
||||
`;
|
||||
@@ -23,7 +23,7 @@ class Query {
|
||||
}
|
||||
|
||||
unfilter(exprs) {
|
||||
let exprSet = new Set(exprs);
|
||||
const exprSet = new Set(exprs);
|
||||
return new Query({
|
||||
...this.state,
|
||||
filterExpressions: this.state.filterExpressions.filter(
|
||||
@@ -37,13 +37,13 @@ class Query {
|
||||
exprs = [exprs];
|
||||
}
|
||||
|
||||
let query = new Query({ ...this.state, selectExpressions: exprs });
|
||||
const query = new Query({ ...this.state, selectExpressions: exprs });
|
||||
query.state.calculation = false;
|
||||
return query;
|
||||
}
|
||||
|
||||
calculate(expr) {
|
||||
let query = this.select({ result: expr });
|
||||
const query = this.select({ result: expr });
|
||||
query.state.calculation = true;
|
||||
return query;
|
||||
}
|
||||
@@ -99,6 +99,6 @@ class Query {
|
||||
}
|
||||
}
|
||||
|
||||
export default function q(table) {
|
||||
export function q(table) {
|
||||
return new Query({ table });
|
||||
}
|
||||
|
||||
@@ -1,31 +0,0 @@
|
||||
import fetch from 'node-fetch';
|
||||
|
||||
import * as bundle from './app/bundle.api';
|
||||
import * as injected from './injected';
|
||||
|
||||
let actualApp;
|
||||
export const internal = bundle.lib;
|
||||
|
||||
export * as methods from './methods';
|
||||
export * as utils from './utils';
|
||||
|
||||
export async function init(config = {}) {
|
||||
if (actualApp) {
|
||||
return;
|
||||
}
|
||||
|
||||
global.fetch = fetch;
|
||||
|
||||
await bundle.init(config);
|
||||
actualApp = bundle.lib;
|
||||
|
||||
injected.override(bundle.lib.send);
|
||||
return bundle.lib;
|
||||
}
|
||||
|
||||
export async function shutdown() {
|
||||
if (actualApp) {
|
||||
await actualApp.send('close-budget');
|
||||
actualApp = null;
|
||||
}
|
||||
}
|
||||
53
packages/api/index.ts
Normal file
53
packages/api/index.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
import type {
|
||||
RequestInfo as FetchInfo,
|
||||
RequestInit as FetchInit,
|
||||
// @ts-ignore: false-positive commonjs module error on build until typescript 5.3
|
||||
} from 'node-fetch'; // with { 'resolution-mode': 'import' };
|
||||
|
||||
// loot-core types
|
||||
import type { InitConfig } from 'loot-core/server/main';
|
||||
|
||||
// @ts-ignore: bundle not available until we build it
|
||||
// eslint-disable-next-line import/extensions
|
||||
import * as bundle from './app/bundle.api.js';
|
||||
import * as injected from './injected';
|
||||
import { validateNodeVersion } from './validateNodeVersion';
|
||||
|
||||
let actualApp: null | typeof bundle.lib;
|
||||
export const internal = bundle.lib;
|
||||
|
||||
// DEPRECATED: remove the next line in @actual-app/api v7
|
||||
export * as methods from './methods';
|
||||
|
||||
export * from './methods';
|
||||
export * as utils from './utils';
|
||||
|
||||
export async function init(config: InitConfig = {}) {
|
||||
if (actualApp) {
|
||||
return;
|
||||
}
|
||||
|
||||
validateNodeVersion();
|
||||
|
||||
if (!globalThis.fetch) {
|
||||
globalThis.fetch = (url: URL | RequestInfo, init?: RequestInit) => {
|
||||
return import('node-fetch').then(({ default: fetch }) =>
|
||||
fetch(url as unknown as FetchInfo, init as unknown as FetchInit),
|
||||
) as unknown as Promise<Response>;
|
||||
};
|
||||
}
|
||||
|
||||
await bundle.init(config);
|
||||
actualApp = bundle.lib;
|
||||
|
||||
injected.override(bundle.lib.send);
|
||||
return bundle.lib;
|
||||
}
|
||||
|
||||
export async function shutdown() {
|
||||
if (actualApp) {
|
||||
await actualApp.send('sync');
|
||||
await actualApp.send('close-budget');
|
||||
actualApp = null;
|
||||
}
|
||||
}
|
||||
24
packages/api/jest.config.js
Normal file
24
packages/api/jest.config.js
Normal file
@@ -0,0 +1,24 @@
|
||||
module.exports = {
|
||||
moduleFileExtensions: [
|
||||
'testing.js',
|
||||
'testing.ts',
|
||||
'api.js',
|
||||
'api.ts',
|
||||
'api.tsx',
|
||||
'electron.js',
|
||||
'electron.ts',
|
||||
'mjs',
|
||||
'js',
|
||||
'ts',
|
||||
'tsx',
|
||||
'json',
|
||||
],
|
||||
testEnvironment: 'node',
|
||||
testPathIgnorePatterns: ['/node_modules/'],
|
||||
watchPathIgnorePatterns: ['<rootDir>/mocks/budgets/'],
|
||||
setupFilesAfterEnv: ['<rootDir>/../loot-core/src/mocks/setup.ts'],
|
||||
transformIgnorePatterns: ['/node_modules/'],
|
||||
transform: {
|
||||
'^.+\\.(t|j)sx?$': '@swc/jest',
|
||||
},
|
||||
};
|
||||
@@ -1,167 +0,0 @@
|
||||
import * as injected from './injected';
|
||||
|
||||
export { default as q } from './app/query';
|
||||
|
||||
function send(name, args) {
|
||||
return injected.send(name, args);
|
||||
}
|
||||
|
||||
export async function runImport(name, func) {
|
||||
await send('api/start-import', { budgetName: name });
|
||||
try {
|
||||
await func();
|
||||
} catch (e) {
|
||||
await send('api/abort-import');
|
||||
throw e;
|
||||
}
|
||||
await send('api/finish-import');
|
||||
}
|
||||
|
||||
export async function loadBudget(budgetId) {
|
||||
return send('api/load-budget', { id: budgetId });
|
||||
}
|
||||
|
||||
export async function downloadBudget(syncId, { password } = {}) {
|
||||
return send('api/download-budget', { syncId, password });
|
||||
}
|
||||
|
||||
export async function batchBudgetUpdates(func) {
|
||||
await send('api/batch-budget-start');
|
||||
try {
|
||||
await func();
|
||||
} finally {
|
||||
await send('api/batch-budget-end');
|
||||
}
|
||||
}
|
||||
|
||||
export function runQuery(query) {
|
||||
return send('api/query', { query: query.serialize() });
|
||||
}
|
||||
|
||||
export function getBudgetMonths() {
|
||||
return send('api/budget-months');
|
||||
}
|
||||
|
||||
export function getBudgetMonth(month) {
|
||||
return send('api/budget-month', { month });
|
||||
}
|
||||
|
||||
export function setBudgetAmount(month, categoryId, value) {
|
||||
return send('api/budget-set-amount', { month, categoryId, amount: value });
|
||||
}
|
||||
|
||||
export function setBudgetCarryover(month, categoryId, flag) {
|
||||
return send('api/budget-set-carryover', { month, categoryId, flag });
|
||||
}
|
||||
|
||||
export function addTransactions(accountId, transactions) {
|
||||
return send('api/transactions-add', { accountId, transactions });
|
||||
}
|
||||
|
||||
export function importTransactions(accountId, transactions) {
|
||||
return send('api/transactions-import', { accountId, transactions });
|
||||
}
|
||||
|
||||
export function getTransactions(accountId, startDate, endDate) {
|
||||
return send('api/transactions-get', { accountId, startDate, endDate });
|
||||
}
|
||||
|
||||
export function filterTransactions(accountId, text) {
|
||||
return send('api/transactions-filter', { accountId, text });
|
||||
}
|
||||
|
||||
export function updateTransaction(id, fields) {
|
||||
return send('api/transaction-update', { id, fields });
|
||||
}
|
||||
|
||||
export function deleteTransaction(id) {
|
||||
return send('api/transaction-delete', { id });
|
||||
}
|
||||
|
||||
export function getAccounts() {
|
||||
return send('api/accounts-get');
|
||||
}
|
||||
|
||||
export function createAccount(account, initialBalance) {
|
||||
return send('api/account-create', { account, initialBalance });
|
||||
}
|
||||
|
||||
export function updateAccount(id, fields) {
|
||||
return send('api/account-update', { id, fields });
|
||||
}
|
||||
|
||||
export function closeAccount(id, transferAccountId, transferCategoryId) {
|
||||
return send('api/account-close', {
|
||||
id,
|
||||
transferAccountId,
|
||||
transferCategoryId,
|
||||
});
|
||||
}
|
||||
|
||||
export function reopenAccount(id) {
|
||||
return send('api/account-reopen', { id });
|
||||
}
|
||||
|
||||
export function deleteAccount(id) {
|
||||
return send('api/account-delete', { id });
|
||||
}
|
||||
|
||||
export function createCategoryGroup(group) {
|
||||
return send('api/category-group-create', { group });
|
||||
}
|
||||
|
||||
export function updateCategoryGroup(id, fields) {
|
||||
return send('api/category-group-update', { id, fields });
|
||||
}
|
||||
|
||||
export function deleteCategoryGroup(id, transferCategoryId) {
|
||||
return send('api/category-group-delete', { id, transferCategoryId });
|
||||
}
|
||||
|
||||
export function getCategories() {
|
||||
return send('api/categories-get', { grouped: false });
|
||||
}
|
||||
|
||||
export function createCategory(category) {
|
||||
return send('api/category-create', { category });
|
||||
}
|
||||
|
||||
export function updateCategory(id, fields) {
|
||||
return send('api/category-update', { id, fields });
|
||||
}
|
||||
|
||||
export function deleteCategory(id, transferCategoryId) {
|
||||
return send('api/category-delete', { id, transferCategoryId });
|
||||
}
|
||||
|
||||
export function getPayees() {
|
||||
return send('api/payees-get');
|
||||
}
|
||||
|
||||
export function createPayee(payee) {
|
||||
return send('api/payee-create', { payee });
|
||||
}
|
||||
|
||||
export function updatePayee(id, fields) {
|
||||
return send('api/payee-update', { id, fields });
|
||||
}
|
||||
|
||||
export function deletePayee(id) {
|
||||
return send('api/payee-delete', { id });
|
||||
}
|
||||
|
||||
export function getPayeeRules(payeeId) {
|
||||
return send('api/payee-rules-get', { payeeId });
|
||||
}
|
||||
|
||||
export function createPayeeRule(payeeId, rule) {
|
||||
return send('api/payee-rule-create', { payee_id: payeeId, rule });
|
||||
}
|
||||
|
||||
export function updatePayeeRule(id, fields) {
|
||||
return send('api/payee-rule-update', { id, fields });
|
||||
}
|
||||
|
||||
export function deletePayeeRule(id) {
|
||||
return send('api/payee-rule-delete', { id });
|
||||
}
|
||||
663
packages/api/methods.test.ts
Normal file
663
packages/api/methods.test.ts
Normal file
@@ -0,0 +1,663 @@
|
||||
// @ts-strict-ignore
|
||||
import * as fs from 'fs/promises';
|
||||
import * as path from 'path';
|
||||
|
||||
import * as api from './index';
|
||||
|
||||
const budgetName = 'test-budget';
|
||||
|
||||
beforeEach(async () => {
|
||||
// we need real datetime if we are going to mix new timestamps with our mock data
|
||||
global.restoreDateNow();
|
||||
|
||||
const budgetPath = path.join(__dirname, '/mocks/budgets/', budgetName);
|
||||
await fs.rm(budgetPath, { force: true, recursive: true });
|
||||
|
||||
await createTestBudget('default-budget-template', budgetName);
|
||||
await api.init({
|
||||
dataDir: path.join(__dirname, '/mocks/budgets/'),
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
global.currentMonth = null;
|
||||
await api.shutdown();
|
||||
});
|
||||
|
||||
async function createTestBudget(templateName: string, name: string) {
|
||||
const templatePath = path.join(
|
||||
__dirname,
|
||||
'/../loot-core/src/mocks/files',
|
||||
templateName,
|
||||
);
|
||||
const budgetPath = path.join(__dirname, '/mocks/budgets/', name);
|
||||
|
||||
await fs.mkdir(budgetPath);
|
||||
await fs.copyFile(
|
||||
path.join(templatePath, 'metadata.json'),
|
||||
path.join(budgetPath, 'metadata.json'),
|
||||
);
|
||||
await fs.copyFile(
|
||||
path.join(templatePath, 'db.sqlite'),
|
||||
path.join(budgetPath, 'db.sqlite'),
|
||||
);
|
||||
}
|
||||
|
||||
describe('API setup and teardown', () => {
|
||||
// apis: loadBudget, getBudgetMonths
|
||||
test('successfully loads budget', async () => {
|
||||
await expect(api.loadBudget(budgetName)).resolves.toBeUndefined();
|
||||
|
||||
await expect(api.getBudgetMonths()).resolves.toMatchSnapshot();
|
||||
});
|
||||
});
|
||||
|
||||
describe('API CRUD operations', () => {
|
||||
beforeEach(async () => {
|
||||
// load test budget
|
||||
await api.loadBudget(budgetName);
|
||||
});
|
||||
|
||||
// api: getBudgets
|
||||
test('getBudgets', async () => {
|
||||
const budgets = await api.getBudgets();
|
||||
expect(budgets).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
id: 'test-budget',
|
||||
name: 'Default Test Db',
|
||||
}),
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
// apis: getCategoryGroups, createCategoryGroup, updateCategoryGroup, deleteCategoryGroup
|
||||
test('CategoryGroups: successfully update category groups', async () => {
|
||||
const month = '2023-10';
|
||||
global.currentMonth = month;
|
||||
|
||||
// get existing category groups
|
||||
const groups = await api.getCategoryGroups();
|
||||
expect(groups).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
hidden: 0,
|
||||
id: 'fc3825fd-b982-4b72-b768-5b30844cf832',
|
||||
is_income: 0,
|
||||
name: 'Usual Expenses',
|
||||
sort_order: 16384,
|
||||
tombstone: 0,
|
||||
}),
|
||||
expect.objectContaining({
|
||||
hidden: 0,
|
||||
id: 'a137772f-cf2f-4089-9432-822d2ddc1466',
|
||||
is_income: 0,
|
||||
name: 'Investments and Savings',
|
||||
sort_order: 32768,
|
||||
tombstone: 0,
|
||||
}),
|
||||
expect.objectContaining({
|
||||
hidden: 0,
|
||||
id: '2E1F5BDB-209B-43F9-AF2C-3CE28E380C00',
|
||||
is_income: 1,
|
||||
name: 'Income',
|
||||
sort_order: 32768,
|
||||
tombstone: 0,
|
||||
}),
|
||||
]),
|
||||
);
|
||||
|
||||
// create our test category group
|
||||
const mainGroupId = await api.createCategoryGroup({
|
||||
name: 'test-group',
|
||||
});
|
||||
|
||||
let budgetMonth = await api.getBudgetMonth(month);
|
||||
expect(budgetMonth.categoryGroups).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
id: mainGroupId,
|
||||
}),
|
||||
]),
|
||||
);
|
||||
|
||||
// update group
|
||||
await api.updateCategoryGroup(mainGroupId, {
|
||||
name: 'update-tests',
|
||||
});
|
||||
|
||||
budgetMonth = await api.getBudgetMonth(month);
|
||||
expect(budgetMonth.categoryGroups).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
id: mainGroupId,
|
||||
}),
|
||||
]),
|
||||
);
|
||||
|
||||
// delete group
|
||||
await api.deleteCategoryGroup(mainGroupId);
|
||||
|
||||
budgetMonth = await api.getBudgetMonth(month);
|
||||
expect(budgetMonth.categoryGroups).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.not.objectContaining({
|
||||
id: mainGroupId,
|
||||
}),
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
// apis: createCategory, getCategories, updateCategory, deleteCategory
|
||||
test('Categories: successfully update categories', async () => {
|
||||
const month = '2023-10';
|
||||
global.currentMonth = month;
|
||||
|
||||
// create our test category group
|
||||
const mainGroupId = await api.createCategoryGroup({
|
||||
name: 'test-group',
|
||||
});
|
||||
const secondaryGroupId = await api.createCategoryGroup({
|
||||
name: 'test-secondary-group',
|
||||
});
|
||||
const categoryId = await api.createCategory({
|
||||
name: 'test-budget',
|
||||
group_id: mainGroupId,
|
||||
});
|
||||
const categoryIdHidden = await api.createCategory({
|
||||
name: 'test-budget-hidden',
|
||||
group_id: mainGroupId,
|
||||
hidden: true,
|
||||
});
|
||||
|
||||
let categories = await api.getCategories();
|
||||
expect(categories).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
id: categoryId,
|
||||
name: 'test-budget',
|
||||
hidden: false,
|
||||
group_id: mainGroupId,
|
||||
}),
|
||||
expect.objectContaining({
|
||||
id: categoryIdHidden,
|
||||
name: 'test-budget-hidden',
|
||||
hidden: true,
|
||||
group_id: mainGroupId,
|
||||
}),
|
||||
]),
|
||||
);
|
||||
|
||||
// update/move category
|
||||
await api.updateCategory(categoryId, {
|
||||
name: 'updated-budget',
|
||||
group_id: secondaryGroupId,
|
||||
});
|
||||
|
||||
await api.updateCategory(categoryIdHidden, {
|
||||
name: 'updated-budget-hidden',
|
||||
group_id: secondaryGroupId,
|
||||
hidden: false,
|
||||
});
|
||||
|
||||
categories = await api.getCategories();
|
||||
expect(categories).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
id: categoryId,
|
||||
name: 'updated-budget',
|
||||
hidden: false,
|
||||
group_id: secondaryGroupId,
|
||||
}),
|
||||
expect.objectContaining({
|
||||
id: categoryIdHidden,
|
||||
name: 'updated-budget-hidden',
|
||||
hidden: false,
|
||||
group_id: secondaryGroupId,
|
||||
}),
|
||||
]),
|
||||
);
|
||||
|
||||
// delete categories
|
||||
await api.deleteCategory(categoryId);
|
||||
|
||||
expect(categories).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.not.objectContaining({
|
||||
id: categoryId,
|
||||
}),
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
// apis: setBudgetAmount, setBudgetCarryover, getBudgetMonth
|
||||
test('Budgets: successfully update budgets', async () => {
|
||||
const month = '2023-10';
|
||||
global.currentMonth = month;
|
||||
|
||||
// create some new categories to test with
|
||||
const groupId = await api.createCategoryGroup({
|
||||
name: 'tests',
|
||||
});
|
||||
const categoryId = await api.createCategory({
|
||||
name: 'test-budget',
|
||||
group_id: groupId,
|
||||
});
|
||||
|
||||
await api.setBudgetAmount(month, categoryId, 100);
|
||||
await api.setBudgetCarryover(month, categoryId, true);
|
||||
|
||||
const budgetMonth = await api.getBudgetMonth(month);
|
||||
expect(budgetMonth.categoryGroups).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
id: groupId,
|
||||
categories: expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
id: categoryId,
|
||||
budgeted: 100,
|
||||
carryover: true,
|
||||
}),
|
||||
]),
|
||||
}),
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
//apis: createAccount, getAccounts, updateAccount, closeAccount, deleteAccount, reopenAccount, getAccountBalance
|
||||
test('Accounts: successfully complete account operators', async () => {
|
||||
const accountId1 = await api.createAccount(
|
||||
{ name: 'test-account1', offbudget: true },
|
||||
1000,
|
||||
);
|
||||
const accountId2 = await api.createAccount({ name: 'test-account2' }, 0);
|
||||
let accounts = await api.getAccounts();
|
||||
|
||||
// accounts successfully created
|
||||
expect(accounts).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
id: accountId1,
|
||||
name: 'test-account1',
|
||||
offbudget: true,
|
||||
}),
|
||||
expect.objectContaining({ id: accountId2, name: 'test-account2' }),
|
||||
]),
|
||||
);
|
||||
|
||||
expect(await api.getAccountBalance(accountId1)).toEqual(1000);
|
||||
expect(await api.getAccountBalance(accountId2)).toEqual(0);
|
||||
|
||||
await api.updateAccount(accountId1, { offbudget: false });
|
||||
await api.closeAccount(accountId1, accountId2, null);
|
||||
await api.deleteAccount(accountId2);
|
||||
|
||||
// accounts successfully updated, and one of them deleted
|
||||
accounts = await api.getAccounts();
|
||||
expect(accounts).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
id: accountId1,
|
||||
name: 'test-account1',
|
||||
closed: true,
|
||||
offbudget: false,
|
||||
}),
|
||||
expect.not.objectContaining({ id: accountId2 }),
|
||||
]),
|
||||
);
|
||||
|
||||
await api.reopenAccount(accountId1);
|
||||
|
||||
// the non-deleted account is reopened
|
||||
accounts = await api.getAccounts();
|
||||
expect(accounts).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
id: accountId1,
|
||||
name: 'test-account1',
|
||||
closed: false,
|
||||
}),
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
// apis: createPayee, getPayees, updatePayee, deletePayee
|
||||
test('Payees: successfully update payees', async () => {
|
||||
const payeeId1 = await api.createPayee({ name: 'test-payee1' });
|
||||
const payeeId2 = await api.createPayee({ name: 'test-payee2' });
|
||||
let payees = await api.getPayees();
|
||||
|
||||
// payees successfully created
|
||||
expect(payees).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
id: payeeId1,
|
||||
name: 'test-payee1',
|
||||
}),
|
||||
expect.objectContaining({
|
||||
id: payeeId2,
|
||||
name: 'test-payee2',
|
||||
}),
|
||||
]),
|
||||
);
|
||||
|
||||
await api.updatePayee(payeeId1, { name: 'test-updated-payee' });
|
||||
await api.deletePayee(payeeId2);
|
||||
|
||||
// confirm update and delete were successful
|
||||
payees = await api.getPayees();
|
||||
expect(payees).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
id: payeeId1,
|
||||
name: 'test-updated-payee',
|
||||
}),
|
||||
expect.not.objectContaining({
|
||||
name: 'test-payee1',
|
||||
}),
|
||||
expect.not.objectContaining({
|
||||
id: payeeId2,
|
||||
}),
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
// apis: getRules, getPayeeRules, createRule, updateRule, deleteRule
|
||||
test('Rules: successfully update rules', async () => {
|
||||
await api.createPayee({ name: 'test-payee' });
|
||||
await api.createPayee({ name: 'test-payee2' });
|
||||
|
||||
// create our test rules
|
||||
const rule = await api.createRule({
|
||||
stage: 'pre',
|
||||
conditionsOp: 'and',
|
||||
conditions: [
|
||||
{
|
||||
field: 'payee',
|
||||
op: 'is',
|
||||
value: 'test-payee',
|
||||
},
|
||||
],
|
||||
actions: [
|
||||
{
|
||||
op: 'set',
|
||||
field: 'category',
|
||||
value: 'fc3825fd-b982-4b72-b768-5b30844cf832',
|
||||
},
|
||||
],
|
||||
});
|
||||
const rule2 = await api.createRule({
|
||||
stage: 'pre',
|
||||
conditionsOp: 'and',
|
||||
conditions: [
|
||||
{
|
||||
field: 'payee',
|
||||
op: 'is',
|
||||
value: 'test-payee2',
|
||||
},
|
||||
],
|
||||
actions: [
|
||||
{
|
||||
op: 'set',
|
||||
field: 'category',
|
||||
value: 'fc3825fd-b982-4b72-b768-5b30844cf832',
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
// get existing rules
|
||||
const rules = await api.getRules();
|
||||
expect(rules).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
actions: expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
field: 'category',
|
||||
op: 'set',
|
||||
type: 'id',
|
||||
value: 'fc3825fd-b982-4b72-b768-5b30844cf832',
|
||||
}),
|
||||
]),
|
||||
conditions: expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
field: 'payee',
|
||||
op: 'is',
|
||||
type: 'id',
|
||||
value: 'test-payee2',
|
||||
}),
|
||||
]),
|
||||
conditionsOp: 'and',
|
||||
id: rule2.id,
|
||||
stage: 'pre',
|
||||
}),
|
||||
expect.objectContaining({
|
||||
actions: expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
field: 'category',
|
||||
op: 'set',
|
||||
type: 'id',
|
||||
value: 'fc3825fd-b982-4b72-b768-5b30844cf832',
|
||||
}),
|
||||
]),
|
||||
conditions: expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
field: 'payee',
|
||||
op: 'is',
|
||||
type: 'id',
|
||||
value: 'test-payee',
|
||||
}),
|
||||
]),
|
||||
conditionsOp: 'and',
|
||||
id: rule.id,
|
||||
stage: 'pre',
|
||||
}),
|
||||
]),
|
||||
);
|
||||
|
||||
// get by payee
|
||||
expect(await api.getPayeeRules('test-payee')).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
actions: expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
field: 'category',
|
||||
op: 'set',
|
||||
type: 'id',
|
||||
value: 'fc3825fd-b982-4b72-b768-5b30844cf832',
|
||||
}),
|
||||
]),
|
||||
conditions: expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
field: 'payee',
|
||||
op: 'is',
|
||||
type: 'id',
|
||||
value: 'test-payee',
|
||||
}),
|
||||
]),
|
||||
conditionsOp: 'and',
|
||||
id: rule.id,
|
||||
stage: 'pre',
|
||||
}),
|
||||
]),
|
||||
);
|
||||
|
||||
expect(await api.getPayeeRules('test-payee2')).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
actions: expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
field: 'category',
|
||||
op: 'set',
|
||||
type: 'id',
|
||||
value: 'fc3825fd-b982-4b72-b768-5b30844cf832',
|
||||
}),
|
||||
]),
|
||||
conditions: expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
field: 'payee',
|
||||
op: 'is',
|
||||
type: 'id',
|
||||
value: 'test-payee2',
|
||||
}),
|
||||
]),
|
||||
conditionsOp: 'and',
|
||||
id: rule2.id,
|
||||
stage: 'pre',
|
||||
}),
|
||||
]),
|
||||
);
|
||||
|
||||
// update one rule
|
||||
const updatedRule = {
|
||||
...rule,
|
||||
stage: 'post',
|
||||
conditionsOp: 'or',
|
||||
};
|
||||
expect(await api.updateRule(updatedRule)).toEqual(updatedRule);
|
||||
|
||||
expect(await api.getRules()).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
actions: expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
field: 'category',
|
||||
op: 'set',
|
||||
type: 'id',
|
||||
value: 'fc3825fd-b982-4b72-b768-5b30844cf832',
|
||||
}),
|
||||
]),
|
||||
conditions: expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
field: 'payee',
|
||||
op: 'is',
|
||||
type: 'id',
|
||||
value: 'test-payee',
|
||||
}),
|
||||
]),
|
||||
conditionsOp: 'or',
|
||||
id: rule.id,
|
||||
stage: 'post',
|
||||
}),
|
||||
expect.objectContaining({
|
||||
actions: expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
field: 'category',
|
||||
op: 'set',
|
||||
type: 'id',
|
||||
value: 'fc3825fd-b982-4b72-b768-5b30844cf832',
|
||||
}),
|
||||
]),
|
||||
conditions: expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
field: 'payee',
|
||||
op: 'is',
|
||||
type: 'id',
|
||||
value: 'test-payee2',
|
||||
}),
|
||||
]),
|
||||
conditionsOp: 'and',
|
||||
id: rule2.id,
|
||||
stage: 'pre',
|
||||
}),
|
||||
]),
|
||||
);
|
||||
|
||||
// delete rules
|
||||
await api.deleteRule(rules[1]);
|
||||
expect(await api.getRules()).toHaveLength(1);
|
||||
|
||||
await api.deleteRule(rules[0]);
|
||||
expect(await api.getRules()).toHaveLength(0);
|
||||
});
|
||||
|
||||
// apis: addTransactions, getTransactions, importTransactions, updateTransaction, deleteTransaction
|
||||
test('Transactions: successfully update transactions', async () => {
|
||||
const accountId = await api.createAccount({ name: 'test-account' }, 0);
|
||||
|
||||
let newTransaction = [
|
||||
{ date: '2023-11-03', imported_id: '11', amount: 100, notes: 'notes' },
|
||||
{ date: '2023-11-03', imported_id: '12', amount: 100, notes: '' },
|
||||
];
|
||||
|
||||
const addResult = await api.addTransactions(accountId, newTransaction, {
|
||||
learnCategories: true,
|
||||
runTransfers: true,
|
||||
});
|
||||
expect(addResult).toBe('ok');
|
||||
|
||||
expect(await api.getAccountBalance(accountId)).toEqual(200);
|
||||
expect(
|
||||
await api.getAccountBalance(accountId, new Date(2023, 10, 2)),
|
||||
).toEqual(0);
|
||||
|
||||
// confirm added transactions exist
|
||||
let transactions = await api.getTransactions(
|
||||
accountId,
|
||||
'2023-11-01',
|
||||
'2023-11-30',
|
||||
);
|
||||
expect(transactions).toEqual(
|
||||
expect.arrayContaining(
|
||||
newTransaction.map(trans => expect.objectContaining(trans)),
|
||||
),
|
||||
);
|
||||
expect(transactions).toHaveLength(2);
|
||||
|
||||
newTransaction = [
|
||||
{ date: '2023-12-03', imported_id: '11', amount: 100, notes: 'notes' },
|
||||
{ date: '2023-12-03', imported_id: '12', amount: 100, notes: 'notes' },
|
||||
{ date: '2023-12-03', imported_id: '22', amount: 200, notes: '' },
|
||||
];
|
||||
|
||||
const reconciled = await api.importTransactions(accountId, newTransaction);
|
||||
|
||||
// Expect it to reconcile and to have updated one of the previous transactions
|
||||
expect(reconciled.added).toHaveLength(1);
|
||||
expect(reconciled.updated).toHaveLength(1);
|
||||
|
||||
// confirm imported transactions exist
|
||||
transactions = await api.getTransactions(
|
||||
accountId,
|
||||
'2023-12-01',
|
||||
'2023-12-31',
|
||||
);
|
||||
expect(transactions).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({ imported_id: '22', amount: 200 }),
|
||||
]),
|
||||
);
|
||||
expect(transactions).toHaveLength(1);
|
||||
|
||||
// confirm imported transactions update perfomed
|
||||
transactions = await api.getTransactions(
|
||||
accountId,
|
||||
'2023-11-01',
|
||||
'2023-11-30',
|
||||
);
|
||||
expect(transactions).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({ notes: 'notes', amount: 100 }),
|
||||
]),
|
||||
);
|
||||
expect(transactions).toHaveLength(2);
|
||||
|
||||
const idToUpdate = reconciled.added[0];
|
||||
const idToDelete = reconciled.updated[0];
|
||||
await api.updateTransaction(idToUpdate, { amount: 500 });
|
||||
await api.deleteTransaction(idToDelete);
|
||||
|
||||
// confirm updates and deletions work
|
||||
transactions = await api.getTransactions(
|
||||
accountId,
|
||||
'2023-12-01',
|
||||
'2023-12-31',
|
||||
);
|
||||
expect(transactions).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({ id: idToUpdate, amount: 500 }),
|
||||
expect.not.objectContaining({ id: idToDelete }),
|
||||
]),
|
||||
);
|
||||
expect(transactions).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
206
packages/api/methods.ts
Normal file
206
packages/api/methods.ts
Normal file
@@ -0,0 +1,206 @@
|
||||
// @ts-strict-ignore
|
||||
import type { Handlers } from 'loot-core/src/types/handlers';
|
||||
|
||||
import * as injected from './injected';
|
||||
|
||||
export { q } from './app/query';
|
||||
|
||||
function send<K extends keyof Handlers, T extends Handlers[K]>(
|
||||
name: K,
|
||||
args?: Parameters<T>[0],
|
||||
): Promise<Awaited<ReturnType<T>>> {
|
||||
return injected.send(name, args);
|
||||
}
|
||||
|
||||
export async function runImport(name, func) {
|
||||
await send('api/start-import', { budgetName: name });
|
||||
try {
|
||||
await func();
|
||||
} catch (e) {
|
||||
await send('api/abort-import');
|
||||
throw e;
|
||||
}
|
||||
await send('api/finish-import');
|
||||
}
|
||||
|
||||
export async function loadBudget(budgetId) {
|
||||
return send('api/load-budget', { id: budgetId });
|
||||
}
|
||||
|
||||
export async function downloadBudget(syncId, { password }: { password? } = {}) {
|
||||
return send('api/download-budget', { syncId, password });
|
||||
}
|
||||
|
||||
export async function getBudgets() {
|
||||
return send('api/get-budgets');
|
||||
}
|
||||
|
||||
export async function sync() {
|
||||
return send('api/sync');
|
||||
}
|
||||
|
||||
export async function runBankSync(args?: { accountId: string }) {
|
||||
return send('api/bank-sync', args);
|
||||
}
|
||||
|
||||
export async function batchBudgetUpdates(func) {
|
||||
await send('api/batch-budget-start');
|
||||
try {
|
||||
await func();
|
||||
} finally {
|
||||
await send('api/batch-budget-end');
|
||||
}
|
||||
}
|
||||
|
||||
export function runQuery(query) {
|
||||
return send('api/query', { query: query.serialize() });
|
||||
}
|
||||
|
||||
export function getBudgetMonths() {
|
||||
return send('api/budget-months');
|
||||
}
|
||||
|
||||
export function getBudgetMonth(month) {
|
||||
return send('api/budget-month', { month });
|
||||
}
|
||||
|
||||
export function setBudgetAmount(month, categoryId, value) {
|
||||
return send('api/budget-set-amount', { month, categoryId, amount: value });
|
||||
}
|
||||
|
||||
export function setBudgetCarryover(month, categoryId, flag) {
|
||||
return send('api/budget-set-carryover', { month, categoryId, flag });
|
||||
}
|
||||
|
||||
export function addTransactions(
|
||||
accountId,
|
||||
transactions,
|
||||
{ learnCategories = false, runTransfers = false } = {},
|
||||
) {
|
||||
return send('api/transactions-add', {
|
||||
accountId,
|
||||
transactions,
|
||||
learnCategories,
|
||||
runTransfers,
|
||||
});
|
||||
}
|
||||
|
||||
export function importTransactions(accountId, transactions) {
|
||||
return send('api/transactions-import', { accountId, transactions });
|
||||
}
|
||||
|
||||
export function getTransactions(accountId, startDate, endDate) {
|
||||
return send('api/transactions-get', { accountId, startDate, endDate });
|
||||
}
|
||||
|
||||
export function updateTransaction(id, fields) {
|
||||
return send('api/transaction-update', { id, fields });
|
||||
}
|
||||
|
||||
export function deleteTransaction(id) {
|
||||
return send('api/transaction-delete', { id });
|
||||
}
|
||||
|
||||
export function getAccounts() {
|
||||
return send('api/accounts-get');
|
||||
}
|
||||
|
||||
export function createAccount(account, initialBalance?) {
|
||||
return send('api/account-create', { account, initialBalance });
|
||||
}
|
||||
|
||||
export function updateAccount(id, fields) {
|
||||
return send('api/account-update', { id, fields });
|
||||
}
|
||||
|
||||
export function closeAccount(id, transferAccountId?, transferCategoryId?) {
|
||||
return send('api/account-close', {
|
||||
id,
|
||||
transferAccountId,
|
||||
transferCategoryId,
|
||||
});
|
||||
}
|
||||
|
||||
export function reopenAccount(id) {
|
||||
return send('api/account-reopen', { id });
|
||||
}
|
||||
|
||||
export function deleteAccount(id) {
|
||||
return send('api/account-delete', { id });
|
||||
}
|
||||
|
||||
export function getAccountBalance(id, cutoff?) {
|
||||
return send('api/account-balance', { id, cutoff });
|
||||
}
|
||||
|
||||
export function getCategoryGroups() {
|
||||
return send('api/category-groups-get');
|
||||
}
|
||||
|
||||
export function createCategoryGroup(group) {
|
||||
return send('api/category-group-create', { group });
|
||||
}
|
||||
|
||||
export function updateCategoryGroup(id, fields) {
|
||||
return send('api/category-group-update', { id, fields });
|
||||
}
|
||||
|
||||
export function deleteCategoryGroup(id, transferCategoryId?) {
|
||||
return send('api/category-group-delete', { id, transferCategoryId });
|
||||
}
|
||||
|
||||
export function getCategories() {
|
||||
return send('api/categories-get', { grouped: false });
|
||||
}
|
||||
|
||||
export function createCategory(category) {
|
||||
return send('api/category-create', { category });
|
||||
}
|
||||
|
||||
export function updateCategory(id, fields) {
|
||||
return send('api/category-update', { id, fields });
|
||||
}
|
||||
|
||||
export function deleteCategory(id, transferCategoryId?) {
|
||||
return send('api/category-delete', { id, transferCategoryId });
|
||||
}
|
||||
|
||||
export function getPayees() {
|
||||
return send('api/payees-get');
|
||||
}
|
||||
|
||||
export function createPayee(payee) {
|
||||
return send('api/payee-create', { payee });
|
||||
}
|
||||
|
||||
export function updatePayee(id, fields) {
|
||||
return send('api/payee-update', { id, fields });
|
||||
}
|
||||
|
||||
export function deletePayee(id) {
|
||||
return send('api/payee-delete', { id });
|
||||
}
|
||||
|
||||
export function mergePayees(targetId, mergeIds) {
|
||||
return send('api/payees-merge', { targetId, mergeIds });
|
||||
}
|
||||
|
||||
export function getRules() {
|
||||
return send('api/rules-get');
|
||||
}
|
||||
|
||||
export function getPayeeRules(id) {
|
||||
return send('api/payee-rules-get', { id });
|
||||
}
|
||||
|
||||
export function createRule(rule) {
|
||||
return send('api/rule-create', { rule });
|
||||
}
|
||||
|
||||
export function updateRule(rule) {
|
||||
return send('api/rule-update', { rule });
|
||||
}
|
||||
|
||||
export function deleteRule(id) {
|
||||
return send('api/rule-delete', { id });
|
||||
}
|
||||
0
packages/api/mocks/budgets/.gitkeep
Normal file
0
packages/api/mocks/budgets/.gitkeep
Normal file
@@ -1,27 +1,40 @@
|
||||
{
|
||||
"name": "@actual-app/api",
|
||||
"version": "5.1.2",
|
||||
"version": "6.8.2",
|
||||
"license": "MIT",
|
||||
"description": "An API for Actual",
|
||||
"engines": {
|
||||
"node": ">=18.12.0"
|
||||
},
|
||||
"main": "dist/index.js",
|
||||
"types": "dist/index.d.ts",
|
||||
"types": "@types/index.d.ts",
|
||||
"files": [
|
||||
"dist",
|
||||
"default-db.sqlite",
|
||||
"migrations"
|
||||
"@types"
|
||||
],
|
||||
"scripts": {
|
||||
"lint": "eslint .",
|
||||
"build:app": "yarn workspace loot-core build:api",
|
||||
"build:node": "tsc --p tsconfig.dist.json",
|
||||
"build": "yarn run build:app && yarn run build:node"
|
||||
"build:node": "tsc --p tsconfig.dist.json && tsc-alias -p tsconfig.dist.json",
|
||||
"build:migrations": "cp migrations/*.sql dist/migrations",
|
||||
"build:default-db": "cp default-db.sqlite dist/",
|
||||
"build": "yarn run clean && yarn run build:app && yarn run build:node && yarn run build:migrations && yarn run build:default-db",
|
||||
"test": "yarn run build:app && jest -c jest.config.js",
|
||||
"clean": "rm -rf dist @types"
|
||||
},
|
||||
"dependencies": {
|
||||
"better-sqlite3": "^8.2.0",
|
||||
"node-fetch": "^2.6.9",
|
||||
"uuid": "3.3.2"
|
||||
"@actual-app/crdt": "workspace:^",
|
||||
"better-sqlite3": "^9.6.0",
|
||||
"compare-versions": "^6.1.0",
|
||||
"node-fetch": "^3.3.2",
|
||||
"uuid": "^9.0.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@swc/core": "^1.5.3",
|
||||
"@swc/jest": "^0.2.36",
|
||||
"@types/jest": "^27.5.2",
|
||||
"@types/uuid": "^9.0.2",
|
||||
"jest": "^27.5.1",
|
||||
"tsc-alias": "^1.8.8",
|
||||
"typescript": "^5.0.2"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +0,0 @@
|
||||
import * as api from './index';
|
||||
|
||||
async function run() {
|
||||
let app = await api.init({ config: { dataDir: '/tmp' } });
|
||||
await app.send('create-budget', { testMode: true });
|
||||
}
|
||||
|
||||
run();
|
||||
@@ -1,11 +1,19 @@
|
||||
{
|
||||
"extends": "../../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"target": "es5",
|
||||
// Using ES2021 because that’s the newest version where
|
||||
// the latest Node 16.x release supports all of the features
|
||||
"target": "ES2021",
|
||||
"module": "CommonJS",
|
||||
"noEmit": false,
|
||||
"declaration": true,
|
||||
"outDir": "dist"
|
||||
"outDir": "dist",
|
||||
"declarationDir": "@types",
|
||||
"paths": {
|
||||
"loot-core/src/*": ["./loot-core/*"],
|
||||
"loot-core/*": ["./@types/loot-core/*"],
|
||||
}
|
||||
},
|
||||
"include": ["."]
|
||||
"include": ["."],
|
||||
"exclude": ["**/node_modules/*", "dist", "@types"]
|
||||
}
|
||||
|
||||
16
packages/api/validateNodeVersion.js
Normal file
16
packages/api/validateNodeVersion.js
Normal file
@@ -0,0 +1,16 @@
|
||||
import { satisfies } from 'compare-versions';
|
||||
|
||||
import * as packageJson from './package.json';
|
||||
|
||||
export function validateNodeVersion() {
|
||||
if (process?.versions?.node) {
|
||||
const nodeVersion = process?.versions?.node;
|
||||
const minimumNodeVersion = packageJson.engines.node;
|
||||
|
||||
if (!satisfies(nodeVersion, minimumNodeVersion)) {
|
||||
throw new Error(
|
||||
`@actual-app/api requires a node version ${minimumNodeVersion}. Found that you are using: ${nodeVersion}. Please upgrade to a higher version`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
2
packages/crdt/.gitignore
vendored
Normal file
2
packages/crdt/.gitignore
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
bin/protoc-gen-js
|
||||
bin/protoc
|
||||
27
packages/crdt/README.md
Normal file
27
packages/crdt/README.md
Normal file
@@ -0,0 +1,27 @@
|
||||
# `@actual-app/crdt`
|
||||
|
||||
This package contains the core CRDT logic that enables Actual’s syncing. It is shared between the client and server. We may or may not follow semver when updating this package; any usage of it outside Actual is undocumented and at your own risk.
|
||||
|
||||
## protobuf
|
||||
|
||||
We use [protobuf](https://developers.google.com/protocol-buffers/) to encode messages as binary data to send across the network.
|
||||
|
||||
### Generating protobuf
|
||||
|
||||
The protobuf is generated by using the [protoc](https://github.com/protocolbuffers/protobuf) compiler.
|
||||
|
||||
This can be installed by downloading one of the [pre-built binaries](https://github.com/protocolbuffers/protobuf/releases/) and placing it in your `$PATH`. The version used to build the current protobuf is [v3.20.1](https://github.com/protocolbuffers/protobuf/releases/tag/v3.20.1). You’ll also need to [download the latest version of `protoc-gen-js`](https://github.com/protocolbuffers/protobuf-javascript/releases/latest). For convenience, you can put both of these binaries in `./bin`.
|
||||
|
||||
Once installed, the protobuf can be generated by running `./bin/generate-proto`.
|
||||
|
||||
However there is one very important thing to remember! The default output includes this near the top:
|
||||
|
||||
```
|
||||
var global = (function() { return this || window || global || self || Function('return this')(); }).call(null);
|
||||
```
|
||||
|
||||
This will not work with our CSP directives. You must manually modify this to this:
|
||||
|
||||
```
|
||||
var global = globalThis;
|
||||
```
|
||||
21
packages/crdt/bin/generate-proto
Executable file
21
packages/crdt/bin/generate-proto
Executable file
@@ -0,0 +1,21 @@
|
||||
#!/bin/bash
|
||||
|
||||
cd "$(dirname "$(dirname "$0")")"
|
||||
|
||||
if ! [ -x "$(command -v protoc)" ]; then
|
||||
echo 'Error: protoc is not installed. See the readme for installation instructions.' >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
export PATH="$PWD/bin:$PATH"
|
||||
|
||||
protoc --plugin="protoc-gen-ts=../../node_modules/.bin/protoc-gen-ts" \
|
||||
--ts_opt=esModuleInterop=true \
|
||||
--ts_out="src/proto" \
|
||||
--js_out=import_style=commonjs,binary:src/proto \
|
||||
--proto_path=src/proto \
|
||||
sync.proto
|
||||
|
||||
../../node_modules/.bin/prettier --write src/proto/*.d.ts
|
||||
|
||||
echo 'One more step! Find the `var global = ...` declaration in src/proto/sync_pb.js and change it to `var global = globalThis;`'
|
||||
1
packages/crdt/index.ts
Normal file
1
packages/crdt/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from './src/main';
|
||||
6
packages/crdt/jest.config.js
Normal file
6
packages/crdt/jest.config.js
Normal file
@@ -0,0 +1,6 @@
|
||||
module.exports = {
|
||||
testEnvironment: 'node',
|
||||
transform: {
|
||||
'^.+\\.(t|j)sx?$': '@swc/jest',
|
||||
},
|
||||
};
|
||||
31
packages/crdt/package.json
Normal file
31
packages/crdt/package.json
Normal file
@@ -0,0 +1,31 @@
|
||||
{
|
||||
"name": "@actual-app/crdt",
|
||||
"version": "2.1.0",
|
||||
"license": "MIT",
|
||||
"description": "CRDT layer of Actual",
|
||||
"main": "dist/index.js",
|
||||
"types": "dist/index.d.ts",
|
||||
"files": [
|
||||
"dist"
|
||||
],
|
||||
"scripts": {
|
||||
"build:node": "tsc --p tsconfig.dist.json",
|
||||
"proto:generate": "./bin/generate-proto",
|
||||
"build": "rm -rf dist && yarn run build:node && cp src/proto/sync_pb.d.ts dist/src/proto/",
|
||||
"test": "jest -c jest.config.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"google-protobuf": "^3.12.0-rc.1",
|
||||
"murmurhash": "^2.0.1",
|
||||
"uuid": "^9.0.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@swc/core": "^1.5.3",
|
||||
"@swc/jest": "^0.2.36",
|
||||
"@types/jest": "^27.5.2",
|
||||
"@types/uuid": "^9.0.2",
|
||||
"jest": "^27.5.1",
|
||||
"ts-protoc-gen": "^0.15.0",
|
||||
"typescript": "^5.0.2"
|
||||
}
|
||||
}
|
||||
13
packages/crdt/src/crdt/index.ts
Normal file
13
packages/crdt/src/crdt/index.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import * as merkle from './merkle';
|
||||
|
||||
export { merkle };
|
||||
export {
|
||||
getClock,
|
||||
setClock,
|
||||
makeClock,
|
||||
makeClientId,
|
||||
serializeClock,
|
||||
deserializeClock,
|
||||
type Clock,
|
||||
Timestamp,
|
||||
} from './timestamp';
|
||||
201
packages/crdt/src/crdt/merkle.test.ts
Normal file
201
packages/crdt/src/crdt/merkle.test.ts
Normal file
@@ -0,0 +1,201 @@
|
||||
/* eslint-disable @typescript-eslint/no-non-null-assertion */
|
||||
import * as merkle from './merkle';
|
||||
import { Timestamp } from './timestamp';
|
||||
|
||||
function message(timestampStr: string, hash: number) {
|
||||
const timestamp = Timestamp.parse(timestampStr)!;
|
||||
timestamp.hash = () => hash;
|
||||
return { timestamp };
|
||||
}
|
||||
|
||||
function insertMessages(
|
||||
trie: merkle.TrieNode,
|
||||
messages: ReturnType<typeof message>[],
|
||||
) {
|
||||
messages.forEach(msg => {
|
||||
trie = merkle.insert(trie, msg.timestamp);
|
||||
});
|
||||
return trie;
|
||||
}
|
||||
|
||||
describe('merkle trie', () => {
|
||||
test('adding an item works', () => {
|
||||
let trie = merkle.insert(
|
||||
merkle.emptyTrie(),
|
||||
Timestamp.parse('2018-11-12T13:21:40.122Z-0000-0123456789ABCDEF')!,
|
||||
);
|
||||
trie = merkle.insert(
|
||||
trie,
|
||||
Timestamp.parse('2018-11-13T13:21:40.122Z-0000-0123456789ABCDEF')!,
|
||||
);
|
||||
expect(trie).toMatchSnapshot();
|
||||
});
|
||||
|
||||
test('diff returns the correct time difference', () => {
|
||||
let trie1 = merkle.emptyTrie();
|
||||
let trie2 = merkle.emptyTrie();
|
||||
|
||||
const messages = [
|
||||
// First client messages
|
||||
message('2018-11-13T13:20:40.122Z-0000-0123456789ABCDEF', 1000),
|
||||
message('2018-11-14T13:05:35.122Z-0000-0123456789ABCDEF', 1100),
|
||||
message('2018-11-15T22:19:00.122Z-0000-0123456789ABCDEF', 1200),
|
||||
|
||||
// Second client messages
|
||||
message('2018-11-20T13:19:40.122Z-0000-0123456789ABCDEF', 1300),
|
||||
message('2018-11-25T13:19:40.122Z-0000-0123456789ABCDEF', 1400),
|
||||
];
|
||||
|
||||
trie1 = merkle.insert(trie1, messages[0].timestamp);
|
||||
trie1 = merkle.insert(trie1, messages[1].timestamp);
|
||||
trie1 = merkle.insert(trie1, messages[2].timestamp);
|
||||
expect(trie1.hash).toBe(788);
|
||||
|
||||
trie2 = merkle.insert(trie2, messages[3].timestamp);
|
||||
trie2 = merkle.insert(trie2, messages[4].timestamp);
|
||||
expect(trie2.hash).toBe(108);
|
||||
|
||||
expect(new Date(merkle.diff(trie1, trie2)!).toISOString()).toBe(
|
||||
'2018-11-02T17:15:00.000Z',
|
||||
);
|
||||
|
||||
trie1 = merkle.insert(trie1, messages[3].timestamp);
|
||||
trie1 = merkle.insert(trie1, messages[4].timestamp);
|
||||
trie2 = merkle.insert(trie2, messages[0].timestamp);
|
||||
trie2 = merkle.insert(trie2, messages[1].timestamp);
|
||||
trie2 = merkle.insert(trie2, messages[2].timestamp);
|
||||
expect(trie1.hash).toBe(888);
|
||||
expect(trie1.hash).toBe(trie2.hash);
|
||||
});
|
||||
|
||||
test('diffing works with empty tries', () => {
|
||||
const trie1 = merkle.emptyTrie();
|
||||
const trie2 = merkle.insert(
|
||||
merkle.emptyTrie(),
|
||||
Timestamp.parse('2009-01-02T10:17:37.789Z-0000-0000testinguuid1')!,
|
||||
);
|
||||
|
||||
expect(merkle.diff(trie1, trie2)).toBe(0);
|
||||
});
|
||||
|
||||
test('pruning works and keeps correct hashes', () => {
|
||||
const messages = [
|
||||
message('2018-11-01T01:00:00.000Z-0000-0123456789ABCDEF', 1000),
|
||||
message('2018-11-01T01:09:00.000Z-0000-0123456789ABCDEF', 1100),
|
||||
message('2018-11-01T01:18:00.000Z-0000-0123456789ABCDEF', 1200),
|
||||
message('2018-11-01T01:27:00.000Z-0000-0123456789ABCDEF', 1300),
|
||||
message('2018-11-01T01:36:00.000Z-0000-0123456789ABCDEF', 1400),
|
||||
message('2018-11-01T01:45:00.000Z-0000-0123456789ABCDEF', 1500),
|
||||
message('2018-11-01T01:54:00.000Z-0000-0123456789ABCDEF', 1600),
|
||||
message('2018-11-01T02:03:00.000Z-0000-0123456789ABCDEF', 1700),
|
||||
message('2018-11-01T02:10:00.000Z-0000-0123456789ABCDEF', 1800),
|
||||
message('2018-11-01T02:19:00.000Z-0000-0123456789ABCDEF', 1900),
|
||||
message('2018-11-01T02:28:00.000Z-0000-0123456789ABCDEF', 2000),
|
||||
message('2018-11-01T02:37:00.000Z-0000-0123456789ABCDEF', 2100),
|
||||
];
|
||||
|
||||
let trie = merkle.emptyTrie();
|
||||
messages.forEach(msg => {
|
||||
trie = merkle.insert(trie, msg.timestamp);
|
||||
});
|
||||
expect(trie.hash).toBe(2496);
|
||||
expect(trie).toMatchSnapshot();
|
||||
|
||||
const pruned = merkle.prune(trie);
|
||||
expect(pruned.hash).toBe(2496);
|
||||
expect(pruned).toMatchSnapshot();
|
||||
});
|
||||
|
||||
test('diffing differently shaped tries returns correct time', () => {
|
||||
const messages = [
|
||||
message('2018-11-01T01:00:00.000Z-0000-0123456789ABCDEF', 1000),
|
||||
message('2018-11-01T01:09:00.000Z-0000-0123456789ABCDEF', 1100),
|
||||
message('2018-11-01T01:18:00.000Z-0000-0123456789ABCDEF', 1200),
|
||||
message('2018-11-01T01:27:00.000Z-0000-0123456789ABCDEF', 1300),
|
||||
message('2018-11-01T01:36:00.000Z-0000-0123456789ABCDEF', 1400),
|
||||
message('2018-11-01T01:45:00.000Z-0000-0123456789ABCDEF', 1500),
|
||||
message('2018-11-01T01:54:00.000Z-0000-0123456789ABCDEF', 1600),
|
||||
message('2018-11-01T02:03:00.000Z-0000-0123456789ABCDEF', 1700),
|
||||
message('2018-11-01T02:10:00.000Z-0000-0123456789ABCDEF', 1800),
|
||||
message('2018-11-01T02:19:00.000Z-0000-0123456789ABCDEF', 1900),
|
||||
message('2018-11-01T02:28:00.000Z-0000-0123456789ABCDEF', 2000),
|
||||
message('2018-11-01T02:37:00.000Z-0000-0123456789ABCDEF', 2100),
|
||||
];
|
||||
|
||||
const trie = insertMessages({}, messages);
|
||||
|
||||
// Case 0: It always returns a base time when comparing with an
|
||||
// empty trie
|
||||
expect(new Date(merkle.diff(merkle.emptyTrie(), trie)!).toISOString()).toBe(
|
||||
'1970-01-01T00:00:00.000Z',
|
||||
);
|
||||
expect(new Date(merkle.diff(trie, merkle.emptyTrie())!).toISOString()).toBe(
|
||||
'1970-01-01T00:00:00.000Z',
|
||||
);
|
||||
|
||||
// Case 1: Add an older message that modifies the trie in such a
|
||||
// way that it modifies the 1st out of 3 branches (so it will be
|
||||
// pruned away)
|
||||
const trie1 = insertMessages(trie, [
|
||||
message('2018-11-01T00:59:00.000Z-0000-0123456789ABCDEF', 900),
|
||||
]);
|
||||
|
||||
// Normal comparison works
|
||||
expect(new Date(merkle.diff(trie1, trie)!).toISOString()).toBe(
|
||||
'2018-11-01T00:54:00.000Z',
|
||||
);
|
||||
|
||||
// Comparing the pruned new trie is lossy, so it returns an even older time
|
||||
expect(
|
||||
new Date(merkle.diff(merkle.prune(trie1), trie)!).toISOString(),
|
||||
).toBe('2018-11-01T00:45:00.000Z');
|
||||
|
||||
// Comparing the pruned original trie is just as lossy
|
||||
expect(
|
||||
new Date(merkle.diff(trie1, merkle.prune(trie))!).toISOString(),
|
||||
).toBe('2018-11-01T00:45:00.000Z');
|
||||
|
||||
// Pruning both tries is just as lossy as well, since the changed
|
||||
// key is pruned away in both cases and it won't find a changed
|
||||
// key so it bails at the point
|
||||
expect(
|
||||
new Date(
|
||||
merkle.diff(merkle.prune(trie1), merkle.prune(trie))!,
|
||||
).toISOString(),
|
||||
).toBe('2018-11-01T00:45:00.000Z');
|
||||
|
||||
// Case 2: Add two messages similar to the above case, but the
|
||||
// second message modifies the 2nd key at the same level as the
|
||||
// first message modifying the 1st key
|
||||
const trie2 = insertMessages(trie, [
|
||||
message('2018-11-01T00:59:00.000Z-0000-0123456789ABCDEF', 900),
|
||||
message('2018-11-01T01:15:00.000Z-0000-0123456789ABCDEF', 1422),
|
||||
]);
|
||||
|
||||
// Normal comparison works
|
||||
expect(new Date(merkle.diff(trie2, trie)!).toISOString()).toBe(
|
||||
'2018-11-01T00:54:00.000Z',
|
||||
);
|
||||
|
||||
// Same as case 1
|
||||
expect(
|
||||
new Date(merkle.diff(merkle.prune(trie2), trie)!).toISOString(),
|
||||
).toBe('2018-11-01T00:45:00.000Z');
|
||||
|
||||
// Same as case 1
|
||||
expect(
|
||||
new Date(merkle.diff(trie2, merkle.prune(trie))!).toISOString(),
|
||||
).toBe('2018-11-01T00:45:00.000Z');
|
||||
|
||||
// Pruning both tries is very lossy and this ends up returning a
|
||||
// time that only covers the second message. Syncing will need
|
||||
// multiple passes to sync up. This happens because the second
|
||||
// message provides a "changed path" that the diff takes which
|
||||
// ignores the first message.
|
||||
expect(
|
||||
new Date(
|
||||
merkle.diff(merkle.prune(trie2), merkle.prune(trie))!,
|
||||
).toISOString(),
|
||||
).toBe('2018-11-01T01:12:00.000Z');
|
||||
});
|
||||
});
|
||||
180
packages/crdt/src/crdt/merkle.ts
Normal file
180
packages/crdt/src/crdt/merkle.ts
Normal file
@@ -0,0 +1,180 @@
|
||||
// TODO: Ok, several problems:
|
||||
//
|
||||
// * If nothing matches between two merkle trees, we should fallback
|
||||
// * to the last window instead the front one (use 0 instead of the
|
||||
// * key)
|
||||
//
|
||||
// * Need to check to make sure if account exists when handling
|
||||
// * transaction changes in syncing
|
||||
|
||||
import { Timestamp } from './timestamp';
|
||||
|
||||
/**
|
||||
* Represents a node within a trinary radix trie.
|
||||
*/
|
||||
export type TrieNode = {
|
||||
'0'?: TrieNode;
|
||||
'1'?: TrieNode;
|
||||
'2'?: TrieNode;
|
||||
hash?: number;
|
||||
};
|
||||
|
||||
type NumberTrieNodeKey = keyof Omit<TrieNode, 'hash'>;
|
||||
|
||||
export function emptyTrie(): TrieNode {
|
||||
return { hash: 0 };
|
||||
}
|
||||
|
||||
function isNumberTrieNodeKey(input: string): input is NumberTrieNodeKey {
|
||||
return ['0', '1', '2'].includes(input);
|
||||
}
|
||||
|
||||
export function getKeys(trie: TrieNode): NumberTrieNodeKey[] {
|
||||
return Object.keys(trie).filter(isNumberTrieNodeKey);
|
||||
}
|
||||
|
||||
export function keyToTimestamp(key: string): number {
|
||||
// 16 is the length of the base 3 value of the current time in
|
||||
// minutes. Ensure it's padded to create the full value
|
||||
const fullkey = key + '0'.repeat(16 - key.length);
|
||||
|
||||
// Parse the base 3 representation
|
||||
return parseInt(fullkey, 3) * 1000 * 60;
|
||||
}
|
||||
|
||||
/**
|
||||
* Mutates `trie` to insert a node at `timestamp`
|
||||
*/
|
||||
export function insert(trie: TrieNode, timestamp: Timestamp) {
|
||||
const hash = timestamp.hash();
|
||||
const key = Number(Math.floor(timestamp.millis() / 1000 / 60)).toString(3);
|
||||
|
||||
trie = Object.assign({}, trie, { hash: (trie.hash || 0) ^ hash });
|
||||
return insertKey(trie, key, hash);
|
||||
}
|
||||
|
||||
function insertKey(trie: TrieNode, key: string, hash: number): TrieNode {
|
||||
if (key.length === 0) {
|
||||
return trie;
|
||||
}
|
||||
const c = key[0];
|
||||
const t = isNumberTrieNodeKey(c) ? trie[c] : undefined;
|
||||
const n = t || {};
|
||||
return Object.assign({}, trie, {
|
||||
[c]: Object.assign({}, n, insertKey(n, key.slice(1), hash), {
|
||||
hash: (n.hash || 0) ^ hash,
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
export function build(timestamps: Timestamp[]) {
|
||||
const trie = emptyTrie();
|
||||
for (const timestamp of timestamps) {
|
||||
insert(trie, timestamp);
|
||||
}
|
||||
return trie;
|
||||
}
|
||||
|
||||
export function diff(trie1: TrieNode, trie2: TrieNode): number | null {
|
||||
if (trie1.hash === trie2.hash) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let node1 = trie1;
|
||||
let node2 = trie2;
|
||||
let k = '';
|
||||
|
||||
// This loop will eventually stop when it traverses down to find
|
||||
// where the hashes differ, or otherwise when there are no leaves
|
||||
// left (this shouldn't happen, if that's the case the hash check at
|
||||
// the top of this function should pass)
|
||||
while (1) {
|
||||
const keyset = new Set([...getKeys(node1), ...getKeys(node2)]);
|
||||
const keys = [...keyset.values()];
|
||||
keys.sort();
|
||||
|
||||
let diffkey: null | '0' | '1' | '2' = null;
|
||||
|
||||
// Traverse down the trie through keys that aren't the same. We
|
||||
// traverse down the keys in order. Stop in two cases: either one
|
||||
// of the nodes doesn't have the key, or a different key isn't
|
||||
// found. For the former case, we have to that because pruning is
|
||||
// lossy. We don't know if we've pruned off a changed key so we
|
||||
// can't traverse down anymore. For the latter case, it means two
|
||||
// things: either we've hit the bottom of the tree, or the changed
|
||||
// key has been pruned off. In the latter case we have a "partial"
|
||||
// key and will fill the rest with 0s. Note that if multiple older
|
||||
// messages were added into one trie, it's possible we will
|
||||
// generate a time that only encompasses *some* of the those
|
||||
// messages. Pruning is lossy, and we traverse down the left-most
|
||||
// changed time that we know of, because of pruning it might take
|
||||
// multiple passes to sync up a trie.
|
||||
for (let i = 0; i < keys.length; i++) {
|
||||
const key = keys[i];
|
||||
|
||||
const next1 = node1[key];
|
||||
const next2 = node2[key];
|
||||
|
||||
if (!next1 || !next2) {
|
||||
break;
|
||||
}
|
||||
|
||||
if (next1.hash !== next2.hash) {
|
||||
diffkey = key;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!diffkey) {
|
||||
return keyToTimestamp(k);
|
||||
}
|
||||
|
||||
k += diffkey;
|
||||
node1 = node1[diffkey] || emptyTrie();
|
||||
node2 = node2[diffkey] || emptyTrie();
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export function prune(trie: TrieNode, n = 2): TrieNode {
|
||||
// Do nothing if empty
|
||||
if (!trie.hash) {
|
||||
return trie;
|
||||
}
|
||||
|
||||
const keys = getKeys(trie);
|
||||
keys.sort();
|
||||
|
||||
const next: TrieNode = { hash: trie.hash };
|
||||
|
||||
// Prune child nodes.
|
||||
for (const k of keys.slice(-n)) {
|
||||
const node = trie[k];
|
||||
|
||||
if (!node) {
|
||||
throw new Error(`TrieNode for key ${k} could not be found`);
|
||||
}
|
||||
|
||||
next[k] = prune(node, n);
|
||||
}
|
||||
|
||||
return next;
|
||||
}
|
||||
|
||||
export function debug(trie: TrieNode, k = '', indent = 0): string {
|
||||
const str =
|
||||
' '.repeat(indent) +
|
||||
(k !== '' ? `k: ${k} ` : '') +
|
||||
`hash: ${trie.hash || '(empty)'}\n`;
|
||||
return (
|
||||
str +
|
||||
getKeys(trie)
|
||||
.map(key => {
|
||||
const node = trie[key];
|
||||
if (!node) return '';
|
||||
return debug(node, key, indent + 2);
|
||||
})
|
||||
.join('')
|
||||
);
|
||||
}
|
||||
327
packages/crdt/src/crdt/timestamp.test.ts
Normal file
327
packages/crdt/src/crdt/timestamp.test.ts
Normal file
@@ -0,0 +1,327 @@
|
||||
/* eslint-disable @typescript-eslint/no-non-null-assertion */
|
||||
import { Timestamp } from './timestamp';
|
||||
|
||||
describe('Timestamp', function () {
|
||||
let now = 0;
|
||||
let prevNow: typeof Date.now;
|
||||
|
||||
beforeEach(function () {
|
||||
prevNow = Date.now;
|
||||
Date.now = () => now;
|
||||
Timestamp.init({ node: '1' });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
Date.now = prevNow;
|
||||
});
|
||||
|
||||
describe('comparison', function () {
|
||||
it('should be in order', function () {
|
||||
const sendTimestamp = Timestamp.send();
|
||||
|
||||
expect(Timestamp.zero).toBe(Timestamp.zero);
|
||||
expect(Timestamp.max > Timestamp.zero).toBeTruthy();
|
||||
expect(sendTimestamp && sendTimestamp > Timestamp.zero).toBeTruthy();
|
||||
expect(sendTimestamp && sendTimestamp < Timestamp.max).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
describe('parsing', function () {
|
||||
it('should not parse', function () {
|
||||
const invalidInputs = [
|
||||
null,
|
||||
undefined,
|
||||
{},
|
||||
[],
|
||||
42,
|
||||
'',
|
||||
' ',
|
||||
'0',
|
||||
'invalid',
|
||||
'1969-1-1T0:0:0.0Z-0-0-0',
|
||||
'1969-01-01T00:00:00.000Z-0000-0000000000000000',
|
||||
'10000-01-01T00:00:00.000Z-FFFF-FFFFFFFFFFFFFFFF',
|
||||
'9999-12-31T23:59:59.999Z-10000-FFFFFFFFFFFFFFFF',
|
||||
'9999-12-31T23:59:59.999Z-FFFF-10000000000000000',
|
||||
];
|
||||
for (const invalidInput of invalidInputs) {
|
||||
expect(Timestamp.parse(invalidInput as string)).toBe(null);
|
||||
}
|
||||
});
|
||||
|
||||
it('should parse', function () {
|
||||
const validInputs = [
|
||||
'1970-01-01T00:00:00.000Z-0000-0000000000000000',
|
||||
'2015-04-24T22:23:42.123Z-1000-0123456789ABCDEF',
|
||||
'9999-12-31T23:59:59.999Z-FFFF-FFFFFFFFFFFFFFFF',
|
||||
];
|
||||
for (const validInput of validInputs) {
|
||||
const parsed = Timestamp.parse(validInput)!;
|
||||
expect(typeof parsed).toBe('object');
|
||||
expect(parsed.millis() >= 0).toBeTruthy();
|
||||
expect(parsed.millis() < 253402300800000).toBeTruthy();
|
||||
expect(parsed.counter() >= 0).toBeTruthy();
|
||||
expect(parsed.counter() < 65536).toBeTruthy();
|
||||
expect(typeof parsed.node()).toBe('string');
|
||||
expect(parsed.toString()).toBe(validInput);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('send', function () {
|
||||
it('should send monotonically with a monotonic clock', function () {
|
||||
now = 10;
|
||||
expect(Timestamp.send()).toEqual(
|
||||
Timestamp.parse('1970-01-01T00:00:00.010Z-0000-0000000000000001'),
|
||||
);
|
||||
now++;
|
||||
expect(Timestamp.send()).toEqual(
|
||||
Timestamp.parse('1970-01-01T00:00:00.011Z-0000-0000000000000001'),
|
||||
);
|
||||
now++;
|
||||
expect(Timestamp.send()).toEqual(
|
||||
Timestamp.parse('1970-01-01T00:00:00.012Z-0000-0000000000000001'),
|
||||
);
|
||||
});
|
||||
|
||||
it('should send monotonically with a stuttering clock', function () {
|
||||
now = 20;
|
||||
expect(Timestamp.send()).toEqual(
|
||||
Timestamp.parse('1970-01-01T00:00:00.020Z-0000-0000000000000001'),
|
||||
);
|
||||
expect(Timestamp.send()).toEqual(
|
||||
Timestamp.parse('1970-01-01T00:00:00.020Z-0001-0000000000000001'),
|
||||
);
|
||||
expect(Timestamp.send()).toEqual(
|
||||
Timestamp.parse('1970-01-01T00:00:00.020Z-0002-0000000000000001'),
|
||||
);
|
||||
now++;
|
||||
expect(Timestamp.send()).toEqual(
|
||||
Timestamp.parse('1970-01-01T00:00:00.021Z-0000-0000000000000001'),
|
||||
);
|
||||
});
|
||||
|
||||
it('should send monotonically with a regressing clock', function () {
|
||||
now = 30;
|
||||
expect(Timestamp.send()).toEqual(
|
||||
Timestamp.parse('1970-01-01T00:00:00.030Z-0000-0000000000000001'),
|
||||
);
|
||||
now--;
|
||||
expect(Timestamp.send()).toEqual(
|
||||
Timestamp.parse('1970-01-01T00:00:00.030Z-0001-0000000000000001'),
|
||||
);
|
||||
expect(Timestamp.send()).toEqual(
|
||||
Timestamp.parse('1970-01-01T00:00:00.030Z-0002-0000000000000001'),
|
||||
);
|
||||
now = 31;
|
||||
expect(Timestamp.send()).toEqual(
|
||||
Timestamp.parse('1970-01-01T00:00:00.031Z-0000-0000000000000001'),
|
||||
);
|
||||
});
|
||||
|
||||
it('should fail with counter overflow', function () {
|
||||
now = 40;
|
||||
for (let i = 0; i < 65536; i++) Timestamp.send();
|
||||
expect(Timestamp.send).toThrow(Timestamp.OverflowError);
|
||||
});
|
||||
|
||||
it('should fail with clock drift', function () {
|
||||
now = -(5 * 60 * 1000 + 1);
|
||||
expect(Timestamp.send).toThrow(Timestamp.ClockDriftError);
|
||||
});
|
||||
});
|
||||
|
||||
describe('recv', function () {
|
||||
it('should receive monotonically with a global monotonic clock', function () {
|
||||
now = 52;
|
||||
expect(
|
||||
Timestamp.recv(
|
||||
Timestamp.parse('1970-01-01T00:00:00.051Z-0000-0000000000000002')!,
|
||||
),
|
||||
).toEqual(
|
||||
Timestamp.parse('1970-01-01T00:00:00.052Z-0000-0000000000000001'),
|
||||
);
|
||||
now = 54;
|
||||
expect(
|
||||
Timestamp.recv(
|
||||
Timestamp.parse('1970-01-01T00:00:00.053Z-0000-0000000000000002')!,
|
||||
),
|
||||
).toEqual(
|
||||
Timestamp.parse('1970-01-01T00:00:00.054Z-0000-0000000000000001'),
|
||||
);
|
||||
now = 56;
|
||||
expect(
|
||||
Timestamp.recv(
|
||||
Timestamp.parse('1970-01-01T00:00:00.055Z-0000-0000000000000002')!,
|
||||
),
|
||||
).toEqual(
|
||||
Timestamp.parse('1970-01-01T00:00:00.056Z-0000-0000000000000001'),
|
||||
);
|
||||
});
|
||||
|
||||
it('should receive monotonically with a global stuttering clock', function () {
|
||||
now = 61;
|
||||
expect(
|
||||
Timestamp.recv(
|
||||
Timestamp.parse('1970-01-01T00:00:00.062Z-0000-0000000000000002')!,
|
||||
),
|
||||
).toEqual(
|
||||
Timestamp.parse('1970-01-01T00:00:00.062Z-0001-0000000000000001'),
|
||||
);
|
||||
now = 62;
|
||||
expect(
|
||||
Timestamp.recv(
|
||||
Timestamp.parse('1970-01-01T00:00:00.062Z-0001-0000000000000002')!,
|
||||
),
|
||||
).toEqual(
|
||||
Timestamp.parse('1970-01-01T00:00:00.062Z-0002-0000000000000001'),
|
||||
);
|
||||
now = 62;
|
||||
expect(
|
||||
Timestamp.recv(
|
||||
Timestamp.parse('1970-01-01T00:00:00.062Z-0002-0000000000000002')!,
|
||||
),
|
||||
).toEqual(
|
||||
Timestamp.parse('1970-01-01T00:00:00.062Z-0003-0000000000000001'),
|
||||
);
|
||||
now = 63;
|
||||
expect(
|
||||
Timestamp.recv(
|
||||
Timestamp.parse('1970-01-01T00:00:00.062Z-0004-0000000000000002')!,
|
||||
),
|
||||
).toEqual(
|
||||
Timestamp.parse('1970-01-01T00:00:00.063Z-0000-0000000000000001'),
|
||||
);
|
||||
});
|
||||
|
||||
it('should receive monotonically with a local stuttering clock', function () {
|
||||
now = 73;
|
||||
expect(
|
||||
Timestamp.recv(
|
||||
Timestamp.parse('1970-01-01T00:00:00.071Z-0000-0000000000000002')!,
|
||||
),
|
||||
).toEqual(
|
||||
Timestamp.parse('1970-01-01T00:00:00.073Z-0000-0000000000000001'),
|
||||
);
|
||||
now = 73;
|
||||
expect(
|
||||
Timestamp.recv(
|
||||
Timestamp.parse('1970-01-01T00:00:00.072Z-0000-0000000000000002')!,
|
||||
),
|
||||
).toEqual(
|
||||
Timestamp.parse('1970-01-01T00:00:00.073Z-0001-0000000000000001'),
|
||||
);
|
||||
now = 74;
|
||||
expect(
|
||||
Timestamp.recv(
|
||||
Timestamp.parse('1970-01-01T00:00:00.073Z-0000-0000000000000002')!,
|
||||
),
|
||||
).toEqual(
|
||||
Timestamp.parse('1970-01-01T00:00:00.074Z-0000-0000000000000001'),
|
||||
);
|
||||
});
|
||||
|
||||
it('should receive monotonically with a remote stuttering clock', function () {
|
||||
now = 81;
|
||||
expect(
|
||||
Timestamp.recv(
|
||||
Timestamp.parse('1970-01-01T00:00:00.083Z-0000-0000000000000002')!,
|
||||
),
|
||||
).toEqual(
|
||||
Timestamp.parse('1970-01-01T00:00:00.083Z-0001-0000000000000001'),
|
||||
);
|
||||
now = 82;
|
||||
expect(
|
||||
Timestamp.recv(
|
||||
Timestamp.parse('1970-01-01T00:00:00.083Z-0001-0000000000000002')!,
|
||||
),
|
||||
).toEqual(
|
||||
Timestamp.parse('1970-01-01T00:00:00.083Z-0002-0000000000000001'),
|
||||
);
|
||||
now = 83;
|
||||
expect(
|
||||
Timestamp.recv(
|
||||
Timestamp.parse('1970-01-01T00:00:00.083Z-0002-0000000000000002')!,
|
||||
),
|
||||
).toEqual(
|
||||
Timestamp.parse('1970-01-01T00:00:00.083Z-0003-0000000000000001'),
|
||||
);
|
||||
now = 84;
|
||||
expect(
|
||||
Timestamp.recv(
|
||||
Timestamp.parse('1970-01-01T00:00:00.083Z-0003-0000000000000002')!,
|
||||
),
|
||||
).toEqual(
|
||||
Timestamp.parse('1970-01-01T00:00:00.084Z-0000-0000000000000001'),
|
||||
);
|
||||
});
|
||||
it('should receive monotonically with a local regressing clock', function () {
|
||||
now = 93;
|
||||
expect(
|
||||
Timestamp.recv(
|
||||
Timestamp.parse('1970-01-01T00:00:00.091Z-0000-0000000000000002')!,
|
||||
),
|
||||
).toEqual(
|
||||
Timestamp.parse('1970-01-01T00:00:00.093Z-0000-0000000000000001'),
|
||||
);
|
||||
now = 92;
|
||||
expect(
|
||||
Timestamp.recv(
|
||||
Timestamp.parse('1970-01-01T00:00:00.092Z-0000-0000000000000002')!,
|
||||
),
|
||||
).toEqual(
|
||||
Timestamp.parse('1970-01-01T00:00:00.093Z-0001-0000000000000001'),
|
||||
);
|
||||
now = 91;
|
||||
expect(
|
||||
Timestamp.recv(
|
||||
Timestamp.parse('1970-01-01T00:00:00.093Z-0000-0000000000000002')!,
|
||||
),
|
||||
).toEqual(
|
||||
Timestamp.parse('1970-01-01T00:00:00.093Z-0002-0000000000000001'),
|
||||
);
|
||||
});
|
||||
it('should receive monotonically with a remote regressing clock', function () {
|
||||
now = 101;
|
||||
expect(
|
||||
Timestamp.recv(
|
||||
Timestamp.parse('1970-01-01T00:00:00.103Z-0000-0000000000000002')!,
|
||||
),
|
||||
).toEqual(
|
||||
Timestamp.parse('1970-01-01T00:00:00.103Z-0001-0000000000000001'),
|
||||
);
|
||||
now = 102;
|
||||
expect(
|
||||
Timestamp.recv(
|
||||
Timestamp.parse('1970-01-01T00:00:00.102Z-0000-0000000000000002')!,
|
||||
),
|
||||
).toEqual(
|
||||
Timestamp.parse('1970-01-01T00:00:00.103Z-0002-0000000000000001'),
|
||||
);
|
||||
now = 103;
|
||||
expect(
|
||||
Timestamp.recv(
|
||||
Timestamp.parse('1970-01-01T00:00:00.101Z-0000-0000000000000002')!,
|
||||
),
|
||||
).toEqual(
|
||||
Timestamp.parse('1970-01-01T00:00:00.103Z-0003-0000000000000001'),
|
||||
);
|
||||
});
|
||||
|
||||
// it('should fail with a duplicate node id', function() {
|
||||
// expect(function() {
|
||||
// Timestamp.recv(
|
||||
// Timestamp.parse('1970-01-01T00:00:00.101Z-0000-0000000000000001')
|
||||
// );
|
||||
// }).toThrow(Timestamp.DuplicateNodeError);
|
||||
// });
|
||||
|
||||
it('should fail with clock drift', function () {
|
||||
expect(function () {
|
||||
Timestamp.recv(
|
||||
Timestamp.parse('1980-01-01T00:00:00.101Z-0000-0000000000000002')!,
|
||||
);
|
||||
}).toThrow(Timestamp.ClockDriftError);
|
||||
});
|
||||
});
|
||||
});
|
||||
358
packages/crdt/src/crdt/timestamp.ts
Normal file
358
packages/crdt/src/crdt/timestamp.ts
Normal file
@@ -0,0 +1,358 @@
|
||||
import murmurhash from 'murmurhash';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
|
||||
import { TrieNode } from './merkle';
|
||||
|
||||
/**
|
||||
* Hybrid Unique Logical Clock (HULC) timestamp generator
|
||||
*
|
||||
* Globally-unique, monotonic timestamps are generated from the
|
||||
* combination of the unreliable system time, a counter, and an
|
||||
* identifier for the current node (instance, machine, process, etc.).
|
||||
* These timestamps can accommodate clock stuttering (duplicate values),
|
||||
* regression, and node differences within the configured maximum drift.
|
||||
*
|
||||
* In order to generate timestamps locally or for transmission to another
|
||||
* node, use the send() method. For global causality, timestamps must
|
||||
* be included in each message processed by the system. Whenever a
|
||||
* message is received, its timestamp must be passed to the recv()
|
||||
* method.
|
||||
*
|
||||
* Timestamps serialize into a 46-character collatable string
|
||||
* example: 2015-04-24T22:23:42.123Z-1000-0123456789ABCDEF
|
||||
* example: 2015-04-24T22:23:42.123Z-1000-A219E7A71CC18912
|
||||
*
|
||||
* The 64-bit hybrid clock is based on the HLC specification,
|
||||
* http://www.cse.buffalo.edu/tech-reports/2014-04.pdf
|
||||
*/
|
||||
|
||||
export type Clock = {
|
||||
timestamp: MutableTimestamp;
|
||||
merkle: TrieNode;
|
||||
};
|
||||
|
||||
// A mutable global clock
|
||||
let clock: Clock;
|
||||
|
||||
export function setClock(clock_: Clock): void {
|
||||
clock = clock_;
|
||||
}
|
||||
|
||||
export function getClock(): Clock {
|
||||
return clock;
|
||||
}
|
||||
|
||||
export function makeClock(timestamp: Timestamp, merkle: TrieNode = {}) {
|
||||
return { timestamp: MutableTimestamp.from(timestamp), merkle };
|
||||
}
|
||||
|
||||
export function serializeClock(clock: Clock): string {
|
||||
return JSON.stringify({
|
||||
timestamp: clock.timestamp.toString(),
|
||||
merkle: clock.merkle,
|
||||
});
|
||||
}
|
||||
|
||||
export function deserializeClock(clock: string): Clock {
|
||||
let data;
|
||||
try {
|
||||
data = JSON.parse(clock);
|
||||
} catch (e) {
|
||||
data = {
|
||||
timestamp: '1970-01-01T00:00:00.000Z-0000-' + makeClientId(),
|
||||
merkle: {},
|
||||
};
|
||||
}
|
||||
|
||||
const ts = Timestamp.parse(data.timestamp);
|
||||
|
||||
if (!ts) {
|
||||
throw new Timestamp.InvalidError(data.timestamp);
|
||||
}
|
||||
|
||||
return {
|
||||
timestamp: MutableTimestamp.from(ts),
|
||||
merkle: data.merkle,
|
||||
};
|
||||
}
|
||||
|
||||
export function makeClientId() {
|
||||
return uuidv4().replace(/-/g, '').slice(-16);
|
||||
}
|
||||
|
||||
const config = {
|
||||
// Allow 5 minutes of clock drift
|
||||
maxDrift: 5 * 60 * 1000,
|
||||
};
|
||||
|
||||
const MAX_COUNTER = parseInt('0xFFFF');
|
||||
const MAX_NODE_LENGTH = 16;
|
||||
|
||||
/**
|
||||
* timestamp instance class
|
||||
*/
|
||||
export class Timestamp {
|
||||
_state: { millis: number; counter: number; node: string };
|
||||
|
||||
constructor(millis: number, counter: number, node: string) {
|
||||
this._state = {
|
||||
millis,
|
||||
counter,
|
||||
node,
|
||||
};
|
||||
}
|
||||
|
||||
valueOf() {
|
||||
return this.toString();
|
||||
}
|
||||
|
||||
toString() {
|
||||
return [
|
||||
new Date(this.millis()).toISOString(),
|
||||
('0000' + this.counter().toString(16).toUpperCase()).slice(-4),
|
||||
('0000000000000000' + this.node()).slice(-16),
|
||||
].join('-');
|
||||
}
|
||||
|
||||
millis() {
|
||||
return this._state.millis;
|
||||
}
|
||||
|
||||
counter() {
|
||||
return this._state.counter;
|
||||
}
|
||||
|
||||
node() {
|
||||
return this._state.node;
|
||||
}
|
||||
|
||||
hash() {
|
||||
return murmurhash.v3(this.toString());
|
||||
}
|
||||
|
||||
// Timestamp generator initialization
|
||||
// * sets the node ID to an arbitrary value
|
||||
// * useful for mocking/unit testing
|
||||
static init(options: { maxDrift?: number; node?: string } = {}) {
|
||||
if (options.maxDrift) {
|
||||
config.maxDrift = options.maxDrift;
|
||||
}
|
||||
|
||||
setClock(
|
||||
makeClock(
|
||||
new Timestamp(
|
||||
0,
|
||||
0,
|
||||
options.node
|
||||
? ('0000000000000000' + options.node).toString().slice(-16)
|
||||
: '',
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* maximum timestamp
|
||||
*/
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
static max = Timestamp.parse(
|
||||
'9999-12-31T23:59:59.999Z-FFFF-FFFFFFFFFFFFFFFF',
|
||||
)!;
|
||||
|
||||
/**
|
||||
* timestamp parsing
|
||||
* converts a fixed-length string timestamp to the structured value
|
||||
*/
|
||||
static parse(timestamp: string | Timestamp): Timestamp | null {
|
||||
if (timestamp instanceof Timestamp) {
|
||||
return timestamp;
|
||||
}
|
||||
if (typeof timestamp === 'string') {
|
||||
const parts = timestamp.split('-');
|
||||
if (parts && parts.length === 5) {
|
||||
const millis = Date.parse(parts.slice(0, 3).join('-')).valueOf();
|
||||
const counter = parseInt(parts[3], 16);
|
||||
const node = parts[4];
|
||||
if (
|
||||
!isNaN(millis) &&
|
||||
millis >= 0 &&
|
||||
!isNaN(counter) &&
|
||||
counter <= MAX_COUNTER &&
|
||||
typeof node === 'string' &&
|
||||
node.length <= MAX_NODE_LENGTH
|
||||
) {
|
||||
return new Timestamp(millis, counter, node);
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Timestamp send. Generates a unique, monotonic timestamp suitable
|
||||
* for transmission to another system in string format
|
||||
*/
|
||||
static send(): Timestamp | null {
|
||||
if (!clock) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// retrieve the local wall time
|
||||
const phys = Date.now();
|
||||
|
||||
// unpack the clock.timestamp logical time and counter
|
||||
const lOld = clock.timestamp.millis();
|
||||
const cOld = clock.timestamp.counter();
|
||||
|
||||
// calculate the next logical time and counter
|
||||
// * ensure that the logical time never goes backward
|
||||
// * increment the counter if phys time does not advance
|
||||
const lNew = Math.max(lOld, phys);
|
||||
const cNew = lOld === lNew ? cOld + 1 : 0;
|
||||
|
||||
// check the result for drift and counter overflow
|
||||
if (lNew - phys > config.maxDrift) {
|
||||
throw new Timestamp.ClockDriftError(lNew, phys, config.maxDrift);
|
||||
}
|
||||
if (cNew > MAX_COUNTER) {
|
||||
throw new Timestamp.OverflowError();
|
||||
}
|
||||
|
||||
// repack the logical time/counter
|
||||
clock.timestamp.setMillis(lNew);
|
||||
clock.timestamp.setCounter(cNew);
|
||||
|
||||
return new Timestamp(
|
||||
clock.timestamp.millis(),
|
||||
clock.timestamp.counter(),
|
||||
clock.timestamp.node(),
|
||||
);
|
||||
}
|
||||
|
||||
// Timestamp receive. Parses and merges a timestamp from a remote
|
||||
// system with the local timeglobal uniqueness and monotonicity are
|
||||
// preserved
|
||||
static recv(msg: Timestamp): Timestamp | null {
|
||||
if (!clock) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// retrieve the local wall time
|
||||
const phys = Date.now();
|
||||
|
||||
// unpack the message wall time/counter
|
||||
const lMsg = msg.millis();
|
||||
const cMsg = msg.counter();
|
||||
|
||||
// assert the node id and remote clock drift
|
||||
// if (msg.node() === clock.timestamp.node()) {
|
||||
// throw new Timestamp.DuplicateNodeError(clock.timestamp.node());
|
||||
// }
|
||||
if (lMsg - phys > config.maxDrift) {
|
||||
throw new Timestamp.ClockDriftError();
|
||||
}
|
||||
|
||||
// unpack the clock.timestamp logical time and counter
|
||||
const lOld = clock.timestamp.millis();
|
||||
const cOld = clock.timestamp.counter();
|
||||
|
||||
// calculate the next logical time and counter
|
||||
// . ensure that the logical time never goes backward
|
||||
// . if all logical clocks are equal, increment the max counter
|
||||
// . if max = old > message, increment local counter
|
||||
// . if max = messsage > old, increment message counter
|
||||
// . otherwise, clocks are monotonic, reset counter
|
||||
const lNew = Math.max(Math.max(lOld, phys), lMsg);
|
||||
const cNew =
|
||||
lNew === lOld && lNew === lMsg
|
||||
? Math.max(cOld, cMsg) + 1
|
||||
: lNew === lOld
|
||||
? cOld + 1
|
||||
: lNew === lMsg
|
||||
? cMsg + 1
|
||||
: 0;
|
||||
|
||||
// check the result for drift and counter overflow
|
||||
if (lNew - phys > config.maxDrift) {
|
||||
throw new Timestamp.ClockDriftError();
|
||||
}
|
||||
if (cNew > MAX_COUNTER) {
|
||||
throw new Timestamp.OverflowError();
|
||||
}
|
||||
|
||||
// repack the logical time/counter
|
||||
clock.timestamp.setMillis(lNew);
|
||||
clock.timestamp.setCounter(cNew);
|
||||
|
||||
return new Timestamp(
|
||||
clock.timestamp.millis(),
|
||||
clock.timestamp.counter(),
|
||||
clock.timestamp.node(),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* zero/minimum timestamp
|
||||
*/
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
static zero = Timestamp.parse(
|
||||
'1970-01-01T00:00:00.000Z-0000-0000000000000000',
|
||||
)!;
|
||||
|
||||
static since = (isoString: string) => isoString + '-0000-0000000000000000';
|
||||
|
||||
/**
|
||||
* error classes
|
||||
*/
|
||||
static DuplicateNodeError = class DuplicateNodeError extends Error {
|
||||
constructor(node: string) {
|
||||
super('duplicate node identifier ' + node);
|
||||
this.name = 'DuplicateNodeError';
|
||||
}
|
||||
};
|
||||
|
||||
static ClockDriftError = class ClockDriftError extends Error {
|
||||
constructor(...args: unknown[]) {
|
||||
super(
|
||||
['maximum clock drift exceeded'].concat(args as string[]).join(' '),
|
||||
);
|
||||
this.name = 'ClockDriftError';
|
||||
}
|
||||
};
|
||||
|
||||
static OverflowError = class OverflowError extends Error {
|
||||
constructor() {
|
||||
super('timestamp counter overflow');
|
||||
this.name = 'OverflowError';
|
||||
}
|
||||
};
|
||||
|
||||
static InvalidError = class InvalidError extends Error {
|
||||
constructor(...args: unknown[]) {
|
||||
super(['timestamp is not valid'].concat(args.map(String)).join(' '));
|
||||
this.name = 'InvalidError';
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
class MutableTimestamp extends Timestamp {
|
||||
static from(timestamp: Timestamp) {
|
||||
return new MutableTimestamp(
|
||||
timestamp.millis(),
|
||||
timestamp.counter(),
|
||||
timestamp.node(),
|
||||
);
|
||||
}
|
||||
|
||||
setMillis(n: number) {
|
||||
this._state.millis = n;
|
||||
}
|
||||
|
||||
setCounter(n: number) {
|
||||
this._state.counter = n;
|
||||
}
|
||||
|
||||
setNode(n: string) {
|
||||
this._state.node = n;
|
||||
}
|
||||
}
|
||||
14
packages/crdt/src/main.ts
Normal file
14
packages/crdt/src/main.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import * as SyncPb from './proto/sync_pb';
|
||||
export {
|
||||
merkle,
|
||||
getClock,
|
||||
setClock,
|
||||
makeClock,
|
||||
makeClientId,
|
||||
serializeClock,
|
||||
deserializeClock,
|
||||
type Clock,
|
||||
Timestamp,
|
||||
} from './crdt';
|
||||
|
||||
export const SyncProtoBuf = SyncPb;
|
||||
216
packages/crdt/src/proto/sync_pb.d.ts
vendored
Normal file
216
packages/crdt/src/proto/sync_pb.d.ts
vendored
Normal file
@@ -0,0 +1,216 @@
|
||||
// package:
|
||||
// file: sync.proto
|
||||
|
||||
import * as jspb from 'google-protobuf';
|
||||
|
||||
export class EncryptedData extends jspb.Message {
|
||||
getIv(): Uint8Array | string;
|
||||
getIv_asU8(): Uint8Array;
|
||||
getIv_asB64(): string;
|
||||
setIv(value: Uint8Array | string): void;
|
||||
|
||||
getAuthtag(): Uint8Array | string;
|
||||
getAuthtag_asU8(): Uint8Array;
|
||||
getAuthtag_asB64(): string;
|
||||
setAuthtag(value: Uint8Array | string): void;
|
||||
|
||||
getData(): Uint8Array | string;
|
||||
getData_asU8(): Uint8Array;
|
||||
getData_asB64(): string;
|
||||
setData(value: Uint8Array | string): void;
|
||||
|
||||
serializeBinary(): Uint8Array;
|
||||
toObject(includeInstance?: boolean): EncryptedData.AsObject;
|
||||
static toObject(
|
||||
includeInstance: boolean,
|
||||
msg: EncryptedData,
|
||||
): EncryptedData.AsObject;
|
||||
static extensions: { [key: number]: jspb.ExtensionFieldInfo<jspb.Message> };
|
||||
static extensionsBinary: {
|
||||
[key: number]: jspb.ExtensionFieldBinaryInfo<jspb.Message>;
|
||||
};
|
||||
static serializeBinaryToWriter(
|
||||
message: EncryptedData,
|
||||
writer: jspb.BinaryWriter,
|
||||
): void;
|
||||
static deserializeBinary(bytes: Uint8Array): EncryptedData;
|
||||
static deserializeBinaryFromReader(
|
||||
message: EncryptedData,
|
||||
reader: jspb.BinaryReader,
|
||||
): EncryptedData;
|
||||
}
|
||||
|
||||
export namespace EncryptedData {
|
||||
export type AsObject = {
|
||||
iv: Uint8Array | string;
|
||||
authtag: Uint8Array | string;
|
||||
data: Uint8Array | string;
|
||||
};
|
||||
}
|
||||
|
||||
export class Message extends jspb.Message {
|
||||
getDataset(): string;
|
||||
setDataset(value: string): void;
|
||||
|
||||
getRow(): string;
|
||||
setRow(value: string): void;
|
||||
|
||||
getColumn(): string;
|
||||
setColumn(value: string): void;
|
||||
|
||||
getValue(): string;
|
||||
setValue(value: string): void;
|
||||
|
||||
serializeBinary(): Uint8Array;
|
||||
toObject(includeInstance?: boolean): Message.AsObject;
|
||||
static toObject(includeInstance: boolean, msg: Message): Message.AsObject;
|
||||
static extensions: { [key: number]: jspb.ExtensionFieldInfo<jspb.Message> };
|
||||
static extensionsBinary: {
|
||||
[key: number]: jspb.ExtensionFieldBinaryInfo<jspb.Message>;
|
||||
};
|
||||
static serializeBinaryToWriter(
|
||||
message: Message,
|
||||
writer: jspb.BinaryWriter,
|
||||
): void;
|
||||
static deserializeBinary(bytes: Uint8Array): Message;
|
||||
static deserializeBinaryFromReader(
|
||||
message: Message,
|
||||
reader: jspb.BinaryReader,
|
||||
): Message;
|
||||
}
|
||||
|
||||
export namespace Message {
|
||||
export type AsObject = {
|
||||
dataset: string;
|
||||
row: string;
|
||||
column: string;
|
||||
value: string;
|
||||
};
|
||||
}
|
||||
|
||||
export class MessageEnvelope extends jspb.Message {
|
||||
getTimestamp(): string;
|
||||
setTimestamp(value: string): void;
|
||||
|
||||
getIsencrypted(): boolean;
|
||||
setIsencrypted(value: boolean): void;
|
||||
|
||||
getContent(): Uint8Array | string;
|
||||
getContent_asU8(): Uint8Array;
|
||||
getContent_asB64(): string;
|
||||
setContent(value: Uint8Array | string): void;
|
||||
|
||||
serializeBinary(): Uint8Array;
|
||||
toObject(includeInstance?: boolean): MessageEnvelope.AsObject;
|
||||
static toObject(
|
||||
includeInstance: boolean,
|
||||
msg: MessageEnvelope,
|
||||
): MessageEnvelope.AsObject;
|
||||
static extensions: { [key: number]: jspb.ExtensionFieldInfo<jspb.Message> };
|
||||
static extensionsBinary: {
|
||||
[key: number]: jspb.ExtensionFieldBinaryInfo<jspb.Message>;
|
||||
};
|
||||
static serializeBinaryToWriter(
|
||||
message: MessageEnvelope,
|
||||
writer: jspb.BinaryWriter,
|
||||
): void;
|
||||
static deserializeBinary(bytes: Uint8Array): MessageEnvelope;
|
||||
static deserializeBinaryFromReader(
|
||||
message: MessageEnvelope,
|
||||
reader: jspb.BinaryReader,
|
||||
): MessageEnvelope;
|
||||
}
|
||||
|
||||
export namespace MessageEnvelope {
|
||||
export type AsObject = {
|
||||
timestamp: string;
|
||||
isencrypted: boolean;
|
||||
content: Uint8Array | string;
|
||||
};
|
||||
}
|
||||
|
||||
export class SyncRequest extends jspb.Message {
|
||||
clearMessagesList(): void;
|
||||
getMessagesList(): Array<MessageEnvelope>;
|
||||
setMessagesList(value: Array<MessageEnvelope>): void;
|
||||
addMessages(value?: MessageEnvelope, index?: number): MessageEnvelope;
|
||||
|
||||
getFileid(): string;
|
||||
setFileid(value: string): void;
|
||||
|
||||
getGroupid(): string;
|
||||
setGroupid(value: string): void;
|
||||
|
||||
getKeyid(): string;
|
||||
setKeyid(value: string): void;
|
||||
|
||||
getSince(): string;
|
||||
setSince(value: string): void;
|
||||
|
||||
serializeBinary(): Uint8Array;
|
||||
toObject(includeInstance?: boolean): SyncRequest.AsObject;
|
||||
static toObject(
|
||||
includeInstance: boolean,
|
||||
msg: SyncRequest,
|
||||
): SyncRequest.AsObject;
|
||||
static extensions: { [key: number]: jspb.ExtensionFieldInfo<jspb.Message> };
|
||||
static extensionsBinary: {
|
||||
[key: number]: jspb.ExtensionFieldBinaryInfo<jspb.Message>;
|
||||
};
|
||||
static serializeBinaryToWriter(
|
||||
message: SyncRequest,
|
||||
writer: jspb.BinaryWriter,
|
||||
): void;
|
||||
static deserializeBinary(bytes: Uint8Array): SyncRequest;
|
||||
static deserializeBinaryFromReader(
|
||||
message: SyncRequest,
|
||||
reader: jspb.BinaryReader,
|
||||
): SyncRequest;
|
||||
}
|
||||
|
||||
export namespace SyncRequest {
|
||||
export type AsObject = {
|
||||
messagesList: Array<MessageEnvelope.AsObject>;
|
||||
fileid: string;
|
||||
groupid: string;
|
||||
keyid: string;
|
||||
since: string;
|
||||
};
|
||||
}
|
||||
|
||||
export class SyncResponse extends jspb.Message {
|
||||
clearMessagesList(): void;
|
||||
getMessagesList(): Array<MessageEnvelope>;
|
||||
setMessagesList(value: Array<MessageEnvelope>): void;
|
||||
addMessages(value?: MessageEnvelope, index?: number): MessageEnvelope;
|
||||
|
||||
getMerkle(): string;
|
||||
setMerkle(value: string): void;
|
||||
|
||||
serializeBinary(): Uint8Array;
|
||||
toObject(includeInstance?: boolean): SyncResponse.AsObject;
|
||||
static toObject(
|
||||
includeInstance: boolean,
|
||||
msg: SyncResponse,
|
||||
): SyncResponse.AsObject;
|
||||
static extensions: { [key: number]: jspb.ExtensionFieldInfo<jspb.Message> };
|
||||
static extensionsBinary: {
|
||||
[key: number]: jspb.ExtensionFieldBinaryInfo<jspb.Message>;
|
||||
};
|
||||
static serializeBinaryToWriter(
|
||||
message: SyncResponse,
|
||||
writer: jspb.BinaryWriter,
|
||||
): void;
|
||||
static deserializeBinary(bytes: Uint8Array): SyncResponse;
|
||||
static deserializeBinaryFromReader(
|
||||
message: SyncResponse,
|
||||
reader: jspb.BinaryReader,
|
||||
): SyncResponse;
|
||||
}
|
||||
|
||||
export namespace SyncResponse {
|
||||
export type AsObject = {
|
||||
messagesList: Array<MessageEnvelope.AsObject>;
|
||||
merkle: string;
|
||||
};
|
||||
}
|
||||
15
packages/crdt/tsconfig.dist.json
Normal file
15
packages/crdt/tsconfig.dist.json
Normal file
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"extends": "../../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
// Using ES2021 because that’s the newest version where
|
||||
// the latest Node 16.x release supports all of the features
|
||||
"target": "ES2021",
|
||||
"module": "CommonJS",
|
||||
"noEmit": false,
|
||||
"declaration": true,
|
||||
"strict": true,
|
||||
"outDir": "dist"
|
||||
},
|
||||
"include": ["."],
|
||||
"exclude": ["dist"]
|
||||
}
|
||||
@@ -1,3 +0,0 @@
|
||||
bundle.browser.js
|
||||
build/
|
||||
public/kcab/
|
||||
4
packages/desktop-client/.gitignore
vendored
4
packages/desktop-client/.gitignore
vendored
@@ -9,13 +9,17 @@ test-results
|
||||
|
||||
# production
|
||||
build
|
||||
build-stats
|
||||
stats.json
|
||||
|
||||
# misc
|
||||
.DS_Store
|
||||
.env
|
||||
npm-debug.log
|
||||
.swc
|
||||
|
||||
*kcab.*
|
||||
public/kcab
|
||||
public/data
|
||||
public/data-file-index.txt
|
||||
public/*.wasm
|
||||
|
||||
16
packages/desktop-client/.swcrc
Normal file
16
packages/desktop-client/.swcrc
Normal file
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"jsc": {
|
||||
"target": "es2022",
|
||||
"transform": {
|
||||
"react": {
|
||||
"runtime": "automatic"
|
||||
}
|
||||
},
|
||||
"externalHelpers": true,
|
||||
"parser": {
|
||||
"syntax": "typescript",
|
||||
"tsx": true
|
||||
}
|
||||
},
|
||||
"sourceMaps": true
|
||||
}
|
||||
@@ -4,11 +4,13 @@ Actual on the web
|
||||
|
||||
E2E (end-to-end) tests use [Playwright](https://playwright.dev/). Running them requires an Actual server to be running either locally or on a remote server.
|
||||
|
||||
### Functional
|
||||
|
||||
Running against the local server:
|
||||
|
||||
```sh
|
||||
# Start the development server
|
||||
yarn start:browser
|
||||
yarn start
|
||||
|
||||
# Run against the local server (localhost:3001)
|
||||
yarn e2e
|
||||
@@ -19,3 +21,75 @@ Running against a remote server:
|
||||
```sh
|
||||
E2E_START_URL=http://my-remote-server.com yarn e2e
|
||||
```
|
||||
|
||||
### Visual regression
|
||||
|
||||
Visual regression tests (also known as screenshot tests) check that the visual appearance of the product has not regressed. Each environment has slightly different colors, fonts etc. Mac differs from Windows which differs from Linux. In order to have a stable test environment for visual comparisons - you must use a standartised docker container. This ensures that the tests are always ran in a consistent environment.
|
||||
|
||||
Prerequisites:
|
||||
|
||||
- Docker installed
|
||||
|
||||
#### Running against the local server
|
||||
|
||||
First start a dev instance:
|
||||
|
||||
```sh
|
||||
HTTPS=true yarn start
|
||||
```
|
||||
|
||||
or using the dev container:
|
||||
```
|
||||
HTTPS=true docker compose up --build
|
||||
```
|
||||
|
||||
Note the network IP address and port the dev instance is listening on.
|
||||
|
||||
Next, navigate to the root of your project folder, run the standardized docker container, and launch the visual regression tests from within it.
|
||||
|
||||
Run via yarn:
|
||||
|
||||
```sh
|
||||
# By default, this connects to https://localhost:3001
|
||||
yarn vrt:docker
|
||||
|
||||
# To use a different ip and port:
|
||||
yarn vrt:docker --e2e-start-url https://ip:port
|
||||
|
||||
# To update snapshots, use the following command:
|
||||
yarn vrt:docker --e2e-start-url https://ip:port --update-snapshots
|
||||
```
|
||||
|
||||
Run manually:
|
||||
|
||||
```sh
|
||||
# Run docker container
|
||||
docker run --rm --network host -v $(pwd):/work/ -w /work/ -it mcr.microsoft.com/playwright:v1.41.1-jammy /bin/bash
|
||||
|
||||
# If you receive an error such as "docker: invalid reference format", please instead use the following command:
|
||||
docker run --rm --network host -v ${pwd}:/work/ -w /work/ -it mcr.microsoft.com/playwright:v1.41.1-jammy /bin/bash
|
||||
|
||||
# Once inside the docker container, run the VRT tests: important - they MUST be ran against a HTTPS server.
|
||||
# Use the ip and port noted earlier
|
||||
E2E_START_URL=https://ip:port yarn vrt
|
||||
|
||||
# To update snapshots, use the following command:
|
||||
E2E_START_URL=https://ip:port yarn vrt --update-snapshots
|
||||
```
|
||||
|
||||
#### Running against a remote server
|
||||
|
||||
You can also run the tests against a remote server by passing the URL:
|
||||
|
||||
Run in standardized docker container:
|
||||
```sh
|
||||
E2E_START_URL=https://my-remote-server.com yarn vrt:docker
|
||||
|
||||
# Or pass in server URL as argument
|
||||
yarn vrt:docker --e2e-start-url https://my-remote-server.com
|
||||
```
|
||||
|
||||
Run locally:
|
||||
```sh
|
||||
E2E_START_URL=https://my-remote-server.com yarn vrt
|
||||
```
|
||||
|
||||
@@ -3,15 +3,16 @@
|
||||
ROOT=`dirname $0`
|
||||
cd "$ROOT/.."
|
||||
|
||||
VERSION=`cat "$ROOT/../../desktop-client/package.json" | grep version | head -n1 | awk -F "\"" '{print $4}' | tr -d '\r\n'`
|
||||
|
||||
echo "Building version $VERSION for the browser..."
|
||||
echo "Building the browser..."
|
||||
|
||||
rm -fr build
|
||||
|
||||
export IS_GENERIC_BROWSER=1
|
||||
export INLINE_RUNTIME_CHUNK=false
|
||||
export REACT_APP_BACKEND_WORKER_HASH=`ls "$ROOT"/../public/kcab/kcab.worker.*.js | sed 's/.*kcab\.worker\.\(.*\)\.js/\1/'`
|
||||
export REACT_APP_ACTUAL_VERSION="$VERSION"
|
||||
|
||||
yarn build
|
||||
|
||||
rm -fr build-stats
|
||||
mkdir build-stats
|
||||
mv build/kcab/stats.json build-stats/loot-core-stats.json
|
||||
mv ./stats.json build-stats/web-stats.json
|
||||
|
||||
@@ -3,11 +3,8 @@
|
||||
ROOT=`dirname $0`
|
||||
cd "$ROOT/.."
|
||||
|
||||
VERSION=`cat "$ROOT/../../desktop-client/package.json" | grep version | head -n1 | awk -F "\"" '{print $4}' | tr -d '\r\n'`
|
||||
|
||||
export IS_GENERIC_BROWSER=1
|
||||
export PORT=3001
|
||||
export REACT_APP_BACKEND_WORKER_HASH="dev"
|
||||
export REACT_APP_ACTUAL_VERSION="$VERSION"
|
||||
|
||||
yarn start
|
||||
|
||||
@@ -1,45 +0,0 @@
|
||||
const path = require('path');
|
||||
|
||||
const {
|
||||
addWebpackResolve,
|
||||
override,
|
||||
overrideDevServer,
|
||||
babelInclude,
|
||||
} = require('customize-cra');
|
||||
|
||||
if (process.env.CI) {
|
||||
process.env.DISABLE_ESLINT_PLUGIN = 'true';
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
webpack: override(
|
||||
babelInclude([path.resolve('src'), path.resolve('../loot-core')]),
|
||||
addWebpackResolve({
|
||||
extensions: [
|
||||
...(process.env.IS_GENERIC_BROWSER
|
||||
? ['.browser.js', '.browser.ts', '.browser.tsx']
|
||||
: []),
|
||||
'.web.js',
|
||||
'.web.ts',
|
||||
'.web.tsx',
|
||||
'.js',
|
||||
'.ts',
|
||||
'.tsx',
|
||||
],
|
||||
}),
|
||||
config => {
|
||||
config.cache = false;
|
||||
return config;
|
||||
},
|
||||
),
|
||||
devServer: overrideDevServer(config => {
|
||||
return {
|
||||
...config,
|
||||
headers: {
|
||||
...config.headers,
|
||||
'Cross-Origin-Opener-Policy': 'same-origin',
|
||||
'Cross-Origin-Embedder-Policy': 'require-corp',
|
||||
},
|
||||
};
|
||||
}),
|
||||
};
|
||||
@@ -7,6 +7,7 @@ test.describe('Accounts', () => {
|
||||
let page;
|
||||
let navigation;
|
||||
let configurationPage;
|
||||
let accountPage;
|
||||
|
||||
test.beforeAll(async ({ browser }) => {
|
||||
page = await browser.newPage();
|
||||
@@ -22,31 +23,80 @@ test.describe('Accounts', () => {
|
||||
});
|
||||
|
||||
test('creates a new account and views the initial balance transaction', async () => {
|
||||
const accountPage = await navigation.createAccount({
|
||||
accountPage = await navigation.createAccount({
|
||||
name: 'New Account',
|
||||
type: 'Checking / Cash',
|
||||
offBudget: false,
|
||||
balance: 100,
|
||||
});
|
||||
|
||||
expect(await accountPage.getNthTransaction(0)).toMatchObject({
|
||||
payee: 'Starting Balance',
|
||||
notes: '',
|
||||
category: 'Starting Balances',
|
||||
debit: '',
|
||||
credit: '100.00',
|
||||
});
|
||||
const transaction = accountPage.getNthTransaction(0);
|
||||
await expect(transaction.payee).toHaveText('Starting Balance');
|
||||
await expect(transaction.notes).toHaveText('');
|
||||
await expect(transaction.category).toHaveText('Starting Balances');
|
||||
await expect(transaction.debit).toHaveText('');
|
||||
await expect(transaction.credit).toHaveText('100.00');
|
||||
await expect(page).toMatchThemeScreenshots();
|
||||
});
|
||||
|
||||
test('closes an account', async () => {
|
||||
const accountPage = await navigation.goToAccountPage('Roth IRA');
|
||||
accountPage = await navigation.goToAccountPage('Roth IRA');
|
||||
|
||||
await expect(accountPage.accountName).toHaveText('Roth IRA');
|
||||
|
||||
const modal = await accountPage.clickCloseAccount();
|
||||
await modal.selectTransferAccount('Vanguard 401k');
|
||||
await expect(page).toMatchThemeScreenshots();
|
||||
await modal.closeAccount();
|
||||
|
||||
await expect(accountPage.accountName).toHaveText('Closed: Roth IRA');
|
||||
await expect(page).toMatchThemeScreenshots();
|
||||
});
|
||||
|
||||
test.describe('Budgeted Accounts', () => {
|
||||
// Reset filters
|
||||
test.afterEach(async () => {
|
||||
await accountPage.removeFilter(0);
|
||||
});
|
||||
|
||||
test('creates a transfer from two existing transactions', async () => {
|
||||
accountPage = await navigation.goToAccountPage('For budget');
|
||||
await expect(accountPage.accountName).toHaveText('Budgeted Accounts');
|
||||
|
||||
await accountPage.filterByNote('Test Acc Transfer');
|
||||
|
||||
await accountPage.createSingleTransaction({
|
||||
account: 'Ally Savings',
|
||||
payee: '',
|
||||
notes: 'Test Acc Transfer',
|
||||
category: 'Food',
|
||||
debit: '34.56',
|
||||
});
|
||||
|
||||
await accountPage.createSingleTransaction({
|
||||
account: 'HSBC',
|
||||
payee: '',
|
||||
notes: 'Test Acc Transfer',
|
||||
category: 'Food',
|
||||
credit: '34.56',
|
||||
});
|
||||
|
||||
await page.waitForTimeout(100); // Give time for the previous transaction to be rendered
|
||||
|
||||
await accountPage.selectNthTransaction(0);
|
||||
await accountPage.selectNthTransaction(1);
|
||||
await accountPage.clickSelectAction('Make transfer');
|
||||
|
||||
let transaction = accountPage.getNthTransaction(0);
|
||||
await expect(transaction.payee).toHaveText('Ally Savings');
|
||||
await expect(transaction.category).toHaveText('Transfer');
|
||||
await expect(transaction.credit).toHaveText('34.56');
|
||||
await expect(transaction.account).toHaveText('HSBC');
|
||||
|
||||
transaction = accountPage.getNthTransaction(1);
|
||||
await expect(transaction.payee).toHaveText('HSBC');
|
||||
await expect(transaction.category).toHaveText('Transfer');
|
||||
await expect(transaction.debit).toHaveText('34.56');
|
||||
await expect(transaction.account).toHaveText('Ally Savings');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 127 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 126 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 125 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 117 KiB |
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user