mirror of
https://github.com/actualbudget/actual.git
synced 2026-03-09 11:42:54 -05:00
Compare commits
2438 Commits
v23.8.0
...
sync-serve
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cef14e1a79 | ||
|
|
1e5d5b9b78 | ||
|
|
33f6ae7f91 | ||
|
|
50fba76c47 | ||
|
|
744ae1625d | ||
|
|
9dda58b61d | ||
|
|
734bb86126 | ||
|
|
efb0d80aa4 | ||
|
|
605206d2f7 | ||
|
|
f7b40fca64 | ||
|
|
dc811552be | ||
|
|
295839ebbb | ||
|
|
99ca34458e | ||
|
|
90ac8d8520 | ||
|
|
52aeec2d59 | ||
|
|
0c280d60f6 | ||
|
|
148ca92584 | ||
|
|
90e848ebe8 | ||
|
|
b034d5039f | ||
|
|
5ac29473f2 | ||
|
|
3b0db2bed7 | ||
|
|
7a886810bc | ||
|
|
8bf0997275 | ||
|
|
2f965266ab | ||
|
|
499f24f7fd | ||
|
|
4c5be62f56 | ||
|
|
1446c7d93f | ||
|
|
ad9980307e | ||
|
|
d4ad31fb0c | ||
|
|
05355788e4 | ||
|
|
805e2b1807 | ||
|
|
e54dc0c1ca | ||
|
|
e1c2f0a181 | ||
|
|
cc2e329e8e | ||
|
|
71f849d1e1 | ||
|
|
0ea8bc1fb4 | ||
|
|
f0c7953c0b | ||
|
|
4cf5f9b183 | ||
|
|
80fd997540 | ||
|
|
da93ddf63b | ||
|
|
7846d2e787 | ||
|
|
ca6d80461a | ||
|
|
fa14cbb697 | ||
|
|
1210a74b4a | ||
|
|
534c1e6680 | ||
|
|
14d436712a | ||
|
|
e9f3925124 | ||
|
|
f28229be99 | ||
|
|
1fc922c672 | ||
|
|
c712217a7c | ||
|
|
3559b2df3a | ||
|
|
6365a8f4bb | ||
|
|
14426b64fd | ||
|
|
65790d4b9c | ||
|
|
9af4ba4d07 | ||
|
|
28caf8eaf9 | ||
|
|
81160256bc | ||
|
|
ca5378c0e8 | ||
|
|
08b5b7fdc7 | ||
|
|
67c0b6911b | ||
|
|
4e9e153989 | ||
|
|
b0321ee265 | ||
|
|
753a105b3d | ||
|
|
5a888d44b9 | ||
|
|
7a4799de94 | ||
|
|
4ad369cd8f | ||
|
|
2c9a66cec6 | ||
|
|
6e96b81799 | ||
|
|
f89d4fd13d | ||
|
|
cc0812113a | ||
|
|
59724d445f | ||
|
|
6b99497d5d | ||
|
|
5f5457b226 | ||
|
|
4bdcb27573 | ||
|
|
8ae070ab12 | ||
|
|
0ca5bec094 | ||
|
|
988bc21818 | ||
|
|
f4419b96de | ||
|
|
e30a38ced8 | ||
|
|
98b91cfb8d | ||
|
|
942d3ea4d5 | ||
|
|
3c9b70df79 | ||
|
|
5c18b53888 | ||
|
|
413398531c | ||
|
|
e4c3d4e12a | ||
|
|
91b838c539 | ||
|
|
9eb0e04c6a | ||
|
|
14bf3d611c | ||
|
|
34b6599da3 | ||
|
|
bc1cd9023c | ||
|
|
5ae9176f5e | ||
|
|
2ed908aff4 | ||
|
|
3318dd56e9 | ||
|
|
00ab11cc40 | ||
|
|
25c83eb64d | ||
|
|
7a420b79f2 | ||
|
|
d2cfedf5e4 | ||
|
|
00a4cfcabf | ||
|
|
a18a05f55a | ||
|
|
b399f290a6 | ||
|
|
7c07295448 | ||
|
|
510dd31de6 | ||
|
|
8e5a88bc55 | ||
|
|
bbf91ccbca | ||
|
|
58bc14e1b3 | ||
|
|
de2966a06c | ||
|
|
90b859fd74 | ||
|
|
fafcee071d | ||
|
|
ed40901534 | ||
|
|
338093836b | ||
|
|
4df05aa37c | ||
|
|
5459b8baca | ||
|
|
073d91a7b7 | ||
|
|
58a638cee2 | ||
|
|
23f1bae7db | ||
|
|
57240284a3 | ||
|
|
6c6d8931bb | ||
|
|
cae8fa4e6f | ||
|
|
48ae371ecc | ||
|
|
e8d93fb797 | ||
|
|
6790f99de2 | ||
|
|
68f0b05aed | ||
|
|
c954d3924e | ||
|
|
adf4bd2d0f | ||
|
|
102c6eaff6 | ||
|
|
21105fc25b | ||
|
|
c69142f58e | ||
|
|
fe32bf14c6 | ||
|
|
92e43bc3b5 | ||
|
|
165be3d0df | ||
|
|
3dd22994b7 | ||
|
|
96bfc69332 | ||
|
|
284fc13161 | ||
|
|
30102b1474 | ||
|
|
3a8eb96d76 | ||
|
|
91a8bc3ef1 | ||
|
|
dc2ab4843f | ||
|
|
89e5676cfb | ||
|
|
645342d47d | ||
|
|
116c695964 | ||
|
|
a5d18929c8 | ||
|
|
989d332e1b | ||
|
|
169d08e721 | ||
|
|
a74da11904 | ||
|
|
cccd66713d | ||
|
|
1ce53b2762 | ||
|
|
d75f984186 | ||
|
|
692ade7254 | ||
|
|
da0ac0b144 | ||
|
|
be20f65b6e | ||
|
|
1067e32028 | ||
|
|
dcb1c69e67 | ||
|
|
f084e28086 | ||
|
|
f54e459e03 | ||
|
|
ccdde60bfe | ||
|
|
712d315229 | ||
|
|
31c6362307 | ||
|
|
d1519993d6 | ||
|
|
ebde78434a | ||
|
|
8fcaff8e3a | ||
|
|
13bc99738f | ||
|
|
959824d317 | ||
|
|
2abc144b03 | ||
|
|
71250f5fb7 | ||
|
|
c5f050f6f8 | ||
|
|
0d46e221f9 | ||
|
|
6bf2f581a3 | ||
|
|
3c34603111 | ||
|
|
3e488ae8f7 | ||
|
|
bacf3091b6 | ||
|
|
ac77c0f360 | ||
|
|
e21256e7a2 | ||
|
|
22237d11ca | ||
|
|
63604c1161 | ||
|
|
74b95ca83e | ||
|
|
6a9028464b | ||
|
|
186d417c6e | ||
|
|
c898116412 | ||
|
|
85bd6bfb81 | ||
|
|
72616376e2 | ||
|
|
136ad055f7 | ||
|
|
c621f68e0a | ||
|
|
4f611ca458 | ||
|
|
d98e8375a8 | ||
|
|
4f50c1a889 | ||
|
|
0fa582b3d3 | ||
|
|
80cd2cf347 | ||
|
|
f328332ab2 | ||
|
|
7465bdb54d | ||
|
|
1ad3406e84 | ||
|
|
48166952ce | ||
|
|
994b959050 | ||
|
|
7f4f5005a5 | ||
|
|
1e05b169c8 | ||
|
|
c54a5b3405 | ||
|
|
5c11a0a51a | ||
|
|
8f69669cc6 | ||
|
|
9ebdba27fd | ||
|
|
8df3d23e03 | ||
|
|
5d238c238d | ||
|
|
e69235a35b | ||
|
|
611f7b046a | ||
|
|
ee8f7453ba | ||
|
|
380fae1ccd | ||
|
|
8d84f16604 | ||
|
|
239a087542 | ||
|
|
895d69f875 | ||
|
|
1cb5e97fab | ||
|
|
13bd08d243 | ||
|
|
d946852ee9 | ||
|
|
4cdff76547 | ||
|
|
1a1975b5ab | ||
|
|
6e2154d401 | ||
|
|
45bfd23daa | ||
|
|
c44b32805f | ||
|
|
45530638fe | ||
|
|
a15ff85c20 | ||
|
|
1861060bda | ||
|
|
9281acb819 | ||
|
|
dc86441809 | ||
|
|
78bcac95ed | ||
|
|
45610bae81 | ||
|
|
f5a6700b21 | ||
|
|
8cea059834 | ||
|
|
31b31f2edb | ||
|
|
4b56e0cb54 | ||
|
|
f8dd4897e7 | ||
|
|
526d7a9baa | ||
|
|
af8f26f200 | ||
|
|
548f36e8d6 | ||
|
|
9aed1f400c | ||
|
|
59bcee9369 | ||
|
|
ff7529f6e5 | ||
|
|
77e99af297 | ||
|
|
82a3c97222 | ||
|
|
cafcc823cb | ||
|
|
582e27dbdb | ||
|
|
d747563915 | ||
|
|
84dec137bb | ||
|
|
76de8bf67f | ||
|
|
5db7026435 | ||
|
|
e4a993ad67 | ||
|
|
38ab63638a | ||
|
|
4eea349966 | ||
|
|
f66baeabd3 | ||
|
|
19180138bb | ||
|
|
7705a2df08 | ||
|
|
202af094af | ||
|
|
333e7ff7bc | ||
|
|
eb11e14e94 | ||
|
|
dbcfb63857 | ||
|
|
64f4d200dd | ||
|
|
4be7e03570 | ||
|
|
ca969cc61b | ||
|
|
682d439c34 | ||
|
|
37d91a90f7 | ||
|
|
3a09d91399 | ||
|
|
405c8b986f | ||
|
|
bd9f0aec89 | ||
|
|
a4f0d5bca8 | ||
|
|
0085c3b58a | ||
|
|
cfb0f51e36 | ||
|
|
2d95fe6d03 | ||
|
|
80bb4ab299 | ||
|
|
2f71e007d6 | ||
|
|
7b71374e79 | ||
|
|
c1d70722b8 | ||
|
|
2cd79960a9 | ||
|
|
9a6afda6de | ||
|
|
979fa43c4a | ||
|
|
a20805bfae | ||
|
|
485830c859 | ||
|
|
4aea6f4898 | ||
|
|
1f828b6562 | ||
|
|
ad9a84ddf1 | ||
|
|
d9a171b249 | ||
|
|
e5c84d4ae0 | ||
|
|
94a76a008d | ||
|
|
432c2b6165 | ||
|
|
46eb2a7c38 | ||
|
|
3214d5dd53 | ||
|
|
66d8f1a631 | ||
|
|
e3aa63d1fa | ||
|
|
9478707ebb | ||
|
|
9952412e1d | ||
|
|
8231bbbf5a | ||
|
|
eadd88ce31 | ||
|
|
6c61cf6a8d | ||
|
|
0fb9c252ca | ||
|
|
d73ead135e | ||
|
|
2c87c44168 | ||
|
|
15beba2ca3 | ||
|
|
667cc24fac | ||
|
|
4cc542a658 | ||
|
|
093d799ba0 | ||
|
|
68d10f6b29 | ||
|
|
252f04e02c | ||
|
|
13cb85835b | ||
|
|
39cf04c74d | ||
|
|
562b5e2afd | ||
|
|
4b4e32d0e2 | ||
|
|
d821f1cebc | ||
|
|
53e3694a38 | ||
|
|
8647452ccc | ||
|
|
5a40b017f0 | ||
|
|
8ccc1af77e | ||
|
|
07904c209e | ||
|
|
234f008dcf | ||
|
|
d130b427b3 | ||
|
|
39cd71aa48 | ||
|
|
100711ccfb | ||
|
|
7c9f3f241d | ||
|
|
6509e80061 | ||
|
|
fbd6989a18 | ||
|
|
180431f9ed | ||
|
|
d27d62b5fc | ||
|
|
b5f29ccb4a | ||
|
|
0a5acebeaf | ||
|
|
fa544d9c08 | ||
|
|
8976a59c3a | ||
|
|
9713d09603 | ||
|
|
814f4fe955 | ||
|
|
dbe6b27d9f | ||
|
|
31a7902a08 | ||
|
|
eb35b41c6d | ||
|
|
a025d2b621 | ||
|
|
d72140b8b6 | ||
|
|
254059d4c8 | ||
|
|
92bc1e8ec9 | ||
|
|
b211b67f5e | ||
|
|
a2abb2b2ae | ||
|
|
3fab1be737 | ||
|
|
bf9fbc5137 | ||
|
|
52eced1f21 | ||
|
|
359af05cc4 | ||
|
|
9f1a8f6d5c | ||
|
|
b22d712b4f | ||
|
|
098cacd904 | ||
|
|
127f114914 | ||
|
|
b56e26ee56 | ||
|
|
cd6b141117 | ||
|
|
cd15aded05 | ||
|
|
cac318255d | ||
|
|
6872dd235b | ||
|
|
649932b42f | ||
|
|
d372b71f36 | ||
|
|
47cb5e1ecf | ||
|
|
02c59d9a1c | ||
|
|
f9f6917fcd | ||
|
|
7441b5fa92 | ||
|
|
bfb2d61286 | ||
|
|
09b12b8218 | ||
|
|
1c46655e30 | ||
|
|
82329b7de2 | ||
|
|
a34c94d9fe | ||
|
|
ae6eed65f7 | ||
|
|
53398624f3 | ||
|
|
47ee6eeb51 | ||
|
|
6e3a337945 | ||
|
|
d2d8ce2353 | ||
|
|
7d38f6934d | ||
|
|
d4b09ecb27 | ||
|
|
a508a8705c | ||
|
|
f3b2507516 | ||
|
|
583ddab2ac | ||
|
|
1876ba9fe7 | ||
|
|
4c15647f7f | ||
|
|
174e13b3fe | ||
|
|
2e9a752baa | ||
|
|
83f6706020 | ||
|
|
4dba95842a | ||
|
|
af499c6503 | ||
|
|
913a2c9a68 | ||
|
|
a4b0c4a0be | ||
|
|
32a04cbbcb | ||
|
|
e950bbb1df | ||
|
|
188cd21cf1 | ||
|
|
f7a45d2081 | ||
|
|
99768a9aae | ||
|
|
7dbb3404f1 | ||
|
|
ca09bbb858 | ||
|
|
0dca8498fe | ||
|
|
b5ece8e221 | ||
|
|
351e252129 | ||
|
|
720d0fda6d | ||
|
|
fdf213865d | ||
|
|
b2ffa1d846 | ||
|
|
21fb090ddf | ||
|
|
c389c6c637 | ||
|
|
7bb6aff756 | ||
|
|
3f4ddfdfe0 | ||
|
|
7dd98c4f86 | ||
|
|
f3fc4b8d22 | ||
|
|
644e8df3e1 | ||
|
|
9dfbefa1d2 | ||
|
|
516977f666 | ||
|
|
70362f6801 | ||
|
|
d225e5d5e1 | ||
|
|
ae00fa2841 | ||
|
|
b475951075 | ||
|
|
d9dd96d0de | ||
|
|
45ff94590b | ||
|
|
466875a8dd | ||
|
|
0431039eb6 | ||
|
|
2215c131a5 | ||
|
|
4de5fb34b9 | ||
|
|
4f6b97ae4a | ||
|
|
6d921a48b6 | ||
|
|
2d0716233f | ||
|
|
6f3bfd2ad4 | ||
|
|
10a143a3ae | ||
|
|
82c469648e | ||
|
|
ab3088b6fa | ||
|
|
582bb59097 | ||
|
|
01c68bcfc7 | ||
|
|
dd749483f5 | ||
|
|
11b86fc33c | ||
|
|
6e5f4bfb18 | ||
|
|
d87b78c02a | ||
|
|
3cab9a374b | ||
|
|
96949b701e | ||
|
|
0f55c67d3e | ||
|
|
b0adcb1333 | ||
|
|
04da20e34c | ||
|
|
48715bf6b4 | ||
|
|
6c278a72a5 | ||
|
|
17173d3ff0 | ||
|
|
64caf0f28b | ||
|
|
0a8f820652 | ||
|
|
e30735104e | ||
|
|
8878f36eaf | ||
|
|
9531a57f1c | ||
|
|
9d99fe2838 | ||
|
|
3a18718fa0 | ||
|
|
090345bd95 | ||
|
|
4a7439da5e | ||
|
|
a3c9525c83 | ||
|
|
0d60da5ba9 | ||
|
|
388a9f6c86 | ||
|
|
cabb7149f0 | ||
|
|
3c47c1ca27 | ||
|
|
cc204ba70e | ||
|
|
7074b4dd82 | ||
|
|
c23cbb4b0e | ||
|
|
8da1c59362 | ||
|
|
916a27a89a | ||
|
|
97b99d1f16 | ||
|
|
cf114ef69e | ||
|
|
7064106748 | ||
|
|
b92a1cacf7 | ||
|
|
f5c1879de1 | ||
|
|
92f444f548 | ||
|
|
94600731d9 | ||
|
|
5d59ecd72c | ||
|
|
54d9f0ff41 | ||
|
|
eebdbdf34e | ||
|
|
bddc372650 | ||
|
|
d027adc734 | ||
|
|
e67c064d1f | ||
|
|
2f1ccad25a | ||
|
|
a2b9153d02 | ||
|
|
f1594e5bca | ||
|
|
fed426d704 | ||
|
|
aeb26b75af | ||
|
|
4b58335780 | ||
|
|
153eaeecc5 | ||
|
|
e28db52c70 | ||
|
|
83f7a79c76 | ||
|
|
7e3ebb7e5f | ||
|
|
a8d41e88a5 | ||
|
|
62ab6a12e8 | ||
|
|
d91fd2fb0e | ||
|
|
4e9d51be1f | ||
|
|
1bc87fb35a | ||
|
|
2f5464c735 | ||
|
|
c85769eb82 | ||
|
|
a657021303 | ||
|
|
523a62c8a0 | ||
|
|
925efc4cb6 | ||
|
|
31fe766a2b | ||
|
|
b9e7d7e9c2 | ||
|
|
04aa086c9e | ||
|
|
cc02970a16 | ||
|
|
9489e2f3b4 | ||
|
|
608b4be765 | ||
|
|
01f45d5072 | ||
|
|
1b5be7f9d2 | ||
|
|
e5d301c66b | ||
|
|
fe3fe47b67 | ||
|
|
b4ad639bb5 | ||
|
|
c77168fa18 | ||
|
|
6fb7fe1343 | ||
|
|
490ec22b8a | ||
|
|
0d420ab4d9 | ||
|
|
6b22e7422b | ||
|
|
35a833ecba | ||
|
|
140039ea1f | ||
|
|
afab6ee773 | ||
|
|
cefc8da7bc | ||
|
|
91aa2ae47b | ||
|
|
a2878ff2a2 | ||
|
|
0dfa2910c7 | ||
|
|
4e0ab44e2f | ||
|
|
aba97e8b74 | ||
|
|
1d6b70d160 | ||
|
|
a9047bfcd6 | ||
|
|
af03477d3a | ||
|
|
c4b4108eca | ||
|
|
26ee3179e1 | ||
|
|
82b6589c37 | ||
|
|
56f509dcda | ||
|
|
bbfa0093cd | ||
|
|
aef38f1679 | ||
|
|
e04ca554e2 | ||
|
|
77949ad276 | ||
|
|
45f9cc3c1d | ||
|
|
31ed12832c | ||
|
|
0f8a1aeb2b | ||
|
|
161c0625b1 | ||
|
|
009a3dff4e | ||
|
|
5d01e109e6 | ||
|
|
b2287cded3 | ||
|
|
19dbfd0673 | ||
|
|
36c40d90d2 | ||
|
|
00ff2e2522 | ||
|
|
1d074730f4 | ||
|
|
1e685b993b | ||
|
|
6b2d2420a5 | ||
|
|
af09e5b1d5 | ||
|
|
8f12893ff0 | ||
|
|
a85dc890e5 | ||
|
|
8180476531 | ||
|
|
ad1df689d7 | ||
|
|
d9716caf5d | ||
|
|
b2cca2337c | ||
|
|
e0bddaeb99 | ||
|
|
2c4c5014ea | ||
|
|
c7f3dadc07 | ||
|
|
999010cca6 | ||
|
|
b9603d0e54 | ||
|
|
0c85523037 | ||
|
|
eb5944b353 | ||
|
|
f35a850e3d | ||
|
|
3ca9f6ecbc | ||
|
|
602b84342b | ||
|
|
b5cbaa52b2 | ||
|
|
5fdaa98249 | ||
|
|
c8813e9953 | ||
|
|
074d5b76cf | ||
|
|
9f9f349cbf | ||
|
|
78e763659e | ||
|
|
0cc817f2ef | ||
|
|
f00484b17c | ||
|
|
0cdaac6944 | ||
|
|
864aaacd11 | ||
|
|
bdf76f6c63 | ||
|
|
47c0d394ee | ||
|
|
fdac2839c9 | ||
|
|
379a84d2e2 | ||
|
|
d16d022b50 | ||
|
|
606e39252f | ||
|
|
2523c091c3 | ||
|
|
d4b37baa4d | ||
|
|
ed4ef5b30e | ||
|
|
baadafb15e | ||
|
|
346727a539 | ||
|
|
ddcd771488 | ||
|
|
3ecfb4be7e | ||
|
|
bf154db3d6 | ||
|
|
4b48f09112 | ||
|
|
6c7df7c1f9 | ||
|
|
0b9b440a7a | ||
|
|
ebb83aca51 | ||
|
|
e5c2ef47ac | ||
|
|
af068d1f89 | ||
|
|
35fe969b1b | ||
|
|
cc44fad62e | ||
|
|
42294d4a53 | ||
|
|
d8fcd43324 | ||
|
|
ccb07e12db | ||
|
|
f2267587c8 | ||
|
|
4bc34776fb | ||
|
|
63657a8496 | ||
|
|
b3e82c55f6 | ||
|
|
9a45d6707d | ||
|
|
46138997d1 | ||
|
|
c19d5a5722 | ||
|
|
c2cb61eb70 | ||
|
|
ca5cd55585 | ||
|
|
6fd5164987 | ||
|
|
60a864a790 | ||
|
|
c510f722ef | ||
|
|
1fed3ebbd7 | ||
|
|
84e4cda6ad | ||
|
|
4fb5bbca34 | ||
|
|
e0805c125f | ||
|
|
a2fc6bdd31 | ||
|
|
866f4891ce | ||
|
|
0f7a1e6b97 | ||
|
|
0b15ba18a0 | ||
|
|
cfc8186da1 | ||
|
|
47ad1492dc | ||
|
|
90af0466f4 | ||
|
|
98e4ece50a | ||
|
|
9e82032595 | ||
|
|
abb6e86f35 | ||
|
|
c8d14cae09 | ||
|
|
d815a22f6b | ||
|
|
d5e2030d23 | ||
|
|
f9b8dde199 | ||
|
|
c17cd28525 | ||
|
|
5ebbff4f87 | ||
|
|
ca2a930bd5 | ||
|
|
7346198360 | ||
|
|
17c4d168fa | ||
|
|
b74175e660 | ||
|
|
3628deee95 | ||
|
|
7650eed644 | ||
|
|
eeeb1d3bcd | ||
|
|
3863ce369d | ||
|
|
e10b105756 | ||
|
|
89006275a0 | ||
|
|
b4f423eac0 | ||
|
|
d5f55cee80 | ||
|
|
fcf110749d | ||
|
|
6db52ca7a8 | ||
|
|
e91f8e7e06 | ||
|
|
1425671d16 | ||
|
|
61364fb846 | ||
|
|
1a5f83d78c | ||
|
|
b0deedb411 | ||
|
|
103d95bbc8 | ||
|
|
d11fc59ec9 | ||
|
|
1c931cf01c | ||
|
|
7f50c73866 | ||
|
|
a7cde1f90d | ||
|
|
398ada0afd | ||
|
|
8b928b3b21 | ||
|
|
3897a5a51c | ||
|
|
94f94497af | ||
|
|
a72fd74c5e | ||
|
|
5214549ed3 | ||
|
|
3c8212e130 | ||
|
|
eface19216 | ||
|
|
f413fa03ae | ||
|
|
81b30d74e4 | ||
|
|
0fddcac76d | ||
|
|
1511587e88 | ||
|
|
ff7c358b83 | ||
|
|
f88b00ae77 | ||
|
|
f5a1293e3c | ||
|
|
5a79beadfa | ||
|
|
6e934e46a8 | ||
|
|
18423a3167 | ||
|
|
c1a70377b9 | ||
|
|
dd4b891fd9 | ||
|
|
89a92ecbbe | ||
|
|
bde99b75ae | ||
|
|
8d3b046bef | ||
|
|
f7f58847dc | ||
|
|
23752f1da9 | ||
|
|
1cc2da1de3 | ||
|
|
04b5bdc62f | ||
|
|
fc78d5b546 | ||
|
|
f5586501bf | ||
|
|
d902c38253 | ||
|
|
a085945898 | ||
|
|
def06693ab | ||
|
|
bf72a26e5c | ||
|
|
10c35e18d3 | ||
|
|
aea6e79402 | ||
|
|
81db7399da | ||
|
|
7c9a499f91 | ||
|
|
df9e6ec66d | ||
|
|
a4d3f277ea | ||
|
|
cab24df3ce | ||
|
|
c92e445416 | ||
|
|
e86cbcdbb3 | ||
|
|
3a14d46a05 | ||
|
|
14c1d9f03b | ||
|
|
1884a188b7 | ||
|
|
c471c8908b | ||
|
|
7a252d0694 | ||
|
|
3b6c97faab | ||
|
|
bc09c0412c | ||
|
|
87779d3199 | ||
|
|
e5eb8646c2 | ||
|
|
0504becaf5 | ||
|
|
04f8140d26 | ||
|
|
9fd004205c | ||
|
|
f90fc69341 | ||
|
|
7e1c0a8682 | ||
|
|
454f019f7a | ||
|
|
f1a4c888b2 | ||
|
|
524707cecc | ||
|
|
8351edc8bb | ||
|
|
4957cd652b | ||
|
|
ad58561f4e | ||
|
|
dbdc5af2b9 | ||
|
|
679b94a626 | ||
|
|
5e217ec9a2 | ||
|
|
52ce2a179e | ||
|
|
be1c194eb4 | ||
|
|
7d9190ea9c | ||
|
|
c883e71e49 | ||
|
|
6710456c05 | ||
|
|
be2126e136 | ||
|
|
ac08b87273 | ||
|
|
7ddd79c61e | ||
|
|
4ee70e7f1f | ||
|
|
7faecf4273 | ||
|
|
ce99c49ddb | ||
|
|
c947d964fa | ||
|
|
46aa79ab7c | ||
|
|
af3598ec76 | ||
|
|
83ba751410 | ||
|
|
722e30e385 | ||
|
|
e88bd783f2 | ||
|
|
8c0ca48781 | ||
|
|
5f1fadb7cc | ||
|
|
cac4761982 | ||
|
|
e1a694a554 | ||
|
|
c4ad24edde | ||
|
|
f5b9a5b23d | ||
|
|
8b49a25ab4 | ||
|
|
15298703ac | ||
|
|
7096d00fc6 | ||
|
|
e8b4a750ed | ||
|
|
4a7b0e7365 | ||
|
|
f1da358186 | ||
|
|
a23a28522f | ||
|
|
6a5de96033 | ||
|
|
abeeb05091 | ||
|
|
bd063423e5 | ||
|
|
ca480a8269 | ||
|
|
bdf4dda3a8 | ||
|
|
0c1c8e6adb | ||
|
|
ed91fb1ef4 | ||
|
|
b14c77aed7 | ||
|
|
64df0e107c | ||
|
|
9b59333563 | ||
|
|
b09d800e40 | ||
|
|
d1c3b9bab1 | ||
|
|
663f830cc9 | ||
|
|
0312f516ad | ||
|
|
9dcd22962c | ||
|
|
f09f4af667 | ||
|
|
1f5e5d41a4 | ||
|
|
46f04f5d4c | ||
|
|
caaa801d24 | ||
|
|
5448a5c264 | ||
|
|
977657a0be | ||
|
|
2f8b839036 | ||
|
|
1cf64f87ab | ||
|
|
012cfd09ea | ||
|
|
39361e5b62 | ||
|
|
5ada00cf97 | ||
|
|
0f2226e993 | ||
|
|
f0c81eebbf | ||
|
|
a84af23e7e | ||
|
|
1442662eb7 | ||
|
|
4850034e6f | ||
|
|
90dc050102 | ||
|
|
7791b7401e | ||
|
|
a97471557b | ||
|
|
5f823156b7 | ||
|
|
dd2b0a8bd5 | ||
|
|
c53e9e9e41 | ||
|
|
6cbf3e33e6 | ||
|
|
cdbf3e06c1 | ||
|
|
1f2c6541b8 | ||
|
|
ecb4d7153c | ||
|
|
e70dc4efb0 | ||
|
|
bbebf71378 | ||
|
|
f35c5a0ed9 | ||
|
|
9d63b23463 | ||
|
|
705985a8df | ||
|
|
eb31071043 | ||
|
|
91c4e3e067 | ||
|
|
7cb53502b8 | ||
|
|
87c26042b9 | ||
|
|
6070166f4e | ||
|
|
66619fa20d | ||
|
|
5e8a24f283 | ||
|
|
278e4ad74f | ||
|
|
c347653566 | ||
|
|
c4593f3be9 | ||
|
|
99724f611c | ||
|
|
f07ad1f8c6 | ||
|
|
4bfb64cdfc | ||
|
|
626e7973ac | ||
|
|
7ae6442296 | ||
|
|
774402503f | ||
|
|
cf360ad398 | ||
|
|
2a275b3821 | ||
|
|
87428a7b65 | ||
|
|
6655f51ccc | ||
|
|
ceeef91a45 | ||
|
|
b831d15eab | ||
|
|
26907d3b12 | ||
|
|
b9eaeafc1c | ||
|
|
45d53ff771 | ||
|
|
b1627d7073 | ||
|
|
5fc3e2ea47 | ||
|
|
97b28ca375 | ||
|
|
aa529a2cf1 | ||
|
|
205ccfe3d6 | ||
|
|
b92fa709eb | ||
|
|
5d91d29d77 | ||
|
|
61d41cc28a | ||
|
|
5921a35340 | ||
|
|
6573a52411 | ||
|
|
bec841932d | ||
|
|
629b001c01 | ||
|
|
4bb59fd7f6 | ||
|
|
a1be1d43f6 | ||
|
|
1c6697a7ee | ||
|
|
da13dfa570 | ||
|
|
6bcccaa943 | ||
|
|
58f87dc80f | ||
|
|
5a34c06859 | ||
|
|
92c93b3f6e | ||
|
|
c017b8a228 | ||
|
|
34ffc5c4b2 | ||
|
|
14b0cd7b1d | ||
|
|
daca767808 | ||
|
|
6111f94b51 | ||
|
|
ce0ca60bcf | ||
|
|
3fbe6d05c8 | ||
|
|
cc1c11aac9 | ||
|
|
7dad36528c | ||
|
|
c956f8003b | ||
|
|
0637b1d5f8 | ||
|
|
e6ed4505b3 | ||
|
|
a5d591fed7 | ||
|
|
215e00ac14 | ||
|
|
1f44903e4b | ||
|
|
1808f51e85 | ||
|
|
0f1c231d37 | ||
|
|
bd77dfd111 | ||
|
|
39cfa11b25 | ||
|
|
af0a14ce3d | ||
|
|
1f2155053f | ||
|
|
d5ebcced38 | ||
|
|
7c2408daa6 | ||
|
|
95180cc780 | ||
|
|
82e1922bee | ||
|
|
8f66605994 | ||
|
|
eadd11b7f0 | ||
|
|
832fd1e5d8 | ||
|
|
928260ca3a | ||
|
|
be5bfa275e | ||
|
|
1e65939147 | ||
|
|
7060e4b657 | ||
|
|
2005c1b0ac | ||
|
|
da613ab673 | ||
|
|
d894281465 | ||
|
|
5c577aa069 | ||
|
|
e6aeea668b | ||
|
|
ded2f39e13 | ||
|
|
3f6068fe88 | ||
|
|
9213ed75b5 | ||
|
|
93262e7fb4 | ||
|
|
cd8bb8e139 | ||
|
|
bd126b499b | ||
|
|
8976ffc256 | ||
|
|
0b2c8ccd88 | ||
|
|
cde81da72c | ||
|
|
2ef397112c | ||
|
|
6c57b4ec9e | ||
|
|
6cfb9d2a7a | ||
|
|
4ce5e2fd07 | ||
|
|
11bde73fa5 | ||
|
|
efb50edf9f | ||
|
|
96c37350d5 | ||
|
|
f80eb888a0 | ||
|
|
94666a2ac1 | ||
|
|
b6fbcef6f0 | ||
|
|
1165c4c008 | ||
|
|
70f6afbda6 | ||
|
|
0d06bc1f7e | ||
|
|
6281d54a38 | ||
|
|
d637a69ee4 | ||
|
|
8446356cc6 | ||
|
|
ec977ee51a | ||
|
|
ef95850e93 | ||
|
|
81fc029a03 | ||
|
|
9e6a486c90 | ||
|
|
9af3539b91 | ||
|
|
62d8358f90 | ||
|
|
219e139d55 | ||
|
|
298b734539 | ||
|
|
e96b986ad0 | ||
|
|
5104a1a563 | ||
|
|
6ea77324ef | ||
|
|
2b908e9263 | ||
|
|
a2892270d2 | ||
|
|
d649eec4db | ||
|
|
5717d90544 | ||
|
|
a35af73023 | ||
|
|
e4b40fb831 | ||
|
|
fa8ff79208 | ||
|
|
3ce7ae91d9 | ||
|
|
645958bbeb | ||
|
|
1b25235cc7 | ||
|
|
f207803f7a | ||
|
|
df958eb35c | ||
|
|
39dbdc0418 | ||
|
|
df7bc5d2f0 | ||
|
|
5e7538fde3 | ||
|
|
2c0bd6bafd | ||
|
|
d3a7b6228a | ||
|
|
501c8653ef | ||
|
|
484211185b | ||
|
|
22623ce65e | ||
|
|
8506b87f2c | ||
|
|
c25e3d4163 | ||
|
|
69a04a5c21 | ||
|
|
826511779e | ||
|
|
339fac2806 | ||
|
|
2ebaa527be | ||
|
|
c5411518c4 | ||
|
|
36839ff153 | ||
|
|
9d6db12921 | ||
|
|
590ac1f95e | ||
|
|
8e76a65e0c | ||
|
|
c3eda4247e | ||
|
|
022b9b76b1 | ||
|
|
19f0037256 | ||
|
|
c626fc2f17 | ||
|
|
30f21497a6 | ||
|
|
f523d25052 | ||
|
|
278ac0c730 | ||
|
|
0696c8113d | ||
|
|
688de5f604 | ||
|
|
881410bc74 | ||
|
|
b4d2d6a884 | ||
|
|
5cf439883e | ||
|
|
23bb89b96e | ||
|
|
7010ab1eb6 | ||
|
|
18f538c54b | ||
|
|
e170c0d274 | ||
|
|
dad702e5c2 | ||
|
|
224d445840 | ||
|
|
670419b087 | ||
|
|
58baf74992 | ||
|
|
d08be58f95 | ||
|
|
db68170cce | ||
|
|
1e1092e472 | ||
|
|
d1324408f4 | ||
|
|
9e478014c5 | ||
|
|
dd69e539d3 | ||
|
|
2cb668a40c | ||
|
|
3cefd98ce9 | ||
|
|
fa2830a1fd | ||
|
|
57ac062edc | ||
|
|
0c94214a8f | ||
|
|
b1bf7ee7cd | ||
|
|
266de169db | ||
|
|
d412590b33 | ||
|
|
635ef27696 | ||
|
|
2b72b2f2f2 | ||
|
|
985b653a87 | ||
|
|
f14b160e5c | ||
|
|
8eafa1e741 | ||
|
|
aefd9504bf | ||
|
|
1f6977da81 | ||
|
|
290402ee6a | ||
|
|
c3b95886db | ||
|
|
e53d444c32 | ||
|
|
ed098c4a69 | ||
|
|
c0f9073f35 | ||
|
|
19c6f85f5e | ||
|
|
d4f1f703ea | ||
|
|
914f59197f | ||
|
|
7c24c269e2 | ||
|
|
c52e5c856d | ||
|
|
b08756cc39 | ||
|
|
29fc22a171 | ||
|
|
815f69a051 | ||
|
|
83ceea4250 | ||
|
|
59d685fab6 | ||
|
|
a267e3abb5 | ||
|
|
e078ed21ba | ||
|
|
b98ff3f50d | ||
|
|
879869c85a | ||
|
|
41d5922635 | ||
|
|
6f07894be7 | ||
|
|
871de93f2d | ||
|
|
15b2ef1591 | ||
|
|
2cd3c9f8a9 | ||
|
|
81afedb610 | ||
|
|
1c9b43671e | ||
|
|
1c05d7e5fe | ||
|
|
6666014fe5 | ||
|
|
dc425042ec | ||
|
|
b5f8aa4d05 | ||
|
|
e659ccf3f4 | ||
|
|
603f970f12 | ||
|
|
59835a3ac1 | ||
|
|
b349edd9e0 | ||
|
|
f265dd9df0 | ||
|
|
a6da06a8ef | ||
|
|
f25dc1f261 | ||
|
|
5751d5d107 | ||
|
|
4b063450a4 | ||
|
|
fbb0f9bd75 | ||
|
|
6af0dbab56 | ||
|
|
5c94e3878e | ||
|
|
10ca29e1e9 | ||
|
|
4d89a9b86a | ||
|
|
34f3ccacf6 | ||
|
|
1b883aa0ab | ||
|
|
c9e6d7897b | ||
|
|
cc347aef08 | ||
|
|
49c5adc9cf | ||
|
|
b8c92b98b8 | ||
|
|
f6f49b1fe7 | ||
|
|
df42cccce7 | ||
|
|
54054736e9 | ||
|
|
5cf170a442 | ||
|
|
f9eb017a54 | ||
|
|
15351e034e | ||
|
|
1895bc80c2 | ||
|
|
a91a8859ab | ||
|
|
a3256f5686 | ||
|
|
715bc00e3b | ||
|
|
4e07357221 | ||
|
|
03f2cabc18 | ||
|
|
259beb7665 | ||
|
|
0f3efde855 | ||
|
|
9aac44c58f | ||
|
|
0d9528e22c | ||
|
|
3f31d19d8a | ||
|
|
225c93914c | ||
|
|
c25e97b0f6 | ||
|
|
e775306f81 | ||
|
|
02824ad240 | ||
|
|
1a13e98f49 | ||
|
|
3d9e90f797 | ||
|
|
c30981638e | ||
|
|
b253246fe2 | ||
|
|
778fc713f3 | ||
|
|
e0f0d8e241 | ||
|
|
310d299ebd | ||
|
|
130f357bab | ||
|
|
f89817170a | ||
|
|
ec37b39e34 | ||
|
|
23f75a6b6a | ||
|
|
f206ba2f0f | ||
|
|
bd5c0cb981 | ||
|
|
3635c8c88a | ||
|
|
5cb97d6f2f | ||
|
|
e8af5b9014 | ||
|
|
328196c485 | ||
|
|
644fe8bdc6 | ||
|
|
15b1b73379 | ||
|
|
8c7e93616f | ||
|
|
a56d6f9e05 | ||
|
|
75acfc79e1 | ||
|
|
300ddc6311 | ||
|
|
a8c4c5fa23 | ||
|
|
05dda5f9d7 | ||
|
|
37ad584826 | ||
|
|
f9c08a995d | ||
|
|
e37a42faf9 | ||
|
|
9f279486ce | ||
|
|
0b3155608c | ||
|
|
3301cfa2fd | ||
|
|
23de23bd4e | ||
|
|
79f640cbc0 | ||
|
|
f786bdcec3 | ||
|
|
f3ae31055e | ||
|
|
21cb684b26 | ||
|
|
e455369443 | ||
|
|
6d122c898d | ||
|
|
e6024f7a8b | ||
|
|
1485d9c871 | ||
|
|
290c6f646f | ||
|
|
85b3c5714e | ||
|
|
ce4b80f499 | ||
|
|
464d9878c6 | ||
|
|
71c208e444 | ||
|
|
1dce3183e5 | ||
|
|
051c8a6ed0 | ||
|
|
bdeb19424b | ||
|
|
5369494925 | ||
|
|
e653ad33a6 | ||
|
|
a7b8d1251c | ||
|
|
d5e0b7da5d | ||
|
|
279d545a28 | ||
|
|
0b6ea52d9b | ||
|
|
38c5f89c41 | ||
|
|
b774a3b216 | ||
|
|
dc5d1174c7 | ||
|
|
33a7524cd7 | ||
|
|
0a0e26372b | ||
|
|
a28fb93cec | ||
|
|
365da79783 | ||
|
|
df92c80c27 | ||
|
|
d0caf9f521 | ||
|
|
3f85aedd0b | ||
|
|
6658dc2197 | ||
|
|
9b7a79a01c | ||
|
|
125510c981 | ||
|
|
f3385dafa2 | ||
|
|
327887b87d | ||
|
|
47ef916873 | ||
|
|
5064b06f2c | ||
|
|
73f2de1ea6 | ||
|
|
4df03984bd | ||
|
|
92980ab55b | ||
|
|
3b97d1eec7 | ||
|
|
545c8d5456 | ||
|
|
f79edf866a | ||
|
|
83ea40dff9 | ||
|
|
444ac83697 | ||
|
|
8f725c7911 | ||
|
|
6725d56bb8 | ||
|
|
666b7870b7 | ||
|
|
686ce5b504 | ||
|
|
4373f4d8f9 | ||
|
|
479572fadb | ||
|
|
0757f9d680 | ||
|
|
835b6987a7 | ||
|
|
6e627c4e2e | ||
|
|
0f41e95952 | ||
|
|
2e70c11c74 | ||
|
|
7e889300ef | ||
|
|
c497d3a941 | ||
|
|
fe17c6ba75 | ||
|
|
3a9a929f56 | ||
|
|
88a7432975 | ||
|
|
373dfb0465 | ||
|
|
80a7a9873a | ||
|
|
9c2bb9b3de | ||
|
|
2acf996430 | ||
|
|
f3451bfc2e | ||
|
|
48cdffbc03 | ||
|
|
37d201b6fb | ||
|
|
d1ecb3db44 | ||
|
|
90e2fe60d1 | ||
|
|
09e3721036 | ||
|
|
6354598d48 | ||
|
|
55df377a20 | ||
|
|
c01e229bd7 | ||
|
|
e8ff02b2e7 | ||
|
|
634508a3bc | ||
|
|
b1c8b3d689 | ||
|
|
ec55e8dc9a | ||
|
|
a1bc66b10a | ||
|
|
4485a631cd | ||
|
|
25a4041958 | ||
|
|
e6bf6da381 | ||
|
|
e507b8ff43 | ||
|
|
5e12d4013a | ||
|
|
84af8b76be | ||
|
|
b3669b3001 | ||
|
|
6f41b20caf | ||
|
|
37d391b4fc | ||
|
|
7702ee4f4f | ||
|
|
d0ba623cfa | ||
|
|
ea675f11ee | ||
|
|
7b314e3b25 | ||
|
|
f78383be29 | ||
|
|
17fd06894a | ||
|
|
4e6a3bbace | ||
|
|
3743a328e3 | ||
|
|
6c87d85920 | ||
|
|
5b685ecc64 | ||
|
|
9ea7bd255a | ||
|
|
ae01066fe2 | ||
|
|
fefd1be22c | ||
|
|
bdbf6e9ca6 | ||
|
|
c5193b6d43 | ||
|
|
183c4b25a9 | ||
|
|
933804e836 | ||
|
|
0a59f793bf | ||
|
|
cfa9ac09d7 | ||
|
|
fe8532578d | ||
|
|
420aad0878 | ||
|
|
16944a6140 | ||
|
|
b2d7b65ce9 | ||
|
|
8c8c248ef7 | ||
|
|
6e8cdb30e8 | ||
|
|
3985d2549e | ||
|
|
68a2af0248 | ||
|
|
7231959f81 | ||
|
|
8498d7f788 | ||
|
|
d752389710 | ||
|
|
95ed7aaf27 | ||
|
|
21dc573f3f | ||
|
|
cb0411b180 | ||
|
|
41b12151f6 | ||
|
|
62dbe3acf5 | ||
|
|
bbff543768 | ||
|
|
008a8a78b9 | ||
|
|
c466189007 | ||
|
|
b856c4874e | ||
|
|
407e3143eb | ||
|
|
d60e7501cc | ||
|
|
ac90eb21a6 | ||
|
|
61bffa3d31 | ||
|
|
fca1bccda3 | ||
|
|
8e6fb4c64f | ||
|
|
5229fe7d16 | ||
|
|
bc04a8cbec | ||
|
|
0a34ede61a | ||
|
|
8a4a9ba083 | ||
|
|
61f5dcfd02 | ||
|
|
5cfa2cf577 | ||
|
|
3f8963273b | ||
|
|
1aa65946c2 | ||
|
|
0760583359 | ||
|
|
a757ba6bdc | ||
|
|
44375e72ad | ||
|
|
6454c10e63 | ||
|
|
7a018e09a9 | ||
|
|
2a9546ced1 | ||
|
|
8926ff69b1 | ||
|
|
340169bfb6 | ||
|
|
3a905d3f9a | ||
|
|
c4d01fe63f | ||
|
|
7738ea0c00 | ||
|
|
446f40714d | ||
|
|
3b26aa05b5 | ||
|
|
8e077e0282 | ||
|
|
ae608f0cb8 | ||
|
|
f1c0d0b8a6 | ||
|
|
d613a6be6e | ||
|
|
d9adb750d4 | ||
|
|
2b37d5a642 | ||
|
|
1750cd9081 | ||
|
|
7769d0303e | ||
|
|
6a41d28404 | ||
|
|
9108b63355 | ||
|
|
1b70e59bde | ||
|
|
b48d256ec4 | ||
|
|
9c0e6a307b | ||
|
|
3e5ce72e27 | ||
|
|
b347f03fbb | ||
|
|
f3660c166f | ||
|
|
aaf96bbc2c | ||
|
|
6d84b0e371 | ||
|
|
db4b504e53 | ||
|
|
8d4dbbf5f2 | ||
|
|
b8d2797259 | ||
|
|
d6afc85a8c | ||
|
|
ee21155d1a | ||
|
|
65a7c58441 | ||
|
|
51ec600de2 | ||
|
|
8201085ccb | ||
|
|
c16a8faa3f | ||
|
|
4ce7f55e0c | ||
|
|
574448ff3b | ||
|
|
af5fd5b3ef | ||
|
|
7fcda084ab | ||
|
|
eccdc52342 | ||
|
|
4c192d7e1e | ||
|
|
baf04a4d48 | ||
|
|
f715ceafc9 | ||
|
|
af73dcd722 | ||
|
|
5e3485a8e2 | ||
|
|
1458dbc307 | ||
|
|
a879960a2d | ||
|
|
9ac77af077 | ||
|
|
3e07d18acd | ||
|
|
fa6cc26416 | ||
|
|
a1ca871b24 | ||
|
|
d9066a49c4 | ||
|
|
63ad6dadf2 | ||
|
|
89b096aa65 | ||
|
|
ee0156d35d | ||
|
|
9c17d55e0d | ||
|
|
411a6791b2 | ||
|
|
6f3af7b609 | ||
|
|
43ff1c033e | ||
|
|
09c44d351d | ||
|
|
a22160579d | ||
|
|
81df2ce7fd | ||
|
|
119d0b339d | ||
|
|
d1362c3d74 | ||
|
|
8142dd1ec9 | ||
|
|
2afd6967b4 | ||
|
|
eec5fbb1cc | ||
|
|
fe922ec22e | ||
|
|
30a70f5627 | ||
|
|
65c5f2c559 | ||
|
|
1abca7619d | ||
|
|
b74f0f2982 | ||
|
|
6a85f84565 | ||
|
|
65329398fd | ||
|
|
a2e434a1fb | ||
|
|
d2bbe6a98e | ||
|
|
2c1967d788 | ||
|
|
798aee78c3 | ||
|
|
2807c98c2c | ||
|
|
5e9b976676 | ||
|
|
44ce976ffa | ||
|
|
5ba80fcbdc | ||
|
|
7b77f60458 | ||
|
|
81f59ff776 | ||
|
|
12f4295932 | ||
|
|
d33e5cc766 | ||
|
|
63d9547e7c | ||
|
|
d18fd36ae1 | ||
|
|
2b1ba88983 | ||
|
|
8be867f884 | ||
|
|
cafe480ba4 | ||
|
|
6472c70960 | ||
|
|
56c5a533e7 | ||
|
|
7e3ff1ad03 | ||
|
|
df3aaf961d | ||
|
|
e0d7233b40 | ||
|
|
1b4c4319e1 | ||
|
|
14f29941b0 | ||
|
|
6b57e45e04 | ||
|
|
4389329bfa | ||
|
|
842e11b3a1 | ||
|
|
3a38c32b4c | ||
|
|
e3101fb86b | ||
|
|
c3c6acd37c | ||
|
|
8de0f6a72a | ||
|
|
2799dbee3e | ||
|
|
58eeee825e | ||
|
|
6653dca776 | ||
|
|
77ba15f54c | ||
|
|
943f903646 | ||
|
|
b4a620e74e | ||
|
|
653a0ab104 | ||
|
|
2c26fa51a3 | ||
|
|
dff9911a15 | ||
|
|
3d5818f017 | ||
|
|
efd294dcef | ||
|
|
0eb62a09bc | ||
|
|
73d52fa0d0 | ||
|
|
5b0cc63f73 | ||
|
|
26a591f07f | ||
|
|
fe8851c797 | ||
|
|
511f677ae4 | ||
|
|
1cef0d11ee | ||
|
|
536cabb75b | ||
|
|
cceda03905 | ||
|
|
982f555a21 | ||
|
|
fe70ecb635 | ||
|
|
d3d9f70657 | ||
|
|
5c0bee6031 | ||
|
|
4439bb6abe | ||
|
|
b432204b4b | ||
|
|
9a85a72089 | ||
|
|
a970a78932 | ||
|
|
ed65805d53 | ||
|
|
88ae7e9375 | ||
|
|
0135a4d1b9 | ||
|
|
4af2c4f214 | ||
|
|
89a8f102dc | ||
|
|
c19cc56c55 | ||
|
|
d032fce7ea | ||
|
|
2fdc7fef32 | ||
|
|
1e41d695c5 | ||
|
|
12f91f7d86 | ||
|
|
f75d0f8099 | ||
|
|
07bbe00059 | ||
|
|
be0d363576 | ||
|
|
c2e648c9d5 | ||
|
|
33049a77e7 | ||
|
|
89241623f3 | ||
|
|
40e432dedb | ||
|
|
8434e8f5ce | ||
|
|
9b99debacc | ||
|
|
a23ec33591 | ||
|
|
aaea04fc00 | ||
|
|
b4f0087eef | ||
|
|
7824c52d9f | ||
|
|
f81c452ba5 | ||
|
|
7072674111 | ||
|
|
16e887c917 | ||
|
|
145659b256 | ||
|
|
572033debe | ||
|
|
b85f9102ce | ||
|
|
942aebedd0 | ||
|
|
32d830440a | ||
|
|
4575616961 | ||
|
|
4a0e2ea306 | ||
|
|
14ec9a9089 | ||
|
|
e91b4070aa | ||
|
|
6dd34b0c63 | ||
|
|
ab4639f48f | ||
|
|
aa3cbd881b | ||
|
|
8681c9c3e6 | ||
|
|
9ec9aef632 | ||
|
|
3be7dd753d | ||
|
|
7e84cf897c | ||
|
|
259e84cea5 | ||
|
|
f9014f0e19 | ||
|
|
e59f5c9af8 | ||
|
|
3661c1585e | ||
|
|
771c01c8b4 | ||
|
|
9f72b43826 | ||
|
|
17cdea9beb | ||
|
|
ec3475d834 | ||
|
|
5ea9c587a8 | ||
|
|
228f38617b | ||
|
|
1e38055376 | ||
|
|
0ee9126820 | ||
|
|
9e455e4c1e | ||
|
|
d77b54f27b | ||
|
|
ff36d1efbe | ||
|
|
cbbbaf65cf | ||
|
|
f129b07dc9 | ||
|
|
74ade73476 | ||
|
|
81acd29c73 | ||
|
|
f1caf21deb | ||
|
|
a28ea6be8f | ||
|
|
f36c5e002b | ||
|
|
803289ee1f | ||
|
|
76cdad4fe6 | ||
|
|
1da9c821ee | ||
|
|
d03b30bc00 | ||
|
|
710d9ab8ac | ||
|
|
d008944022 | ||
|
|
f18bce6094 | ||
|
|
31eb00a155 | ||
|
|
a67c969189 | ||
|
|
58e6c6f23a | ||
|
|
f046d75b75 | ||
|
|
30bcfedc86 | ||
|
|
866b4d6cd4 | ||
|
|
a42938fa64 | ||
|
|
e02b0f9bc7 | ||
|
|
2006d885b8 | ||
|
|
049a41f366 | ||
|
|
7f30680fb3 | ||
|
|
167522d322 | ||
|
|
2d4256b239 | ||
|
|
247e3e8d93 | ||
|
|
5951b92668 | ||
|
|
a9ee670eb4 | ||
|
|
3990aaf38f | ||
|
|
48f5880f1d | ||
|
|
3332f58376 | ||
|
|
46ea8fbf72 | ||
|
|
6a21f8e3de | ||
|
|
f02ca4e3d2 | ||
|
|
7f658691bb | ||
|
|
f5307e4bd4 | ||
|
|
5b1a730f11 | ||
|
|
0c14eb17c4 | ||
|
|
7bb0425c81 | ||
|
|
8832c2b234 | ||
|
|
437e202d27 | ||
|
|
d34f5eccb6 | ||
|
|
382d347508 | ||
|
|
c792c0f54e | ||
|
|
73d0f04d45 | ||
|
|
f1d3902e3e | ||
|
|
8b6ef7b325 | ||
|
|
6ad0b47c7c | ||
|
|
96964224f4 | ||
|
|
0ed5e3ebe6 | ||
|
|
925926f542 | ||
|
|
44ddf210ea | ||
|
|
62c6a8775c | ||
|
|
64cd6ee3c9 | ||
|
|
abc4636662 | ||
|
|
ade25b3304 | ||
|
|
b192ad955e | ||
|
|
e9da476b51 | ||
|
|
12719e3049 | ||
|
|
e178d9914d | ||
|
|
6fd728aa2d | ||
|
|
bf26ca4eb9 | ||
|
|
f606d92c5c | ||
|
|
8b850f1410 | ||
|
|
c992e340ca | ||
|
|
06f9db06b0 | ||
|
|
2b96bb3d52 | ||
|
|
1af5ab09e9 | ||
|
|
ebb9452b8f | ||
|
|
196f03b84e | ||
|
|
93e784a0fe | ||
|
|
026194e5e2 | ||
|
|
98341b440a | ||
|
|
417f5805a8 | ||
|
|
094f0b8a91 | ||
|
|
aa22e6951d | ||
|
|
55724acafa | ||
|
|
b89a32025a | ||
|
|
d62919a357 | ||
|
|
1c7d9bf141 | ||
|
|
e3934b96dc | ||
|
|
6d117f44de | ||
|
|
64821e6a64 | ||
|
|
e7c6611c88 | ||
|
|
6220aadb2d | ||
|
|
2959054d0c | ||
|
|
7d960579f9 | ||
|
|
5fd1d05670 | ||
|
|
0e86dea544 | ||
|
|
2221fd8dff | ||
|
|
c92266fd7f | ||
|
|
08aff05a06 | ||
|
|
1195af76a6 | ||
|
|
e4bc0caa51 | ||
|
|
c3783b6498 | ||
|
|
55ef74eabc | ||
|
|
9667e1c269 | ||
|
|
b803731bc5 | ||
|
|
8415e7cddc | ||
|
|
5f16349a19 | ||
|
|
aff5bba4ec | ||
|
|
d79b8c6cb2 | ||
|
|
bbc123c3b8 | ||
|
|
04273d8064 | ||
|
|
3f1fd55a7b | ||
|
|
bfda42a2be | ||
|
|
201f0fd336 | ||
|
|
99ab8db99f | ||
|
|
f604fdaf36 | ||
|
|
c311d4a8df | ||
|
|
1fc7c9974b | ||
|
|
e63fb46c8f | ||
|
|
db6b4e4800 | ||
|
|
2901b5e5d0 | ||
|
|
abd049e715 | ||
|
|
c51e636637 | ||
|
|
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 | ||
|
|
3a486ed973 | ||
|
|
2178da0414 | ||
|
|
33c204dedb | ||
|
|
f8809df59c | ||
|
|
41a34d0f1c | ||
|
|
04bc0c3c64 | ||
|
|
0aa6a057b1 | ||
|
|
f1488077dd | ||
|
|
b34aaab5f5 | ||
|
|
688bfe59e8 | ||
|
|
8f543a29e0 | ||
|
|
ad73a404c4 | ||
|
|
4ed9f4fff4 | ||
|
|
176c8466a4 | ||
|
|
703c319f7f | ||
|
|
ca8a8174c4 | ||
|
|
f8af8f95ac | ||
|
|
98a7aac736 | ||
|
|
d49f907f53 | ||
|
|
164dd399b0 | ||
|
|
f41d3f22c7 | ||
|
|
469c789c14 | ||
|
|
6ec3728280 | ||
|
|
c8cc479358 | ||
|
|
51ad488ce2 | ||
|
|
985411d48f | ||
|
|
53b5f3a0fc | ||
|
|
c0b21a9f7e | ||
|
|
3ec4ef71c6 | ||
|
|
5df02c19dc | ||
|
|
464c9de6fb | ||
|
|
aad609f427 | ||
|
|
7313cf722d | ||
|
|
978a658c1e | ||
|
|
cdad43ff32 | ||
|
|
4c29afd424 | ||
|
|
c98a21b030 | ||
|
|
f5fde34952 | ||
|
|
59f854c075 | ||
|
|
4ae654d54d | ||
|
|
85affae70f | ||
|
|
1bbba663b2 | ||
|
|
44c7b4eab0 | ||
|
|
57d4cc57cd | ||
|
|
7d892d8164 | ||
|
|
84fbc7e464 | ||
|
|
5f77b87b07 | ||
|
|
97ec8f8d3a | ||
|
|
407ad4deee | ||
|
|
1c04aeae39 | ||
|
|
02e7d036d5 | ||
|
|
16ef674910 | ||
|
|
19bcfbe6e9 | ||
|
|
a2c1c2dea6 | ||
|
|
e9b5bfcc53 | ||
|
|
672dd5ea4b | ||
|
|
b101f9989b | ||
|
|
cf9b8c357e | ||
|
|
c71e1d2e8a | ||
|
|
1e462714e4 | ||
|
|
d9f55460dd | ||
|
|
77a670bbc3 | ||
|
|
1a1fe9ac9d | ||
|
|
ae0faf467f | ||
|
|
16df116d1d | ||
|
|
d7075ae551 | ||
|
|
e500cba7e9 | ||
|
|
2d188b3941 | ||
|
|
e88ea69801 | ||
|
|
9aeab0ff5b | ||
|
|
36c700d92d | ||
|
|
eed6105222 | ||
|
|
a5a5f30dd7 | ||
|
|
291e3a8d14 | ||
|
|
edd34b7903 | ||
|
|
345ea71eed | ||
|
|
d89a016ab1 | ||
|
|
b87951855b | ||
|
|
70e37c0119 | ||
|
|
f55bd860ba | ||
|
|
0f960df8cf | ||
|
|
915c562545 | ||
|
|
770d86258f | ||
|
|
a955fe2474 | ||
|
|
4e9130ac29 | ||
|
|
e94a5505d8 | ||
|
|
1ee2cbec1c | ||
|
|
da6b039f10 | ||
|
|
310cc04a2b | ||
|
|
6fce10aea0 | ||
|
|
97a664159b | ||
|
|
c2291d4b5d | ||
|
|
d6de90a296 | ||
|
|
f06bbf9a51 | ||
|
|
90305965ba | ||
|
|
b655009477 | ||
|
|
2e600e52c7 | ||
|
|
b5f617dbe5 | ||
|
|
b3fc23201e | ||
|
|
6f251e6024 | ||
|
|
b356004f68 | ||
|
|
66dc593fa5 | ||
|
|
8b34ba3958 | ||
|
|
2df774d9f5 | ||
|
|
c96c957a34 | ||
|
|
f3d7641ba9 | ||
|
|
83fd85f059 | ||
|
|
308f8339ae | ||
|
|
9697795279 | ||
|
|
9b40610f26 | ||
|
|
1c61508abb | ||
|
|
2a1c452aac | ||
|
|
1dd1c188d6 | ||
|
|
deec1f998c | ||
|
|
8f634099e2 | ||
|
|
7ee48e4c1d | ||
|
|
270705b3cd | ||
|
|
9588a76109 | ||
|
|
ada9e7da31 | ||
|
|
38ffccb903 | ||
|
|
f5023a7c07 | ||
|
|
bc6a0f8e60 | ||
|
|
5ee7d336ef | ||
|
|
24e42daa51 | ||
|
|
4bafd13c55 | ||
|
|
bf404104c2 | ||
|
|
afaee6bc16 | ||
|
|
e44fbb3847 | ||
|
|
ff70c654a2 | ||
|
|
586a26968c | ||
|
|
5fde6561d0 | ||
|
|
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 | ||
|
|
6e9eddeb56 | ||
|
|
b700aee87d | ||
|
|
b926af286a | ||
|
|
9fca85209f | ||
|
|
a9362cc6f9 | ||
|
|
40296dc876 | ||
|
|
ed1e0ceb30 | ||
|
|
55817b0e70 | ||
|
|
6d7d12138c | ||
|
|
f43097f813 | ||
|
|
52f1f79c01 | ||
|
|
991fc4f450 | ||
|
|
04147fb9b9 | ||
|
|
4a8c692d06 | ||
|
|
0609f47cc3 | ||
|
|
0d1e6f2ee7 | ||
|
|
8568aebdbb | ||
|
|
bfb7c1d213 | ||
|
|
e526555748 | ||
|
|
45c4b262a2 | ||
|
|
e1f805b9c9 | ||
|
|
d0c11cd3af | ||
|
|
0c5bce8baf | ||
|
|
e650e00cb8 | ||
|
|
422996f8a7 | ||
|
|
9cad57c607 | ||
|
|
cd4a2b6678 | ||
|
|
2a0f8335ed | ||
|
|
16faf49438 | ||
|
|
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 | ||
|
|
13a39168c4 | ||
|
|
9fdffcc8e9 | ||
|
|
3daff4381f | ||
|
|
5914469b11 | ||
|
|
39e7f2598b | ||
|
|
c8d326d24b | ||
|
|
10d53fbe5f | ||
|
|
21c65cf68f | ||
|
|
d8639a2a71 | ||
|
|
734191424b | ||
|
|
5d4fcfde00 | ||
|
|
54d7e5460a | ||
|
|
43ebe9e0fd | ||
|
|
515bdf5a74 | ||
|
|
018714610a | ||
|
|
00ee165f8e | ||
|
|
68442ae9e6 | ||
|
|
b937bfae04 | ||
|
|
317e7f135e | ||
|
|
5adb083575 | ||
|
|
524bd4e9eb | ||
|
|
9dfd6ce34c | ||
|
|
5d28bc0e3b | ||
|
|
a4e97e0070 | ||
|
|
a6e38ad2ae | ||
|
|
bb0ae4ebc3 | ||
|
|
dd29e02c5c | ||
|
|
75186183eb | ||
|
|
e7f6348d9a | ||
|
|
83f13cbdc8 | ||
|
|
0045d9212e | ||
|
|
dd254c6c23 | ||
|
|
c66d6e00f5 | ||
|
|
ae3be13aa8 | ||
|
|
cd9d1fb674 | ||
|
|
edba670d3a | ||
|
|
fbb1a9647d | ||
|
|
06cf65497f | ||
|
|
62a0a0fedc | ||
|
|
106dce25dd | ||
|
|
af6de6b585 | ||
|
|
d8c7a0a358 | ||
|
|
58f7b09e8d | ||
|
|
ff11399180 | ||
|
|
363c9e4afb | ||
|
|
e745a4073b | ||
|
|
7d1a895b48 | ||
|
|
a8b42bcd50 | ||
|
|
f33bce41ea | ||
|
|
cdefe6133f | ||
|
|
44a5199a31 | ||
|
|
dccad902d6 | ||
|
|
b477f7c2f1 | ||
|
|
540c410957 | ||
|
|
e205344867 | ||
|
|
7b36c2207a | ||
|
|
8b968579b1 | ||
|
|
6ce794ffcc | ||
|
|
6de1fe34e3 | ||
|
|
d5359a96ca | ||
|
|
3eee0b11d2 | ||
|
|
e792afb1fd | ||
|
|
4fb55d0d70 | ||
|
|
b330991855 | ||
|
|
165ad45822 | ||
|
|
295917036b | ||
|
|
ef9a7cfe85 | ||
|
|
ff073b1221 | ||
|
|
4ece4a7ff6 | ||
|
|
761b3c6a16 | ||
|
|
7ace8c52dd | ||
|
|
f6dd0ecdb9 | ||
|
|
1d4eaaa79c | ||
|
|
3b83f064d4 | ||
|
|
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 | ||
|
|
c330020487 | ||
|
|
83d2472a55 | ||
|
|
458d556e51 | ||
|
|
d9aeb8db1e | ||
|
|
85c0352abb | ||
|
|
7a40a645e6 | ||
|
|
7e9921e9e5 | ||
|
|
2f7b3917ed | ||
|
|
02aff1acbe | ||
|
|
e0d66d3083 | ||
|
|
d999e466b7 | ||
|
|
e589163bb7 | ||
|
|
d2b86d100c | ||
|
|
d8996405c4 | ||
|
|
e548125907 | ||
|
|
edbcaba89b | ||
|
|
b39d106c04 | ||
|
|
4894118809 | ||
|
|
933fc27126 | ||
|
|
c6723da780 | ||
|
|
7a8c320560 | ||
|
|
6fbf0fdc10 | ||
|
|
efaefcfaa9 | ||
|
|
73d281b6ee | ||
|
|
03e943f383 | ||
|
|
5854dffd50 | ||
|
|
c727e3e980 | ||
|
|
b385c715ef | ||
|
|
77e550f028 | ||
|
|
9ea8e86bb3 | ||
|
|
273fa8cd59 | ||
|
|
d4c8b5fa16 | ||
|
|
e62e8ca24e | ||
|
|
4497fbcb10 | ||
|
|
c910b6efcb | ||
|
|
633463a184 | ||
|
|
8a15caf42d | ||
|
|
af9efa09f0 | ||
|
|
a5557ca032 | ||
|
|
0bf587bcf5 | ||
|
|
e11b65719e | ||
|
|
c09a85f340 | ||
|
|
f90fe04b2b | ||
|
|
ca55d9c85e | ||
|
|
4ada92071e | ||
|
|
e848946898 | ||
|
|
9da05543c3 | ||
|
|
8a721bf2e0 | ||
|
|
36914abcd4 | ||
|
|
cd2d186599 | ||
|
|
b3bcff094d | ||
|
|
7955fe7aed | ||
|
|
5ac4c58d1e | ||
|
|
2741422c0a | ||
|
|
cde4551eb7 | ||
|
|
7174fccfe4 | ||
|
|
0303292a28 | ||
|
|
f95df06800 | ||
|
|
f93893e007 | ||
|
|
3680d45814 | ||
|
|
dc872647a9 | ||
|
|
7a45d467b7 | ||
|
|
79aa07ff1a | ||
|
|
c4ff099f26 | ||
|
|
ad2b577515 | ||
|
|
5f52801869 | ||
|
|
6bfd9586e0 | ||
|
|
f36713e0be | ||
|
|
8b20169b59 | ||
|
|
42699ac044 | ||
|
|
da03acc394 | ||
|
|
5d1f2d48ae | ||
|
|
bb07652cf8 | ||
|
|
dc47c6d72a | ||
|
|
c96782d7c1 | ||
|
|
5d61dfce68 | ||
|
|
4b12561905 | ||
|
|
5a81a25b7e | ||
|
|
29f323e721 | ||
|
|
c462604833 | ||
|
|
e48f36cd20 | ||
|
|
1ed84059d7 | ||
|
|
5d8b96a749 | ||
|
|
0920bdc3fa | ||
|
|
c3a9b4dda3 | ||
|
|
eab0068428 | ||
|
|
0d1b962b2f | ||
|
|
67b9143dfc | ||
|
|
e7f298e32a | ||
|
|
516f0c259f | ||
|
|
2c87c60328 | ||
|
|
b4fd6e86ed | ||
|
|
a3e71a8b49 | ||
|
|
aa9c992f2e | ||
|
|
ca498b19cc | ||
|
|
ca4ea97768 | ||
|
|
8be2389fd9 | ||
|
|
46988800ef | ||
|
|
881999bcfe | ||
|
|
9c124e8e44 | ||
|
|
3306963c54 | ||
|
|
baa474843a | ||
|
|
6ed1b58321 | ||
|
|
87813b398a | ||
|
|
28266ed9a2 | ||
|
|
cd6bd9ece8 | ||
|
|
fb2f712c16 | ||
|
|
f58fae8d16 | ||
|
|
a10b10a87b | ||
|
|
47007232b8 | ||
|
|
4761e9ce2f | ||
|
|
849262c95e | ||
|
|
e6e184c412 | ||
|
|
9c6d9ecf0a | ||
|
|
65273127f5 | ||
|
|
b5530085bb | ||
|
|
9ec82d52c9 | ||
|
|
7865e08c91 | ||
|
|
93922567fc | ||
|
|
7c0b7a2f17 | ||
|
|
e8055bbc35 | ||
|
|
7fdd9767ba | ||
|
|
1f105999af | ||
|
|
e116d01453 | ||
|
|
f970263c72 | ||
|
|
fecc9178b3 | ||
|
|
08c80b6f58 | ||
|
|
a3995582c4 | ||
|
|
5008eb022f | ||
|
|
353e7be31f | ||
|
|
30c7024663 | ||
|
|
22efe74ec8 | ||
|
|
a6c1443669 | ||
|
|
5792b15cb5 | ||
|
|
168b79a178 | ||
|
|
7062f2a7d9 | ||
|
|
af666458d3 | ||
|
|
184b302273 | ||
|
|
3004f336da | ||
|
|
45bb7696c3 | ||
|
|
199a9f838d | ||
|
|
de604b9f3a | ||
|
|
df5aa3186c | ||
|
|
1c68c3f964 | ||
|
|
1f79708ea3 | ||
|
|
494d67459f | ||
|
|
a15f80e771 | ||
|
|
be18891957 | ||
|
|
a6dae398da | ||
|
|
d8c885bf4e | ||
|
|
f94d8aff9f | ||
|
|
a549cdf0e7 | ||
|
|
b7a2f16246 | ||
|
|
0ec88ecc24 | ||
|
|
9e7c2e7a65 | ||
|
|
39fa9f2097 | ||
|
|
9040cab600 | ||
|
|
e56cb4bc85 | ||
|
|
fc08baf7ae | ||
|
|
7325153da1 | ||
|
|
e12793e7eb | ||
|
|
5fe146ee0a | ||
|
|
0af2987cef | ||
|
|
e266093a4a | ||
|
|
19f0efb654 | ||
|
|
e0b2eab475 | ||
|
|
25f4e2a8b5 | ||
|
|
d338a73794 | ||
|
|
75f2bf8b1b | ||
|
|
1f5ff932b1 | ||
|
|
eb54487d8e | ||
|
|
5cc6bad34b | ||
|
|
acf4456077 | ||
|
|
b45615e6fc | ||
|
|
38609ee25a | ||
|
|
cae2b9cd5a | ||
|
|
4cacc845c0 | ||
|
|
3dfbd23f96 | ||
|
|
21effa654d | ||
|
|
c33dc6fbad | ||
|
|
057caf127a | ||
|
|
767bc8ecb6 | ||
|
|
bdf5c45cda | ||
|
|
3dfe633428 | ||
|
|
efba86a72d | ||
|
|
316da144e1 | ||
|
|
d3ab8f9812 | ||
|
|
0ea7f1852b | ||
|
|
3c341fc583 | ||
|
|
de90504a6d | ||
|
|
75af7b9987 | ||
|
|
b8fbc9b19c | ||
|
|
fc643d28c6 | ||
|
|
f6e2d3b1f3 | ||
|
|
f1973d55c0 | ||
|
|
476070b0a7 | ||
|
|
9d6b9c556d | ||
|
|
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 | ||
|
|
46dbc89482 | ||
|
|
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 | ||
|
|
bde303ec45 | ||
|
|
6512c465f0 | ||
|
|
59de6b0035 | ||
|
|
3931625133 | ||
|
|
6a0b7d6b7d | ||
|
|
6817c45ddc | ||
|
|
6f216cc1aa | ||
|
|
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 | ||
|
|
ad65fcdffc | ||
|
|
0228072c6f | ||
|
|
87747a83b9 | ||
|
|
efec507bf8 | ||
|
|
1d65184241 | ||
|
|
0501deac2d | ||
|
|
d3f9a9c3a0 | ||
|
|
95c7d5bc2c | ||
|
|
91217b6d5e | ||
|
|
af875ab035 | ||
|
|
8ada28775e | ||
|
|
6ebcbc8738 | ||
|
|
42af73cdff | ||
|
|
71f885b899 | ||
|
|
b325bd9b18 | ||
|
|
a0ecd65e70 | ||
|
|
2ef0fc9415 | ||
|
|
ba6eb26e6e | ||
|
|
09380db661 | ||
|
|
b208294185 | ||
|
|
52c1676005 | ||
|
|
448c4546f2 | ||
|
|
9c9f6645d3 | ||
|
|
9102d974a5 | ||
|
|
b2738db441 | ||
|
|
c667118f10 | ||
|
|
218a4a761a | ||
|
|
e1dc58d456 | ||
|
|
e9137fccc7 | ||
|
|
facc3acf31 | ||
|
|
c2d5d475b9 | ||
|
|
e17d90ce5f | ||
|
|
9fed15f88b | ||
|
|
7cef3fe24b | ||
|
|
4a9b30d4d5 | ||
|
|
40d94141c4 | ||
|
|
023badc39c | ||
|
|
e78430db62 | ||
|
|
8ee4768f58 | ||
|
|
c581a8016c | ||
|
|
293692d5c5 | ||
|
|
7b7e6e4db0 | ||
|
|
cd54a2093e | ||
|
|
92099dc763 | ||
|
|
9d27379b25 | ||
|
|
b84546826f | ||
|
|
421aa65e6d | ||
|
|
fac3af6360 | ||
|
|
a9d34dfcc8 | ||
|
|
9bfbf229db | ||
|
|
2d11c0f61e | ||
|
|
fe033d68cf | ||
|
|
64ad07b9db | ||
|
|
e7f8288244 | ||
|
|
496d60c3a7 | ||
|
|
ce8d53a513 | ||
|
|
20c2f1950b | ||
|
|
723d9cd62c | ||
|
|
1de788ea68 | ||
|
|
354152dbb5 | ||
|
|
c2ee92878c | ||
|
|
8237eb56ab | ||
|
|
28e0d712ab | ||
|
|
debb33a63d | ||
|
|
5252f45073 | ||
|
|
5954141c15 | ||
|
|
ca27949989 | ||
|
|
8ac74ed397 | ||
|
|
7c8e9bb5ec | ||
|
|
d1ff06840e | ||
|
|
c2c8c1719e | ||
|
|
5d36ecbbbb | ||
|
|
9169bfabad | ||
|
|
fb6dc5e8f1 | ||
|
|
d60bb6a19e | ||
|
|
996f238648 | ||
|
|
7c744f0e3d | ||
|
|
2f54a948be | ||
|
|
652d75a939 | ||
|
|
104c9803f7 | ||
|
|
bc93604576 | ||
|
|
a1af1ff3d2 | ||
|
|
47d77a3198 | ||
|
|
82b4643c78 | ||
|
|
0096907368 | ||
|
|
1504127979 | ||
|
|
6f7306cd37 | ||
|
|
063d468836 | ||
|
|
7e88de182e | ||
|
|
bf4319d978 | ||
|
|
c3d89b6509 | ||
|
|
cb731099e6 | ||
|
|
b6073f7e6f | ||
|
|
9b2d74c3ad | ||
|
|
7713848067 | ||
|
|
c8a901d3ff | ||
|
|
d02e1b3640 | ||
|
|
19dbf815e6 | ||
|
|
df70a791fd | ||
|
|
a84036e0c6 | ||
|
|
973070f22b | ||
|
|
5f0df5ed53 | ||
|
|
80be279e11 | ||
|
|
1aaafbf435 | ||
|
|
1dd1283d56 | ||
|
|
184ec5f1c0 | ||
|
|
56e5c33c3e | ||
|
|
37c5278abf | ||
|
|
d1482e38fe | ||
|
|
1cce6137a0 | ||
|
|
27ea0ddd98 | ||
|
|
bd7992a45a | ||
|
|
95a0769eaa | ||
|
|
af9713d5b1 | ||
|
|
ab9f771829 | ||
|
|
fa0c91b22c | ||
|
|
2e5f5b48fa | ||
|
|
a52ae2dba1 | ||
|
|
19cd163b30 | ||
|
|
06b687e899 | ||
|
|
fa42c2202e | ||
|
|
0537544de2 | ||
|
|
be1c119799 | ||
|
|
76f398dd16 | ||
|
|
064d3e5dbb | ||
|
|
c6c3243234 | ||
|
|
60b183b094 | ||
|
|
9b5fb43ad3 | ||
|
|
7c58261b72 | ||
|
|
712ca71656 | ||
|
|
c8ad8a693b | ||
|
|
1be74096f6 | ||
|
|
92b4eec36a | ||
|
|
44038fbdce | ||
|
|
b4c7996d1a | ||
|
|
39fd8a5457 | ||
|
|
24da9b7b5a | ||
|
|
3edb8db94b | ||
|
|
909b4b1c0b | ||
|
|
38f2ab252e | ||
|
|
f0772b147f | ||
|
|
9fc9d5c642 | ||
|
|
73db82042c | ||
|
|
a14f558258 | ||
|
|
97081d46c4 | ||
|
|
60f83b334e | ||
|
|
340ac869ce | ||
|
|
b2b6ba4921 | ||
|
|
3274a06e34 | ||
|
|
e9850bfc56 | ||
|
|
529c42cccf | ||
|
|
2a00227486 | ||
|
|
5252edbf70 | ||
|
|
74c15b4f42 | ||
|
|
d482b9baf6 | ||
|
|
cde216523e | ||
|
|
8aeb815b5a | ||
|
|
3c602268e3 | ||
|
|
9177fb4d77 | ||
|
|
e3f1fafad9 | ||
|
|
32bf923c1a | ||
|
|
d3a0e8067e | ||
|
|
105d5007cf | ||
|
|
bafa486668 | ||
|
|
80a2b34d43 | ||
|
|
a5e1e38e74 | ||
|
|
0486e9e37b | ||
|
|
09722d8678 | ||
|
|
cd22e38660 | ||
|
|
f83fe76280 | ||
|
|
b9e1e6030f | ||
|
|
4874b53c7c | ||
|
|
b1a48f4f27 | ||
|
|
3fee9cbb42 | ||
|
|
a2a460a883 | ||
|
|
6bcd67a906 | ||
|
|
9e2d253fb6 | ||
|
|
a7efc82944 | ||
|
|
25f4bb5557 | ||
|
|
74d6b7edc5 | ||
|
|
1204b5b1a6 | ||
|
|
59ddc965ec | ||
|
|
9cc4ffaf33 | ||
|
|
11ba63d086 | ||
|
|
06d2aba57c | ||
|
|
7ecaad529f | ||
|
|
5ef3aa4153 | ||
|
|
5e83e14637 | ||
|
|
8dbc10efd7 | ||
|
|
592f0540f9 | ||
|
|
0e28f77a1f | ||
|
|
618609dbfa | ||
|
|
c86f1f5546 | ||
|
|
a09a028dc9 | ||
|
|
44da71bcdd | ||
|
|
343ea0c306 | ||
|
|
a60f22ef5c | ||
|
|
d614070f44 | ||
|
|
fd5d81e399 | ||
|
|
841d3ac115 | ||
|
|
3df101a91d | ||
|
|
3124c29052 | ||
|
|
a4a4eda0eb | ||
|
|
7d418b91b4 | ||
|
|
9ef7771adc | ||
|
|
d0a8b678d3 | ||
|
|
6d52cc7c73 | ||
|
|
2acc034fc1 | ||
|
|
51a2cb91f2 | ||
|
|
44d045f546 | ||
|
|
6be206ff28 | ||
|
|
c4badb9daf | ||
|
|
f8ad766695 | ||
|
|
a55d4634b1 | ||
|
|
42bc4c8acb | ||
|
|
efd273bfa5 | ||
|
|
3fb71b74cd | ||
|
|
2bb3e3ea48 | ||
|
|
59ec877ddd | ||
|
|
31e9025df3 | ||
|
|
1b42bc0e75 | ||
|
|
0ad3f12686 | ||
|
|
6de775a1bf | ||
|
|
1d74b9d782 | ||
|
|
933a5f5e7c | ||
|
|
cd280802de | ||
|
|
b53a4e5bf8 | ||
|
|
c20648c33a | ||
|
|
fa32948508 | ||
|
|
9ec349e733 | ||
|
|
4fa2bf1b29 | ||
|
|
3d01a76296 | ||
|
|
f9c0539d68 | ||
|
|
dde1495854 | ||
|
|
065f87c2e8 | ||
|
|
c049cf440f | ||
|
|
1149730962 | ||
|
|
16de8cb91b | ||
|
|
caec05df14 | ||
|
|
e387f4ca1c | ||
|
|
6d7ffe6a25 | ||
|
|
ad0c0f6865 | ||
|
|
dd541e5f70 |
12
.cursor/rules/commands.mdc
Normal file
12
.cursor/rules/commands.mdc
Normal file
@@ -0,0 +1,12 @@
|
||||
---
|
||||
alwaysApply: true
|
||||
---
|
||||
|
||||
When running yarn commands - always run them in the root directory. Do not run them in child workspaces.
|
||||
|
||||
The following commands can be useful:
|
||||
|
||||
- `yarn typecheck` to run typechecker
|
||||
- `yarn lint` to run the code linter and formatter
|
||||
- `yarn lint:fix` to fix some of the code lint issues (running this is preferred over `yarn lint`)
|
||||
- `yarn test` to run all the tests
|
||||
37
.cursor/rules/typescript.mdc
Normal file
37
.cursor/rules/typescript.mdc
Normal file
@@ -0,0 +1,37 @@
|
||||
---
|
||||
description:
|
||||
globs: *.ts,*.tsx
|
||||
alwaysApply: false
|
||||
---
|
||||
|
||||
You are an expert in TypeScript and React.
|
||||
|
||||
Code Style and Structure
|
||||
|
||||
- Write concise, technical TypeScript code.
|
||||
- Use functional and declarative programming patterns; avoid classes.
|
||||
- Prefer iteration and modularization over code duplication.
|
||||
- Use descriptive variable names with auxiliary verbs (e.g., isLoaded, hasError).
|
||||
- Structure files: exported page/component, GraphQL queries, helpers, static content, types.
|
||||
- When creating a new component, place it in its own file rather than grouping multiple components in a single file.
|
||||
|
||||
Naming Conventions
|
||||
|
||||
- Favor named exports for components and utilities.
|
||||
|
||||
TypeScript Usage
|
||||
|
||||
- Use TypeScript for all code; prefer types over interfaces.
|
||||
- Avoid enums; use objects or maps instead.
|
||||
- Avoid using `any` or `unknown` unless absolutely necessary. Look for type definitions in the codebase instead.
|
||||
- Avoid type assertions with `as` or `!`; prefer using `satisfies`.
|
||||
|
||||
Syntax and Formatting
|
||||
|
||||
- Use the "function" keyword for pure functions.
|
||||
- Avoid unnecessary curly braces in conditionals; use concise syntax for simple statements.
|
||||
- Use declarative JSX, keeping JSX minimal and readable.
|
||||
|
||||
Change validation
|
||||
|
||||
- Run `yarn typecheck` in the root directory to validate that the generated TypeScript code is correct
|
||||
14
.cursor/rules/unit-tests.mdc
Normal file
14
.cursor/rules/unit-tests.mdc
Normal file
@@ -0,0 +1,14 @@
|
||||
---
|
||||
description:
|
||||
globs:
|
||||
alwaysApply: true
|
||||
---
|
||||
Vitest test runner is used for unit tests.
|
||||
|
||||
When running unit tests, always include the flag `--watch=false` to prevent watch mode.
|
||||
|
||||
To run unit tests for a specific package in the monorepo, use the following command:
|
||||
|
||||
`yarn workspace <workspaceNameFromPackageJson> run test <pathToTest>`
|
||||
|
||||
Recommendation: Minimize the number of dependencies you mock. The fewer dependencies you mock, the better.
|
||||
@@ -1,14 +1,11 @@
|
||||
// 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"
|
||||
"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"
|
||||
}
|
||||
|
||||
@@ -1,28 +0,0 @@
|
||||
packages/api/app/bundle.api.js
|
||||
packages/api/dist
|
||||
packages/api/migrations
|
||||
|
||||
packages/crdt/dist
|
||||
|
||||
packages/desktop-client/bundle.browser.js
|
||||
packages/desktop-client/build/
|
||||
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-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/*
|
||||
|
||||
packages/node-libofx/libofx.*.js
|
||||
packages/node-libofx/libofx/
|
||||
packages/node-libofx/OpenSP-*/
|
||||
208
.eslintrc.js
208
.eslintrc.js
@@ -1,208 +0,0 @@
|
||||
/* eslint-disable rulesdir/typography */
|
||||
const path = require('path');
|
||||
|
||||
const rulesDirPlugin = require('eslint-plugin-rulesdir');
|
||||
rulesDirPlugin.RULES_DIR = path.join(
|
||||
__dirname,
|
||||
'packages',
|
||||
'eslint-plugin-actual',
|
||||
'lib',
|
||||
'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",
|
||||
},
|
||||
];
|
||||
|
||||
module.exports = {
|
||||
plugins: ['prettier', 'import', 'rulesdir', '@typescript-eslint'],
|
||||
extends: ['react-app', 'plugin:@typescript-eslint/recommended'],
|
||||
parser: '@typescript-eslint/parser',
|
||||
parserOptions: { project: [path.join(__dirname, './tsconfig.json')] },
|
||||
reportUnusedDisableDirectives: true,
|
||||
rules: {
|
||||
'prettier/prettier': 'error',
|
||||
'no-unused-vars': 'off',
|
||||
'@typescript-eslint/no-unused-vars': [
|
||||
'error',
|
||||
{
|
||||
args: 'none',
|
||||
varsIgnorePattern: '^_',
|
||||
ignoreRestSiblings: true,
|
||||
},
|
||||
],
|
||||
|
||||
curly: ['error', 'multi-line', 'consistent'],
|
||||
|
||||
'no-restricted-globals': ['error'].concat(
|
||||
require('confusing-browser-globals').filter(g => g !== 'self'),
|
||||
),
|
||||
|
||||
'react/jsx-no-useless-fragment': 'error',
|
||||
'react/self-closing-comp': 'error',
|
||||
|
||||
'rulesdir/typography': 'error',
|
||||
'rulesdir/prefer-if-statement': 'error',
|
||||
|
||||
// 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',
|
||||
// 'react-hooks/exhaustive-deps': [
|
||||
// 'error',
|
||||
// {
|
||||
// additionalHooks: 'useLiveQuery',
|
||||
// },
|
||||
// ],
|
||||
|
||||
'import/extensions': [
|
||||
'error',
|
||||
'never',
|
||||
{
|
||||
json: 'always',
|
||||
},
|
||||
],
|
||||
'import/no-useless-path-segments': 'error',
|
||||
'import/no-duplicates': ['error', { 'prefer-inline': true }],
|
||||
'import/no-unused-modules': ['error', { unusedExports: true }],
|
||||
'import/order': [
|
||||
'error',
|
||||
{
|
||||
alphabetize: {
|
||||
caseInsensitive: true,
|
||||
order: 'asc',
|
||||
},
|
||||
groups: [
|
||||
'builtin', // Built-in types are first
|
||||
'external',
|
||||
'parent',
|
||||
'sibling',
|
||||
'index', // Then the index file
|
||||
],
|
||||
'newlines-between': 'always',
|
||||
pathGroups: [
|
||||
// Enforce that React (and react-related packages) is the first import
|
||||
{ group: 'builtin', pattern: 'react?(-*)', position: 'before' },
|
||||
// Separate imports from Actual from "real" external imports
|
||||
{
|
||||
group: 'external',
|
||||
pattern: 'loot-{core,design}/**/*',
|
||||
position: 'after',
|
||||
},
|
||||
],
|
||||
pathGroupsExcludedImportTypes: ['react'],
|
||||
},
|
||||
],
|
||||
|
||||
'no-restricted-syntax': [
|
||||
'error',
|
||||
{
|
||||
// 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 <LinkButton> or <ExternalLink>
|
||||
selector: 'JSXOpeningElement[name.name="a"]',
|
||||
message:
|
||||
'Using <a> is discouraged, please use <LinkButton> or <ExternalLink> instead.',
|
||||
},
|
||||
],
|
||||
'no-restricted-imports': ['error', { patterns: restrictedImportPatterns }],
|
||||
|
||||
// Rules disable during TS migration
|
||||
'@typescript-eslint/no-var-requires': 'off',
|
||||
'prefer-const': 'off',
|
||||
'prefer-spread': 'off',
|
||||
'@typescript-eslint/no-empty-function': 'off',
|
||||
},
|
||||
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': ['error', 'type'],
|
||||
// enforce import type
|
||||
'@typescript-eslint/consistent-type-imports': [
|
||||
'error',
|
||||
{ prefer: 'type-imports', fixStyle: 'inline-type-imports' },
|
||||
],
|
||||
'@typescript-eslint/ban-types': [
|
||||
'error',
|
||||
{
|
||||
types: {
|
||||
// forbid FC as superflous
|
||||
FunctionComponent: { message: ruleFCMsg },
|
||||
FC: { message: ruleFCMsg },
|
||||
},
|
||||
extendDefaults: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
files: ['./packages/loot-core/src/**/*'],
|
||||
rules: {
|
||||
'no-restricted-imports': [
|
||||
'error',
|
||||
{
|
||||
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' },
|
||||
},
|
||||
],
|
||||
settings: {
|
||||
'import/parsers': {
|
||||
'@typescript-eslint/parser': ['.ts', '.tsx'],
|
||||
},
|
||||
'import/resolver': {
|
||||
typescript: {
|
||||
alwaysTryTypes: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
2
.gitattributes
vendored
2
.gitattributes
vendored
@@ -16,4 +16,4 @@ 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
|
||||
23
.github/ISSUE_TEMPLATE/bug-report.yml
vendored
23
.github/ISSUE_TEMPLATE/bug-report.yml
vendored
@@ -1,13 +1,19 @@
|
||||
name: Bug Report
|
||||
description: File a bug report also known as an issue or problem.
|
||||
title: '[Bug]: '
|
||||
labels: ['bug']
|
||||
labels: ['needs triage', 'bug']
|
||||
type: 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
|
||||
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,8 +22,6 @@ body:
|
||||
options:
|
||||
- label: 'I have searched and found no existing issue'
|
||||
required: true
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: what-happened
|
||||
attributes:
|
||||
@@ -28,14 +32,14 @@ body:
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: errors-received
|
||||
id: reproduction
|
||||
attributes:
|
||||
label: 'What error did you receive?'
|
||||
description: 'If you received an error or a message on the screen, please provide that here.'
|
||||
label: How can we reproduce the issue?
|
||||
description: Please give step-by-step instructions on how to reproduce the issue. In most cases this might also require uploading a sample budget/import file.
|
||||
value: 'How can we reproduce the issue?'
|
||||
validations:
|
||||
required: false
|
||||
required: true
|
||||
- type: markdown
|
||||
id: env-info
|
||||
attributes:
|
||||
value: '## Environment Details'
|
||||
- type: dropdown
|
||||
@@ -47,6 +51,7 @@ body:
|
||||
- Locally via Yarn
|
||||
- Docker
|
||||
- Fly.io
|
||||
- Pikapods
|
||||
- NAS
|
||||
- Desktop App (Electron)
|
||||
- Other
|
||||
|
||||
10
.github/ISSUE_TEMPLATE/config.yml
vendored
10
.github/ISSUE_TEMPLATE/config.yml
vendored
@@ -1 +1,11 @@
|
||||
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.
|
||||
- name: Translations
|
||||
url: https://hosted.weblate.org/projects/actualbudget/actual/
|
||||
about: Found a string that needs a better translation? Add your suggestion or upvote an existing one in Weblate.
|
||||
|
||||
4
.github/ISSUE_TEMPLATE/feature-request.yml
vendored
4
.github/ISSUE_TEMPLATE/feature-request.yml
vendored
@@ -2,9 +2,9 @@ name: Feature request
|
||||
description: Request a missing feature
|
||||
title: '[Feature] '
|
||||
labels: ['feature']
|
||||
type: Feature
|
||||
body:
|
||||
- type: markdown
|
||||
id: intro-md
|
||||
attributes:
|
||||
value: |
|
||||
Thanks for taking the time to fill out this feature request! Please ensure you provide as much information as possible so we can better understand what you’re proposing so we can come up with the best solution for everyone.
|
||||
@@ -16,8 +16,6 @@ body:
|
||||
options:
|
||||
- label: 'I have searched and found no existing issue'
|
||||
required: true
|
||||
validations:
|
||||
required: true
|
||||
- type: checkboxes
|
||||
attributes:
|
||||
label: '💻'
|
||||
|
||||
2
.github/PULL_REQUEST_TEMPLATE.md
vendored
2
.github/PULL_REQUEST_TEMPLATE.md
vendored
@@ -1 +1 @@
|
||||
<!-- Thank you for submitting a pull request! Make sure to follow the instructions to write release notes for your PR — it should only take a minute or two: https://github.com/actualbudget/docs#writing-good-release-notes -->
|
||||
<!-- Thank you for submitting a pull request! Make sure to follow the instructions to write release notes for your PR — it should only take a minute or two: https://github.com/actualbudget/docs#writing-good-release-notes. Try running yarn generate:release-notes *before* pushing your PR for an interactive experience. -->
|
||||
|
||||
75
.github/actions/ai-generated-release-notes/check-first-comment.js
vendored
Executable file
75
.github/actions/ai-generated-release-notes/check-first-comment.js
vendored
Executable file
@@ -0,0 +1,75 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import { Octokit } from '@octokit/rest';
|
||||
import fs from 'fs';
|
||||
|
||||
const token = process.env.GITHUB_TOKEN;
|
||||
const repo = process.env.GITHUB_REPOSITORY;
|
||||
const issueNumber = process.env.GITHUB_EVENT_ISSUE_NUMBER;
|
||||
const commentId = process.env.GITHUB_EVENT_COMMENT_ID;
|
||||
|
||||
if (!token || !repo || !issueNumber || !commentId) {
|
||||
console.log('Missing required environment variables');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const [owner, repoName] = repo.split('/');
|
||||
const octokit = new Octokit({ auth: token });
|
||||
|
||||
function setOutput(name, value) {
|
||||
fs.appendFileSync(process.env.GITHUB_OUTPUT, `${name}=${value}\n`);
|
||||
}
|
||||
|
||||
async function checkFirstComment() {
|
||||
try {
|
||||
console.log('Fetching comments with Octokit...');
|
||||
|
||||
// Get all comments with automatic pagination
|
||||
const comments = await octokit.paginate(octokit.rest.issues.listComments, {
|
||||
owner,
|
||||
repo: repoName,
|
||||
issue_number: issueNumber,
|
||||
});
|
||||
|
||||
console.log(`Total comments found: ${comments.length}`);
|
||||
|
||||
// Filter for CodeRabbit summary comments (containing the specific marker)
|
||||
const coderabbitSummaryComments = comments.filter(comment => {
|
||||
const isCodeRabbit = comment.user.login === 'coderabbitai[bot]';
|
||||
const hasSummaryMarker = comment.body.includes(
|
||||
'<!-- This is an auto-generated comment: summarize by coderabbit.ai -->',
|
||||
);
|
||||
|
||||
if (isCodeRabbit) {
|
||||
console.log(
|
||||
`CodeRabbit comment found (ID: ${comment.id}), has summary marker: ${hasSummaryMarker}`,
|
||||
);
|
||||
}
|
||||
|
||||
return isCodeRabbit && hasSummaryMarker;
|
||||
});
|
||||
|
||||
const isFirstSummaryComment =
|
||||
coderabbitSummaryComments.length === 1 &&
|
||||
coderabbitSummaryComments[0].id == commentId;
|
||||
|
||||
console.log(
|
||||
`CodeRabbit summary comments found: ${coderabbitSummaryComments.length}`,
|
||||
);
|
||||
console.log(`Current comment ID: ${commentId}`);
|
||||
console.log(`Is first summary comment: ${isFirstSummaryComment}`);
|
||||
setOutput('result', isFirstSummaryComment);
|
||||
} catch (error) {
|
||||
console.log('Error checking CodeRabbit comment:', error.message);
|
||||
console.log('Stack:', error.stack);
|
||||
setOutput('result', 'false');
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
checkFirstComment().catch(error => {
|
||||
console.log('Unhandled error:', error.message);
|
||||
console.log('Stack:', error.stack);
|
||||
setOutput('result', 'false');
|
||||
process.exit(1);
|
||||
});
|
||||
76
.github/actions/ai-generated-release-notes/check-release-notes-exists.js
vendored
Executable file
76
.github/actions/ai-generated-release-notes/check-release-notes-exists.js
vendored
Executable file
@@ -0,0 +1,76 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import { Octokit } from '@octokit/rest';
|
||||
import fs from 'fs';
|
||||
|
||||
const token = process.env.GITHUB_TOKEN;
|
||||
const repo = process.env.GITHUB_REPOSITORY;
|
||||
const issueNumber = process.env.GITHUB_EVENT_ISSUE_NUMBER;
|
||||
const prDetailsJson = process.env.PR_DETAILS;
|
||||
|
||||
if (!token || !repo || !issueNumber || !prDetailsJson) {
|
||||
console.log('Missing required environment variables');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const [owner, repoName] = repo.split('/');
|
||||
const octokit = new Octokit({ auth: token });
|
||||
|
||||
function setOutput(name, value) {
|
||||
fs.appendFileSync(process.env.GITHUB_OUTPUT, `${name}=${value}\n`);
|
||||
}
|
||||
|
||||
async function checkReleaseNotesExists() {
|
||||
try {
|
||||
const prDetails = JSON.parse(prDetailsJson);
|
||||
if (!prDetails) {
|
||||
console.log('No PR details available, skipping file check');
|
||||
setOutput('result', 'false');
|
||||
return;
|
||||
}
|
||||
|
||||
const fileName = `upcoming-release-notes/${prDetails.number}.md`;
|
||||
|
||||
// Get PR info to get head SHA
|
||||
const { data: pr } = await octokit.rest.pulls.get({
|
||||
owner,
|
||||
repo: repoName,
|
||||
pull_number: issueNumber,
|
||||
});
|
||||
|
||||
const prHeadSha = pr.head.sha;
|
||||
console.log(
|
||||
`Checking for file on PR branch: ${pr.head.ref} (${prHeadSha})`,
|
||||
);
|
||||
|
||||
// Check if file exists
|
||||
try {
|
||||
await octokit.rest.repos.getContent({
|
||||
owner,
|
||||
repo: repoName,
|
||||
path: fileName,
|
||||
ref: prHeadSha,
|
||||
});
|
||||
|
||||
console.log(
|
||||
`Release notes file already exists on PR branch: ${fileName}`,
|
||||
);
|
||||
setOutput('result', 'true');
|
||||
} catch (error) {
|
||||
if (error.status === 404) {
|
||||
console.log(
|
||||
`No existing release notes file found on PR branch: ${fileName}`,
|
||||
);
|
||||
setOutput('result', 'false');
|
||||
} else {
|
||||
console.log('Error checking file existence:', error.message);
|
||||
setOutput('result', 'false');
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.log('Error in file existence check:', error.message);
|
||||
setOutput('result', 'false');
|
||||
}
|
||||
}
|
||||
|
||||
checkReleaseNotesExists();
|
||||
77
.github/actions/ai-generated-release-notes/comment-on-pr.js
vendored
Executable file
77
.github/actions/ai-generated-release-notes/comment-on-pr.js
vendored
Executable file
@@ -0,0 +1,77 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import { Octokit } from '@octokit/rest';
|
||||
|
||||
const token = process.env.GITHUB_TOKEN;
|
||||
const repo = process.env.GITHUB_REPOSITORY;
|
||||
const issueNumber = process.env.GITHUB_EVENT_ISSUE_NUMBER;
|
||||
const summaryDataJson = process.env.SUMMARY_DATA;
|
||||
const category = process.env.CATEGORY;
|
||||
|
||||
if (!token || !repo || !issueNumber || !summaryDataJson || !category) {
|
||||
console.log('Missing required environment variables');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const [owner, repoName] = repo.split('/');
|
||||
const octokit = new Octokit({ auth: token });
|
||||
|
||||
async function commentOnPR() {
|
||||
try {
|
||||
const summaryData = JSON.parse(summaryDataJson);
|
||||
|
||||
if (!summaryData) {
|
||||
console.log('No summary data available, skipping comment');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!category || category === 'null') {
|
||||
console.log('No valid category available, skipping comment');
|
||||
return;
|
||||
}
|
||||
|
||||
// Clean category for display
|
||||
const cleanCategory =
|
||||
typeof category === 'string'
|
||||
? category.replace(/^["']|["']$/g, '')
|
||||
: category;
|
||||
|
||||
// Get PR info for the file URL
|
||||
const { data: pr } = await octokit.rest.pulls.get({
|
||||
owner,
|
||||
repo: repoName,
|
||||
pull_number: issueNumber,
|
||||
});
|
||||
|
||||
const prBranch = pr.head.ref;
|
||||
const headOwner = pr.head.repo.owner.login;
|
||||
const headRepo = pr.head.repo.name;
|
||||
const fileUrl = `https://github.com/${headOwner}/${headRepo}/blob/${prBranch}/upcoming-release-notes/${summaryData.prNumber}.md`;
|
||||
|
||||
const commentBody = [
|
||||
'🤖 **Auto-generated Release Notes**',
|
||||
'',
|
||||
`Hey @${summaryData.author}! I've automatically created a release notes file based on CodeRabbit's analysis:`,
|
||||
'',
|
||||
`**Category:** ${cleanCategory}`,
|
||||
`**Summary:** ${summaryData.summary}`,
|
||||
`**File:** [upcoming-release-notes/${summaryData.prNumber}.md](${fileUrl})`,
|
||||
'',
|
||||
// 'The release notes file has been committed to the repository. You can edit it if needed before merging.',
|
||||
"If you're happy with this release note, you can add it to your pull request. If not, you'll need to add your own before a maintainer can review your change.",
|
||||
].join('\n');
|
||||
|
||||
await octokit.rest.issues.createComment({
|
||||
owner,
|
||||
repo: repoName,
|
||||
issue_number: issueNumber,
|
||||
body: commentBody,
|
||||
});
|
||||
|
||||
console.log('✅ Successfully commented on PR');
|
||||
} catch (error) {
|
||||
console.log('Error commenting on PR:', error.message);
|
||||
}
|
||||
}
|
||||
|
||||
commentOnPR();
|
||||
96
.github/actions/ai-generated-release-notes/create-release-notes-file.js
vendored
Executable file
96
.github/actions/ai-generated-release-notes/create-release-notes-file.js
vendored
Executable file
@@ -0,0 +1,96 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import { Octokit } from '@octokit/rest';
|
||||
|
||||
const token = process.env.GITHUB_TOKEN;
|
||||
const repo = process.env.GITHUB_REPOSITORY;
|
||||
const issueNumber = process.env.GITHUB_EVENT_ISSUE_NUMBER;
|
||||
const summaryDataJson = process.env.SUMMARY_DATA;
|
||||
const category = process.env.CATEGORY;
|
||||
|
||||
if (!token || !repo || !issueNumber || !summaryDataJson || !category) {
|
||||
console.log('Missing required environment variables');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const [owner, repoName] = repo.split('/');
|
||||
const octokit = new Octokit({ auth: token });
|
||||
|
||||
async function createReleaseNotesFile() {
|
||||
try {
|
||||
const summaryData = JSON.parse(summaryDataJson);
|
||||
|
||||
console.log('Debug - Category value:', category);
|
||||
console.log('Debug - Category type:', typeof category);
|
||||
console.log('Debug - Category JSON stringified:', JSON.stringify(category));
|
||||
|
||||
if (!summaryData) {
|
||||
console.log('No summary data available, cannot create file');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!category || category === 'null') {
|
||||
console.log('No valid category available, cannot create file');
|
||||
return;
|
||||
}
|
||||
|
||||
// Create file content - ensure category is not quoted
|
||||
const cleanCategory =
|
||||
typeof category === 'string'
|
||||
? category.replace(/^["']|["']$/g, '')
|
||||
: category;
|
||||
console.log('Debug - Clean category:', cleanCategory);
|
||||
|
||||
const fileContent = `---
|
||||
category: ${cleanCategory}
|
||||
authors: [${summaryData.author}]
|
||||
---
|
||||
|
||||
${summaryData.summary}`;
|
||||
|
||||
const fileName = `upcoming-release-notes/${summaryData.prNumber}.md`;
|
||||
|
||||
console.log(`Creating release notes file: ${fileName}`);
|
||||
console.log('File content:');
|
||||
console.log(fileContent);
|
||||
|
||||
// Get PR info
|
||||
const { data: pr } = await octokit.rest.pulls.get({
|
||||
owner,
|
||||
repo: repoName,
|
||||
pull_number: issueNumber,
|
||||
});
|
||||
|
||||
const prBranch = pr.head.ref;
|
||||
const headOwner = pr.head.repo.owner.login;
|
||||
const headRepo = pr.head.repo.name;
|
||||
|
||||
console.log(
|
||||
`Committing to PR branch: ${headOwner}/${headRepo}:${prBranch}`,
|
||||
);
|
||||
|
||||
// Create the file via GitHub API on the PR branch
|
||||
await octokit.rest.repos.createOrUpdateFileContents({
|
||||
owner: headOwner,
|
||||
repo: headRepo,
|
||||
path: fileName,
|
||||
message: `Add release notes for PR #${summaryData.prNumber}`,
|
||||
content: Buffer.from(`${fileContent}\n\n`).toString('base64'),
|
||||
branch: prBranch,
|
||||
committer: {
|
||||
name: 'github-actions[bot]',
|
||||
email: 'github-actions[bot]@users.noreply.github.com',
|
||||
},
|
||||
author: {
|
||||
name: 'github-actions[bot]',
|
||||
email: 'github-actions[bot]@users.noreply.github.com',
|
||||
},
|
||||
});
|
||||
|
||||
console.log(`✅ Successfully created release notes file: ${fileName}`);
|
||||
} catch (error) {
|
||||
console.log('Error creating release notes file:', error.message);
|
||||
}
|
||||
}
|
||||
|
||||
createReleaseNotesFile();
|
||||
118
.github/actions/ai-generated-release-notes/determine-category.js
vendored
Executable file
118
.github/actions/ai-generated-release-notes/determine-category.js
vendored
Executable file
@@ -0,0 +1,118 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
const https = require('https');
|
||||
const fs = require('fs');
|
||||
|
||||
const commentBody = process.env.GITHUB_EVENT_COMMENT_BODY;
|
||||
const prDetailsJson = process.env.PR_DETAILS;
|
||||
const summaryDataJson = process.env.SUMMARY_DATA;
|
||||
const openaiApiKey = process.env.OPENAI_API_KEY;
|
||||
|
||||
if (!commentBody || !prDetailsJson || !summaryDataJson || !openaiApiKey) {
|
||||
console.log('Missing required environment variables');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
function setOutput(name, value) {
|
||||
fs.appendFileSync(process.env.GITHUB_OUTPUT, `${name}=${value}\n`);
|
||||
}
|
||||
|
||||
try {
|
||||
const prDetails = JSON.parse(prDetailsJson);
|
||||
const summaryData = JSON.parse(summaryDataJson);
|
||||
|
||||
if (!summaryData || !prDetails) {
|
||||
console.log('Missing data for categorization');
|
||||
setOutput('result', 'null');
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
const data = JSON.stringify({
|
||||
model: 'gpt-4o-mini',
|
||||
messages: [
|
||||
{
|
||||
role: 'system',
|
||||
content:
|
||||
'You are categorizing pull requests for release notes. You must respond with exactly one of these categories: "Features", "Enhancements", "Bugfix", or "Maintenance". No other text or explanation.',
|
||||
},
|
||||
{
|
||||
role: 'user',
|
||||
content: `PR Title: ${prDetails.title}\n\nGenerated Summary: ${summaryData.summary}\n\nCodeRabbit Analysis:\n${commentBody}\n\nCategories:\n- Features: New functionality or capabilities\n- Bugfix: Fixes for broken or incorrect behavior\n- Enhancements: Improvements to existing functionality\n- Maintenance: Code cleanup, refactoring, dependencies, etc.\n\nWhat category does this PR belong to?`,
|
||||
},
|
||||
],
|
||||
max_tokens: 10,
|
||||
temperature: 0.1,
|
||||
});
|
||||
|
||||
const options = {
|
||||
hostname: 'api.openai.com',
|
||||
path: '/v1/chat/completions',
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${openaiApiKey}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
};
|
||||
|
||||
const req = https.request(options, res => {
|
||||
let responseData = '';
|
||||
res.on('data', chunk => (responseData += chunk));
|
||||
res.on('end', () => {
|
||||
if (res.statusCode !== 200) {
|
||||
console.log('OpenAI API error for categorization');
|
||||
setOutput('result', 'null');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = JSON.parse(responseData);
|
||||
console.log('OpenAI raw response:', JSON.stringify(response, null, 2));
|
||||
|
||||
const rawContent = response.choices[0].message.content.trim();
|
||||
console.log('Raw content from OpenAI:', rawContent);
|
||||
|
||||
let category;
|
||||
try {
|
||||
category = JSON.parse(rawContent);
|
||||
console.log('Parsed category:', category);
|
||||
} catch (parseError) {
|
||||
console.log(
|
||||
'JSON parse error, using raw content:',
|
||||
parseError.message,
|
||||
);
|
||||
category = rawContent;
|
||||
}
|
||||
|
||||
// Validate the category response
|
||||
const validCategories = [
|
||||
'Features',
|
||||
'Bugfix',
|
||||
'Enhancements',
|
||||
'Maintenance',
|
||||
];
|
||||
if (validCategories.includes(category)) {
|
||||
console.log('OpenAI categorized as:', category);
|
||||
setOutput('result', category);
|
||||
} else {
|
||||
console.log('Invalid category from OpenAI:', category);
|
||||
console.log('Valid categories are:', validCategories);
|
||||
setOutput('result', 'null');
|
||||
}
|
||||
} catch (error) {
|
||||
console.log('Error parsing OpenAI response:', error.message);
|
||||
setOutput('result', 'null');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
req.on('error', error => {
|
||||
console.log('Error in categorization:', error.message);
|
||||
setOutput('result', 'null');
|
||||
});
|
||||
|
||||
req.write(data);
|
||||
req.end();
|
||||
} catch (error) {
|
||||
console.log('Error in categorization:', error.message);
|
||||
setOutput('result', 'null');
|
||||
}
|
||||
97
.github/actions/ai-generated-release-notes/generate-summary.js
vendored
Executable file
97
.github/actions/ai-generated-release-notes/generate-summary.js
vendored
Executable file
@@ -0,0 +1,97 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
const https = require('https');
|
||||
const fs = require('fs');
|
||||
|
||||
const commentBody = process.env.GITHUB_EVENT_COMMENT_BODY;
|
||||
const prDetailsJson = process.env.PR_DETAILS;
|
||||
const openaiApiKey = process.env.OPENAI_API_KEY;
|
||||
|
||||
if (!commentBody || !prDetailsJson || !openaiApiKey) {
|
||||
console.log('Missing required environment variables');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
function setOutput(name, value) {
|
||||
fs.appendFileSync(process.env.GITHUB_OUTPUT, `${name}=${value}\n`);
|
||||
}
|
||||
|
||||
try {
|
||||
const prDetails = JSON.parse(prDetailsJson);
|
||||
|
||||
if (!prDetails) {
|
||||
console.log('No PR details available, cannot generate summary');
|
||||
setOutput('result', 'null');
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
console.log('CodeRabbit comment body:', commentBody);
|
||||
|
||||
const data = JSON.stringify({
|
||||
model: 'gpt-4o-mini',
|
||||
messages: [
|
||||
{
|
||||
role: 'system',
|
||||
content:
|
||||
'You are a technical writer helping to create concise release notes. Generate a maximum 15-word summary that describes what this PR does. Focus on the user-facing changes or bug fixes. Do not include "This PR" or similar phrases - just describe the change directly. Start with a base form verb (e.g., "Add" not "Adds", "Fix" not "Fixes", "Introduce" not "Introduces").',
|
||||
},
|
||||
{
|
||||
role: 'user',
|
||||
content: `PR Title: ${prDetails.title}\n\nCodeRabbit Analysis:\n${commentBody}\n\nPlease provide a concise summary (max 15 words) of what this PR accomplishes.`,
|
||||
},
|
||||
],
|
||||
max_tokens: 50,
|
||||
temperature: 0.3,
|
||||
});
|
||||
|
||||
const options = {
|
||||
hostname: 'api.openai.com',
|
||||
path: '/v1/chat/completions',
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${openaiApiKey}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
};
|
||||
|
||||
const req = https.request(options, res => {
|
||||
let responseData = '';
|
||||
res.on('data', chunk => (responseData += chunk));
|
||||
res.on('end', () => {
|
||||
if (res.statusCode !== 200) {
|
||||
console.log(`OpenAI API error: ${res.statusCode} ${res.statusMessage}`);
|
||||
setOutput('result', 'null');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = JSON.parse(responseData);
|
||||
const summary = response.choices[0].message.content.trim();
|
||||
|
||||
console.log('Generated summary:', summary);
|
||||
|
||||
const result = {
|
||||
summary: summary,
|
||||
prNumber: prDetails.number,
|
||||
author: prDetails.author,
|
||||
};
|
||||
|
||||
setOutput('result', JSON.stringify(result));
|
||||
} catch (error) {
|
||||
console.log('Error parsing OpenAI response:', error.message);
|
||||
setOutput('result', 'null');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
req.on('error', error => {
|
||||
console.log('Error generating summary:', error.message);
|
||||
setOutput('result', 'null');
|
||||
});
|
||||
|
||||
req.write(data);
|
||||
req.end();
|
||||
} catch (error) {
|
||||
console.log('Error generating summary:', error.message);
|
||||
setOutput('result', 'null');
|
||||
}
|
||||
59
.github/actions/ai-generated-release-notes/pr-details.js
vendored
Executable file
59
.github/actions/ai-generated-release-notes/pr-details.js
vendored
Executable file
@@ -0,0 +1,59 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import { Octokit } from '@octokit/rest';
|
||||
import fs from 'fs';
|
||||
|
||||
const token = process.env.GITHUB_TOKEN;
|
||||
const repo = process.env.GITHUB_REPOSITORY;
|
||||
const issueNumber = process.env.GITHUB_EVENT_ISSUE_NUMBER;
|
||||
|
||||
if (!token || !repo || !issueNumber) {
|
||||
console.log('Missing required environment variables');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const [owner, repoName] = repo.split('/');
|
||||
const octokit = new Octokit({ auth: token });
|
||||
|
||||
function setOutput(name, value) {
|
||||
fs.appendFileSync(process.env.GITHUB_OUTPUT, `${name}=${value}\n`);
|
||||
}
|
||||
|
||||
async function getPRDetails() {
|
||||
try {
|
||||
console.log(
|
||||
`Fetching PR details for ${owner}/${repoName}#${issueNumber}...`,
|
||||
);
|
||||
|
||||
const { data: pr } = await octokit.rest.pulls.get({
|
||||
owner,
|
||||
repo: repoName,
|
||||
pull_number: issueNumber,
|
||||
});
|
||||
|
||||
console.log('PR details fetched successfully');
|
||||
console.log('- PR Number:', pr.number);
|
||||
console.log('- PR Author:', pr.user.login);
|
||||
console.log('- PR Title:', pr.title);
|
||||
|
||||
const result = {
|
||||
number: pr.number,
|
||||
author: pr.user.login,
|
||||
title: pr.title,
|
||||
};
|
||||
|
||||
setOutput('result', JSON.stringify(result));
|
||||
} catch (error) {
|
||||
console.log('Error getting PR details:', error.message);
|
||||
console.log('Stack:', error.stack);
|
||||
setOutput('result', 'null');
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
getPRDetails().catch(error => {
|
||||
console.log('Unhandled error:', error.message);
|
||||
console.log('Stack:', error.stack);
|
||||
setOutput('result', 'null');
|
||||
process.exit(1);
|
||||
});
|
||||
6
.github/actions/netlify-wait-for-build
vendored
6
.github/actions/netlify-wait-for-build
vendored
@@ -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"
|
||||
|
||||
40
.github/actions/setup/action.yml
vendored
40
.github/actions/setup/action.yml
vendored
@@ -1,19 +1,49 @@
|
||||
name: Setup
|
||||
description: Setup the environment for the project
|
||||
|
||||
inputs:
|
||||
working-directory:
|
||||
description: 'Working directory to run in, default .'
|
||||
required: false
|
||||
default: '.'
|
||||
download-translations:
|
||||
description: 'Whether to download translations as part of setup, default true'
|
||||
required: false
|
||||
default: 'true'
|
||||
|
||||
runs:
|
||||
using: composite
|
||||
steps:
|
||||
- name: Install node
|
||||
uses: actions/setup-node@v3
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 18.16.0
|
||||
node-version: 20
|
||||
- name: Install yarn
|
||||
run: npm install -g yarn
|
||||
shell: bash
|
||||
if: ${{ env.ACT }}
|
||||
- name: Get Node version
|
||||
id: get-node
|
||||
run: echo "version=$(node -v)" >> "$GITHUB_OUTPUT"
|
||||
shell: bash
|
||||
- name: Cache
|
||||
uses: actions/cache@v3
|
||||
uses: actions/cache@v4
|
||||
id: cache
|
||||
with:
|
||||
path: '**/node_modules'
|
||||
key: yarn-v1-${{ runner.os }}-${{ hashFiles('.nvmrc') }}-${{ hashFiles('**/yarn.lock') }}
|
||||
path: ${{ format('{0}/**/node_modules', inputs.working-directory) }}
|
||||
key: yarn-v1-${{ runner.os }}-${{ steps.get-node.outputs.version }}-${{ hashFiles(format('{0}/**/yarn.lock', inputs.working-directory)) }}
|
||||
- name: Install
|
||||
working-directory: ${{ inputs.working-directory }}
|
||||
run: yarn --immutable
|
||||
shell: bash
|
||||
if: steps.cache.outputs.cache-hit != 'true'
|
||||
- name: Download translations
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
repository: actualbudget/translations
|
||||
path: ${{ inputs.working-directory }}/packages/desktop-client/locale
|
||||
if: ${{ inputs.download-translations == 'true' }}
|
||||
- name: Remove untranslated languages
|
||||
run: packages/desktop-client/bin/remove-untranslated-languages
|
||||
shell: bash
|
||||
if: ${{ inputs.download-translations == 'true' }}
|
||||
|
||||
363
.github/scripts/count-points.mjs
vendored
Normal file
363
.github/scripts/count-points.mjs
vendored
Normal file
@@ -0,0 +1,363 @@
|
||||
import { Octokit } from '@octokit/rest';
|
||||
import { minimatch } from 'minimatch';
|
||||
import pLimit from 'p-limit';
|
||||
|
||||
const limit = pLimit(50);
|
||||
|
||||
/** Repository-specific configuration for points calculation */
|
||||
const REPOSITORY_CONFIG = new Map([
|
||||
[
|
||||
'actual',
|
||||
{
|
||||
POINTS_PER_ISSUE_TRIAGE_ACTION: 1,
|
||||
POINTS_PER_ISSUE_CLOSING_ACTION: 1,
|
||||
POINTS_PER_RELEASE_PR: 0,
|
||||
PR_REVIEW_POINT_TIERS: [
|
||||
{ minChanges: 500, points: 8 },
|
||||
{ minChanges: 100, points: 6 },
|
||||
{ minChanges: 10, points: 2 },
|
||||
{ minChanges: 0, points: 1 },
|
||||
],
|
||||
EXCLUDED_FILES: [
|
||||
'yarn.lock',
|
||||
'.yarn/**/*',
|
||||
'packages/component-library/src/icons/**/*',
|
||||
'release-notes/**/*',
|
||||
],
|
||||
},
|
||||
],
|
||||
[
|
||||
'docs',
|
||||
{
|
||||
POINTS_PER_ISSUE_TRIAGE_ACTION: 1,
|
||||
POINTS_PER_ISSUE_CLOSING_ACTION: 1,
|
||||
POINTS_PER_RELEASE_PR: 4,
|
||||
PR_REVIEW_POINT_TIERS: [
|
||||
{ minChanges: 2000, points: 6 },
|
||||
{ minChanges: 200, points: 4 },
|
||||
{ minChanges: 0, points: 2 },
|
||||
],
|
||||
EXCLUDED_FILES: ['yarn.lock', '.yarn/**/*'],
|
||||
},
|
||||
],
|
||||
]);
|
||||
|
||||
/**
|
||||
* Get the start and end dates for the last month.
|
||||
* @returns {Object} An object containing the start and end dates.
|
||||
*/
|
||||
function getLastMonthDates() {
|
||||
// Get data relating to the last month
|
||||
const now = new Date();
|
||||
// Always use UTC for calculations
|
||||
const firstDayOfLastMonth = new Date(
|
||||
Date.UTC(now.getUTCFullYear(), now.getUTCMonth() - 1, 1, 0, 0, 0, 0),
|
||||
);
|
||||
const since = process.env.START_DATE
|
||||
? new Date(Date.parse(process.env.START_DATE))
|
||||
: firstDayOfLastMonth;
|
||||
|
||||
// Calculate the end of the month for the since date in UTC
|
||||
const until = new Date(
|
||||
Date.UTC(
|
||||
since.getUTCFullYear(),
|
||||
since.getUTCMonth() + 1,
|
||||
0,
|
||||
23,
|
||||
59,
|
||||
59,
|
||||
999,
|
||||
),
|
||||
);
|
||||
|
||||
return { since, until };
|
||||
}
|
||||
|
||||
/**
|
||||
* Used for calculating the monthly points each core contributor has earned.
|
||||
* These are used for payouts depending.
|
||||
* @param {string} repo - The repository to analyze ('actual' or 'docs')
|
||||
* @returns {number} The total points earned for the repository
|
||||
*/
|
||||
async function countContributorPoints(repo) {
|
||||
const octokit = new Octokit({
|
||||
auth: process.env.GITHUB_TOKEN,
|
||||
});
|
||||
const owner = 'actualbudget';
|
||||
const config = REPOSITORY_CONFIG.get(repo);
|
||||
|
||||
const { since, until } = getLastMonthDates();
|
||||
|
||||
// Get organization members
|
||||
const { data: orgMembers } = await octokit.orgs.listMembers({
|
||||
org: owner,
|
||||
});
|
||||
const orgMemberLogins = new Set(orgMembers.map(member => member.login));
|
||||
|
||||
// Initialize stats map with all org members
|
||||
const stats = new Map(
|
||||
Array.from(orgMemberLogins).map(login => [
|
||||
login,
|
||||
{
|
||||
reviews: [], // Will store objects with PR number and points
|
||||
labelRemovals: [],
|
||||
issueClosings: [],
|
||||
points: 0,
|
||||
},
|
||||
]),
|
||||
);
|
||||
|
||||
// Helper function to print statistics
|
||||
const printStats = (title, getValue, formatLine) => {
|
||||
console.log(`\n${title}:`);
|
||||
console.log('='.repeat(title.length + 1));
|
||||
|
||||
const entries = Array.from(stats.entries())
|
||||
.map(([user, userStats]) => [user, getValue(userStats)])
|
||||
.filter(([, count]) => count > 0)
|
||||
.sort((a, b) => b[1] - a[1]);
|
||||
|
||||
if (entries.length === 0) {
|
||||
console.log(`No ${title.toLowerCase()} found in the last month.`);
|
||||
} else {
|
||||
entries.forEach(([user, count]) => {
|
||||
console.log(formatLine(user, count));
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// Get all PRs using search
|
||||
const searchQuery = `repo:${owner}/${repo} is:pr is:merged merged:${since.toISOString()}..${until.toISOString()}`;
|
||||
const recentPRs = await octokit.paginate(
|
||||
'GET /search/issues',
|
||||
{
|
||||
q: searchQuery,
|
||||
per_page: 100,
|
||||
advanced_search: true,
|
||||
},
|
||||
response => response.data.filter(pr => pr.number),
|
||||
);
|
||||
|
||||
// Get reviews and PR details for each PR
|
||||
await Promise.all(
|
||||
recentPRs.map(pr =>
|
||||
limit(async () => {
|
||||
const [reviews, modifiedFiles] = await Promise.all([
|
||||
octokit.pulls.listReviews({ owner, repo, pull_number: pr.number }),
|
||||
octokit.paginate(
|
||||
octokit.pulls.listFiles,
|
||||
{
|
||||
owner,
|
||||
repo,
|
||||
pull_number: pr.number,
|
||||
per_page: 100,
|
||||
},
|
||||
res => res.data,
|
||||
),
|
||||
]);
|
||||
|
||||
const totalChanges = modifiedFiles
|
||||
.filter(
|
||||
file =>
|
||||
!config.EXCLUDED_FILES.some(pattern =>
|
||||
minimatch(file.filename, pattern),
|
||||
),
|
||||
)
|
||||
.reduce((sum, file) => sum + file.additions + file.deletions, 0);
|
||||
|
||||
const isReleasePR = pr.title.match(/🔖.*\d+\.\d+\.\d+/);
|
||||
const prPoints =
|
||||
config.PR_REVIEW_POINT_TIERS.find(t => totalChanges >= t.minChanges)
|
||||
?.points ?? 0;
|
||||
|
||||
if (isReleasePR) {
|
||||
if (stats.has(pr.user.login)) {
|
||||
const creatorStats = stats.get(pr.user.login);
|
||||
creatorStats.reviews.push({
|
||||
pr: pr.number.toString(),
|
||||
points: config.POINTS_PER_RELEASE_PR,
|
||||
isReleaseCreator: true,
|
||||
});
|
||||
creatorStats.points += config.POINTS_PER_RELEASE_PR;
|
||||
}
|
||||
} else {
|
||||
const uniqueReviewers = new Set();
|
||||
reviews.data
|
||||
.filter(
|
||||
review =>
|
||||
stats.has(review.user?.login) &&
|
||||
review.state === 'APPROVED' &&
|
||||
!uniqueReviewers.has(review.user?.login),
|
||||
)
|
||||
.forEach(({ user: { login: reviewer } }) => {
|
||||
uniqueReviewers.add(reviewer);
|
||||
const userStats = stats.get(reviewer);
|
||||
userStats.reviews.push({
|
||||
pr: pr.number.toString(),
|
||||
points: prPoints,
|
||||
});
|
||||
userStats.points += prPoints;
|
||||
});
|
||||
}
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
// Get all issues with label events in the last month
|
||||
const issues = await octokit.paginate(octokit.issues.listForRepo, {
|
||||
owner,
|
||||
repo,
|
||||
state: 'all',
|
||||
sort: 'updated',
|
||||
direction: 'desc',
|
||||
per_page: 100,
|
||||
since: since.toISOString(),
|
||||
});
|
||||
|
||||
// Get label events for each issue
|
||||
await Promise.all(
|
||||
issues.map(issue =>
|
||||
limit(async () => {
|
||||
const { data: events } = await octokit.issues.listEventsForTimeline({
|
||||
owner,
|
||||
repo,
|
||||
issue_number: issue.number,
|
||||
});
|
||||
|
||||
events
|
||||
.filter(event => {
|
||||
const createdAt = new Date(event.created_at);
|
||||
return (
|
||||
createdAt.getTime() > since.getTime() &&
|
||||
createdAt.getTime() <= until.getTime() &&
|
||||
stats.has(event.actor?.login)
|
||||
);
|
||||
})
|
||||
.forEach(event => {
|
||||
if (
|
||||
event.event === 'unlabeled' &&
|
||||
event.label?.name.toLowerCase() === 'needs triage'
|
||||
) {
|
||||
const remover = event.actor.login;
|
||||
const userStats = stats.get(remover);
|
||||
userStats.labelRemovals.push(issue.number.toString());
|
||||
userStats.points += config.POINTS_PER_ISSUE_TRIAGE_ACTION;
|
||||
}
|
||||
|
||||
if (
|
||||
event.event === 'closed' &&
|
||||
event.state_reason === 'not_planned'
|
||||
) {
|
||||
const closer = event.actor.login;
|
||||
const userStats = stats.get(closer);
|
||||
userStats.issueClosings.push(issue.number.toString());
|
||||
userStats.points += config.POINTS_PER_ISSUE_CLOSING_ACTION;
|
||||
}
|
||||
});
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
// Print all statistics
|
||||
printStats(
|
||||
`PR Review Statistics (${repo})`,
|
||||
stats => stats.reviews.length,
|
||||
(user, count) =>
|
||||
`${user}: ${count} (PRs: ${stats
|
||||
.get(user)
|
||||
.reviews.map(r => {
|
||||
if (r.isReleaseCreator) {
|
||||
return `#${r.pr} (${r.points}pts - Release Creator)`;
|
||||
}
|
||||
return `#${r.pr} (${r.points}pts)`;
|
||||
})
|
||||
.join(', ')})`,
|
||||
);
|
||||
printStats(
|
||||
`"Needs Triage" Label Removal Statistics (${repo})`,
|
||||
stats => stats.labelRemovals.length,
|
||||
(user, count) =>
|
||||
`${user}: ${count} (Issues: ${stats.get(user).labelRemovals.join(', ')})`,
|
||||
);
|
||||
printStats(
|
||||
`Issue Closing Statistics (${repo})`,
|
||||
stats => stats.issueClosings.length,
|
||||
(user, count) =>
|
||||
`${user}: ${count} (Issues: ${stats.get(user).issueClosings.join(', ')})`,
|
||||
);
|
||||
|
||||
// Print points summary
|
||||
printStats(
|
||||
`Points Summary (${repo})`,
|
||||
stats => stats.points,
|
||||
(user, userPoints) => `${user}: ${userPoints}`,
|
||||
);
|
||||
|
||||
// Calculate and print total points
|
||||
const totalPoints = Array.from(stats.values()).reduce(
|
||||
(sum, userStats) => sum + userStats.points,
|
||||
0,
|
||||
);
|
||||
console.log(`\nTotal points earned for ${repo}: ${totalPoints}`);
|
||||
|
||||
// Return the points
|
||||
return new Map(
|
||||
Array.from(stats.entries()).map(([login, userStats]) => [
|
||||
login,
|
||||
userStats.points,
|
||||
]),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate the points for both repositories and print cumulative results
|
||||
*/
|
||||
async function calculateCumulativePoints() {
|
||||
// Get stats for each repository
|
||||
const repoPointsResults = await Promise.all(
|
||||
Array.from(REPOSITORY_CONFIG.keys()).map(countContributorPoints),
|
||||
);
|
||||
|
||||
// Calculate cumulative stats
|
||||
const cumulativeStats = new Map(repoPointsResults[0]);
|
||||
|
||||
// Combine stats from all repositories
|
||||
for (let i = 1; i < repoPointsResults.length; i++) {
|
||||
for (const [login, points] of repoPointsResults[i].entries()) {
|
||||
if (!cumulativeStats.has(login)) {
|
||||
cumulativeStats.set(login, 0);
|
||||
}
|
||||
|
||||
cumulativeStats.set(login, cumulativeStats.get(login) + points);
|
||||
}
|
||||
}
|
||||
|
||||
// Print cumulative statistics
|
||||
console.log('\n\nCUMULATIVE STATISTICS ACROSS ALL REPOSITORIES');
|
||||
console.log('='.repeat(50));
|
||||
|
||||
console.log('\nCumulative Points Summary:');
|
||||
console.log('='.repeat('Cumulative Points Summary'.length + 1));
|
||||
|
||||
const entries = Array.from(cumulativeStats.entries())
|
||||
.filter(([, count]) => count > 0)
|
||||
.sort((a, b) => b[1] - a[1]);
|
||||
|
||||
if (entries.length === 0) {
|
||||
console.log('No cumulative points summary found.');
|
||||
} else {
|
||||
entries.forEach(([user, points]) => {
|
||||
console.log(`${user}: ${points}`);
|
||||
});
|
||||
}
|
||||
|
||||
// Calculate and print total cumulative points
|
||||
const totalCumulativePoints = Array.from(cumulativeStats.values()).reduce(
|
||||
(sum, points) => sum + points,
|
||||
0,
|
||||
);
|
||||
console.log('\nTotal cumulative points earned: ' + totalCumulativePoints);
|
||||
}
|
||||
|
||||
// Run the calculations
|
||||
calculateCumulativePoints().catch(console.error);
|
||||
89
.github/workflows/ai-generated-release-notes.yml
vendored
Normal file
89
.github/workflows/ai-generated-release-notes.yml
vendored
Normal file
@@ -0,0 +1,89 @@
|
||||
name: Generate Release Notes from CodeRabbit summary
|
||||
|
||||
on:
|
||||
issue_comment:
|
||||
types: [created]
|
||||
|
||||
jobs:
|
||||
generate-release-notes:
|
||||
# Only run on PR comments from CodeRabbit bot
|
||||
if: github.event.issue.pull_request && github.event.comment.user.login == 'coderabbitai[bot]'
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
permissions:
|
||||
contents: write
|
||||
pull-requests: write
|
||||
issues: write
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
- name: Set up environment
|
||||
uses: ./.github/actions/setup
|
||||
|
||||
- name: Check if this is CodeRabbit's first comment
|
||||
id: check-first-comment
|
||||
run: node .github/actions/ai-generated-release-notes/check-first-comment.js
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
GITHUB_REPOSITORY: ${{ github.repository }}
|
||||
GITHUB_EVENT_ISSUE_NUMBER: ${{ github.event.issue.number }}
|
||||
GITHUB_EVENT_COMMENT_ID: ${{ github.event.comment.id }}
|
||||
|
||||
- name: Get PR details
|
||||
if: steps.check-first-comment.outputs.result == 'true'
|
||||
id: pr-details
|
||||
run: node .github/actions/ai-generated-release-notes/pr-details.js
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
GITHUB_REPOSITORY: ${{ github.repository }}
|
||||
GITHUB_EVENT_ISSUE_NUMBER: ${{ github.event.issue.number }}
|
||||
|
||||
- name: Check if release notes file already exists
|
||||
if: steps.check-first-comment.outputs.result == 'true' && steps.pr-details.outputs.result != 'null'
|
||||
id: check-release-notes-exists
|
||||
run: node .github/actions/ai-generated-release-notes/check-release-notes-exists.js
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
GITHUB_REPOSITORY: ${{ github.repository }}
|
||||
GITHUB_EVENT_ISSUE_NUMBER: ${{ github.event.issue.number }}
|
||||
PR_DETAILS: ${{ steps.pr-details.outputs.result }}
|
||||
|
||||
- name: Generate summary with OpenAI
|
||||
if: steps.check-first-comment.outputs.result == 'true' && steps.check-release-notes-exists.outputs.result == 'false'
|
||||
id: generate-summary
|
||||
run: node .github/actions/ai-generated-release-notes/generate-summary.js
|
||||
env:
|
||||
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
|
||||
GITHUB_EVENT_COMMENT_BODY: ${{ github.event.comment.body }}
|
||||
PR_DETAILS: ${{ steps.pr-details.outputs.result }}
|
||||
|
||||
- name: Determine category with OpenAI
|
||||
if: steps.check-first-comment.outputs.result == 'true' && steps.check-release-notes-exists.outputs.result == 'false' && steps.generate-summary.outputs.result != 'null'
|
||||
id: determine-category
|
||||
run: node .github/actions/ai-generated-release-notes/determine-category.js
|
||||
env:
|
||||
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
|
||||
GITHUB_EVENT_COMMENT_BODY: ${{ github.event.comment.body }}
|
||||
PR_DETAILS: ${{ steps.pr-details.outputs.result }}
|
||||
SUMMARY_DATA: ${{ steps.generate-summary.outputs.result }}
|
||||
|
||||
- name: Create and commit release notes file via GitHub API
|
||||
if: steps.check-first-comment.outputs.result == 'true' && steps.check-release-notes-exists.outputs.result == 'false' && steps.generate-summary.outputs.result != 'null' && steps.determine-category.outputs.result != 'null' && steps.determine-category.outputs.result != ''
|
||||
run: node .github/actions/ai-generated-release-notes/create-release-notes-file.js
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
GITHUB_REPOSITORY: ${{ github.repository }}
|
||||
GITHUB_EVENT_ISSUE_NUMBER: ${{ github.event.issue.number }}
|
||||
SUMMARY_DATA: ${{ steps.generate-summary.outputs.result }}
|
||||
CATEGORY: ${{ steps.determine-category.outputs.result }}
|
||||
|
||||
- name: Comment on PR
|
||||
if: steps.check-first-comment.outputs.result == 'true' && steps.check-release-notes-exists.outputs.result == 'false' && steps.generate-summary.outputs.result != 'null' && steps.determine-category.outputs.result != 'null' && steps.determine-category.outputs.result != ''
|
||||
run: node .github/actions/ai-generated-release-notes/comment-on-pr.js
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
GITHUB_REPOSITORY: ${{ github.repository }}
|
||||
GITHUB_EVENT_ISSUE_NUMBER: ${{ github.event.issue.number }}
|
||||
SUMMARY_DATA: ${{ steps.generate-summary.outputs.result }}
|
||||
CATEGORY: ${{ steps.determine-category.outputs.result }}
|
||||
23
.github/workflows/autofix.yml
vendored
Normal file
23
.github/workflows/autofix.yml
vendored
Normal file
@@ -0,0 +1,23 @@
|
||||
name: autofix.ci
|
||||
|
||||
defaults:
|
||||
run:
|
||||
shell: bash
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
types: [opened, synchronize, reopened, ready_for_review]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
autofix:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Set up environment
|
||||
uses: ./.github/actions/setup
|
||||
- name: Format code
|
||||
run: yarn lint:fix
|
||||
- uses: autofix-ci/action@635ffb0c9798bd160680f18fd73371e355b85f27
|
||||
32
.github/workflows/build.yml
vendored
32
.github/workflows/build.yml
vendored
@@ -15,13 +15,13 @@ on:
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
|
||||
cancel-in-progress: true
|
||||
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
|
||||
@@ -29,7 +29,7 @@ jobs:
|
||||
- name: Create package tgz
|
||||
run: cd packages/api && yarn pack && mv package.tgz actual-api.tgz
|
||||
- name: Upload Build
|
||||
uses: actions/upload-artifact@v3
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: actual-api
|
||||
path: packages/api/actual-api.tgz
|
||||
@@ -37,7 +37,7 @@ jobs:
|
||||
crdt:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- uses: actions/checkout@v4
|
||||
- name: Set up environment
|
||||
uses: ./.github/actions/setup
|
||||
- name: Build CRDT
|
||||
@@ -45,7 +45,7 @@ jobs:
|
||||
- name: Create package tgz
|
||||
run: cd packages/crdt && yarn pack && mv package.tgz actual-crdt.tgz
|
||||
- name: Upload Build
|
||||
uses: actions/upload-artifact@v3
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: actual-crdt
|
||||
path: packages/crdt/actual-crdt.tgz
|
||||
@@ -53,18 +53,32 @@ jobs:
|
||||
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
|
||||
run: yarn build:browser
|
||||
- name: Upload Build
|
||||
uses: actions/upload-artifact@v3
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: actual-web
|
||||
path: packages/desktop-client/build
|
||||
- name: Upload Build Stats
|
||||
uses: actions/upload-artifact@v3
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: build-stats
|
||||
path: packages/desktop-client/build-stats
|
||||
|
||||
server:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Set up environment
|
||||
uses: ./.github/actions/setup
|
||||
- name: Build Server
|
||||
run: yarn workspace @actual-app/sync-server build
|
||||
- name: Upload Build
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: sync-server
|
||||
path: packages/sync-server/build
|
||||
|
||||
24
.github/workflows/check.yml
vendored
24
.github/workflows/check.yml
vendored
@@ -8,13 +8,13 @@ on:
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
|
||||
cancel-in-progress: true
|
||||
cancel-in-progress: ${{ github.ref != 'refs/heads/master' }}
|
||||
|
||||
jobs:
|
||||
lint:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- uses: actions/checkout@v4
|
||||
- name: Set up environment
|
||||
uses: ./.github/actions/setup
|
||||
- name: Lint
|
||||
@@ -22,15 +22,25 @@ jobs:
|
||||
typecheck:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- uses: actions/checkout@v4
|
||||
- name: Set up environment
|
||||
uses: ./.github/actions/setup
|
||||
- name: Typecheck
|
||||
run: yarn typecheck
|
||||
validate-cli:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Set up environment
|
||||
uses: ./.github/actions/setup
|
||||
- name: Build Web
|
||||
run: yarn build:server
|
||||
- name: Check that the built CLI works
|
||||
run: node packages/sync-server/build/bin/actual-server.js --version
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- uses: actions/checkout@v4
|
||||
- name: Set up environment
|
||||
uses: ./.github/actions/setup
|
||||
- name: Test
|
||||
@@ -40,9 +50,9 @@ jobs:
|
||||
if: github.event_name == 'pull_request'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- uses: actions/setup-node@v2
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '19'
|
||||
node-version: 20
|
||||
- name: Check migrations
|
||||
run: node ./.github/actions/check-migrations.js
|
||||
|
||||
8
.github/workflows/codeql.yml
vendored
8
.github/workflows/codeql.yml
vendored
@@ -9,7 +9,7 @@ on:
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
|
||||
cancel-in-progress: true
|
||||
cancel-in-progress: ${{ github.ref != 'refs/heads/master' }}
|
||||
|
||||
jobs:
|
||||
analyze:
|
||||
@@ -22,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'
|
||||
|
||||
26
.github/workflows/count-points.yml
vendored
Normal file
26
.github/workflows/count-points.yml
vendored
Normal file
@@ -0,0 +1,26 @@
|
||||
name: Count points
|
||||
|
||||
on:
|
||||
schedule:
|
||||
# Run at 00:00 on the first day of every month
|
||||
- cron: '0 0 1 * *'
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
startDate:
|
||||
description: 'Start date for point counter (YYYY-MM-DD)'
|
||||
required: true
|
||||
type: string
|
||||
|
||||
jobs:
|
||||
count-points:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
- name: Set up environment
|
||||
uses: ./.github/actions/setup
|
||||
- name: Count points
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
START_DATE: ${{ inputs.startDate }}
|
||||
run: node .github/scripts/count-points.mjs
|
||||
109
.github/workflows/docker-edge.yml
vendored
Normal file
109
.github/workflows/docker-edge.yml
vendored
Normal file
@@ -0,0 +1,109 @@
|
||||
name: Build Edge Docker Image
|
||||
|
||||
# Edge Docker images are built for every commit, and daily
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- master
|
||||
paths:
|
||||
- 'packages/sync-server/**'
|
||||
pull_request:
|
||||
branches:
|
||||
- master
|
||||
paths:
|
||||
- 'packages/sync-server/**'
|
||||
schedule:
|
||||
- cron: '0 0 * * *'
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
|
||||
env:
|
||||
IMAGES: |
|
||||
${{ !github.event.repository.fork && 'actualbudget/actual-server' || '' }}
|
||||
ghcr.io/${{ github.repository_owner }}/actual-server
|
||||
ghcr.io/${{ github.repository_owner }}/actual
|
||||
|
||||
# Creates the following tags:
|
||||
# - actual-server:edge
|
||||
TAGS: |
|
||||
type=edge,value=edge
|
||||
type=sha
|
||||
|
||||
jobs:
|
||||
build:
|
||||
if: github.event_name == 'workflow_dispatch' || !github.event.repository.fork
|
||||
name: Build Docker image
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
matrix:
|
||||
os: [ubuntu, alpine]
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v3
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Docker meta
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
# Push to both Docker Hub and Github Container Registry
|
||||
images: ${{ env.IMAGES }}
|
||||
flavor: ${{ matrix.os != 'ubuntu' && format('suffix=-{0}', matrix.os) || '' }}
|
||||
tags: ${{ env.TAGS }}
|
||||
|
||||
- name: Login to Docker Hub
|
||||
uses: docker/login-action@v3
|
||||
if: github.event_name != 'pull_request' && !github.event.repository.fork
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
- name: Login to GitHub Container Registry
|
||||
uses: docker/login-action@v3
|
||||
if: github.event_name != 'pull_request'
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.repository_owner }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
# Building outside of the docker image allows us to build once and push to multiple platforms
|
||||
# This is faster and avoids yarn memory issues
|
||||
- name: Set up environment
|
||||
uses: ./.github/actions/setup
|
||||
- name: Build Web
|
||||
run: yarn build:server
|
||||
|
||||
- name: Build image for testing
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
context: .
|
||||
push: false
|
||||
load: true
|
||||
file: packages/sync-server/docker/${{ matrix.os }}.Dockerfile
|
||||
tags: actualbudget/actual-server-testing
|
||||
|
||||
- name: Test that the docker image boots
|
||||
run: |
|
||||
docker run --detach --network=host actualbudget/actual-server-testing
|
||||
sleep 5
|
||||
curl --fail -sS -LI -w '%{http_code}\n' --retry 10 --retry-delay 1 --retry-connrefused localhost:5006
|
||||
|
||||
# This will use the cache from the earlier build step and not rebuild the image
|
||||
# https://docs.docker.com/build/ci/github-actions/test-before-push/
|
||||
- name: Build and push images
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
context: .
|
||||
push: ${{ github.event_name != 'pull_request' }}
|
||||
file: packages/sync-server/docker/${{ matrix.os }}.Dockerfile
|
||||
platforms: linux/amd64,linux/arm64,linux/arm/v7${{ matrix.os == 'alpine' && ',linux/arm/v6' || '' }}
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
build-args: |
|
||||
GITHUB_TOKEN=${{ secrets.GITHUB_TOKEN }}
|
||||
96
.github/workflows/docker-release.yml
vendored
Normal file
96
.github/workflows/docker-release.yml
vendored
Normal file
@@ -0,0 +1,96 @@
|
||||
name: Build Stable Docker Image
|
||||
|
||||
# Stable Docker images are built for every new tag
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- 'v*.*.*'
|
||||
paths-ignore:
|
||||
- README.md
|
||||
- LICENSE.txt
|
||||
|
||||
env:
|
||||
IMAGES: |
|
||||
actualbudget/actual-server
|
||||
ghcr.io/actualbudget/actual-server
|
||||
ghcr.io/actualbudget/actual
|
||||
|
||||
# Creates the following tags:
|
||||
# - actual-server:latest (see docker/metadata-action flavor inputs, below)
|
||||
# - actual-server:1.3
|
||||
# - actual-server:1.3.7
|
||||
# - actual-server:sha-90dd603
|
||||
TAGS: |
|
||||
type=semver,pattern={{version}}
|
||||
|
||||
jobs:
|
||||
build:
|
||||
name: Build Docker image
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v3
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Docker meta
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
# Push to both Docker Hub and Github Container Registry
|
||||
images: ${{ env.IMAGES }}
|
||||
# Automatically update :latest
|
||||
flavor: latest=true
|
||||
tags: ${{ env.TAGS }}
|
||||
|
||||
- name: Docker meta for Alpine image
|
||||
id: alpine-meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: ${{ env.IMAGES }}
|
||||
# Automatically update :latest
|
||||
flavor: |
|
||||
latest=true
|
||||
suffix=-alpine,onlatest=true
|
||||
tags: ${{ env.TAGS }}
|
||||
|
||||
- name: Login to Docker Hub
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
- name: Login to GitHub Container Registry
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.repository_owner }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
# Building outside of the docker image allows us to build once and push to multiple platforms
|
||||
# This is faster and avoids yarn memory issues
|
||||
- name: Set up environment
|
||||
uses: ./.github/actions/setup
|
||||
- name: Build Web
|
||||
run: yarn build:server
|
||||
|
||||
- name: Build and push ubuntu image
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
context: .
|
||||
push: true
|
||||
file: packages/sync-server/docker/ubuntu.Dockerfile
|
||||
platforms: linux/amd64,linux/arm64,linux/arm/v7
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
|
||||
- name: Build and push alpine image
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
context: .
|
||||
push: true
|
||||
file: packages/sync-server/docker/alpine.Dockerfile
|
||||
platforms: linux/amd64,linux/arm64,linux/arm/v7,linux/arm/v6
|
||||
tags: ${{ steps.alpine-meta.outputs.tags }}
|
||||
71
.github/workflows/e2e-test.yml
vendored
71
.github/workflows/e2e-test.yml
vendored
@@ -1,6 +1,7 @@
|
||||
name: E2E Tests
|
||||
|
||||
on: [pull_request]
|
||||
on:
|
||||
pull_request:
|
||||
|
||||
env:
|
||||
GITHUB_PR_NUMBER: ${{github.event.pull_request.number}}
|
||||
@@ -10,28 +11,82 @@ concurrency:
|
||||
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: ./.github/actions/netlify-wait-for-build
|
||||
|
||||
functional:
|
||||
name: Functional
|
||||
needs: netlify
|
||||
runs-on: ubuntu-latest
|
||||
container:
|
||||
image: mcr.microsoft.com/playwright:v1.55.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
|
||||
|
||||
functional-desktop-app:
|
||||
name: Functional Desktop App
|
||||
runs-on: ubuntu-latest
|
||||
container:
|
||||
image: mcr.microsoft.com/playwright:v1.55.1-jammy
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Set up environment
|
||||
uses: ./.github/actions/setup
|
||||
- name: Run Desktop app E2E Tests
|
||||
run: |
|
||||
xvfb-run --auto-servernum --server-args="-screen 0 1920x1080x24" -- yarn e2e:desktop
|
||||
- uses: actions/upload-artifact@v4
|
||||
if: always()
|
||||
with:
|
||||
name: desktop-app-test-results
|
||||
path: packages/desktop-electron/e2e/test-results/
|
||||
retention-days: 30
|
||||
overwrite: true
|
||||
|
||||
vrt:
|
||||
name: Visual regression
|
||||
needs: netlify
|
||||
runs-on: ubuntu-latest
|
||||
container:
|
||||
image: mcr.microsoft.com/playwright:v1.55.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
|
||||
|
||||
144
.github/workflows/electron-master.yml
vendored
Normal file
144
.github/workflows/electron-master.yml
vendored
Normal file
@@ -0,0 +1,144 @@
|
||||
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-22.04
|
||||
- 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 update
|
||||
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 for Mac
|
||||
if: ${{ startsWith(matrix.os, 'macos') }}
|
||||
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: Build Electron
|
||||
if: ${{ ! startsWith(matrix.os, 'macos') }}
|
||||
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/Actual-windows.exe
|
||||
packages/desktop-electron/dist/*.AppImage
|
||||
packages/desktop-electron/dist/*.flatpak
|
||||
- name: Upload Windows Store Build
|
||||
if: ${{ startsWith(matrix.os, 'windows') }}
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: actual-electron-${{ matrix.os }}-appx
|
||||
path: |
|
||||
packages/desktop-electron/dist/*.appx
|
||||
- name: Process release version
|
||||
id: process_version
|
||||
run: |
|
||||
echo "version=${GITHUB_REF_NAME#v}" >> "$GITHUB_OUTPUT"
|
||||
- name: Add to new release
|
||||
uses: softprops/action-gh-release@v2
|
||||
with:
|
||||
draft: true
|
||||
body: |
|
||||
:link: [View release notes](https://actualbudget.org/blog/release-${{ steps.process_version.outputs.version }})
|
||||
|
||||
## Desktop releases
|
||||
Please note: Microsoft store updates can sometimes lag behind the main release by a couple of days while they verify the new version.
|
||||
|
||||
<a href="https://apps.microsoft.com/detail/9p2hmlhsdbrm?cid=Github+Releases&mode=direct">
|
||||
<img src="https://get.microsoft.com/images/en-us%20dark.svg" width="200"/>
|
||||
</a>
|
||||
files: |
|
||||
packages/desktop-electron/dist/*.dmg
|
||||
packages/desktop-electron/dist/*.exe
|
||||
!packages/desktop-electron/dist/Actual-windows.exe
|
||||
packages/desktop-electron/dist/*.AppImage
|
||||
packages/desktop-electron/dist/*.flatpak
|
||||
|
||||
publish-microsoft-store:
|
||||
needs: build
|
||||
runs-on: windows-latest
|
||||
if: ${{ github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v') }}
|
||||
steps:
|
||||
- name: Install StoreBroker
|
||||
shell: powershell
|
||||
run: |
|
||||
Install-Module -Name StoreBroker -AcceptLicense -Force -Scope CurrentUser -Verbose
|
||||
|
||||
- name: Download Microsoft Store artifacts
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: actual-electron-windows-latest-appx
|
||||
|
||||
- name: Submit to Microsoft Store
|
||||
shell: powershell
|
||||
run: |
|
||||
# Disable telemetry
|
||||
$global:SBDisableTelemetry = $true
|
||||
|
||||
# Authenticate against the store
|
||||
$pass = ConvertTo-SecureString -String '${{ secrets.MICROSOFT_STORE_CLIENT_SECRET }}' -AsPlainText -Force
|
||||
$cred = New-Object -TypeName System.Management.Automation.PSCredential -ArgumentList ${{ secrets.MICROSOFT_STORE_CLIENT_ID }},$pass
|
||||
Set-StoreBrokerAuthentication -TenantId '${{ secrets.MICROSOFT_STORE_TENANT_ID }}' -Credential $cred
|
||||
|
||||
# Zip and create metadata files
|
||||
$artifacts = Get-ChildItem -Path . -Filter *.appx | Select-Object -ExpandProperty FullName
|
||||
New-StoreBrokerConfigFile -Path "$PWD/config.json" -AppId ${{ secrets.MICROSOFT_STORE_PRODUCT_ID }}
|
||||
New-SubmissionPackage -ConfigPath "$PWD/config.json" -DisableAutoPackageNameFormatting -AppxPath $artifacts -OutPath "$PWD" -OutName submission
|
||||
|
||||
# Submit the app
|
||||
# See https://github.com/microsoft/StoreBroker/blob/master/Documentation/USAGE.md#the-easy-way
|
||||
Update-ApplicationSubmission `
|
||||
-AppId ${{ secrets.MICROSOFT_STORE_PRODUCT_ID }} `
|
||||
-SubmissionDataPath "submission.json" `
|
||||
-PackagePath "submission.zip" `
|
||||
-ReplacePackages `
|
||||
-NoStatus `
|
||||
-AutoCommit `
|
||||
-Force
|
||||
65
.github/workflows/electron-pr.yml
vendored
Normal file
65
.github/workflows/electron-pr.yml
vendored
Normal file
@@ -0,0 +1,65 @@
|
||||
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-22.04
|
||||
- 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 update
|
||||
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/Actual-windows.exe
|
||||
packages/desktop-electron/dist/*.AppImage
|
||||
packages/desktop-electron/dist/*.flatpak
|
||||
- name: Upload Windows Store Build
|
||||
if: ${{ startsWith(matrix.os, 'windows') }}
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: actual-electron-${{ matrix.os }}-appx
|
||||
path: |
|
||||
packages/desktop-electron/dist/*.appx
|
||||
42
.github/workflows/electron.yml
vendored
42
.github/workflows/electron.yml
vendored
@@ -1,42 +0,0 @@
|
||||
name: Electron
|
||||
|
||||
defaults:
|
||||
run:
|
||||
shell: bash
|
||||
|
||||
env:
|
||||
CI: true
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- master
|
||||
pull_request:
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
build:
|
||||
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-electron
|
||||
- name: Upload Build
|
||||
uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: actual-electron-${{ matrix.os }}
|
||||
path: |
|
||||
packages/desktop-electron/dist/*.dmg
|
||||
packages/desktop-electron/dist/*.exe
|
||||
packages/desktop-electron/dist/*.AppImage
|
||||
56
.github/workflows/generate-release-pr.yml
vendored
Normal file
56
.github/workflows/generate-release-pr.yml
vendored
Normal file
@@ -0,0 +1,56 @@
|
||||
name: Generate release PR
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
ref:
|
||||
description: 'Commit or branch to release'
|
||||
required: true
|
||||
default: 'master'
|
||||
version:
|
||||
description: 'Version number for the release (optional)'
|
||||
required: false
|
||||
default: ''
|
||||
|
||||
jobs:
|
||||
generate-release-pr:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ github.event.inputs.ref }}
|
||||
- name: Bump package versions
|
||||
id: bump_package_versions
|
||||
shell: bash
|
||||
run: |
|
||||
declare -A packages=(
|
||||
[web]="desktop-client"
|
||||
[electron]="desktop-electron"
|
||||
[sync]="sync-server"
|
||||
[api]="api"
|
||||
)
|
||||
|
||||
for key in "${!packages[@]}"; do
|
||||
pkg="${packages[$key]}"
|
||||
|
||||
if [[ -n "${{ github.event.inputs.version }}" ]]; then
|
||||
version="${{ github.event.inputs.version }}"
|
||||
else
|
||||
version=$(node ./packages/ci-actions/bin/get-next-package-version.js \
|
||||
--package-json "./packages/$pkg/package.json" \
|
||||
--type auto \
|
||||
--update)
|
||||
fi
|
||||
|
||||
eval "NEW_${key^^}_VERSION=\"$version\""
|
||||
done
|
||||
|
||||
echo "version=$NEW_WEB_VERSION" >> "$GITHUB_OUTPUT"
|
||||
- name: Create PR
|
||||
uses: peter-evans/create-pull-request@v7
|
||||
with:
|
||||
commit-message: '🔖 (${{ steps.bump_package_versions.outputs.version }})'
|
||||
title: '🔖 (${{ steps.bump_package_versions.outputs.version }})'
|
||||
body: 'Generated by [generate-release-pr.yml](../tree/master/.github/workflows/generate-release-pr.yml)'
|
||||
branch: 'release/v${{ steps.bump_package_versions.outputs.version }}'
|
||||
87
.github/workflows/i18n-string-extract-master.yml
vendored
Normal file
87
.github/workflows/i18n-string-extract-master.yml
vendored
Normal file
@@ -0,0 +1,87 @@
|
||||
name: Extract and upload i18n strings
|
||||
|
||||
on:
|
||||
schedule:
|
||||
# 4am UTC
|
||||
- cron: '0 4 * * *'
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
extract-and-upload-i18n-strings:
|
||||
runs-on: ubuntu-latest
|
||||
if: github.repository == 'actualbudget/actual'
|
||||
steps:
|
||||
- name: Check out main repository
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
path: actual
|
||||
- name: Set up environment
|
||||
uses: ./actual/.github/actions/setup
|
||||
with:
|
||||
working-directory: actual
|
||||
download-translations: false # As we'll manually clone instead
|
||||
- name: Configure Git config
|
||||
run: |
|
||||
git config --global user.name "github-actions[bot]"
|
||||
git config --global user.email "41898282+github-actions[bot]@users.noreply.github.com"
|
||||
- name: Configure i18n client
|
||||
run: |
|
||||
pip install wlc
|
||||
|
||||
- name: Lock translations
|
||||
run: |
|
||||
wlc \
|
||||
--url https://hosted.weblate.org/api/ \
|
||||
--key "${{ secrets.WEBLATE_API_KEY_CI_STRINGS }}" \
|
||||
lock \
|
||||
actualbudget/actual
|
||||
|
||||
- name: Update VCS with latest translations
|
||||
run: |
|
||||
wlc \
|
||||
--url https://hosted.weblate.org/api/ \
|
||||
--key "${{ secrets.WEBLATE_API_KEY_CI_STRINGS }}" \
|
||||
push \
|
||||
actualbudget/actual
|
||||
- name: Check out updated translations
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
ssh-key: ${{ secrets.STRING_IMPORT_DEPLOY_KEY }}
|
||||
repository: actualbudget/translations
|
||||
path: translations
|
||||
- name: Generate i18n strings
|
||||
working-directory: actual
|
||||
run: |
|
||||
mkdir -p packages/desktop-client/locale/
|
||||
cp ../translations/en.json packages/desktop-client/locale/
|
||||
yarn generate:i18n
|
||||
if [[ ! -f packages/desktop-client/locale/en.json ]]; then
|
||||
echo "File packages/desktop-client/locale/en.json not found. Ensure the file was generated correctly."
|
||||
exit 1
|
||||
fi
|
||||
- name: Check in new i18n strings
|
||||
working-directory: translations
|
||||
run: |
|
||||
cp ../actual/packages/desktop-client/locale/en.json .
|
||||
git add .
|
||||
if git commit -m "Update source strings"; then
|
||||
git push
|
||||
else
|
||||
echo "No changes to commit"
|
||||
fi
|
||||
- name: Update Weblate with latest translations
|
||||
run: |
|
||||
wlc \
|
||||
--url https://hosted.weblate.org/api/ \
|
||||
--key "${{ secrets.WEBLATE_API_KEY_CI_STRINGS }}" \
|
||||
pull \
|
||||
actualbudget/actual
|
||||
|
||||
- name: Unlock translations
|
||||
if: always() # Clean up even on failure
|
||||
run: |
|
||||
wlc \
|
||||
--url https://hosted.weblate.org/api/ \
|
||||
--key "${{ secrets.WEBLATE_API_KEY_CI_STRINGS }}" \
|
||||
unlock \
|
||||
actualbudget/actual
|
||||
@@ -24,7 +24,7 @@ jobs:
|
||||
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).
|
||||
This repository uses a voting-based system for feature requests. While enhancement issues are automatically closed, we still welcome feature requests! The voting system helps us gauge community interest in potential features. We also encourage community contributions for any feature requests marked as needing votes (just post a comment first so we can help guide you toward 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+
|
||||
|
||||
|
||||
@@ -24,10 +24,10 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
# This is not a security concern because we have approved & merged the PR
|
||||
- uses: actions/checkout@v3
|
||||
- uses: actions/setup-node@v2
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '19'
|
||||
node-version: 20
|
||||
- name: Handle feature requests
|
||||
run: node .github/actions/handle-feature-requests.js
|
||||
env:
|
||||
|
||||
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: yarn build: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
|
||||
95
.github/workflows/publish-nightly-npm-packages.yml
vendored
Normal file
95
.github/workflows/publish-nightly-npm-packages.yml
vendored
Normal file
@@ -0,0 +1,95 @@
|
||||
name: Publish nightly npm packages
|
||||
|
||||
# Nightly npm packages are built daily
|
||||
on:
|
||||
schedule:
|
||||
- cron: '0 0 * * *'
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
build-and-pack:
|
||||
runs-on: ubuntu-latest
|
||||
name: Build and pack npm packages
|
||||
if: github.event.repository.fork == false
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Set up environment
|
||||
uses: ./.github/actions/setup
|
||||
|
||||
- name: Update package versions
|
||||
run: |
|
||||
# Get new nightly versions
|
||||
NEW_WEB_VERSION=$(node ./packages/ci-actions/bin/get-next-package-version.js --package-json ./packages/desktop-client/package.json --type nightly)
|
||||
NEW_SYNC_VERSION=$(node ./packages/ci-actions/bin/get-next-package-version.js --package-json ./packages/sync-server/package.json --type nightly)
|
||||
NEW_API_VERSION=$(node ./packages/ci-actions/bin/get-next-package-version.js --package-json ./packages/api/package.json --type nightly)
|
||||
|
||||
# Set package versions
|
||||
npm version $NEW_WEB_VERSION --no-git-tag-version --workspace=@actual-app/web --no-workspaces-update
|
||||
npm version $NEW_SYNC_VERSION --no-git-tag-version --workspace=@actual-app/sync-server --no-workspaces-update
|
||||
npm version $NEW_API_VERSION --no-git-tag-version --workspace=@actual-app/api --no-workspaces-update
|
||||
|
||||
- name: Yarn install
|
||||
run: |
|
||||
yarn install
|
||||
|
||||
- name: Build Server & Web
|
||||
run: yarn build:server
|
||||
|
||||
- name: Pack the web and server packages
|
||||
run: |
|
||||
yarn workspace @actual-app/web pack --filename @actual-app/web.tgz
|
||||
yarn workspace @actual-app/sync-server pack --filename @actual-app/sync-server.tgz
|
||||
|
||||
- name: Build API
|
||||
run: yarn build:api
|
||||
|
||||
- name: Pack the api package
|
||||
run: |
|
||||
yarn workspace @actual-app/api pack --filename @actual-app/api.tgz
|
||||
|
||||
- name: Upload package artifacts
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: npm-packages
|
||||
path: |
|
||||
packages/desktop-client/@actual-app/web.tgz
|
||||
packages/sync-server/@actual-app/sync-server.tgz
|
||||
packages/api/@actual-app/api.tgz
|
||||
|
||||
publish:
|
||||
runs-on: ubuntu-latest
|
||||
name: Publish Nightly npm packages
|
||||
needs: build-and-pack
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
steps:
|
||||
- name: Download the artifacts
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: npm-packages
|
||||
|
||||
- name: Setup node and npm registry
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
registry-url: 'https://registry.npmjs.org'
|
||||
|
||||
- name: Publish Web
|
||||
run: |
|
||||
npm publish desktop-client/@actual-app/web.tgz --access public --tag nightly
|
||||
env:
|
||||
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
|
||||
|
||||
- name: Publish Sync-Server
|
||||
run: |
|
||||
npm publish sync-server/@actual-app/sync-server.tgz --access public --tag nightly
|
||||
env:
|
||||
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
|
||||
|
||||
- name: Publish API
|
||||
run: |
|
||||
npm publish api/@actual-app/api.tgz --access public --tag nightly
|
||||
env:
|
||||
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
|
||||
78
.github/workflows/publish-npm-packages.yml
vendored
Normal file
78
.github/workflows/publish-npm-packages.yml
vendored
Normal file
@@ -0,0 +1,78 @@
|
||||
name: Publish npm packages
|
||||
|
||||
# # Npm packages are published for every new tag
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- 'v*.*.*'
|
||||
|
||||
jobs:
|
||||
build-and-pack:
|
||||
runs-on: ubuntu-latest
|
||||
name: Build and pack npm packages
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Set up environment
|
||||
uses: ./.github/actions/setup
|
||||
|
||||
- name: Build Web
|
||||
run: yarn build:server
|
||||
|
||||
- name: Pack the web and server packages
|
||||
run: |
|
||||
yarn workspace @actual-app/web pack --filename @actual-app/web.tgz
|
||||
yarn workspace @actual-app/sync-server pack --filename @actual-app/sync-server.tgz
|
||||
|
||||
- name: Build API
|
||||
run: yarn build:api
|
||||
|
||||
- name: Pack the api package
|
||||
run: |
|
||||
yarn workspace @actual-app/api pack --filename @actual-app/api.tgz
|
||||
|
||||
- name: Upload package artifacts
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: npm-packages
|
||||
path: |
|
||||
packages/desktop-client/@actual-app/web.tgz
|
||||
packages/sync-server/@actual-app/sync-server.tgz
|
||||
packages/api/@actual-app/api.tgz
|
||||
|
||||
publish:
|
||||
runs-on: ubuntu-latest
|
||||
name: Publish npm packages
|
||||
needs: build-and-pack
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
steps:
|
||||
- name: Download the artifacts
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: npm-packages
|
||||
|
||||
- name: Setup node and npm registry
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
registry-url: 'https://registry.npmjs.org'
|
||||
|
||||
- name: Publish Web
|
||||
run: |
|
||||
npm publish desktop-client/@actual-app/web.tgz --access public
|
||||
env:
|
||||
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
|
||||
|
||||
- name: Publish Sync-Server
|
||||
run: |
|
||||
npm publish sync-server/@actual-app/sync-server.tgz --access public
|
||||
env:
|
||||
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
|
||||
|
||||
- name: Publish API
|
||||
run: |
|
||||
npm publish api/@actual-app/api.tgz --access public
|
||||
env:
|
||||
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
|
||||
2
.github/workflows/release-notes.yml
vendored
2
.github/workflows/release-notes.yml
vendored
@@ -12,7 +12,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v3
|
||||
uses: actions/checkout@v4
|
||||
- name: Check release notes
|
||||
if: startsWith(github.head_ref, 'release/') == false
|
||||
uses: actualbudget/actions/release-notes/check@main
|
||||
|
||||
24
.github/workflows/size-compare.yml
vendored
24
.github/workflows/size-compare.yml
vendored
@@ -13,6 +13,9 @@ name: Compare Sizes
|
||||
|
||||
on:
|
||||
pull_request_target:
|
||||
paths:
|
||||
- 'packages/**'
|
||||
- '!packages/sync-server/**'
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
|
||||
@@ -25,7 +28,7 @@ jobs:
|
||||
pull-requests: write
|
||||
steps:
|
||||
- name: Wait for ${{github.base_ref}} build to succeed
|
||||
uses: fountainhead/action-wait-for-check@v1.1.0
|
||||
uses: fountainhead/action-wait-for-check@v1.2.0
|
||||
id: master-build
|
||||
with:
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
@@ -33,7 +36,7 @@ jobs:
|
||||
ref: ${{github.base_ref}}
|
||||
|
||||
- name: Wait for PR build to succeed
|
||||
uses: fountainhead/action-wait-for-check@v1.1.0
|
||||
uses: fountainhead/action-wait-for-check@v1.2.0
|
||||
id: wait-for-build
|
||||
with:
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
@@ -46,7 +49,7 @@ jobs:
|
||||
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@v2
|
||||
uses: dawidd6/action-download-artifact@v6
|
||||
id: pr-build
|
||||
with:
|
||||
branch: ${{github.base_ref}}
|
||||
@@ -55,25 +58,30 @@ jobs:
|
||||
path: base
|
||||
|
||||
- name: Download build artifact from PR
|
||||
uses: dawidd6/action-download-artifact@v2
|
||||
uses: dawidd6/action-download-artifact@v6
|
||||
with:
|
||||
pr: ${{github.event.pull_request.number}}
|
||||
workflow: build.yml
|
||||
name: build-stats
|
||||
path: head
|
||||
allow_forks: true
|
||||
|
||||
- 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: github/webpack-bundlesize-compare-action@v1.8.2
|
||||
- uses: twk3/rollup-size-compare-action@v1.1.1
|
||||
with:
|
||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
current-stats-json-path: ./head/desktop-client-stats.json
|
||||
base-stats-json-path: ./base/desktop-client-stats.json
|
||||
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
|
||||
- uses: twk3/rollup-size-compare-action@v1.1.1
|
||||
with:
|
||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
current-stats-json-path: ./head/loot-core-stats.json
|
||||
|
||||
42
.github/workflows/stale.yml
vendored
Normal file
42
.github/workflows/stale.yml
vendored
Normal file
@@ -0,0 +1,42 @@
|
||||
name: 'Close stale PRs'
|
||||
on:
|
||||
schedule:
|
||||
- cron: '30 1 * * *'
|
||||
workflow_dispatch: # Allow manual triggering
|
||||
|
||||
jobs:
|
||||
stale:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/stale@v9
|
||||
with:
|
||||
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
|
||||
stale-wip:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/stale@v9
|
||||
with:
|
||||
stale-pr-message: ':wave: Hi! It looks like this PR has not had any changes for a week now. Would you like someone to review this PR? If so - please remove the "[WIP]" prefix from the PR title. That will let the community know that this PR is open for a review.'
|
||||
days-before-stale: 7
|
||||
any-of-labels: ':construction: WIP'
|
||||
days-before-close: -1
|
||||
days-before-issue-stale: -1
|
||||
|
||||
stale-needs-info:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/stale@v9
|
||||
with:
|
||||
stale-issue-label: 'needs info'
|
||||
days-before-stale: -1
|
||||
days-before-close: 7
|
||||
close-issue-message: 'This issue has been automatically closed because there have been no comments for 7 days after the "needs info" label was added. If you still need help, please feel free to reopen the issue with the requested information.'
|
||||
remove-stale-when-updated: false
|
||||
stale-pr-message: '' # Disable PR processing
|
||||
close-pr-message: '' # Disable PR processing
|
||||
days-before-pr-stale: -1 # Disable PR processing
|
||||
days-before-pr-close: -1 # Disable PR processing
|
||||
119
.github/workflows/update-vrt.yml
vendored
Normal file
119
.github/workflows/update-vrt.yml
vendored
Normal file
@@ -0,0 +1,119 @@
|
||||
name: /update-vrt
|
||||
on:
|
||||
issue_comment:
|
||||
types: [created]
|
||||
|
||||
permissions:
|
||||
pull-requests: read
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.event.issue.number }}-${{ contains(github.event.comment.body, '/update-vrt') }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
update-vrt:
|
||||
name: Update VRT
|
||||
runs-on: ubuntu-latest
|
||||
if: |
|
||||
github.event.issue.pull_request &&
|
||||
contains(github.event.comment.body, '/update-vrt')
|
||||
container:
|
||||
image: mcr.microsoft.com/playwright:v1.55.1-jammy
|
||||
steps:
|
||||
- name: Get PR branch
|
||||
# Until https://github.com/xt0rted/pull-request-comment-branch/issues/322 is resolved we use the forked version
|
||||
uses: gotson/pull-request-comment-branch@head-repo-owner-dist
|
||||
id: comment-branch
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
repository: ${{ steps.comment-branch.outputs.head_owner }}/${{ steps.comment-branch.outputs.head_repo }}
|
||||
ref: ${{ steps.comment-branch.outputs.head_ref }}
|
||||
- name: Set up environment
|
||||
uses: ./.github/actions/setup
|
||||
- name: Run VRT Tests on Desktop app
|
||||
continue-on-error: true
|
||||
run: |
|
||||
xvfb-run --auto-servernum --server-args="-screen 0 1920x1080x24" -- yarn e2e:desktop --update-snapshots
|
||||
- name: Wait for Netlify build to finish
|
||||
id: netlify
|
||||
env:
|
||||
COMMIT_SHA: ${{ steps.comment-branch.outputs.head_sha }}
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: ./.github/actions/netlify-wait-for-build
|
||||
- name: Run VRT Tests on Netlify URL
|
||||
run: yarn vrt --update-snapshots
|
||||
env:
|
||||
E2E_START_URL: ${{ steps.netlify.outputs.url }}
|
||||
- name: Create patch
|
||||
run: |
|
||||
git config --system --add safe.directory "*"
|
||||
git config --global user.name "github-actions[bot]"
|
||||
git config --global user.email "github-actions[bot]@users.noreply.github.com"
|
||||
git reset
|
||||
git add "**/*.png"
|
||||
if git diff --staged --quiet; then
|
||||
echo "No changes to commit"
|
||||
exit 0
|
||||
fi
|
||||
git commit -m "Update VRT"
|
||||
git format-patch -1 HEAD --stdout > Update-VRT.patch
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: patch
|
||||
path: Update-VRT.patch
|
||||
|
||||
push-patch:
|
||||
runs-on: ubuntu-latest
|
||||
needs: update-vrt
|
||||
permissions:
|
||||
contents: write
|
||||
pull-requests: write
|
||||
steps:
|
||||
- name: Get PR branch
|
||||
# Until https://github.com/xt0rted/pull-request-comment-branch/issues/322 is resolved we use the forked version
|
||||
uses: gotson/pull-request-comment-branch@head-repo-owner-dist
|
||||
id: comment-branch
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
repository: ${{ steps.comment-branch.outputs.head_owner }}/${{ steps.comment-branch.outputs.head_repo }}
|
||||
ref: ${{ steps.comment-branch.outputs.head_ref }}
|
||||
- uses: actions/download-artifact@v4
|
||||
continue-on-error: true
|
||||
with:
|
||||
name: patch
|
||||
- name: Apply patch and push
|
||||
env:
|
||||
BRANCH_NAME: ${{ steps.comment-branch.outputs.head_ref }}
|
||||
run: |
|
||||
git config --global user.name "github-actions[bot]"
|
||||
git config --global user.email "github-actions[bot]@users.noreply.github.com"
|
||||
git apply Update-VRT.patch
|
||||
git add "**/*.png"
|
||||
if git diff --staged --quiet; then
|
||||
echo "No changes to commit"
|
||||
exit 0
|
||||
fi
|
||||
git commit -m "Update VRT"
|
||||
git push origin HEAD:${BRANCH_NAME}
|
||||
- name: Add finished reaction
|
||||
uses: dkershner6/reaction-action@v2
|
||||
with:
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
commentId: ${{ github.event.comment.id }}
|
||||
reaction: 'rocket'
|
||||
|
||||
add-starting-reaction:
|
||||
runs-on: ubuntu-latest
|
||||
if: |
|
||||
github.event.issue.pull_request &&
|
||||
contains(github.event.comment.body, '/update-vrt')
|
||||
permissions:
|
||||
pull-requests: write
|
||||
steps:
|
||||
- name: React to comment
|
||||
uses: dkershner6/reaction-action@v2
|
||||
with:
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
commentId: ${{ github.event.comment.id }}
|
||||
reaction: '+1'
|
||||
54
.gitignore
vendored
54
.gitignore
vendored
@@ -1,28 +1,37 @@
|
||||
# Sample Data
|
||||
/data/*
|
||||
!data/.gitkeep
|
||||
/data2
|
||||
Actual-*
|
||||
!actual-server.js
|
||||
**/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
|
||||
packages/desktop-client/service-worker
|
||||
packages/plugins-service/dist
|
||||
bundle.desktop.js
|
||||
bundle.desktop.js.map
|
||||
bundle.mobile.js
|
||||
bundle.mobile.js.map
|
||||
export-2020-01-10.csv
|
||||
.idea
|
||||
.vscode
|
||||
|
||||
**/*.log
|
||||
|
||||
# Yarn
|
||||
.pnp.*
|
||||
@@ -35,3 +44,24 @@ export-2020-01-10.csv
|
||||
|
||||
# VSCode
|
||||
.vscode
|
||||
|
||||
# IntelliJ IDEA
|
||||
.idea
|
||||
|
||||
# Misc
|
||||
.#*
|
||||
|
||||
# Local Netlify folder
|
||||
.netlify
|
||||
|
||||
# build output
|
||||
package.tgz
|
||||
|
||||
# Fly.io configuration
|
||||
fly.toml
|
||||
|
||||
# TypeScript cache
|
||||
build/
|
||||
|
||||
# .d.ts files aren't type-checked with skipLibCheck set to true
|
||||
*.d.ts
|
||||
|
||||
1
.husky/pre-commit
Normal file
1
.husky/pre-commit
Normal file
@@ -0,0 +1 @@
|
||||
yarn lint-staged
|
||||
@@ -1 +1,30 @@
|
||||
sync_pb.*
|
||||
packages/api/app/bundle.api.js
|
||||
packages/api/app/stats.json
|
||||
packages/api/dist
|
||||
packages/api/@types
|
||||
packages/api/migrations
|
||||
packages/crdt/dist
|
||||
packages/component-library/src/icons/**/*
|
||||
packages/desktop-client/bundle.browser.js
|
||||
packages/desktop-client/stats.json
|
||||
packages/desktop-client/.swc/
|
||||
packages/desktop-client/build/
|
||||
packages/desktop-client/locale/
|
||||
packages/desktop-client/build-electron/
|
||||
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/test-results/
|
||||
packages/desktop-client/playwright-report/
|
||||
packages/desktop-electron/client-build/
|
||||
packages/desktop-electron/build/
|
||||
packages/desktop-electron/dist/
|
||||
packages/loot-core/**/node_modules/*
|
||||
packages/loot-core/**/lib-dist/*
|
||||
packages/loot-core/**/proto/*
|
||||
packages/sync-server/coverage/
|
||||
.yarn/*
|
||||
upcoming-release-notes/*
|
||||
|
||||
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
|
||||
10
.yarn/patches/adm-zip-npm-0.5.16-4556fea098.patch
Normal file
10
.yarn/patches/adm-zip-npm-0.5.16-4556fea098.patch
Normal file
@@ -0,0 +1,10 @@
|
||||
diff --git a/methods/inflater.js b/methods/inflater.js
|
||||
index 8769e66e82b25541aba80b1ac6429199c9a8179f..1d4402402f0e1aaf64062c1f004c3d6e6fe93e76 100644
|
||||
--- a/methods/inflater.js
|
||||
+++ b/methods/inflater.js
|
||||
@@ -1,4 +1,4 @@
|
||||
-const version = +(process.versions ? process.versions.node : "").split(".")[0] || 0;
|
||||
+const version = +(process?.versions?.node ?? "").split(".")[0] || 0;
|
||||
|
||||
module.exports = function (/*Buffer*/ inbuf, /*number*/ expectedLength) {
|
||||
var zlib = require("zlib");
|
||||
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.5.1.cjs
vendored
873
.yarn/releases/yarn-3.5.1.cjs
vendored
File diff suppressed because one or more lines are too long
948
.yarn/releases/yarn-4.9.1.cjs
vendored
Executable file
948
.yarn/releases/yarn-4.9.1.cjs
vendored
Executable file
File diff suppressed because one or more lines are too long
14
.yarnrc.yml
14
.yarnrc.yml
@@ -1,9 +1,9 @@
|
||||
compressionLevel: mixed
|
||||
|
||||
enableGlobalCache: false
|
||||
|
||||
enableTransparentWorkspaces: 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.5.1.cjs
|
||||
yarnPath: .yarn/releases/yarn-4.9.1.cjs
|
||||
|
||||
10
CODEOWNERS
Normal file
10
CODEOWNERS
Normal file
@@ -0,0 +1,10 @@
|
||||
# CODEOWNERS file for Actual Budget
|
||||
# Please add your name to code-paths that you feel especially
|
||||
# passionate about. You will be notified for any PRs there.
|
||||
|
||||
/packages/api/ @MatissJanis
|
||||
/packages/component-library/ @MatissJanis
|
||||
/packages/desktop-client/src/components/mobile @joel-jeremy
|
||||
/packages/desktop-electron/ @MikesGlitch
|
||||
/packages/loot-core/src/server/budget @youngcw
|
||||
/packages/sync-server/ @matt-fidd
|
||||
@@ -5,7 +5,7 @@
|
||||
# you are doing.
|
||||
###################################################
|
||||
|
||||
FROM node:18-bullseye as dev
|
||||
FROM node:20-bullseye as dev
|
||||
RUN apt-get update -y && apt-get upgrade -y && apt-get install -y openssl
|
||||
WORKDIR /app
|
||||
CMD ["sh", "./bin/docker-start"]
|
||||
|
||||
605
PLUGIN_ARCHITECTURE.md
Normal file
605
PLUGIN_ARCHITECTURE.md
Normal file
@@ -0,0 +1,605 @@
|
||||
# Actual Budget Plugin Architecture
|
||||
|
||||
## Overview
|
||||
|
||||
Actual Budget's plugin system enables extending the sync-server with custom functionality through isolated, sandboxed processes. Plugins run as separate Node.js child processes that communicate with the sync-server via Inter-Process Communication (IPC).
|
||||
|
||||
## Key Concepts
|
||||
|
||||
### Plugin Structure
|
||||
|
||||
A plugin is a standalone Node.js application that:
|
||||
|
||||
- **Runs as a child process** forked from the sync-server
|
||||
- **Uses Express.js** to define HTTP-like routes
|
||||
- **Communicates via IPC** instead of network sockets
|
||||
- **Has isolated dependencies** and runtime environment
|
||||
|
||||
### Core Components
|
||||
|
||||
1. **Plugin Manager** (`sync-server`) - Discovers, loads, and manages plugin lifecycle
|
||||
2. **Plugin Middleware** (`sync-server`) - Routes HTTP requests to appropriate plugins via IPC
|
||||
3. **Plugin Core Library** (`@actual-app/plugins-core-sync-server`) - Utilities for plugin authors
|
||||
4. **Plugin Process** - Your custom plugin code running as a child process
|
||||
|
||||
---
|
||||
|
||||
## Plugin Development
|
||||
|
||||
### 1. Project Setup
|
||||
|
||||
```bash
|
||||
# Create plugin directory
|
||||
mkdir my-plugin
|
||||
cd my-plugin
|
||||
|
||||
# Initialize npm project
|
||||
npm init -y
|
||||
|
||||
# Install dependencies
|
||||
npm install express @actual-app/plugins-core-sync-server
|
||||
npm install -D typescript @types/express @types/node
|
||||
```
|
||||
|
||||
### 2. Create Manifest
|
||||
|
||||
Every plugin needs a `manifest.ts` file that describes the plugin:
|
||||
|
||||
```typescript
|
||||
import { PluginManifest } from '@actual-app/plugins-core-sync-server';
|
||||
|
||||
export const manifest: PluginManifest = {
|
||||
name: 'my-plugin',
|
||||
version: '1.0.0',
|
||||
description: 'My awesome plugin',
|
||||
entry: 'dist/index.js',
|
||||
author: 'Your Name',
|
||||
license: 'MIT',
|
||||
routes: [
|
||||
{
|
||||
path: '/hello',
|
||||
methods: ['GET', 'POST'],
|
||||
auth: 'authenticated', // or 'anonymous'
|
||||
description: 'Hello endpoint',
|
||||
},
|
||||
],
|
||||
bankSync: {
|
||||
// Optional: for bank sync plugins
|
||||
enabled: true,
|
||||
displayName: 'My Bank Provider',
|
||||
description: 'Connect accounts via my provider',
|
||||
requiresAuth: true,
|
||||
endpoints: {
|
||||
status: '/status',
|
||||
accounts: '/accounts',
|
||||
transactions: '/transactions',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export default manifest;
|
||||
```
|
||||
|
||||
### 3. Create Plugin Code
|
||||
|
||||
```typescript
|
||||
import express from 'express';
|
||||
import {
|
||||
attachPluginMiddleware,
|
||||
saveSecret,
|
||||
getSecret,
|
||||
} from '@actual-app/plugins-core-sync-server';
|
||||
|
||||
const app = express();
|
||||
|
||||
// Essential: Parse JSON request bodies
|
||||
app.use(express.json());
|
||||
|
||||
// Essential: Enable IPC communication with sync-server
|
||||
attachPluginMiddleware(app);
|
||||
|
||||
// Define your routes
|
||||
app.get('/hello', (req, res) => {
|
||||
res.json({ message: 'Hello from plugin!' });
|
||||
});
|
||||
|
||||
app.post('/save-config', async (req, res) => {
|
||||
const { apiKey } = req.body;
|
||||
|
||||
// Save secrets (encrypted & user-scoped)
|
||||
await saveSecret(req, 'apiKey', apiKey);
|
||||
|
||||
res.json({ success: true });
|
||||
});
|
||||
|
||||
app.get('/config', async (req, res) => {
|
||||
// Retrieve secrets
|
||||
const result = await getSecret(req, 'apiKey');
|
||||
|
||||
res.json({ configured: !!result.value });
|
||||
});
|
||||
|
||||
// No need to call app.listen() - IPC handles communication
|
||||
console.log('My plugin loaded successfully');
|
||||
```
|
||||
|
||||
### 4. Build Configuration
|
||||
|
||||
```json
|
||||
{
|
||||
"scripts": {
|
||||
"build": "tsc && node build-manifest.js",
|
||||
"dev": "tsc --watch"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
The build process should:
|
||||
|
||||
1. Compile TypeScript to JavaScript
|
||||
2. Convert `manifest.ts` to `manifest.json`
|
||||
|
||||
---
|
||||
|
||||
## Plugin Loading Process
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
A[Sync-Server Starts] --> B[Initialize PluginManager]
|
||||
B --> C[Scan plugins-api Directory]
|
||||
C --> D{Find Plugins}
|
||||
D -->|For each plugin| E[Read manifest.json]
|
||||
E --> F{Valid Manifest?}
|
||||
F -->|No| G[Skip Plugin]
|
||||
F -->|Yes| H[Fork Child Process]
|
||||
H --> I[Pass Environment Variables]
|
||||
I --> J[Plugin Process Starts]
|
||||
J --> K[attachPluginMiddleware Called]
|
||||
K --> L[Plugin Sends 'ready' Message]
|
||||
L --> M{Ready within timeout?}
|
||||
M -->|No| N[Reject Plugin]
|
||||
M -->|Yes| O[Mark Plugin as Online]
|
||||
O --> P[Register Routes]
|
||||
P --> Q[Plugin Available]
|
||||
|
||||
style A fill:#e1f5ff
|
||||
style Q fill:#d4edda
|
||||
style G fill:#f8d7da
|
||||
style N fill:#f8d7da
|
||||
```
|
||||
|
||||
### Loading Sequence Diagram
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant SS as Sync-Server
|
||||
participant PM as PluginManager
|
||||
participant FS as File System
|
||||
participant PP as Plugin Process
|
||||
|
||||
SS->>PM: Initialize(pluginsDir)
|
||||
SS->>PM: loadPlugins()
|
||||
|
||||
PM->>FS: Read plugins-api directory
|
||||
FS-->>PM: List of plugin folders
|
||||
|
||||
loop For each plugin
|
||||
PM->>FS: Read manifest.json
|
||||
FS-->>PM: Manifest data
|
||||
|
||||
PM->>PM: Validate manifest
|
||||
|
||||
PM->>PP: fork(entryPoint)
|
||||
Note over PP: Plugin process starts
|
||||
|
||||
PP->>PP: Create Express app
|
||||
PP->>PP: Define routes
|
||||
PP->>PP: attachPluginMiddleware()
|
||||
|
||||
PP-->>PM: IPC: {type: 'ready'}
|
||||
|
||||
PM->>PM: Mark plugin as online
|
||||
PM->>PM: Register routes
|
||||
end
|
||||
|
||||
PM-->>SS: All plugins loaded
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Communication Architecture
|
||||
|
||||
### HTTP Request Flow
|
||||
|
||||
When a client makes a request to a plugin endpoint:
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant C as Client
|
||||
participant SS as Sync-Server
|
||||
participant PM as PluginMiddleware
|
||||
participant MGR as PluginManager
|
||||
participant PP as Plugin Process
|
||||
|
||||
C->>SS: POST /plugins-api/my-plugin/hello
|
||||
SS->>PM: Route to plugin middleware
|
||||
|
||||
PM->>PM: Extract plugin slug & route
|
||||
PM->>PM: Check authentication
|
||||
PM->>PM: Verify route permissions
|
||||
|
||||
PM->>MGR: sendRequest(pluginSlug, requestData)
|
||||
|
||||
MGR->>PP: IPC: {type: 'request', method, path, body}
|
||||
|
||||
Note over PP: Plugin receives IPC message
|
||||
PP->>PP: Simulate HTTP request
|
||||
PP->>PP: Route to Express handler
|
||||
PP->>PP: Execute business logic
|
||||
|
||||
PP-->>MGR: IPC: {type: 'response', status, body}
|
||||
|
||||
MGR-->>PM: Response data
|
||||
PM-->>SS: Forward response
|
||||
SS-->>C: HTTP Response
|
||||
```
|
||||
|
||||
### IPC Message Types
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
subgraph "Sync-Server → Plugin"
|
||||
A[request<br/>HTTP request data]
|
||||
B[secret-response<br/>Secret value response]
|
||||
end
|
||||
|
||||
subgraph "Plugin → Sync-Server"
|
||||
C[ready<br/>Plugin initialized]
|
||||
D[response<br/>HTTP response data]
|
||||
E[secret-get<br/>Request secret]
|
||||
F[secret-set<br/>Save secret]
|
||||
G[error<br/>Error occurred]
|
||||
end
|
||||
|
||||
style A fill:#fff3cd
|
||||
style B fill:#fff3cd
|
||||
style C fill:#d4edda
|
||||
style D fill:#d4edda
|
||||
style E fill:#d1ecf1
|
||||
style F fill:#d1ecf1
|
||||
style G fill:#f8d7da
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Secrets Management
|
||||
|
||||
Plugins can store encrypted, user-scoped secrets (API keys, tokens, etc.):
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant PH as Plugin Handler
|
||||
participant PC as Plugin Core
|
||||
participant PP as Plugin Process (IPC)
|
||||
participant PM as PluginManager
|
||||
participant SS as Secrets Store
|
||||
|
||||
Note over PH: User saves API key
|
||||
|
||||
PH->>PC: saveSecret(req, 'apiKey', 'abc123')
|
||||
PC->>PC: Namespace: 'my-plugin_apiKey'
|
||||
PC->>PP: process.send({type: 'secret-set'})
|
||||
|
||||
PP-->>PM: IPC: secret-set message
|
||||
PM->>SS: Store secret (encrypted)
|
||||
SS-->>PM: Success
|
||||
|
||||
PM-->>PP: IPC: secret-response
|
||||
PP-->>PC: Promise resolves
|
||||
PC-->>PH: {success: true}
|
||||
|
||||
Note over PH: Later: retrieve secret
|
||||
|
||||
PH->>PC: getSecret(req, 'apiKey')
|
||||
PC->>PP: process.send({type: 'secret-get'})
|
||||
PP-->>PM: IPC: secret-get message
|
||||
PM->>SS: Retrieve secret
|
||||
SS-->>PM: Decrypted value
|
||||
PM-->>PP: IPC: secret-response
|
||||
PP-->>PC: Promise resolves
|
||||
PC-->>PH: {value: 'abc123'}
|
||||
```
|
||||
|
||||
**Key Features:**
|
||||
|
||||
- **User-scoped**: Each user has their own secrets
|
||||
- **Encrypted**: Stored securely in the database
|
||||
- **Namespaced**: Automatically prefixed with plugin slug
|
||||
- **Async**: Uses IPC promises for retrieval
|
||||
|
||||
---
|
||||
|
||||
## Plugin Architecture Diagram
|
||||
|
||||
```mermaid
|
||||
flowchart TB
|
||||
subgraph Client["Client (Browser/App)"]
|
||||
UI[User Interface]
|
||||
end
|
||||
|
||||
subgraph SyncServer["Sync-Server Process"]
|
||||
HTTP[HTTP Server]
|
||||
AUTH[Authentication]
|
||||
API[API Routes]
|
||||
PMW[Plugin Middleware]
|
||||
MGR[Plugin Manager]
|
||||
SEC[Secrets Store]
|
||||
end
|
||||
|
||||
subgraph Plugin1["Plugin Process 1"]
|
||||
P1APP[Express App]
|
||||
P1MW[Plugin Middleware]
|
||||
P1ROUTES[Route Handlers]
|
||||
P1LOGIC[Business Logic]
|
||||
end
|
||||
|
||||
subgraph Plugin2["Plugin Process 2"]
|
||||
P2APP[Express App]
|
||||
P2MW[Plugin Middleware]
|
||||
P2ROUTES[Route Handlers]
|
||||
P2LOGIC[Business Logic]
|
||||
end
|
||||
|
||||
UI -->|HTTP Request| HTTP
|
||||
HTTP --> AUTH
|
||||
AUTH --> API
|
||||
API --> PMW
|
||||
PMW -->|Route| MGR
|
||||
|
||||
MGR <-->|IPC<br/>Messages| P1MW
|
||||
MGR <-->|IPC<br/>Messages| P2MW
|
||||
|
||||
P1MW --> P1APP
|
||||
P1APP --> P1ROUTES
|
||||
P1ROUTES --> P1LOGIC
|
||||
|
||||
P2MW --> P2APP
|
||||
P2APP --> P2ROUTES
|
||||
P2ROUTES --> P2LOGIC
|
||||
|
||||
P1LOGIC <-.->|Secret<br/>Requests| MGR
|
||||
P2LOGIC <-.->|Secret<br/>Requests| MGR
|
||||
MGR <-.-> SEC
|
||||
|
||||
style Client fill:#e1f5ff
|
||||
style SyncServer fill:#fff3cd
|
||||
style Plugin1 fill:#d4edda
|
||||
style Plugin2 fill:#d4edda
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Bank Sync Plugins
|
||||
|
||||
Bank sync plugins follow a specific contract to integrate with Actual's account linking:
|
||||
|
||||
### Required Endpoints
|
||||
|
||||
1. **`/status`** - Check if plugin is configured
|
||||
|
||||
```json
|
||||
Response: {
|
||||
"status": "ok",
|
||||
"data": { "configured": true }
|
||||
}
|
||||
```
|
||||
|
||||
2. **`/accounts`** - Fetch available accounts
|
||||
|
||||
```json
|
||||
Response: {
|
||||
"status": "ok",
|
||||
"data": {
|
||||
"accounts": [
|
||||
{
|
||||
"account_id": "ext-123",
|
||||
"name": "Checking",
|
||||
"institution": "My Bank",
|
||||
"balance": 1000,
|
||||
"mask": "1234",
|
||||
"official_name": "Primary Checking",
|
||||
"orgDomain": "mybank.com",
|
||||
"orgId": "bank-001"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
3. **`/transactions`** - Fetch transactions
|
||||
|
||||
```json
|
||||
Request: {
|
||||
"accountId": "ext-123",
|
||||
"startDate": "2024-01-01"
|
||||
}
|
||||
|
||||
Response: {
|
||||
"status": "ok",
|
||||
"data": {
|
||||
"transactions": {
|
||||
"booked": [...],
|
||||
"pending": [...]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Best Practices
|
||||
|
||||
### 1. Error Handling
|
||||
|
||||
```typescript
|
||||
app.post('/endpoint', async (req, res) => {
|
||||
try {
|
||||
const result = await doSomething();
|
||||
res.json({ status: 'ok', data: result });
|
||||
} catch (error) {
|
||||
res.json({
|
||||
status: 'error',
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
});
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
### 2. Input Validation
|
||||
|
||||
```typescript
|
||||
app.post('/config', async (req, res) => {
|
||||
const { apiKey } = req.body;
|
||||
|
||||
if (!apiKey || typeof apiKey !== 'string') {
|
||||
return res.json({
|
||||
status: 'error',
|
||||
error: 'apiKey is required',
|
||||
});
|
||||
}
|
||||
|
||||
// Process...
|
||||
});
|
||||
```
|
||||
|
||||
### 3. Logging
|
||||
|
||||
```typescript
|
||||
// Plugin stdout/stderr is visible in sync-server logs
|
||||
console.log('[MY-PLUGIN] Processing request...');
|
||||
console.error('[MY-PLUGIN] Error occurred:', error);
|
||||
```
|
||||
|
||||
### 4. Graceful Shutdown
|
||||
|
||||
```typescript
|
||||
process.on('SIGTERM', () => {
|
||||
console.log('[MY-PLUGIN] Shutting down...');
|
||||
// Cleanup resources
|
||||
process.exit(0);
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Deployment
|
||||
|
||||
### File Structure
|
||||
|
||||
```
|
||||
sync-server/
|
||||
└── user-files/
|
||||
└── plugins-api/
|
||||
└── my-plugin/
|
||||
├── manifest.json
|
||||
├── package.json
|
||||
├── node_modules/
|
||||
└── dist/
|
||||
└── index.js
|
||||
```
|
||||
|
||||
### Installation Steps
|
||||
|
||||
1. **Build the plugin** (as ZIP or folder)
|
||||
2. **Place in plugins-api directory**
|
||||
3. **Restart sync-server** (auto-loads on startup)
|
||||
|
||||
### ZIP Format (Recommended)
|
||||
|
||||
```
|
||||
my-plugin.zip
|
||||
├── manifest.json
|
||||
├── package.json
|
||||
├── node_modules/
|
||||
└── dist/
|
||||
└── index.js
|
||||
```
|
||||
|
||||
The plugin manager automatically extracts ZIPs to a temporary directory.
|
||||
|
||||
---
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Plugin Not Loading
|
||||
|
||||
- Check `manifest.json` exists and is valid JSON
|
||||
- Verify `entry` field points to correct file
|
||||
- Check sync-server logs for error messages
|
||||
|
||||
### IPC Communication Failures
|
||||
|
||||
- Ensure `attachPluginMiddleware(app)` is called
|
||||
- Verify plugin sends `ready` message within 10s timeout
|
||||
- Check that `process.send` is available (forked process)
|
||||
|
||||
### Route Not Found
|
||||
|
||||
- Verify route is defined in `manifest.json`
|
||||
- Check authentication requirements match
|
||||
- Ensure route path matches exactly (case-sensitive)
|
||||
|
||||
### Secrets Not Persisting
|
||||
|
||||
- Confirm user is authenticated
|
||||
- Check `pluginSlug` is passed in request context
|
||||
- Verify secrets store is properly initialized
|
||||
|
||||
---
|
||||
|
||||
## Example: Complete Bank Sync Plugin
|
||||
|
||||
See the [Pluggy.ai plugin](packages/bank-sync-plugin-pluggy.ai/) for a full working example that demonstrates:
|
||||
|
||||
- Authentication and configuration
|
||||
- Account fetching with proper typing
|
||||
- Transaction synchronization
|
||||
- Secret management
|
||||
- Error handling
|
||||
- TypeScript usage
|
||||
|
||||
---
|
||||
|
||||
## API Reference
|
||||
|
||||
### `attachPluginMiddleware(app: Express)`
|
||||
|
||||
Enables IPC communication for the plugin. Must be called before defining routes.
|
||||
|
||||
### `saveSecret(req: Request, key: string, value: string)`
|
||||
|
||||
Saves an encrypted, user-scoped secret.
|
||||
|
||||
### `getSecret(req: Request, key: string)`
|
||||
|
||||
Retrieves a secret by key.
|
||||
|
||||
### `saveSecrets(req: Request, secrets: Record<string, string>)`
|
||||
|
||||
Saves multiple secrets at once.
|
||||
|
||||
### `getSecrets(req: Request, keys: string[])`
|
||||
|
||||
Retrieves multiple secrets at once.
|
||||
|
||||
---
|
||||
|
||||
## Security Considerations
|
||||
|
||||
1. **Process Isolation** - Each plugin runs in its own process
|
||||
2. **Route Authentication** - Manifest declares auth requirements
|
||||
3. **Secret Encryption** - All secrets encrypted at rest
|
||||
4. **User Scoping** - Secrets isolated per user
|
||||
5. **Namespace Isolation** - Secrets auto-prefixed with plugin slug
|
||||
6. **No Direct DB Access** - Plugins can't access database directly
|
||||
7. **Controlled IPC** - Only specific message types allowed
|
||||
44
README.md
44
README.md
@@ -14,22 +14,40 @@ Want to say thanks? Click the ⭐ at the top of the page.
|
||||
|
||||
- Actual [discord](https://discord.gg/pRYNYr4W5A) community.
|
||||
- Actual [Community Documentation](https://actualbudget.org/docs)
|
||||
- [Frequently asked questions](https://actualbudget.org/docs/faq)
|
||||
|
||||
## Installation
|
||||
|
||||
If you are only interested in running the latest version and not contributing to the source code, you don't need to clone this repo. You can get the latest version through npm.
|
||||
There are four ways to deploy Actual:
|
||||
|
||||
### The easy way: using a server (recommended)
|
||||
1. One-click deployment [via PikaPods](https://www.pikapods.com/pods?run=actual) (~1.40 $/month) - recommended for non-technical users
|
||||
1. Managed hosting [via Fly.io](https://actualbudget.org/docs/install/fly) (~1.50 $/month)
|
||||
1. Self-hosted by using [a Docker image](https://actualbudget.org/docs/install/docker)
|
||||
1. Local-only apps - [downloadable Windows, Mac and Linux apps](https://actualbudget.org/download/) you can run on your device
|
||||
|
||||
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.
|
||||
Learn more in the [installation instructions docs](https://actualbudget.org/docs/install/).
|
||||
|
||||
You can get up and running quickly and easily by following our [Running Actual Locally Guide](https://actualbudget.org/docs/install/local)
|
||||
## Ready to Start Budgeting?
|
||||
|
||||
Read about [Envelope budgeting](https://actualbudget.org/docs/getting-started/envelope-budgeting) to know more about the idea behind Actual Budget.
|
||||
|
||||
### Are you new to budgeting or want to start fresh?
|
||||
|
||||
Check out the community's [Starting Fresh](https://actualbudget.org/docs/getting-started/starting-fresh) guide so you can quickly get up and running!
|
||||
|
||||
### Are you migrating from other budgeting apps?
|
||||
|
||||
Check out the community's [Migration](https://actualbudget.org/docs/migration/) guide to start jumping on the Actual Budget train!
|
||||
|
||||
## Documentation
|
||||
|
||||
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
|
||||
## Contributing
|
||||
|
||||
Actual is a community driven product. Learn more about [contributing to Actual](https://actualbudget.org/docs/contributing/).
|
||||
|
||||
### Code structure
|
||||
|
||||
The Actual app is split up into a few packages:
|
||||
|
||||
@@ -39,15 +57,27 @@ The Actual app is split up into a few packages:
|
||||
|
||||
More information on the project structure is available in our [community documentation](https://actualbudget.org/docs/contributing/project-details).
|
||||
|
||||
## Feature Requests
|
||||
### 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.
|
||||
|
||||
### Translation
|
||||
|
||||
Make Actual Budget accessible to more people by helping with the [Internationalization](https://actualbudget.org/docs/contributing/i18n/) of Actual. We are using a crowd sourcing tool to manage the translations, see our [Weblate Project](https://hosted.weblate.org/projects/actualbudget/). Weblate proudly supports open-source software projects through their [Libre plan](https://weblate.org/en/hosting/#libre).
|
||||
|
||||
<a href="https://hosted.weblate.org/engage/actualbudget/">
|
||||
<img src="https://hosted.weblate.org/widget/actualbudget/actual/287x66-grey.png" alt="Translation status" />
|
||||
</a>
|
||||
|
||||
## Repo Activity
|
||||
|
||||

|
||||
|
||||
## Sponsors
|
||||
|
||||
Thanks to our wonderful sponsors who make Actual budget possible!
|
||||
Thanks to our wonderful sponsors who make Actual Budget possible!
|
||||
|
||||
<a href="https://www.netlify.com"> <img src="https://www.netlify.com/v3/img/components/netlify-color-accent.svg" alt="Deploys by Netlify" /> </a>
|
||||
|
||||
@@ -10,4 +10,4 @@ if [ ! -d "node_modules" ] || [ "$(ls -A node_modules)" = "" ]; then
|
||||
yarn
|
||||
fi
|
||||
|
||||
yarn start:browser
|
||||
BROWSER=0 yarn start:browser
|
||||
|
||||
@@ -4,6 +4,19 @@ ROOT=`dirname $0`
|
||||
|
||||
cd "$ROOT/.."
|
||||
|
||||
echo "Updating translations..."
|
||||
if ! [ -d packages/desktop-client/locale ]; then
|
||||
git clone https://github.com/actualbudget/translations packages/desktop-client/locale
|
||||
fi
|
||||
pushd packages/desktop-client/locale > /dev/null
|
||||
git checkout .
|
||||
git pull
|
||||
popd > /dev/null
|
||||
packages/desktop-client/bin/remove-untranslated-languages
|
||||
|
||||
export NODE_OPTIONS="--max-old-space-size=4096"
|
||||
|
||||
yarn workspace plugins-service build
|
||||
yarn workspace loot-core build:browser
|
||||
yarn workspace @actual-app/web build:browser
|
||||
|
||||
|
||||
@@ -3,12 +3,11 @@
|
||||
|
||||
ROOT=`dirname $0`
|
||||
RELEASE=""
|
||||
RELEASE_NOTES="" # TODO: figure out automation for release notes when we start publishing electron versions
|
||||
CI=${CI:-false}
|
||||
|
||||
cd "$ROOT/.."
|
||||
|
||||
POSITIONAL=()
|
||||
SKIP_EXE_BUILD=false
|
||||
while [[ $# -gt 0 ]]; do
|
||||
key="$1"
|
||||
|
||||
@@ -17,31 +16,39 @@ while [[ $# -gt 0 ]]; do
|
||||
RELEASE="production"
|
||||
shift
|
||||
;;
|
||||
--skip-exe-build)
|
||||
SKIP_EXE_BUILD=true
|
||||
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
|
||||
# Get translations
|
||||
echo "Updating translations..."
|
||||
if ! [ -d packages/desktop-client/locale ]; then
|
||||
git clone https://github.com/actualbudget/translations packages/desktop-client/locale
|
||||
fi
|
||||
pushd packages/desktop-client/locale > /dev/null
|
||||
git pull
|
||||
popd > /dev/null
|
||||
packages/desktop-client/bin/remove-untranslated-languages
|
||||
|
||||
yarn patch-package
|
||||
|
||||
yarn rebuild-electron
|
||||
export NODE_OPTIONS="--max-old-space-size=4096"
|
||||
|
||||
yarn workspace plugins-service build
|
||||
yarn workspace loot-core build:node
|
||||
yarn workspace @actual-app/web build --mode=desktop # electron specific build
|
||||
|
||||
yarn workspace @actual-app/web build
|
||||
# required for running the sync-server server
|
||||
yarn workspace loot-core build:browser
|
||||
yarn workspace @actual-app/web build:browser
|
||||
yarn workspace @actual-app/sync-server build
|
||||
|
||||
yarn workspace desktop-electron update-client
|
||||
|
||||
@@ -49,16 +56,20 @@ yarn workspace desktop-electron 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 with release notes \"$RELEASE_NOTES\""
|
||||
if [ $SKIP_EXE_BUILD == true ]; then
|
||||
echo "Building the dist"
|
||||
yarn build:dist
|
||||
echo "Skipping exe build"
|
||||
else
|
||||
SKIP_NOTARIZATION=true yarn build --publish never --x64
|
||||
if [ "$RELEASE" == "production" ]; then
|
||||
if [ -f ../../.secret-tokens ]; then
|
||||
source ../../.secret-tokens
|
||||
fi
|
||||
yarn build
|
||||
|
||||
echo "Created release"
|
||||
else
|
||||
SKIP_NOTARIZATION=true yarn build
|
||||
fi
|
||||
fi
|
||||
)
|
||||
|
||||
182
bin/release-note-generator.ts
Normal file
182
bin/release-note-generator.ts
Normal file
@@ -0,0 +1,182 @@
|
||||
import { exec } from 'node:child_process';
|
||||
import { existsSync, writeFile } from 'node:fs';
|
||||
import { exit } from 'node:process';
|
||||
|
||||
import prompts from 'prompts';
|
||||
|
||||
async function run() {
|
||||
const username = await execAsync(
|
||||
// eslint-disable-next-line actual/typography
|
||||
"gh api user --jq '.login'",
|
||||
'To avoid having to enter your username, consider installing the official GitHub CLI (https://github.com/cli/cli) and logging in with `gh auth login`.',
|
||||
);
|
||||
const activePr = await getActivePr(username);
|
||||
if (activePr) {
|
||||
console.log(
|
||||
`Found potentially matching PR ${activePr.number}: ${activePr.title}`,
|
||||
);
|
||||
}
|
||||
const initialPrNumber = activePr?.number ?? (await getNextPrNumber());
|
||||
|
||||
const result = await prompts([
|
||||
{
|
||||
name: 'githubUsername',
|
||||
message: 'Comma-separated GitHub username(s)',
|
||||
type: 'text',
|
||||
initial: username,
|
||||
},
|
||||
{
|
||||
name: 'pullRequestNumber',
|
||||
message: 'PR Number',
|
||||
type: 'number',
|
||||
initial: initialPrNumber,
|
||||
},
|
||||
{
|
||||
name: 'releaseNoteType',
|
||||
message: 'Release Note Type',
|
||||
type: 'select',
|
||||
choices: [
|
||||
{ title: '✨ Features', value: 'Features' },
|
||||
{ title: '👍 Enhancements', value: 'Enhancements' },
|
||||
{ title: '🐛 Bugfix', value: 'Bugfix' },
|
||||
{ title: '⚙️ Maintenance', value: 'Maintenance' },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'oneLineSummary',
|
||||
message: 'Brief Summary',
|
||||
type: 'text',
|
||||
initial: activePr?.title,
|
||||
},
|
||||
]);
|
||||
|
||||
if (
|
||||
!result.githubUsername ||
|
||||
!result.oneLineSummary ||
|
||||
!result.releaseNoteType ||
|
||||
!result.pullRequestNumber
|
||||
) {
|
||||
console.log('All questions must be answered. Exiting');
|
||||
exit(1);
|
||||
}
|
||||
|
||||
const fileContents = getFileContents(
|
||||
result.releaseNoteType,
|
||||
result.githubUsername,
|
||||
result.oneLineSummary,
|
||||
);
|
||||
const prNumber = result.pullRequestNumber;
|
||||
|
||||
const filepath = `./upcoming-release-notes/${prNumber}.md`;
|
||||
if (existsSync(filepath)) {
|
||||
const { confirm } = await prompts({
|
||||
name: 'confirm',
|
||||
type: 'confirm',
|
||||
message: `This will overwrite the existing release note ${filepath} Are you sure?`,
|
||||
});
|
||||
if (!confirm) {
|
||||
console.log('Exiting');
|
||||
exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
writeFile(filepath, fileContents, err => {
|
||||
if (err) {
|
||||
console.error('Failed to write release note file:', err);
|
||||
exit(1);
|
||||
} else {
|
||||
console.log(`Release note generated successfully: ${filepath}`);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// makes an attempt to find an existing open PR from <username>:<branch>
|
||||
async function getActivePr(
|
||||
username: string,
|
||||
): Promise<{ number: number; title: string } | undefined> {
|
||||
if (!username) {
|
||||
return undefined;
|
||||
}
|
||||
const branchName = await execAsync('git rev-parse --abbrev-ref HEAD');
|
||||
if (!branchName) {
|
||||
return undefined;
|
||||
}
|
||||
const forkHead = `${username}:${branchName}`;
|
||||
return getPrNumberFromHead(forkHead);
|
||||
}
|
||||
|
||||
async function getPrNumberFromHead(
|
||||
head: string,
|
||||
): Promise<{ number: number; title: string } | undefined> {
|
||||
try {
|
||||
// head is a weird query parameter in this API call. If nothing matches, it
|
||||
// will return as if the head query parameter doesn't exist. To get around
|
||||
// this, we make the page size 2 and only return the number if the length.
|
||||
const resp = await fetch(
|
||||
'https://api.github.com/repos/actualbudget/actual/pulls?state=open&per_page=2&head=' +
|
||||
head,
|
||||
);
|
||||
if (!resp.ok) {
|
||||
console.warn('error fetching from github pulls api:', resp.status);
|
||||
return undefined;
|
||||
}
|
||||
const ghResponse = await resp.json();
|
||||
if (ghResponse?.length === 1) {
|
||||
return ghResponse[0];
|
||||
} else {
|
||||
return undefined;
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('error fetching from github pulls api:', e);
|
||||
}
|
||||
}
|
||||
|
||||
async function getNextPrNumber(): Promise<number> {
|
||||
try {
|
||||
const resp = await fetch(
|
||||
'https://api.github.com/repos/actualbudget/actual/issues?state=all&per_page=1',
|
||||
);
|
||||
if (!resp.ok) {
|
||||
throw new Error(`API responded with status: ${resp.status}`);
|
||||
}
|
||||
const ghResponse = await resp.json();
|
||||
const latestPrNumber = ghResponse?.[0]?.number;
|
||||
if (!latestPrNumber) {
|
||||
console.error(
|
||||
'Could not find latest issue number in GitHub API response',
|
||||
ghResponse,
|
||||
);
|
||||
exit(1);
|
||||
}
|
||||
return latestPrNumber + 1;
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch next PR number:', error);
|
||||
exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
function getFileContents(type: string, username: string, summary: string) {
|
||||
return `---
|
||||
category: ${type}
|
||||
authors: [${username}]
|
||||
---
|
||||
|
||||
${summary}
|
||||
`;
|
||||
}
|
||||
|
||||
// simple exec that fails silently and returns an empty string on failure
|
||||
async function execAsync(cmd: string, errorLog?: string): Promise<string> {
|
||||
return new Promise<string>(res => {
|
||||
exec(cmd, (error, stdout) => {
|
||||
if (error) {
|
||||
console.log(errorLog);
|
||||
res('');
|
||||
} else {
|
||||
res(stdout.trim());
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
run();
|
||||
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"
|
||||
|
||||
MSYS_NO_PATHCONV=1 docker run --rm --network host -v "$(pwd)":/work/ -w /work/ -it mcr.microsoft.com/playwright:v1.55.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 |
@@ -8,9 +8,10 @@ services:
|
||||
actual-development:
|
||||
build: .
|
||||
image: actual-development
|
||||
environment:
|
||||
- HTTPS
|
||||
ports:
|
||||
- '3001:3001'
|
||||
volumes:
|
||||
- '.:/app'
|
||||
restart: 'no'
|
||||
|
||||
|
||||
820
eslint.config.mjs
Normal file
820
eslint.config.mjs
Normal file
@@ -0,0 +1,820 @@
|
||||
import globals from 'globals';
|
||||
|
||||
import pluginImport from 'eslint-plugin-import';
|
||||
import pluginJSXA11y from 'eslint-plugin-jsx-a11y';
|
||||
import pluginReact from 'eslint-plugin-react';
|
||||
import pluginReactHooks from 'eslint-plugin-react-hooks';
|
||||
import pluginTypescript from 'typescript-eslint';
|
||||
import pluginTypescriptPaths from 'eslint-plugin-typescript-paths';
|
||||
import pluginActual from './packages/eslint-plugin-actual/lib/index.js';
|
||||
|
||||
import tsParser from '@typescript-eslint/parser';
|
||||
|
||||
const confusingBrowserGlobals = [
|
||||
// https://github.com/facebook/create-react-app/tree/main/packages/confusing-browser-globals
|
||||
'addEventListener',
|
||||
'blur',
|
||||
'close',
|
||||
'closed',
|
||||
'confirm',
|
||||
'defaultStatus',
|
||||
'defaultstatus',
|
||||
'event',
|
||||
'external',
|
||||
'find',
|
||||
'focus',
|
||||
'frameElement',
|
||||
'frames',
|
||||
'history',
|
||||
'innerHeight',
|
||||
'innerWidth',
|
||||
'length',
|
||||
'location',
|
||||
'locationbar',
|
||||
'menubar',
|
||||
'moveBy',
|
||||
'moveTo',
|
||||
'name',
|
||||
'onblur',
|
||||
'onerror',
|
||||
'onfocus',
|
||||
'onload',
|
||||
'onresize',
|
||||
'onunload',
|
||||
'open',
|
||||
'opener',
|
||||
'opera',
|
||||
'outerHeight',
|
||||
'outerWidth',
|
||||
'pageXOffset',
|
||||
'pageYOffset',
|
||||
'parent',
|
||||
'print',
|
||||
'removeEventListener',
|
||||
'resizeBy',
|
||||
'resizeTo',
|
||||
'screen',
|
||||
'screenLeft',
|
||||
'screenTop',
|
||||
'screenX',
|
||||
'screenY',
|
||||
'scroll',
|
||||
'scrollbars',
|
||||
'scrollBy',
|
||||
'scrollTo',
|
||||
'scrollX',
|
||||
'scrollY',
|
||||
'status',
|
||||
'statusbar',
|
||||
'stop',
|
||||
'toolbar',
|
||||
'top',
|
||||
];
|
||||
|
||||
export default pluginTypescript.config(
|
||||
{
|
||||
ignores: [
|
||||
// Global ignore patterns
|
||||
'**/node_modules/**',
|
||||
'**/dist/**',
|
||||
'**/*.zip',
|
||||
// Specific ignore patterns
|
||||
'packages/api/app/bundle.api.js',
|
||||
'packages/api/app/stats.json',
|
||||
'packages/api/@types',
|
||||
'packages/api/migrations',
|
||||
'packages/component-library/src/icons/**/*',
|
||||
'packages/desktop-client/bundle.browser.js',
|
||||
'packages/desktop-client/build/',
|
||||
'packages/desktop-client/service-worker/*',
|
||||
'packages/desktop-client/build-electron/',
|
||||
'packages/desktop-client/build-stats/',
|
||||
'packages/desktop-client/public/kcab/',
|
||||
'packages/desktop-client/public/data/',
|
||||
'packages/desktop-client/test-results/',
|
||||
'packages/desktop-client/playwright-report/',
|
||||
'packages/desktop-electron/client-build/',
|
||||
'packages/desktop-electron/build/',
|
||||
'packages/loot-core/**/lib-dist/*',
|
||||
'packages/loot-core/**/proto/*',
|
||||
'packages/sync-server/build/',
|
||||
'.yarn/*',
|
||||
'.github/*',
|
||||
],
|
||||
},
|
||||
{
|
||||
// Temporary until the sync-server is migrated to TypeScript
|
||||
files: [
|
||||
'packages/sync-server/**/*.spec.{js,jsx}',
|
||||
'packages/sync-server/**/*.test.{js,jsx}',
|
||||
],
|
||||
languageOptions: {
|
||||
globals: {
|
||||
vi: true,
|
||||
describe: true,
|
||||
expect: true,
|
||||
it: true,
|
||||
beforeAll: true,
|
||||
beforeEach: true,
|
||||
afterAll: true,
|
||||
afterEach: true,
|
||||
test: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
linterOptions: {
|
||||
reportUnusedDisableDirectives: true,
|
||||
},
|
||||
languageOptions: {
|
||||
globals: {
|
||||
...globals.browser,
|
||||
...globals.commonjs,
|
||||
...globals.node,
|
||||
globalThis: false,
|
||||
vi: true,
|
||||
},
|
||||
},
|
||||
settings: {
|
||||
react: {
|
||||
version: 'detect',
|
||||
},
|
||||
|
||||
'import/resolver': {
|
||||
typescript: {
|
||||
alwaysTryTypes: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
pluginReact.configs.flat.recommended,
|
||||
pluginReact.configs.flat['jsx-runtime'],
|
||||
pluginTypescript.configs.recommended,
|
||||
pluginImport.flatConfigs.recommended,
|
||||
{
|
||||
plugins: {
|
||||
actual: pluginActual,
|
||||
},
|
||||
rules: {
|
||||
'actual/no-untranslated-strings': 'error',
|
||||
'actual/prefer-trans-over-t': 'error',
|
||||
},
|
||||
},
|
||||
{
|
||||
files: ['**/*.{js,ts,jsx,tsx}'],
|
||||
plugins: {
|
||||
'jsx-a11y': pluginJSXA11y,
|
||||
'react-hooks': pluginReactHooks,
|
||||
},
|
||||
rules: {
|
||||
// http://eslint.org/docs/rules/
|
||||
'array-callback-return': 'warn',
|
||||
|
||||
'default-case': [
|
||||
'warn',
|
||||
{
|
||||
commentPattern: '^no default$',
|
||||
},
|
||||
],
|
||||
|
||||
curly: ['warn', 'multi-line', 'consistent'],
|
||||
'dot-location': ['warn', 'property'],
|
||||
eqeqeq: ['warn', 'smart'],
|
||||
'new-parens': 'warn',
|
||||
'no-array-constructor': 'warn',
|
||||
'no-caller': 'warn',
|
||||
'no-cond-assign': ['warn', 'except-parens'],
|
||||
'no-const-assign': 'warn',
|
||||
'no-control-regex': 'warn',
|
||||
'no-delete-var': 'warn',
|
||||
'no-dupe-args': 'warn',
|
||||
'no-dupe-class-members': 'warn',
|
||||
'no-dupe-keys': 'warn',
|
||||
'no-duplicate-case': 'warn',
|
||||
'no-empty-character-class': 'warn',
|
||||
'no-empty-pattern': 'warn',
|
||||
'no-eval': 'warn',
|
||||
'no-ex-assign': 'warn',
|
||||
'no-extend-native': 'warn',
|
||||
'no-extra-bind': 'warn',
|
||||
'no-extra-label': 'warn',
|
||||
'no-fallthrough': 'warn',
|
||||
'no-func-assign': 'warn',
|
||||
'no-implied-eval': 'warn',
|
||||
'no-invalid-regexp': 'warn',
|
||||
'no-iterator': 'warn',
|
||||
'no-label-var': 'warn',
|
||||
|
||||
'no-labels': [
|
||||
'warn',
|
||||
{
|
||||
allowLoop: true,
|
||||
allowSwitch: false,
|
||||
},
|
||||
],
|
||||
|
||||
'no-lone-blocks': 'warn',
|
||||
|
||||
'no-mixed-operators': [
|
||||
'warn',
|
||||
{
|
||||
groups: [
|
||||
['&', '|', '^', '~', '<<', '>>', '>>>'],
|
||||
['==', '!=', '===', '!==', '>', '>=', '<', '<='],
|
||||
['&&', '||'],
|
||||
['in', 'instanceof'],
|
||||
],
|
||||
|
||||
allowSamePrecedence: false,
|
||||
},
|
||||
],
|
||||
|
||||
'no-multi-str': 'warn',
|
||||
'no-global-assign': 'warn',
|
||||
'no-unsafe-negation': 'warn',
|
||||
'no-new-func': 'warn',
|
||||
'no-new-object': 'warn',
|
||||
'no-new-symbol': 'warn',
|
||||
'no-new-wrappers': 'warn',
|
||||
'no-obj-calls': 'warn',
|
||||
'no-octal': 'warn',
|
||||
'no-octal-escape': 'warn',
|
||||
'no-redeclare': 'warn',
|
||||
'no-regex-spaces': 'warn',
|
||||
'no-script-url': 'warn',
|
||||
'no-self-assign': 'warn',
|
||||
'no-self-compare': 'warn',
|
||||
'no-sequences': 'warn',
|
||||
'no-shadow-restricted-names': 'warn',
|
||||
'no-sparse-arrays': 'warn',
|
||||
'no-template-curly-in-string': 'warn',
|
||||
'no-this-before-super': 'warn',
|
||||
'no-throw-literal': 'warn',
|
||||
'no-undef': 'error',
|
||||
'no-unreachable': 'warn',
|
||||
|
||||
'no-unused-expressions': [
|
||||
'error',
|
||||
{
|
||||
allowShortCircuit: true,
|
||||
allowTernary: true,
|
||||
allowTaggedTemplates: true,
|
||||
},
|
||||
],
|
||||
|
||||
'no-unused-labels': 'warn',
|
||||
|
||||
'no-use-before-define': [
|
||||
'warn',
|
||||
{
|
||||
functions: false,
|
||||
classes: false,
|
||||
variables: false,
|
||||
},
|
||||
],
|
||||
|
||||
'no-useless-computed-key': 'warn',
|
||||
'no-useless-concat': 'warn',
|
||||
'no-useless-constructor': 'warn',
|
||||
'no-useless-escape': 'warn',
|
||||
|
||||
'no-useless-rename': [
|
||||
'warn',
|
||||
{
|
||||
ignoreDestructuring: false,
|
||||
ignoreImport: false,
|
||||
ignoreExport: false,
|
||||
},
|
||||
],
|
||||
|
||||
'no-with': 'warn',
|
||||
'no-whitespace-before-property': 'warn',
|
||||
|
||||
'require-yield': 'warn',
|
||||
'rest-spread-spacing': ['warn', 'never'],
|
||||
strict: ['warn', 'never'],
|
||||
'unicode-bom': ['warn', 'never'],
|
||||
'use-isnan': 'warn',
|
||||
'valid-typeof': 'warn',
|
||||
|
||||
'no-restricted-properties': [
|
||||
'error',
|
||||
{
|
||||
object: 'require',
|
||||
property: 'ensure',
|
||||
message:
|
||||
'Please use import() instead. More info: https://facebook.github.io/create-react-app/docs/code-splitting',
|
||||
},
|
||||
{
|
||||
object: 'System',
|
||||
property: 'import',
|
||||
message:
|
||||
'Please use import() instead. More info: https://facebook.github.io/create-react-app/docs/code-splitting',
|
||||
},
|
||||
],
|
||||
|
||||
'getter-return': 'warn',
|
||||
|
||||
// https://github.com/benmosher/eslint-plugin-import/tree/master/docs/rules
|
||||
'import/first': 'error',
|
||||
'import/no-amd': 'error',
|
||||
'import/no-anonymous-default-export': 'warn',
|
||||
'import/no-webpack-loader-syntax': 'error',
|
||||
'import/extensions': [
|
||||
'warn',
|
||||
'never',
|
||||
{
|
||||
json: 'always',
|
||||
},
|
||||
],
|
||||
'import/no-useless-path-segments': 'warn',
|
||||
'import/no-duplicates': [
|
||||
'warn',
|
||||
{
|
||||
'prefer-inline': true,
|
||||
},
|
||||
],
|
||||
'import/order': [
|
||||
'warn',
|
||||
{
|
||||
alphabetize: {
|
||||
caseInsensitive: true,
|
||||
order: 'asc',
|
||||
},
|
||||
|
||||
groups: ['builtin', 'external', 'parent', 'sibling', 'index'],
|
||||
'newlines-between': 'always',
|
||||
|
||||
pathGroups: [
|
||||
{
|
||||
// Enforce that React (and react-related packages) is the first import
|
||||
group: 'builtin',
|
||||
pattern: 'react?(-*)',
|
||||
position: 'before',
|
||||
},
|
||||
{
|
||||
// Separate imports from Actual from "real" external imports
|
||||
group: 'external',
|
||||
pattern: 'loot-{core,design}/**/*',
|
||||
position: 'after',
|
||||
},
|
||||
],
|
||||
|
||||
pathGroupsExcludedImportTypes: ['react'],
|
||||
},
|
||||
],
|
||||
|
||||
// https://github.com/yannickcr/eslint-plugin-react/tree/master/docs/rules
|
||||
'react/forbid-foreign-prop-types': [
|
||||
'warn',
|
||||
{
|
||||
allowInPropTypes: true,
|
||||
},
|
||||
],
|
||||
'react/jsx-no-comment-textnodes': 'warn',
|
||||
'react/jsx-no-duplicate-props': 'warn',
|
||||
'react/jsx-no-target-blank': 'warn',
|
||||
'react/jsx-no-undef': 'error',
|
||||
'react/jsx-pascal-case': [
|
||||
'warn',
|
||||
{
|
||||
allowAllCaps: true,
|
||||
ignore: [],
|
||||
},
|
||||
],
|
||||
'react/no-danger-with-children': 'warn',
|
||||
// Disabled because of undesirable warnings
|
||||
// See https://github.com/facebook/create-react-app/issues/5204 for
|
||||
// blockers until its re-enabled
|
||||
// 'react/no-deprecated': 'warn',
|
||||
'react/no-direct-mutation-state': 'warn',
|
||||
'react/no-is-mounted': 'warn',
|
||||
'react/no-typos': 'error',
|
||||
'react/require-render-return': 'error',
|
||||
'react/style-prop-object': 'warn',
|
||||
'react/jsx-no-useless-fragment': 'warn',
|
||||
'react/self-closing-comp': 'warn',
|
||||
'react/jsx-filename-extension': [
|
||||
'warn',
|
||||
{
|
||||
extensions: ['.jsx', '.tsx'],
|
||||
allow: 'as-needed',
|
||||
},
|
||||
],
|
||||
'react/no-unstable-nested-components': [
|
||||
'warn',
|
||||
{
|
||||
allowAsProps: true,
|
||||
customValidators: ['formatter'],
|
||||
},
|
||||
],
|
||||
// Don't need this as we're using TypeScript
|
||||
'react/prop-types': 'off',
|
||||
|
||||
// https://github.com/evcohen/eslint-plugin-jsx-a11y/tree/master/docs/rules
|
||||
'jsx-a11y/alt-text': 'warn',
|
||||
'jsx-a11y/anchor-has-content': 'warn',
|
||||
'jsx-a11y/anchor-is-valid': [
|
||||
'warn',
|
||||
{
|
||||
aspects: ['noHref', 'invalidHref'],
|
||||
},
|
||||
],
|
||||
'jsx-a11y/aria-activedescendant-has-tabindex': 'warn',
|
||||
'jsx-a11y/aria-props': 'warn',
|
||||
'jsx-a11y/aria-proptypes': 'warn',
|
||||
'jsx-a11y/aria-role': [
|
||||
'warn',
|
||||
{
|
||||
ignoreNonDOM: true,
|
||||
},
|
||||
],
|
||||
'jsx-a11y/aria-unsupported-elements': 'warn',
|
||||
'jsx-a11y/heading-has-content': 'warn',
|
||||
'jsx-a11y/iframe-has-title': 'warn',
|
||||
'jsx-a11y/img-redundant-alt': 'warn',
|
||||
'jsx-a11y/no-access-key': 'warn',
|
||||
'jsx-a11y/no-distracting-elements': 'warn',
|
||||
'jsx-a11y/no-redundant-roles': 'warn',
|
||||
'jsx-a11y/role-has-required-aria-props': 'warn',
|
||||
'jsx-a11y/role-supports-aria-props': 'warn',
|
||||
'jsx-a11y/scope': 'warn',
|
||||
|
||||
// https://github.com/facebook/react/tree/main/packages/eslint-plugin-react-hooks
|
||||
'react-hooks/rules-of-hooks': 'error',
|
||||
'react-hooks/exhaustive-deps': [
|
||||
'warn',
|
||||
{
|
||||
additionalHooks: '(useQuery)',
|
||||
},
|
||||
],
|
||||
|
||||
'actual/typography': 'warn',
|
||||
'actual/prefer-if-statement': 'warn',
|
||||
'actual/prefer-logger-over-console': 'error',
|
||||
|
||||
// Note: base rule explicitly disabled in favor of the TS one
|
||||
'no-unused-vars': 'off',
|
||||
'@typescript-eslint/no-unused-vars': [
|
||||
'warn',
|
||||
{
|
||||
varsIgnorePattern: '^(_|React)',
|
||||
argsIgnorePattern: '^(_|React)',
|
||||
ignoreRestSiblings: true,
|
||||
caughtErrors: 'none',
|
||||
},
|
||||
],
|
||||
|
||||
'no-restricted-globals': ['warn', ...confusingBrowserGlobals],
|
||||
|
||||
// https://github.com/eslint/eslint/issues/16954
|
||||
// https://github.com/eslint/eslint/issues/16953
|
||||
'no-loop-func': 'off',
|
||||
|
||||
// TODO: re-enable these rules
|
||||
'react/react-in-jsx-scope': 'off',
|
||||
'no-var': 'warn',
|
||||
'react/jsx-curly-brace-presence': 'warn',
|
||||
'object-shorthand': ['warn', 'properties'],
|
||||
|
||||
'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',
|
||||
{
|
||||
paths: [
|
||||
{
|
||||
name: 'react-router',
|
||||
importNames: ['useNavigate'],
|
||||
message:
|
||||
"Please import Actual's useNavigate() hook from `src/hooks` instead.",
|
||||
},
|
||||
{
|
||||
name: 'react-redux',
|
||||
importNames: ['useDispatch'],
|
||||
message:
|
||||
"Please import Actual's useDispatch() hook from `src/redux` instead.",
|
||||
},
|
||||
{
|
||||
name: 'react-redux',
|
||||
importNames: ['useSelector'],
|
||||
message:
|
||||
"Please import Actual's useSelector() hook from `src/redux` instead.",
|
||||
},
|
||||
{
|
||||
name: 'react-redux',
|
||||
importNames: ['useStore'],
|
||||
message:
|
||||
"Please import Actual's useStore() hook from `src/redux` instead.",
|
||||
},
|
||||
],
|
||||
patterns: [
|
||||
{
|
||||
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",
|
||||
},
|
||||
{
|
||||
group: ['**/style', '**/colors'],
|
||||
importNames: ['colors'],
|
||||
message: 'Please use themes instead of colors',
|
||||
},
|
||||
{
|
||||
group: ['@actual-app/web/*'],
|
||||
message: 'Please do not import `@actual-app/web` in `loot-core`',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
|
||||
'@typescript-eslint/ban-ts-comment': [
|
||||
'error',
|
||||
{
|
||||
'ts-ignore': 'allow-with-description',
|
||||
},
|
||||
],
|
||||
|
||||
// Rules disabled during TS migration
|
||||
'@typescript-eslint/no-var-requires': 'off',
|
||||
'prefer-const': 'warn',
|
||||
'prefer-spread': 'off',
|
||||
'@typescript-eslint/no-empty-function': 'off',
|
||||
'@typescript-eslint/no-require-imports': 'off',
|
||||
'import/no-default-export': 'warn',
|
||||
},
|
||||
},
|
||||
{
|
||||
files: ['**/*.{ts,tsx}'],
|
||||
|
||||
languageOptions: {
|
||||
parser: tsParser,
|
||||
ecmaVersion: 2018,
|
||||
sourceType: 'module',
|
||||
|
||||
parserOptions: {
|
||||
projectService: true,
|
||||
ecmaFeatures: {
|
||||
jsx: true,
|
||||
},
|
||||
|
||||
// typescript-eslint specific options
|
||||
warnOnUnsupportedTypeScriptVersion: true,
|
||||
},
|
||||
},
|
||||
|
||||
// If adding a typescript-eslint version of an existing ESLint rule,
|
||||
// make sure to disable the ESLint rule here.
|
||||
rules: {
|
||||
// TypeScript's `noFallthroughCasesInSwitch` option is more robust (#6906)
|
||||
'default-case': 'off',
|
||||
// 'tsc' already handles this (https://github.com/typescript-eslint/typescript-eslint/issues/291)
|
||||
'no-dupe-class-members': 'off',
|
||||
// 'tsc' already handles this (https://github.com/typescript-eslint/typescript-eslint/issues/477)
|
||||
'no-undef': 'off',
|
||||
|
||||
// TypeScript already handles these (https://typescript-eslint.io/troubleshooting/typed-linting/performance/#eslint-plugin-import)
|
||||
'import/named': 'off',
|
||||
'import/namespace': 'off',
|
||||
'import/default': 'off',
|
||||
'import/no-named-as-default-member': 'off',
|
||||
'import/no-unresolved': 'off',
|
||||
|
||||
// Add TypeScript specific rules (and turn off ESLint equivalents)
|
||||
'@typescript-eslint/consistent-type-assertions': 'warn',
|
||||
'no-array-constructor': 'off',
|
||||
'@typescript-eslint/no-array-constructor': 'warn',
|
||||
'no-redeclare': 'off',
|
||||
'@typescript-eslint/no-redeclare': 'warn',
|
||||
'no-use-before-define': 'off',
|
||||
|
||||
'@typescript-eslint/no-use-before-define': [
|
||||
'warn',
|
||||
{
|
||||
functions: false,
|
||||
classes: false,
|
||||
variables: false,
|
||||
typedefs: false,
|
||||
},
|
||||
],
|
||||
|
||||
'no-unused-expressions': 'off',
|
||||
|
||||
'@typescript-eslint/no-unused-expressions': [
|
||||
'error',
|
||||
{
|
||||
allowShortCircuit: true,
|
||||
allowTernary: true,
|
||||
allowTaggedTemplates: true,
|
||||
},
|
||||
],
|
||||
|
||||
'no-useless-constructor': 'off',
|
||||
'@typescript-eslint/no-useless-constructor': 'warn',
|
||||
},
|
||||
},
|
||||
{
|
||||
files: ['packages/desktop-client/**/*.{js,ts,jsx,tsx}'],
|
||||
plugins: {
|
||||
'typescript-paths': pluginTypescriptPaths,
|
||||
},
|
||||
rules: {
|
||||
'typescript-paths/absolute-parent-import': [
|
||||
'error',
|
||||
{ preferPathOverBaseUrl: true },
|
||||
],
|
||||
'typescript-paths/absolute-import': ['error', { enableAlias: false }],
|
||||
},
|
||||
},
|
||||
{
|
||||
files: [
|
||||
'packages/desktop-client/**/*.{ts,tsx}',
|
||||
'packages/loot-core/src/client/**/*.{ts,tsx}',
|
||||
],
|
||||
rules: {
|
||||
// enforce import type
|
||||
'@typescript-eslint/consistent-type-imports': [
|
||||
'warn',
|
||||
{
|
||||
prefer: 'type-imports',
|
||||
fixStyle: 'inline-type-imports',
|
||||
},
|
||||
],
|
||||
|
||||
'@typescript-eslint/no-restricted-types': [
|
||||
'warn',
|
||||
{
|
||||
types: {
|
||||
// forbid FC as superflous
|
||||
FunctionComponent: {
|
||||
message:
|
||||
'Type the props argument and let TS infer or use ComponentType for a component prop',
|
||||
},
|
||||
|
||||
FC: {
|
||||
message:
|
||||
'Type the props argument and let TS infer or use ComponentType for a component prop',
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
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/api/migrations/*', 'packages/loot-core/migrations/*'],
|
||||
|
||||
rules: {
|
||||
'import/no-default-export': 'off',
|
||||
},
|
||||
},
|
||||
{
|
||||
files: ['packages/api/index.ts'],
|
||||
rules: {
|
||||
'import/no-unresolved': 'off',
|
||||
},
|
||||
},
|
||||
|
||||
// Allow configuring vitest with default exports (recommended as per vitest docs)
|
||||
{
|
||||
files: ['**/vitest.config.ts', '**/vitest.web.config.ts'],
|
||||
rules: {
|
||||
'import/no-anonymous-default-export': 'off',
|
||||
'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/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/envelope/HoldMenu.tsx',
|
||||
'packages/desktop-client/src/components/budget/envelope/TransferMenu.tsx',
|
||||
'packages/component-library/src/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',
|
||||
],
|
||||
|
||||
rules: {
|
||||
'react-hooks/exhaustive-deps': 'off',
|
||||
},
|
||||
},
|
||||
{
|
||||
files: ['**/*.cjs'],
|
||||
rules: {
|
||||
'@typescript-eslint/no-require-imports': 'off',
|
||||
},
|
||||
},
|
||||
{
|
||||
files: ['**/manifest.ts'],
|
||||
rules: {
|
||||
'import/no-default-export': 'off',
|
||||
},
|
||||
},
|
||||
{
|
||||
files: [
|
||||
'eslint.config.mjs',
|
||||
'**/*.test.js',
|
||||
'**/*.test.ts',
|
||||
'**/*.test.jsx',
|
||||
'**/*.test.tsx',
|
||||
'**/*.spec.js',
|
||||
],
|
||||
|
||||
rules: {
|
||||
'actual/typography': 'off',
|
||||
'actual/no-untranslated-strings': 'off',
|
||||
'actual/prefer-logger-over-console': 'off',
|
||||
},
|
||||
},
|
||||
{
|
||||
files: [
|
||||
'packages/desktop-client/**/*.{ts,tsx}',
|
||||
'packages/loot-core/src/client/**/*.{ts,tsx}',
|
||||
],
|
||||
ignores: ['**/**/globals.d.ts'],
|
||||
rules: {
|
||||
// enforce type over interface
|
||||
'@typescript-eslint/consistent-type-definitions': ['warn', 'type'],
|
||||
},
|
||||
},
|
||||
{
|
||||
files: ['packages/sync-server/**/*'],
|
||||
// TODO: fix the issues in these files
|
||||
rules: {
|
||||
'import/extensions': 'off',
|
||||
'actual/typography': 'off',
|
||||
},
|
||||
},
|
||||
{
|
||||
files: ['packages/sync-server/src/app-gocardless/banks/*.js'],
|
||||
rules: {
|
||||
'import/no-anonymous-default-export': 'off',
|
||||
'import/no-default-export': 'off',
|
||||
},
|
||||
},
|
||||
);
|
||||
95
package.json
95
package.json
@@ -19,51 +19,90 @@
|
||||
},
|
||||
"scripts": {
|
||||
"start": "yarn start:browser",
|
||||
"start:desktop": "npm-run-all --parallel 'start:desktop-*'",
|
||||
"start:server": "yarn workspace @actual-app/sync-server start",
|
||||
"start:server-monitor": "yarn workspace @actual-app/sync-server start-monitor",
|
||||
"start:server-dev": "NODE_ENV=development BROWSER_OPEN=localhost:5006 yarn npm-run-all --parallel 'start:server-monitor' 'start'",
|
||||
"start:desktop": "yarn desktop-dependencies && npm-run-all --parallel 'start:desktop-*'",
|
||||
"desktop-dependencies": "npm-run-all --parallel rebuild-electron build:browser-backend build:plugins-service",
|
||||
"start:desktop-node": "yarn workspace loot-core watch:node",
|
||||
"start:desktop-client": "yarn workspace @actual-app/web watch",
|
||||
"start:desktop-server-client": "yarn workspace @actual-app/web build:browser",
|
||||
"start:desktop-electron": "yarn workspace desktop-electron watch",
|
||||
"start:electron": "yarn start:desktop",
|
||||
"start:browser": "npm-run-all --parallel 'start:browser-*'",
|
||||
"start:browser": "yarn workspace plugins-service build-dev && npm-run-all --parallel 'start:browser-*'",
|
||||
"start:service-plugins": "yarn workspace plugins-service watch",
|
||||
"start:browser-backend": "yarn workspace loot-core watch:browser",
|
||||
"start:browser-frontend": "yarn workspace @actual-app/web start:browser",
|
||||
"build:browser-backend": "yarn workspace loot-core build:browser",
|
||||
"build:server": "yarn build:browser && yarn workspace @actual-app/sync-server build",
|
||||
"build:browser": "./bin/package-browser",
|
||||
"build:desktop": "./bin/package-electron",
|
||||
"build:plugins-service": "yarn workspace plugins-service build",
|
||||
"build:api": "yarn workspace @actual-app/api build",
|
||||
"test": "yarn workspaces foreach --parallel --verbose run test",
|
||||
"test:debug": "yarn workspaces foreach --verbose run test",
|
||||
"e2e": "yarn workspaces foreach --parallel --verbose run e2e",
|
||||
"rebuild-electron": "./node_modules/.bin/electron-rebuild -f -m ./packages/loot-core",
|
||||
"generate:i18n": "yarn workspace @actual-app/web generate:i18n",
|
||||
"generate:release-notes": "ts-node ./bin/release-note-generator.ts",
|
||||
"test": "yarn workspaces foreach --all --parallel --verbose run test",
|
||||
"test:debug": "yarn workspaces foreach --all --verbose run test",
|
||||
"e2e": "yarn workspaces foreach --all --exclude desktop-electron --parallel --verbose run e2e",
|
||||
"e2e:desktop": "yarn build:desktop --skip-exe-build && yarn workspace desktop-electron e2e",
|
||||
"playwright": "yarn workspace @actual-app/web run playwright",
|
||||
"vrt": "yarn workspaces foreach --all --parallel --verbose run vrt",
|
||||
"vrt:docker": "./bin/run-vrt",
|
||||
"rebuild-electron": "./node_modules/.bin/electron-rebuild -m ./packages/loot-core",
|
||||
"rebuild-node": "yarn workspace loot-core rebuild",
|
||||
"lint": "eslint . --max-warnings 0",
|
||||
"lint:verbose": "DEBUG=eslint:cli-engine eslint . --max-warnings 0",
|
||||
"typecheck": "yarn tsc",
|
||||
"postinstall": "patch-package"
|
||||
"lint": "prettier --check . && eslint . --max-warnings 0",
|
||||
"lint:fix": "prettier --check --write . && eslint . --max-warnings 0 --fix",
|
||||
"install:server": "yarn workspaces focus @actual-app/sync-server --production",
|
||||
"typecheck": "yarn tsc --incremental && tsc-strict",
|
||||
"jq": "./node_modules/node-jq/bin/jq",
|
||||
"prepare": "husky"
|
||||
},
|
||||
"devDependencies": {
|
||||
"cross-env": "^7.0.3",
|
||||
"eslint": "^8.37.0",
|
||||
"eslint-config-react-app": "7.0.1",
|
||||
"eslint-import-resolver-typescript": "3.5.5",
|
||||
"eslint-plugin-import": "2.27.5",
|
||||
"eslint-plugin-prettier": "4.2.1",
|
||||
"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",
|
||||
"@octokit/rest": "^22.0.0",
|
||||
"@types/node": "^22.18.8",
|
||||
"@types/prompts": "^2.4.9",
|
||||
"@typescript-eslint/parser": "^8.45.0",
|
||||
"cross-env": "^10.1.0",
|
||||
"eslint": "^9.37.0",
|
||||
"eslint-config-prettier": "^10.1.8",
|
||||
"eslint-import-resolver-typescript": "^4.4.4",
|
||||
"eslint-plugin-import": "^2.32.0",
|
||||
"eslint-plugin-jsx-a11y": "^6.10.2",
|
||||
"eslint-plugin-react": "^7.37.5",
|
||||
"eslint-plugin-react-hooks": "^6.1.1",
|
||||
"eslint-plugin-typescript-paths": "^0.0.33",
|
||||
"globals": "^15.15.0",
|
||||
"html-to-image": "^1.11.13",
|
||||
"husky": "^9.1.7",
|
||||
"lint-staged": "^16.2.3",
|
||||
"minimatch": "^10.0.3",
|
||||
"node-jq": "^6.0.1",
|
||||
"npm-run-all": "^4.1.5",
|
||||
"p-limit": "^6.2.0",
|
||||
"prettier": "^3.6.2",
|
||||
"prompts": "^2.4.2",
|
||||
"source-map-support": "^0.5.21",
|
||||
"typescript": "^5.0.2"
|
||||
"ts-node": "^10.9.2",
|
||||
"typescript": "^5.9.3",
|
||||
"typescript-eslint": "^8.45.0",
|
||||
"typescript-strict-plugin": "^2.4.4"
|
||||
},
|
||||
"resolutions": {
|
||||
"react-error-overlay": "6.0.9"
|
||||
"rollup": "4.40.1",
|
||||
"socks": ">=2.8.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
"node": ">=20",
|
||||
"yarn": "^4.9.1"
|
||||
},
|
||||
"packageManager": "yarn@3.5.1",
|
||||
"lint-staged": {
|
||||
"*.{js,mjs,jsx,ts,tsx,md,json,yml}": [
|
||||
"eslint --fix",
|
||||
"prettier --write"
|
||||
]
|
||||
},
|
||||
"packageManager": "yarn@4.9.1",
|
||||
"browserslist": [
|
||||
"electron 24.0",
|
||||
"electron >= 35.0",
|
||||
"defaults"
|
||||
]
|
||||
}
|
||||
|
||||
1
packages/api/.gitignore
vendored
1
packages/api/.gitignore
vendored
@@ -2,3 +2,4 @@ app/bundle.api.js*
|
||||
app/stats.json
|
||||
migrations
|
||||
default-db.sqlite
|
||||
mocks/budgets/**/*
|
||||
|
||||
21
packages/api/__snapshots__/methods.test.ts.snap
Normal file
21
packages/api/__snapshots__/methods.test.ts.snap
Normal file
@@ -0,0 +1,21 @@
|
||||
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
|
||||
|
||||
exports[`API setup and teardown > successfully loads budget 1`] = `
|
||||
[
|
||||
"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,37 +0,0 @@
|
||||
/* eslint-disable import/no-unused-modules */
|
||||
|
||||
// eslint-disable-next-line import/extensions
|
||||
import * as bundle from './app/bundle.api.js';
|
||||
import * as injected from './injected';
|
||||
|
||||
let actualApp;
|
||||
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 = {}) {
|
||||
if (actualApp) {
|
||||
return;
|
||||
}
|
||||
|
||||
global.fetch = (...args) =>
|
||||
import('node-fetch').then(({ default: fetch }) => fetch(...args));
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
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,
|
||||
} from 'node-fetch';
|
||||
|
||||
// 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;
|
||||
|
||||
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) {
|
||||
try {
|
||||
await actualApp.send('sync');
|
||||
} catch (e) {
|
||||
// most likely that no budget is loaded, so the sync failed
|
||||
}
|
||||
await actualApp.send('close-budget');
|
||||
actualApp = null;
|
||||
}
|
||||
}
|
||||
@@ -1,155 +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 sync() {
|
||||
return send('api/sync');
|
||||
}
|
||||
|
||||
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 });
|
||||
}
|
||||
861
packages/api/methods.test.ts
Normal file
861
packages/api/methods.test.ts
Normal file
@@ -0,0 +1,861 @@
|
||||
// @ts-strict-ignore
|
||||
import * as fs from 'fs/promises';
|
||||
import * as path from 'path';
|
||||
|
||||
import * as api from './index';
|
||||
|
||||
const budgetName = 'test-budget';
|
||||
|
||||
global.IS_TESTING = true;
|
||||
|
||||
beforeEach(async () => {
|
||||
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: false,
|
||||
id: 'fc3825fd-b982-4b72-b768-5b30844cf832',
|
||||
is_income: false,
|
||||
name: 'Usual Expenses',
|
||||
}),
|
||||
expect.objectContaining({
|
||||
hidden: false,
|
||||
id: 'a137772f-cf2f-4089-9432-822d2ddc1466',
|
||||
is_income: false,
|
||||
name: 'Investments and Savings',
|
||||
}),
|
||||
expect.objectContaining({
|
||||
hidden: false,
|
||||
id: '2E1F5BDB-209B-43F9-AF2C-3CE28E380C00',
|
||||
is_income: true,
|
||||
name: 'Income',
|
||||
}),
|
||||
]),
|
||||
);
|
||||
|
||||
// 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].id);
|
||||
expect(await api.getRules()).toHaveLength(1);
|
||||
|
||||
await api.deleteRule(rules[0].id);
|
||||
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 = [
|
||||
{
|
||||
account: accountId,
|
||||
date: '2023-11-03',
|
||||
imported_id: '11',
|
||||
amount: 100,
|
||||
notes: 'notes',
|
||||
},
|
||||
{
|
||||
account: accountId,
|
||||
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 = [
|
||||
{
|
||||
account: accountId,
|
||||
date: '2023-12-03',
|
||||
imported_id: '11',
|
||||
amount: 100,
|
||||
notes: 'notes',
|
||||
},
|
||||
{
|
||||
account: accountId,
|
||||
date: '2023-12-03',
|
||||
imported_id: '12',
|
||||
amount: 100,
|
||||
notes: 'notes',
|
||||
},
|
||||
{
|
||||
account: accountId,
|
||||
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);
|
||||
});
|
||||
|
||||
test('Transactions: import notes are preserved when importing', async () => {
|
||||
const accountId = await api.createAccount({ name: 'test-account' }, 0);
|
||||
|
||||
// Test with notes
|
||||
const transactionsWithNotes = [
|
||||
{
|
||||
date: '2023-11-03',
|
||||
imported_id: '11',
|
||||
amount: 100,
|
||||
notes: 'test note',
|
||||
},
|
||||
];
|
||||
|
||||
const addResultWithNotes = await api.addTransactions(
|
||||
accountId,
|
||||
transactionsWithNotes,
|
||||
{
|
||||
learnCategories: true,
|
||||
runTransfers: true,
|
||||
},
|
||||
);
|
||||
expect(addResultWithNotes).toBe('ok');
|
||||
|
||||
let transactions = await api.getTransactions(
|
||||
accountId,
|
||||
'2023-11-01',
|
||||
'2023-11-30',
|
||||
);
|
||||
expect(transactions[0].notes).toBe('test note');
|
||||
|
||||
// Clear transactions
|
||||
await api.deleteTransaction(transactions[0].id);
|
||||
|
||||
// Test without notes
|
||||
const transactionsWithoutNotes = [
|
||||
{ date: '2023-11-03', imported_id: '11', amount: 100, notes: null },
|
||||
];
|
||||
|
||||
const addResultWithoutNotes = await api.addTransactions(
|
||||
accountId,
|
||||
transactionsWithoutNotes,
|
||||
{
|
||||
learnCategories: true,
|
||||
runTransfers: true,
|
||||
},
|
||||
);
|
||||
expect(addResultWithoutNotes).toBe('ok');
|
||||
|
||||
transactions = await api.getTransactions(
|
||||
accountId,
|
||||
'2023-11-01',
|
||||
'2023-11-30',
|
||||
);
|
||||
expect(transactions[0].notes).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
//apis: createSchedule, getSchedules, updateSchedule, deleteSchedule
|
||||
test('Schedules: successfully complete schedules operations', async () => {
|
||||
await api.loadBudget(budgetName);
|
||||
//test a schedule with a recuring configuration
|
||||
const ScheduleId1 = await api.createSchedule({
|
||||
name: 'test-schedule 1',
|
||||
posts_transaction: true,
|
||||
// amount: -5000,
|
||||
amountOp: 'is',
|
||||
date: {
|
||||
frequency: 'monthly',
|
||||
interval: 1,
|
||||
start: '2025-06-13',
|
||||
patterns: [],
|
||||
skipWeekend: false,
|
||||
weekendSolveMode: 'after',
|
||||
endMode: 'never',
|
||||
},
|
||||
});
|
||||
//test the creation of non recurring schedule
|
||||
const ScheduleId2 = await api.createSchedule({
|
||||
name: 'test-schedule 2',
|
||||
posts_transaction: false,
|
||||
amount: 4000,
|
||||
amountOp: 'is',
|
||||
date: '2025-06-13',
|
||||
});
|
||||
let schedules = await api.getSchedules();
|
||||
|
||||
// Schedules successfully created
|
||||
expect(schedules).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
name: 'test-schedule 1',
|
||||
posts_transaction: true,
|
||||
// amount: -5000,
|
||||
amountOp: 'is',
|
||||
date: {
|
||||
frequency: 'monthly',
|
||||
interval: 1,
|
||||
start: '2025-06-13',
|
||||
patterns: [],
|
||||
skipWeekend: false,
|
||||
weekendSolveMode: 'after',
|
||||
endMode: 'never',
|
||||
},
|
||||
}),
|
||||
expect.objectContaining({
|
||||
name: 'test-schedule 2',
|
||||
posts_transaction: false,
|
||||
amount: 4000,
|
||||
amountOp: 'is',
|
||||
date: '2025-06-13',
|
||||
}),
|
||||
]),
|
||||
);
|
||||
//check getIDByName works on schedules
|
||||
expect(await api.getIDByName('schedules', 'test-schedule 1')).toEqual(
|
||||
ScheduleId1,
|
||||
);
|
||||
expect(await api.getIDByName('schedules', 'test-schedule 2')).toEqual(
|
||||
ScheduleId2,
|
||||
);
|
||||
|
||||
//check getIDByName works on accounts
|
||||
const schedAccountId1 = await api.createAccount(
|
||||
{ name: 'sched-test-account1', offbudget: true },
|
||||
1000,
|
||||
);
|
||||
|
||||
expect(await api.getIDByName('accounts', 'sched-test-account1')).toEqual(
|
||||
schedAccountId1,
|
||||
);
|
||||
|
||||
//check getIDByName works on payees
|
||||
const schedPayeeId1 = await api.createPayee({ name: 'sched-test-payee1' });
|
||||
|
||||
expect(await api.getIDByName('payees', 'sched-test-payee1')).toEqual(
|
||||
schedPayeeId1,
|
||||
);
|
||||
await api.updateSchedule(ScheduleId1, {
|
||||
amount: -10000,
|
||||
account: schedAccountId1,
|
||||
});
|
||||
await api.deleteSchedule(ScheduleId2);
|
||||
|
||||
// schedules successfully updated, and one of them deleted
|
||||
await api.updateSchedule(ScheduleId1, {
|
||||
amount: -10000,
|
||||
account: schedAccountId1,
|
||||
payee: schedPayeeId1,
|
||||
});
|
||||
await api.deleteSchedule(ScheduleId2);
|
||||
|
||||
schedules = await api.getSchedules();
|
||||
expect(schedules).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
id: ScheduleId1,
|
||||
posts_transaction: true,
|
||||
amount: -10000,
|
||||
account: schedAccountId1,
|
||||
payee: schedPayeeId1,
|
||||
amountOp: 'is',
|
||||
date: {
|
||||
frequency: 'monthly',
|
||||
interval: 1,
|
||||
start: '2025-06-13',
|
||||
patterns: [],
|
||||
skipWeekend: false,
|
||||
weekendSolveMode: 'after',
|
||||
endMode: 'never',
|
||||
},
|
||||
}),
|
||||
expect.not.objectContaining({ id: ScheduleId2 }),
|
||||
]),
|
||||
);
|
||||
});
|
||||
272
packages/api/methods.ts
Normal file
272
packages/api/methods.ts
Normal file
@@ -0,0 +1,272 @@
|
||||
// @ts-strict-ignore
|
||||
import type { Handlers } from 'loot-core/types/handlers';
|
||||
import type { ImportTransactionEntity } from 'loot-core/types/models/import-transaction';
|
||||
|
||||
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');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated Please use `aqlQuery` instead.
|
||||
* This function will be removed in a future release.
|
||||
*/
|
||||
export function runQuery(query) {
|
||||
return send('api/query', { query: query.serialize() });
|
||||
}
|
||||
|
||||
export function aqlQuery(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 interface ImportTransactionsOpts {
|
||||
defaultCleared?: boolean;
|
||||
dryRun?: boolean;
|
||||
}
|
||||
|
||||
export function importTransactions(
|
||||
accountId: string,
|
||||
transactions: ImportTransactionEntity[],
|
||||
opts: ImportTransactionsOpts = {
|
||||
defaultCleared: true,
|
||||
dryRun: false,
|
||||
},
|
||||
) {
|
||||
return send('api/transactions-import', {
|
||||
accountId,
|
||||
transactions,
|
||||
isPreview: opts.dryRun,
|
||||
opts,
|
||||
});
|
||||
}
|
||||
|
||||
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 getCommonPayees() {
|
||||
return send('api/common-payees-get');
|
||||
}
|
||||
|
||||
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: string) {
|
||||
return send('api/rule-delete', id);
|
||||
}
|
||||
|
||||
export function holdBudgetForNextMonth(month, amount) {
|
||||
return send('api/budget-hold-for-next-month', { month, amount });
|
||||
}
|
||||
|
||||
export function resetBudgetHold(month) {
|
||||
return send('api/budget-reset-hold', { month });
|
||||
}
|
||||
|
||||
export function createSchedule(schedule) {
|
||||
return send('api/schedule-create', schedule);
|
||||
}
|
||||
|
||||
export function updateSchedule(id, fields, resetNextDate?: boolean) {
|
||||
return send('api/schedule-update', {
|
||||
id,
|
||||
fields,
|
||||
resetNextDate,
|
||||
});
|
||||
}
|
||||
|
||||
export function deleteSchedule(scheduleId) {
|
||||
return send('api/schedule-delete', scheduleId);
|
||||
}
|
||||
|
||||
export function getSchedules() {
|
||||
return send('api/schedules-get');
|
||||
}
|
||||
|
||||
export function getIDByName(type, name) {
|
||||
return send('api/get-id-by-name', { type, name });
|
||||
}
|
||||
|
||||
export function getServerVersion() {
|
||||
return send('api/get-server-version');
|
||||
}
|
||||
@@ -1,27 +1,37 @@
|
||||
{
|
||||
"name": "@actual-app/api",
|
||||
"version": "6.2.0",
|
||||
"version": "25.10.0",
|
||||
"license": "MIT",
|
||||
"description": "An API for Actual",
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
},
|
||||
"main": "dist/index.js",
|
||||
"types": "dist/index.d.ts",
|
||||
"types": "@types/index.d.ts",
|
||||
"files": [
|
||||
"dist"
|
||||
"dist",
|
||||
"@types"
|
||||
],
|
||||
"scripts": {
|
||||
"build:app": "yarn workspace loot-core build:api",
|
||||
"build:node": "tsc --p tsconfig.dist.json",
|
||||
"build:crdt": "yarn workspace @actual-app/crdt build",
|
||||
"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": "rm -rf dist && yarn run build:app && yarn run build:node && yarn run build:migrations && yarn run build:default-db"
|
||||
"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 && yarn run build:crdt && vitest",
|
||||
"clean": "rm -rf dist @types"
|
||||
},
|
||||
"dependencies": {
|
||||
"better-sqlite3": "^8.2.0",
|
||||
"@actual-app/crdt": "workspace:^",
|
||||
"better-sqlite3": "^12.4.1",
|
||||
"compare-versions": "^6.1.1",
|
||||
"node-fetch": "^3.3.2",
|
||||
"uuid": "^9.0.0"
|
||||
"uuid": "^13.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/uuid": "^9.0.2",
|
||||
"typescript": "^5.0.2"
|
||||
"tsc-alias": "^1.8.16",
|
||||
"typescript": "^5.9.3",
|
||||
"vitest": "^3.2.4"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,13 +3,17 @@
|
||||
"compilerOptions": {
|
||||
// Using ES2021 because that’s the newest version where
|
||||
// the latest Node 16.x release supports all of the features
|
||||
"target": "es2021",
|
||||
"target": "ES2021",
|
||||
"module": "CommonJS",
|
||||
"moduleResolution": "Node16",
|
||||
"moduleResolution": "node10",
|
||||
"noEmit": false,
|
||||
"declaration": true,
|
||||
"outDir": "dist"
|
||||
"outDir": "dist",
|
||||
"declarationDir": "@types",
|
||||
"paths": {
|
||||
"loot-core/*": ["./@types/loot-core/src/*"]
|
||||
}
|
||||
},
|
||||
"include": ["."],
|
||||
"exclude": ["dist"]
|
||||
"exclude": ["**/node_modules/*", "dist", "@types", "*.test.ts"]
|
||||
}
|
||||
|
||||
@@ -1,7 +0,0 @@
|
||||
export function amountToInteger(n) {
|
||||
return Math.round(n * 100);
|
||||
}
|
||||
|
||||
export function integerToAmount(n) {
|
||||
return parseFloat((n / 100).toFixed(2));
|
||||
}
|
||||
1
packages/api/utils.ts
Normal file
1
packages/api/utils.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { amountToInteger, integerToAmount } from 'loot-core/shared/util';
|
||||
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`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
9
packages/api/vitest.config.ts
Normal file
9
packages/api/vitest.config.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
export default {
|
||||
test: {
|
||||
globals: true,
|
||||
onConsoleLog(log: string, type: 'stdout' | 'stderr'): boolean | void {
|
||||
// print only console.error
|
||||
return type === 'stderr';
|
||||
},
|
||||
},
|
||||
};
|
||||
11
packages/bank-sync-plugin-pluggy.ai/.gitignore
vendored
Normal file
11
packages/bank-sync-plugin-pluggy.ai/.gitignore
vendored
Normal file
@@ -0,0 +1,11 @@
|
||||
node_modules/
|
||||
dist/
|
||||
*.log
|
||||
.DS_Store
|
||||
.env
|
||||
.env.local
|
||||
|
||||
# Generated build artifacts
|
||||
manifest.json
|
||||
*.zip
|
||||
|
||||
Binary file not shown.
40911
packages/bank-sync-plugin-pluggy.ai/dist/bundle.js
vendored
Normal file
40911
packages/bank-sync-plugin-pluggy.ai/dist/bundle.js
vendored
Normal file
File diff suppressed because one or more lines are too long
459
packages/bank-sync-plugin-pluggy.ai/dist/index.js
vendored
Normal file
459
packages/bank-sync-plugin-pluggy.ai/dist/index.js
vendored
Normal file
@@ -0,0 +1,459 @@
|
||||
import { attachPluginMiddleware, saveSecret, getSecret, BankSyncErrorCode, } from '@actual-app/plugins-core-sync-server';
|
||||
import express from 'express';
|
||||
import { PluggyClient } from 'pluggy-sdk';
|
||||
// Import manifest (used during build)
|
||||
import './manifest';
|
||||
// Create Express app
|
||||
const app = express();
|
||||
// Use JSON middleware for parsing request bodies
|
||||
app.use(express.json());
|
||||
// Attach the plugin middleware to enable IPC communication with sync-server
|
||||
attachPluginMiddleware(app);
|
||||
// Pluggy client singleton
|
||||
let pluggyClient = null;
|
||||
async function getPluggyClient(req) {
|
||||
// Try to get credentials from secrets first
|
||||
const clientIdResult = await getSecret(req, 'clientId');
|
||||
const clientSecretResult = await getSecret(req, 'clientSecret');
|
||||
const clientId = clientIdResult.value || req.body.clientId;
|
||||
const clientSecret = clientSecretResult.value || req.body.clientSecret;
|
||||
if (!clientId || !clientSecret) {
|
||||
throw new Error('Pluggy.ai credentials not configured');
|
||||
}
|
||||
if (!pluggyClient) {
|
||||
pluggyClient = new PluggyClient({
|
||||
clientId,
|
||||
clientSecret,
|
||||
});
|
||||
}
|
||||
return pluggyClient;
|
||||
}
|
||||
/**
|
||||
* GET /status
|
||||
* Check if Pluggy.ai is configured
|
||||
*/
|
||||
app.get('/status', async (req, res) => {
|
||||
try {
|
||||
const clientIdResult = await getSecret(req, 'clientId');
|
||||
const configured = clientIdResult.value != null;
|
||||
res.json({
|
||||
status: 'ok',
|
||||
data: {
|
||||
configured,
|
||||
},
|
||||
});
|
||||
}
|
||||
catch (error) {
|
||||
res.json({
|
||||
status: 'error',
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
});
|
||||
}
|
||||
});
|
||||
/**
|
||||
* POST /accounts
|
||||
* Fetch accounts from Pluggy.ai
|
||||
* Body: { itemIds: string, clientId?: string, clientSecret?: string }
|
||||
*
|
||||
* If clientId and clientSecret are provided, they will be saved as secrets
|
||||
*/
|
||||
app.post('/accounts', async (req, res) => {
|
||||
try {
|
||||
const { itemIds, clientId, clientSecret } = req.body;
|
||||
// If credentials are provided in request, save them
|
||||
if (clientId && clientSecret) {
|
||||
await saveSecret(req, 'clientId', clientId);
|
||||
await saveSecret(req, 'clientSecret', clientSecret);
|
||||
}
|
||||
// Get itemIds from request or from stored secrets
|
||||
let itemIdsArray;
|
||||
if (itemIds) {
|
||||
// Parse itemIds from request (can be comma-separated string or array)
|
||||
if (typeof itemIds === 'string') {
|
||||
itemIdsArray = itemIds.split(',').map((id) => id.trim());
|
||||
}
|
||||
else if (Array.isArray(itemIds)) {
|
||||
itemIdsArray = itemIds;
|
||||
}
|
||||
else {
|
||||
res.json({
|
||||
status: 'error',
|
||||
error: 'itemIds must be a string or array',
|
||||
});
|
||||
return;
|
||||
}
|
||||
// Save itemIds for future use
|
||||
await saveSecret(req, 'itemIds', itemIdsArray.join(','));
|
||||
}
|
||||
else {
|
||||
// Try to get itemIds from secrets
|
||||
const storedItemIds = await getSecret(req, 'itemIds');
|
||||
if (!storedItemIds.value) {
|
||||
res.json({
|
||||
status: 'error',
|
||||
error: 'itemIds is required (comma-separated string or array). Please provide itemIds in request or configure them first.',
|
||||
});
|
||||
return;
|
||||
}
|
||||
itemIdsArray = storedItemIds.value
|
||||
.split(',')
|
||||
.map((id) => id.trim());
|
||||
}
|
||||
if (!itemIdsArray.length) {
|
||||
res.json({
|
||||
status: 'error',
|
||||
error: 'At least one item ID is required',
|
||||
});
|
||||
return;
|
||||
}
|
||||
const client = await getPluggyClient(req);
|
||||
let accounts = [];
|
||||
// Fetch all accounts and their items with connector info
|
||||
for (const itemId of itemIdsArray) {
|
||||
const partial = await client.fetchAccounts(itemId);
|
||||
// For each account, also fetch the item to get connector details
|
||||
for (const account of partial.results) {
|
||||
try {
|
||||
const item = await client.fetchItem(itemId);
|
||||
// Attach item info to account for transformation
|
||||
account.itemData = item;
|
||||
}
|
||||
catch (error) {
|
||||
console.error(`[PLUGGY ACCOUNTS] Error fetching item ${itemId}:`, error);
|
||||
}
|
||||
}
|
||||
accounts = accounts.concat(partial.results);
|
||||
}
|
||||
// Transform Pluggy accounts to GenericBankSyncAccount format
|
||||
const transformedAccounts = accounts.map((account) => {
|
||||
const institution = account.itemData?.connector?.name ||
|
||||
account.item?.connector?.name ||
|
||||
'Unknown Institution';
|
||||
const connectorId = account.itemData?.connector?.id ||
|
||||
account.item?.connector?.id ||
|
||||
account.itemId;
|
||||
return {
|
||||
account_id: account.id,
|
||||
name: account.name,
|
||||
institution,
|
||||
balance: account.balance || 0,
|
||||
mask: account.number?.substring(account.number.length - 4),
|
||||
official_name: account.name,
|
||||
orgDomain: account.itemData?.connector?.institutionUrl ||
|
||||
account.item?.connector?.institutionUrl ||
|
||||
null,
|
||||
orgId: connectorId?.toString() || null,
|
||||
};
|
||||
});
|
||||
res.json({
|
||||
status: 'ok',
|
||||
data: {
|
||||
accounts: transformedAccounts,
|
||||
},
|
||||
});
|
||||
}
|
||||
catch (error) {
|
||||
console.error('[PLUGGY ACCOUNTS] Error:', error);
|
||||
// Extract Pluggy error message and code if available
|
||||
let pluggyMessage = 'Unknown error';
|
||||
let pluggyCode;
|
||||
if (error instanceof Error) {
|
||||
pluggyMessage = error.message;
|
||||
// Try to parse Pluggy SDK error format from error message
|
||||
// Pluggy errors often include the error details in the message
|
||||
try {
|
||||
// Check if error has a structured format
|
||||
const errorAny = error;
|
||||
if (errorAny.message && typeof errorAny.message === 'string') {
|
||||
pluggyMessage = errorAny.message;
|
||||
}
|
||||
if (errorAny.code !== undefined) {
|
||||
pluggyCode = errorAny.code;
|
||||
}
|
||||
}
|
||||
catch (e) {
|
||||
// Ignore parse errors
|
||||
}
|
||||
}
|
||||
const errorResponse = {
|
||||
error_type: BankSyncErrorCode.UNKNOWN_ERROR,
|
||||
error_code: BankSyncErrorCode.UNKNOWN_ERROR,
|
||||
status: 'error',
|
||||
reason: pluggyMessage, // Use the Pluggy error message directly
|
||||
};
|
||||
// Map HTTP status codes to error types
|
||||
const errorMessageLower = pluggyMessage.toLowerCase();
|
||||
if (pluggyCode === 401 || errorMessageLower.includes('401') || errorMessageLower.includes('unauthorized') || errorMessageLower.includes('invalid credentials')) {
|
||||
errorResponse.error_type = BankSyncErrorCode.INVALID_CREDENTIALS;
|
||||
errorResponse.error_code = BankSyncErrorCode.INVALID_CREDENTIALS;
|
||||
}
|
||||
else if (pluggyCode === 403 || errorMessageLower.includes('403') || errorMessageLower.includes('forbidden')) {
|
||||
errorResponse.error_type = BankSyncErrorCode.UNAUTHORIZED;
|
||||
errorResponse.error_code = BankSyncErrorCode.UNAUTHORIZED;
|
||||
}
|
||||
else if (pluggyCode === 429 || errorMessageLower.includes('429') || errorMessageLower.includes('rate limit')) {
|
||||
errorResponse.error_type = BankSyncErrorCode.RATE_LIMIT;
|
||||
errorResponse.error_code = BankSyncErrorCode.RATE_LIMIT;
|
||||
}
|
||||
else if (pluggyCode === 400 || errorMessageLower.includes('400') || errorMessageLower.includes('bad request')) {
|
||||
errorResponse.error_type = BankSyncErrorCode.INVALID_REQUEST;
|
||||
errorResponse.error_code = BankSyncErrorCode.INVALID_REQUEST;
|
||||
}
|
||||
else if (pluggyCode === 404 || errorMessageLower.includes('404') || errorMessageLower.includes('not found')) {
|
||||
errorResponse.error_type = BankSyncErrorCode.ACCOUNT_NOT_FOUND;
|
||||
errorResponse.error_code = BankSyncErrorCode.ACCOUNT_NOT_FOUND;
|
||||
}
|
||||
else if (errorMessageLower.includes('network') || errorMessageLower.includes('connect') || errorMessageLower.includes('econnrefused')) {
|
||||
errorResponse.error_type = BankSyncErrorCode.NETWORK_ERROR;
|
||||
errorResponse.error_code = BankSyncErrorCode.NETWORK_ERROR;
|
||||
}
|
||||
else if ((pluggyCode && typeof pluggyCode === 'number' && pluggyCode >= 500) || errorMessageLower.includes('500') || errorMessageLower.includes('502') || errorMessageLower.includes('503')) {
|
||||
errorResponse.error_type = BankSyncErrorCode.SERVER_ERROR;
|
||||
errorResponse.error_code = BankSyncErrorCode.SERVER_ERROR;
|
||||
}
|
||||
errorResponse.details = {
|
||||
originalError: pluggyMessage,
|
||||
pluggyCode: pluggyCode,
|
||||
};
|
||||
res.json({
|
||||
status: 'ok',
|
||||
data: errorResponse,
|
||||
});
|
||||
}
|
||||
});
|
||||
/**
|
||||
* POST /transactions
|
||||
* Fetch transactions from Pluggy.ai
|
||||
* Body: { accountId: string, startDate: string, clientId?: string, clientSecret?: string }
|
||||
*/
|
||||
app.post('/transactions', async (req, res) => {
|
||||
try {
|
||||
const { accountId, startDate } = req.body;
|
||||
if (!accountId) {
|
||||
res.json({
|
||||
status: 'error',
|
||||
error: 'accountId is required',
|
||||
});
|
||||
return;
|
||||
}
|
||||
const client = await getPluggyClient(req);
|
||||
const transactions = await getTransactions(client, accountId, startDate);
|
||||
const account = (await client.fetchAccount(accountId));
|
||||
let startingBalance = parseInt(Math.round(account.balance * 100).toString());
|
||||
if (account.type === 'CREDIT') {
|
||||
startingBalance = -startingBalance;
|
||||
}
|
||||
const date = getDate(new Date(account.updatedAt));
|
||||
const balances = [
|
||||
{
|
||||
balanceAmount: {
|
||||
amount: startingBalance,
|
||||
currency: account.currencyCode,
|
||||
},
|
||||
balanceType: 'expected',
|
||||
referenceDate: date,
|
||||
},
|
||||
];
|
||||
const all = [];
|
||||
const booked = [];
|
||||
const pending = [];
|
||||
for (const trans of transactions) {
|
||||
const transRecord = trans;
|
||||
const newTrans = {};
|
||||
newTrans.booked = !(transRecord.status === 'PENDING');
|
||||
const transactionDate = new Date(transRecord.date);
|
||||
if (transactionDate < new Date(startDate) && !transRecord.sandbox) {
|
||||
continue;
|
||||
}
|
||||
newTrans.date = getDate(transactionDate);
|
||||
newTrans.payeeName = getPayeeName(transRecord);
|
||||
newTrans.notes = transRecord.descriptionRaw || transRecord.description;
|
||||
if (account.type === 'CREDIT') {
|
||||
if (transRecord.amountInAccountCurrency) {
|
||||
transRecord.amountInAccountCurrency =
|
||||
transRecord.amountInAccountCurrency * -1;
|
||||
}
|
||||
transRecord.amount = transRecord.amount * -1;
|
||||
}
|
||||
let amountInCurrency = transRecord.amountInAccountCurrency ??
|
||||
transRecord.amount;
|
||||
amountInCurrency = Math.round(amountInCurrency * 100) / 100;
|
||||
newTrans.transactionAmount = {
|
||||
amount: amountInCurrency,
|
||||
currency: transRecord.currencyCode,
|
||||
};
|
||||
newTrans.transactionId = transRecord.id;
|
||||
newTrans.sortOrder = transactionDate.getTime();
|
||||
delete transRecord.amount;
|
||||
const finalTrans = { ...flattenObject(transRecord), ...newTrans };
|
||||
if (newTrans.booked) {
|
||||
booked.push(finalTrans);
|
||||
}
|
||||
else {
|
||||
pending.push(finalTrans);
|
||||
}
|
||||
all.push(finalTrans);
|
||||
}
|
||||
const sortFunction = (a, b) => {
|
||||
const aRec = a;
|
||||
const bRec = b;
|
||||
return bRec.sortOrder - aRec.sortOrder;
|
||||
};
|
||||
const bookedSorted = booked.sort(sortFunction);
|
||||
const pendingSorted = pending.sort(sortFunction);
|
||||
const allSorted = all.sort(sortFunction);
|
||||
res.json({
|
||||
status: 'ok',
|
||||
data: {
|
||||
balances,
|
||||
startingBalance,
|
||||
transactions: {
|
||||
all: allSorted,
|
||||
booked: bookedSorted,
|
||||
pending: pendingSorted,
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
catch (error) {
|
||||
console.error('[PLUGGY TRANSACTIONS] Error:', error);
|
||||
// Extract Pluggy error message and code if available
|
||||
let pluggyMessage = 'Unknown error';
|
||||
let pluggyCode;
|
||||
if (error instanceof Error) {
|
||||
pluggyMessage = error.message;
|
||||
// Try to parse Pluggy SDK error format from error message
|
||||
try {
|
||||
const errorAny = error;
|
||||
if (errorAny.message && typeof errorAny.message === 'string') {
|
||||
pluggyMessage = errorAny.message;
|
||||
}
|
||||
if (errorAny.code !== undefined) {
|
||||
pluggyCode = errorAny.code;
|
||||
}
|
||||
}
|
||||
catch (e) {
|
||||
// Ignore parse errors
|
||||
}
|
||||
}
|
||||
const errorResponse = {
|
||||
error_type: BankSyncErrorCode.UNKNOWN_ERROR,
|
||||
error_code: BankSyncErrorCode.UNKNOWN_ERROR,
|
||||
status: 'error',
|
||||
reason: pluggyMessage, // Use the Pluggy error message directly
|
||||
};
|
||||
// Map HTTP status codes to error types
|
||||
const errorMessageLower = pluggyMessage.toLowerCase();
|
||||
if (pluggyCode === 401 || errorMessageLower.includes('401') || errorMessageLower.includes('unauthorized') || errorMessageLower.includes('invalid credentials')) {
|
||||
errorResponse.error_type = BankSyncErrorCode.INVALID_CREDENTIALS;
|
||||
errorResponse.error_code = BankSyncErrorCode.INVALID_CREDENTIALS;
|
||||
}
|
||||
else if (pluggyCode === 403 || errorMessageLower.includes('403') || errorMessageLower.includes('forbidden')) {
|
||||
errorResponse.error_type = BankSyncErrorCode.UNAUTHORIZED;
|
||||
errorResponse.error_code = BankSyncErrorCode.UNAUTHORIZED;
|
||||
}
|
||||
else if (pluggyCode === 404 || errorMessageLower.includes('404') || errorMessageLower.includes('not found')) {
|
||||
errorResponse.error_type = BankSyncErrorCode.ACCOUNT_NOT_FOUND;
|
||||
errorResponse.error_code = BankSyncErrorCode.ACCOUNT_NOT_FOUND;
|
||||
}
|
||||
else if (pluggyCode === 429 || errorMessageLower.includes('429') || errorMessageLower.includes('rate limit')) {
|
||||
errorResponse.error_type = BankSyncErrorCode.RATE_LIMIT;
|
||||
errorResponse.error_code = BankSyncErrorCode.RATE_LIMIT;
|
||||
}
|
||||
else if (pluggyCode === 400 || errorMessageLower.includes('400') || errorMessageLower.includes('bad request')) {
|
||||
errorResponse.error_type = BankSyncErrorCode.INVALID_REQUEST;
|
||||
errorResponse.error_code = BankSyncErrorCode.INVALID_REQUEST;
|
||||
}
|
||||
else if (errorMessageLower.includes('network') || errorMessageLower.includes('connect') || errorMessageLower.includes('econnrefused')) {
|
||||
errorResponse.error_type = BankSyncErrorCode.NETWORK_ERROR;
|
||||
errorResponse.error_code = BankSyncErrorCode.NETWORK_ERROR;
|
||||
}
|
||||
else if ((pluggyCode && typeof pluggyCode === 'number' && pluggyCode >= 500) || errorMessageLower.includes('500') || errorMessageLower.includes('502') || errorMessageLower.includes('503')) {
|
||||
errorResponse.error_type = BankSyncErrorCode.SERVER_ERROR;
|
||||
errorResponse.error_code = BankSyncErrorCode.SERVER_ERROR;
|
||||
}
|
||||
errorResponse.details = {
|
||||
originalError: pluggyMessage,
|
||||
pluggyCode: pluggyCode,
|
||||
};
|
||||
res.json({
|
||||
status: 'ok',
|
||||
data: errorResponse,
|
||||
});
|
||||
}
|
||||
});
|
||||
// Helper functions
|
||||
async function getTransactions(client, accountId, startDate) {
|
||||
let transactions = [];
|
||||
let result = await getTransactionsByAccountId(client, accountId, startDate, 500, 1);
|
||||
transactions = transactions.concat(result.results);
|
||||
const totalPages = result.totalPages;
|
||||
let currentPage = result.page;
|
||||
while (currentPage !== totalPages) {
|
||||
result = await getTransactionsByAccountId(client, accountId, startDate, 500, currentPage + 1);
|
||||
transactions = transactions.concat(result.results);
|
||||
currentPage = result.page;
|
||||
}
|
||||
return transactions;
|
||||
}
|
||||
async function getTransactionsByAccountId(client, accountId, startDate, pageSize, page) {
|
||||
const account = (await client.fetchAccount(accountId));
|
||||
// Sandbox account handling
|
||||
const sandboxAccount = account.owner === 'John Doe';
|
||||
const fromDate = sandboxAccount ? '2000-01-01' : startDate;
|
||||
const transactions = await client.fetchTransactions(accountId, {
|
||||
from: fromDate,
|
||||
pageSize,
|
||||
page,
|
||||
});
|
||||
if (sandboxAccount) {
|
||||
const mappedResults = transactions.results.map((t) => ({
|
||||
...t,
|
||||
sandbox: true,
|
||||
}));
|
||||
transactions.results =
|
||||
mappedResults;
|
||||
}
|
||||
return transactions;
|
||||
}
|
||||
function getDate(date) {
|
||||
return date.toISOString().split('T')[0];
|
||||
}
|
||||
function flattenObject(obj, prefix = '') {
|
||||
const result = {};
|
||||
for (const [key, value] of Object.entries(obj)) {
|
||||
const newKey = prefix ? `${prefix}.${key}` : key;
|
||||
if (value === null) {
|
||||
continue;
|
||||
}
|
||||
if (typeof value === 'object' && value !== null && !Array.isArray(value)) {
|
||||
Object.assign(result, flattenObject(value, newKey));
|
||||
}
|
||||
else {
|
||||
result[newKey] = value;
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
function getPayeeName(trans) {
|
||||
const merchant = trans.merchant;
|
||||
if (merchant && (merchant.name || merchant.businessName)) {
|
||||
return merchant.name || merchant.businessName || '';
|
||||
}
|
||||
const paymentData = trans.paymentData;
|
||||
if (paymentData) {
|
||||
const { receiver, payer } = paymentData;
|
||||
if (trans.type === 'DEBIT' && receiver) {
|
||||
const receiverData = receiver;
|
||||
const docNum = receiverData.documentNumber;
|
||||
return receiverData.name || docNum?.value || '';
|
||||
}
|
||||
if (trans.type === 'CREDIT' && payer) {
|
||||
const payerData = payer;
|
||||
const docNum = payerData.documentNumber;
|
||||
return payerData.name || docNum?.value || '';
|
||||
}
|
||||
}
|
||||
return '';
|
||||
}
|
||||
console.log('Pluggy.ai Bank Sync Plugin loaded');
|
||||
40
packages/bank-sync-plugin-pluggy.ai/dist/manifest.js
vendored
Normal file
40
packages/bank-sync-plugin-pluggy.ai/dist/manifest.js
vendored
Normal file
@@ -0,0 +1,40 @@
|
||||
export const manifest = {
|
||||
name: 'pluggy-bank-sync',
|
||||
version: '0.0.1',
|
||||
description: 'Pluggy.ai bank synchronization plugin for Actual Budget',
|
||||
entry: 'index.js',
|
||||
author: 'Actual Budget Team',
|
||||
license: 'MIT',
|
||||
routes: [
|
||||
{
|
||||
path: '/status',
|
||||
methods: ['POST'],
|
||||
auth: 'authenticated',
|
||||
description: 'Check Pluggy.ai configuration status',
|
||||
},
|
||||
{
|
||||
path: '/accounts',
|
||||
methods: ['POST'],
|
||||
auth: 'authenticated',
|
||||
description: 'Fetch accounts from Pluggy.ai',
|
||||
},
|
||||
{
|
||||
path: '/transactions',
|
||||
methods: ['POST'],
|
||||
auth: 'authenticated',
|
||||
description: 'Fetch transactions from Pluggy.ai',
|
||||
},
|
||||
],
|
||||
bankSync: {
|
||||
enabled: true,
|
||||
displayName: 'Pluggy.ai',
|
||||
description: 'Connect your bank accounts via Pluggy.ai',
|
||||
requiresAuth: true,
|
||||
endpoints: {
|
||||
status: '/status',
|
||||
accounts: '/accounts',
|
||||
transactions: '/transactions',
|
||||
},
|
||||
},
|
||||
};
|
||||
export default manifest;
|
||||
45
packages/bank-sync-plugin-pluggy.ai/manifest.json
Normal file
45
packages/bank-sync-plugin-pluggy.ai/manifest.json
Normal file
@@ -0,0 +1,45 @@
|
||||
{
|
||||
"name": "pluggy-bank-sync",
|
||||
"version": "0.0.1",
|
||||
"description": "Pluggy.ai bank synchronization plugin for Actual Budget",
|
||||
"entry": "index.js",
|
||||
"author": "Actual Budget Team",
|
||||
"license": "MIT",
|
||||
"routes": [
|
||||
{
|
||||
"path": "/status",
|
||||
"methods": [
|
||||
"POST"
|
||||
],
|
||||
"auth": "authenticated",
|
||||
"description": "Check Pluggy.ai configuration status"
|
||||
},
|
||||
{
|
||||
"path": "/accounts",
|
||||
"methods": [
|
||||
"POST"
|
||||
],
|
||||
"auth": "authenticated",
|
||||
"description": "Fetch accounts from Pluggy.ai"
|
||||
},
|
||||
{
|
||||
"path": "/transactions",
|
||||
"methods": [
|
||||
"POST"
|
||||
],
|
||||
"auth": "authenticated",
|
||||
"description": "Fetch transactions from Pluggy.ai"
|
||||
}
|
||||
],
|
||||
"bankSync": {
|
||||
"enabled": true,
|
||||
"displayName": "Pluggy.ai",
|
||||
"description": "Connect your bank accounts via Pluggy.ai",
|
||||
"requiresAuth": true,
|
||||
"endpoints": {
|
||||
"status": "/status",
|
||||
"accounts": "/accounts",
|
||||
"transactions": "/transactions"
|
||||
}
|
||||
}
|
||||
}
|
||||
40
packages/bank-sync-plugin-pluggy.ai/package.json
Normal file
40
packages/bank-sync-plugin-pluggy.ai/package.json
Normal file
@@ -0,0 +1,40 @@
|
||||
{
|
||||
"name": "@actual-app/bank-sync-plugin-pluggy.ai",
|
||||
"version": "0.0.1",
|
||||
"description": "Pluggy.ai bank sync plugin for Actual Budget",
|
||||
"main": "dist/index.js",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"build": "npm run build:compile && npm run build:bundle && npm run build:manifest && npm run build:zip",
|
||||
"build:compile": "tsc",
|
||||
"build:bundle": "node scripts/build-bundle.cjs",
|
||||
"build:manifest": "node scripts/build-manifest.cjs",
|
||||
"build:zip": "node scripts/build-zip.cjs",
|
||||
"deploy": "npm run build && npm run install:plugin",
|
||||
"install:plugin": "node scripts/install-plugin.cjs",
|
||||
"watch": "tsc --watch",
|
||||
"clean": "rm -rf dist *.zip",
|
||||
"dev": "tsc --watch"
|
||||
},
|
||||
"keywords": [
|
||||
"actual",
|
||||
"plugin",
|
||||
"bank-sync",
|
||||
"pluggy",
|
||||
"pluggyai"
|
||||
],
|
||||
"author": "Actual Budget",
|
||||
"license": "MIT",
|
||||
"devDependencies": {
|
||||
"@types/express": "^4.17.21",
|
||||
"@types/node": "^20.0.0",
|
||||
"archiver": "^7.0.0",
|
||||
"esbuild": "^0.24.0",
|
||||
"typescript": "^5.0.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"@actual-app/plugins-core-sync-server": "workspace:*",
|
||||
"express": "^4.18.0",
|
||||
"pluggy-sdk": "^0.77.0"
|
||||
}
|
||||
}
|
||||
39
packages/bank-sync-plugin-pluggy.ai/scripts/build-bundle.cjs
Normal file
39
packages/bank-sync-plugin-pluggy.ai/scripts/build-bundle.cjs
Normal file
@@ -0,0 +1,39 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
/**
|
||||
* Build script to bundle the plugin with all dependencies
|
||||
* Uses esbuild to create a single self-contained JavaScript file
|
||||
*/
|
||||
|
||||
const esbuild = require('esbuild');
|
||||
const { join } = require('path');
|
||||
|
||||
async function bundle() {
|
||||
try {
|
||||
console.log('Bundling plugin with dependencies...');
|
||||
|
||||
const entryPoint = join(__dirname, '..', 'dist', 'index.js');
|
||||
const outFile = join(__dirname, '..', 'dist', 'bundle.js');
|
||||
|
||||
await esbuild.build({
|
||||
entryPoints: [entryPoint],
|
||||
bundle: true,
|
||||
platform: 'node',
|
||||
target: 'node20',
|
||||
format: 'esm',
|
||||
outfile: outFile,
|
||||
external: [],
|
||||
minify: false,
|
||||
sourcemap: false,
|
||||
treeShaking: true,
|
||||
});
|
||||
|
||||
console.log('Bundle created successfully');
|
||||
console.log(`Output: dist/bundle.js`);
|
||||
} catch (error) {
|
||||
console.error('Failed to bundle:', error.message);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
bundle();
|
||||
51
packages/bank-sync-plugin-pluggy.ai/scripts/build-manifest.cjs
Executable file
51
packages/bank-sync-plugin-pluggy.ai/scripts/build-manifest.cjs
Executable file
@@ -0,0 +1,51 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
/**
|
||||
* Build script to convert TypeScript manifest to JSON
|
||||
* This script imports the manifest.ts file and writes it as JSON to manifest.json
|
||||
*/
|
||||
|
||||
const { writeFileSync } = require('fs');
|
||||
const { join } = require('path');
|
||||
|
||||
// Import the manifest from the built TypeScript file
|
||||
// Note: __dirname is already available in CommonJS and refers to the scripts/ directory
|
||||
async function importManifest() {
|
||||
// First try to import from the compiled JavaScript
|
||||
try {
|
||||
const manifestModule = await import('../dist/manifest.js');
|
||||
return manifestModule.manifest;
|
||||
} catch (error) {
|
||||
console.error('Could not import compiled manifest:', error.message);
|
||||
console.log(
|
||||
'Make sure TypeScript is compiled first. Run: npm run build:compile',
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
async function buildManifest() {
|
||||
try {
|
||||
console.log('Building manifest.json...');
|
||||
|
||||
// Import the manifest from the compiled TypeScript
|
||||
const manifest = await importManifest();
|
||||
|
||||
// Convert to JSON with pretty formatting
|
||||
const jsonContent = JSON.stringify(manifest, null, 2);
|
||||
|
||||
// Write to manifest.json in the root directory
|
||||
const manifestPath = join(__dirname, '..', 'manifest.json');
|
||||
writeFileSync(manifestPath, jsonContent + '\n');
|
||||
|
||||
console.log('manifest.json created successfully');
|
||||
console.log(`Package: ${manifest.name}@${manifest.version}`);
|
||||
console.log(`Description: ${manifest.description}`);
|
||||
console.log(`Entry point: ${manifest.entry}`);
|
||||
} catch (error) {
|
||||
console.error('❌ Failed to build manifest:', error.message);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
buildManifest();
|
||||
89
packages/bank-sync-plugin-pluggy.ai/scripts/build-zip.cjs
Executable file
89
packages/bank-sync-plugin-pluggy.ai/scripts/build-zip.cjs
Executable file
@@ -0,0 +1,89 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
/**
|
||||
* Build script to create a plugin distribution zip file
|
||||
* Creates: {packageName}.{version}.zip containing dist/index.js and manifest.json
|
||||
*/
|
||||
|
||||
const { createWriteStream, existsSync } = require('fs');
|
||||
const { join } = require('path');
|
||||
const archiver = require('archiver');
|
||||
|
||||
// Import package.json to get name and version
|
||||
// Note: __dirname is already available in CommonJS and refers to the scripts/ directory
|
||||
function importPackageJson() {
|
||||
try {
|
||||
const packageJson = require('../package.json');
|
||||
return packageJson;
|
||||
} catch (error) {
|
||||
console.error('Could not import package.json:', error.message);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
async function createZip() {
|
||||
try {
|
||||
console.log('Creating plugin distribution zip...');
|
||||
|
||||
// Get package info
|
||||
const packageJson = importPackageJson();
|
||||
const packageName = packageJson.name;
|
||||
const version = packageJson.version;
|
||||
|
||||
// Create zip filename
|
||||
const zipFilename = `${packageName.replace('@', '').replace('/', '-')}.${version}.zip`;
|
||||
const zipPath = join(__dirname, '..', zipFilename);
|
||||
|
||||
console.log(`Creating ${zipFilename}`);
|
||||
|
||||
// Check if required files exist
|
||||
const bundlePath = join(__dirname, '..', 'dist', 'bundle.js');
|
||||
const manifestPath = join(__dirname, '..', 'manifest.json');
|
||||
|
||||
if (!existsSync(bundlePath)) {
|
||||
console.error('dist/bundle.js not found. Run: npm run build:bundle');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (!existsSync(manifestPath)) {
|
||||
console.error('manifest.json not found. Run: npm run build:manifest');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Create zip file
|
||||
const output = createWriteStream(zipPath);
|
||||
const archive = archiver('zip', {
|
||||
zlib: { level: 9 }, // Maximum compression
|
||||
});
|
||||
|
||||
// Handle archive events
|
||||
archive.on('error', err => {
|
||||
console.error('Archive error:', err);
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||
archive.on('end', () => {
|
||||
const stats = archive.pointer();
|
||||
console.log(`${zipFilename} created successfully`);
|
||||
console.log(`Size: ${(stats / 1024).toFixed(2)} KB`);
|
||||
console.log(
|
||||
`📁 Contents: index.js (bundled with dependencies), manifest.json`,
|
||||
);
|
||||
});
|
||||
|
||||
// Pipe archive to file
|
||||
archive.pipe(output);
|
||||
|
||||
// Add files to archive
|
||||
archive.file(bundlePath, { name: 'index.js' });
|
||||
archive.file(manifestPath, { name: 'manifest.json' });
|
||||
|
||||
// Finalize the archive
|
||||
await archive.finalize();
|
||||
} catch (error) {
|
||||
console.error('Failed to create zip:', error.message);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
createZip();
|
||||
70
packages/bank-sync-plugin-pluggy.ai/scripts/install-plugin.cjs
Executable file
70
packages/bank-sync-plugin-pluggy.ai/scripts/install-plugin.cjs
Executable file
@@ -0,0 +1,70 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const packageJson = require('../package.json');
|
||||
const packageName = packageJson.name;
|
||||
const version = packageJson.version;
|
||||
const pluginName = packageName.replace('@', '').replace('/', '-');
|
||||
const zipFileName = `${pluginName}.${version}.zip`;
|
||||
|
||||
// Source: built zip in package root (not in dist/)
|
||||
const sourceZip = path.join(__dirname, '..', zipFileName);
|
||||
|
||||
// Target: sync-server plugins directory
|
||||
// Go up to monorepo root, then to sync-server
|
||||
const targetDir = path.join(
|
||||
__dirname,
|
||||
'..',
|
||||
'..',
|
||||
'sync-server',
|
||||
'server-files',
|
||||
'plugins',
|
||||
);
|
||||
const targetZip = path.join(targetDir, zipFileName);
|
||||
|
||||
console.log('📦 Installing plugin to sync-server...');
|
||||
console.log(` Source: ${sourceZip}`);
|
||||
console.log(` Target: ${targetZip}`);
|
||||
|
||||
// Check if source exists
|
||||
if (!fs.existsSync(sourceZip)) {
|
||||
console.error(`Error: ZIP file not found at ${sourceZip}`);
|
||||
console.error(' Run "npm run build" first to create the ZIP file.');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Create target directory if it doesn't exist
|
||||
if (!fs.existsSync(targetDir)) {
|
||||
console.log(`Creating plugins directory: ${targetDir}`);
|
||||
fs.mkdirSync(targetDir, { recursive: true });
|
||||
}
|
||||
|
||||
// Remove old versions of this plugin
|
||||
try {
|
||||
const files = fs.readdirSync(targetDir);
|
||||
const oldVersions = files.filter(
|
||||
f => f.startsWith(pluginName) && f.endsWith('.zip') && f !== zipFileName,
|
||||
);
|
||||
|
||||
for (const oldFile of oldVersions) {
|
||||
const oldPath = path.join(targetDir, oldFile);
|
||||
console.log(` Removing old version: ${oldFile}`);
|
||||
fs.unlinkSync(oldPath);
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn(` Warning: Could not clean old versions: ${err.message}`);
|
||||
}
|
||||
|
||||
// Copy the new ZIP
|
||||
try {
|
||||
fs.copyFileSync(sourceZip, targetZip);
|
||||
console.log(` Plugin installed successfully!`);
|
||||
console.log(` Location: ${targetZip}`);
|
||||
console.log('');
|
||||
console.log(' Restart your sync-server to load the plugin.');
|
||||
} catch (err) {
|
||||
console.error(` Error copying file: ${err.message}`);
|
||||
process.exit(1);
|
||||
}
|
||||
605
packages/bank-sync-plugin-pluggy.ai/src/index.ts
Normal file
605
packages/bank-sync-plugin-pluggy.ai/src/index.ts
Normal file
@@ -0,0 +1,605 @@
|
||||
import {
|
||||
attachPluginMiddleware,
|
||||
saveSecret,
|
||||
getSecret,
|
||||
BankSyncErrorCode,
|
||||
BankSyncError,
|
||||
} from '@actual-app/plugins-core-sync-server';
|
||||
import express, { Request, Response } from 'express';
|
||||
import { PluggyClient } from 'pluggy-sdk';
|
||||
|
||||
// Import manifest (used during build)
|
||||
import './manifest';
|
||||
|
||||
// Type definitions for Pluggy account structure
|
||||
type PluggyConnector = {
|
||||
id: number | string;
|
||||
name: string;
|
||||
institutionUrl?: string;
|
||||
};
|
||||
|
||||
type PluggyItem = {
|
||||
connector?: PluggyConnector;
|
||||
};
|
||||
|
||||
type PluggyAccount = {
|
||||
id: string;
|
||||
name: string;
|
||||
number?: string;
|
||||
balance?: number;
|
||||
type?: string;
|
||||
itemId?: string;
|
||||
item?: PluggyItem;
|
||||
itemData?: PluggyItem;
|
||||
updatedAt?: string;
|
||||
currencyCode?: string;
|
||||
owner?: string;
|
||||
};
|
||||
|
||||
// Create Express app
|
||||
const app = express();
|
||||
|
||||
// Use JSON middleware for parsing request bodies
|
||||
app.use(express.json());
|
||||
|
||||
// Attach the plugin middleware to enable IPC communication with sync-server
|
||||
attachPluginMiddleware(app);
|
||||
|
||||
// Pluggy client singleton
|
||||
let pluggyClient: PluggyClient | null = null;
|
||||
|
||||
async function getPluggyClient(req: Request): Promise<PluggyClient> {
|
||||
// Try to get credentials from secrets first
|
||||
const clientIdResult = await getSecret(req, 'clientId');
|
||||
const clientSecretResult = await getSecret(req, 'clientSecret');
|
||||
|
||||
const clientId = clientIdResult.value || req.body.clientId;
|
||||
const clientSecret = clientSecretResult.value || req.body.clientSecret;
|
||||
|
||||
if (!clientId || !clientSecret) {
|
||||
throw new Error('Pluggy.ai credentials not configured');
|
||||
}
|
||||
|
||||
if (!pluggyClient) {
|
||||
pluggyClient = new PluggyClient({
|
||||
clientId,
|
||||
clientSecret,
|
||||
});
|
||||
}
|
||||
|
||||
return pluggyClient;
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /status
|
||||
* Check if Pluggy.ai is configured
|
||||
*/
|
||||
app.get('/status', async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const clientIdResult = await getSecret(req, 'clientId');
|
||||
const configured = clientIdResult.value != null;
|
||||
|
||||
res.json({
|
||||
status: 'ok',
|
||||
data: {
|
||||
configured,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
res.json({
|
||||
status: 'error',
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /accounts
|
||||
* Fetch accounts from Pluggy.ai
|
||||
* Body: { itemIds: string, clientId?: string, clientSecret?: string }
|
||||
*
|
||||
* If clientId and clientSecret are provided, they will be saved as secrets
|
||||
*/
|
||||
app.post('/accounts', async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const { itemIds, clientId, clientSecret } = req.body;
|
||||
|
||||
// If credentials are provided in request, save them
|
||||
if (clientId && clientSecret) {
|
||||
await saveSecret(req, 'clientId', clientId);
|
||||
await saveSecret(req, 'clientSecret', clientSecret);
|
||||
}
|
||||
|
||||
// Get itemIds from request or from stored secrets
|
||||
let itemIdsArray: string[];
|
||||
|
||||
if (itemIds) {
|
||||
// Parse itemIds from request (can be comma-separated string or array)
|
||||
if (typeof itemIds === 'string') {
|
||||
itemIdsArray = itemIds.split(',').map((id: string) => id.trim());
|
||||
} else if (Array.isArray(itemIds)) {
|
||||
itemIdsArray = itemIds;
|
||||
} else {
|
||||
res.json({
|
||||
status: 'error',
|
||||
error: 'itemIds must be a string or array',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Save itemIds for future use
|
||||
await saveSecret(req, 'itemIds', itemIdsArray.join(','));
|
||||
} else {
|
||||
// Try to get itemIds from secrets
|
||||
const storedItemIds = await getSecret(req, 'itemIds');
|
||||
if (!storedItemIds.value) {
|
||||
res.json({
|
||||
status: 'error',
|
||||
error:
|
||||
'itemIds is required (comma-separated string or array). Please provide itemIds in request or configure them first.',
|
||||
});
|
||||
return;
|
||||
}
|
||||
itemIdsArray = storedItemIds.value
|
||||
.split(',')
|
||||
.map((id: string) => id.trim());
|
||||
}
|
||||
|
||||
if (!itemIdsArray.length) {
|
||||
res.json({
|
||||
status: 'error',
|
||||
error: 'At least one item ID is required',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const client = await getPluggyClient(req);
|
||||
let accounts: PluggyAccount[] = [];
|
||||
|
||||
// Fetch all accounts and their items with connector info
|
||||
for (const itemId of itemIdsArray) {
|
||||
const partial = await client.fetchAccounts(itemId);
|
||||
|
||||
// For each account, also fetch the item to get connector details
|
||||
for (const account of partial.results) {
|
||||
try {
|
||||
const item = await client.fetchItem(itemId);
|
||||
// Attach item info to account for transformation
|
||||
(account as PluggyAccount).itemData = item;
|
||||
} catch (error) {
|
||||
console.error(
|
||||
`[PLUGGY ACCOUNTS] Error fetching item ${itemId}:`,
|
||||
error,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
accounts = accounts.concat(partial.results as PluggyAccount[]);
|
||||
}
|
||||
|
||||
// Transform Pluggy accounts to GenericBankSyncAccount format
|
||||
const transformedAccounts = accounts.map((account: PluggyAccount) => {
|
||||
const institution =
|
||||
account.itemData?.connector?.name ||
|
||||
account.item?.connector?.name ||
|
||||
'Unknown Institution';
|
||||
|
||||
const connectorId =
|
||||
account.itemData?.connector?.id ||
|
||||
account.item?.connector?.id ||
|
||||
account.itemId;
|
||||
|
||||
return {
|
||||
account_id: account.id,
|
||||
name: account.name,
|
||||
institution,
|
||||
balance: account.balance || 0,
|
||||
mask: account.number?.substring(account.number.length - 4),
|
||||
official_name: account.name,
|
||||
orgDomain:
|
||||
account.itemData?.connector?.institutionUrl ||
|
||||
account.item?.connector?.institutionUrl ||
|
||||
null,
|
||||
orgId: connectorId?.toString() || null,
|
||||
};
|
||||
});
|
||||
|
||||
res.json({
|
||||
status: 'ok',
|
||||
data: {
|
||||
accounts: transformedAccounts,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('[PLUGGY ACCOUNTS] Error:', error);
|
||||
|
||||
// Extract Pluggy error message and code if available
|
||||
let pluggyMessage = 'Unknown error';
|
||||
let pluggyCode: string | number | undefined;
|
||||
|
||||
if (error instanceof Error) {
|
||||
pluggyMessage = error.message;
|
||||
|
||||
// Try to parse Pluggy SDK error format from error message
|
||||
// Pluggy errors often include the error details in the message
|
||||
try {
|
||||
// Check if error has a structured format
|
||||
const errorAny = error as unknown as Record<string, unknown>;
|
||||
if (errorAny.message && typeof errorAny.message === 'string') {
|
||||
pluggyMessage = errorAny.message;
|
||||
}
|
||||
if (errorAny.code !== undefined) {
|
||||
pluggyCode = errorAny.code as string | number;
|
||||
}
|
||||
} catch (e) {
|
||||
// Ignore parse errors
|
||||
}
|
||||
}
|
||||
|
||||
const errorResponse: BankSyncError = {
|
||||
error_type: BankSyncErrorCode.UNKNOWN_ERROR,
|
||||
error_code: BankSyncErrorCode.UNKNOWN_ERROR,
|
||||
status: 'error',
|
||||
reason: pluggyMessage, // Use the Pluggy error message directly
|
||||
};
|
||||
|
||||
// Map HTTP status codes to error types
|
||||
const errorMessageLower = pluggyMessage.toLowerCase();
|
||||
|
||||
if (pluggyCode === 401 || errorMessageLower.includes('401') || errorMessageLower.includes('unauthorized') || errorMessageLower.includes('invalid credentials')) {
|
||||
errorResponse.error_type = BankSyncErrorCode.INVALID_CREDENTIALS;
|
||||
errorResponse.error_code = BankSyncErrorCode.INVALID_CREDENTIALS;
|
||||
} else if (pluggyCode === 403 || errorMessageLower.includes('403') || errorMessageLower.includes('forbidden')) {
|
||||
errorResponse.error_type = BankSyncErrorCode.UNAUTHORIZED;
|
||||
errorResponse.error_code = BankSyncErrorCode.UNAUTHORIZED;
|
||||
} else if (pluggyCode === 429 || errorMessageLower.includes('429') || errorMessageLower.includes('rate limit')) {
|
||||
errorResponse.error_type = BankSyncErrorCode.RATE_LIMIT;
|
||||
errorResponse.error_code = BankSyncErrorCode.RATE_LIMIT;
|
||||
} else if (pluggyCode === 400 || errorMessageLower.includes('400') || errorMessageLower.includes('bad request')) {
|
||||
errorResponse.error_type = BankSyncErrorCode.INVALID_REQUEST;
|
||||
errorResponse.error_code = BankSyncErrorCode.INVALID_REQUEST;
|
||||
} else if (pluggyCode === 404 || errorMessageLower.includes('404') || errorMessageLower.includes('not found')) {
|
||||
errorResponse.error_type = BankSyncErrorCode.ACCOUNT_NOT_FOUND;
|
||||
errorResponse.error_code = BankSyncErrorCode.ACCOUNT_NOT_FOUND;
|
||||
} else if (errorMessageLower.includes('network') || errorMessageLower.includes('connect') || errorMessageLower.includes('econnrefused')) {
|
||||
errorResponse.error_type = BankSyncErrorCode.NETWORK_ERROR;
|
||||
errorResponse.error_code = BankSyncErrorCode.NETWORK_ERROR;
|
||||
} else if ((pluggyCode && typeof pluggyCode === 'number' && pluggyCode >= 500) || errorMessageLower.includes('500') || errorMessageLower.includes('502') || errorMessageLower.includes('503')) {
|
||||
errorResponse.error_type = BankSyncErrorCode.SERVER_ERROR;
|
||||
errorResponse.error_code = BankSyncErrorCode.SERVER_ERROR;
|
||||
}
|
||||
|
||||
errorResponse.details = {
|
||||
originalError: pluggyMessage,
|
||||
pluggyCode: pluggyCode,
|
||||
};
|
||||
|
||||
res.json({
|
||||
status: 'ok',
|
||||
data: errorResponse,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /transactions
|
||||
* Fetch transactions from Pluggy.ai
|
||||
* Body: { accountId: string, startDate: string, clientId?: string, clientSecret?: string }
|
||||
*/
|
||||
app.post(
|
||||
'/transactions',
|
||||
async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const { accountId, startDate } = req.body;
|
||||
|
||||
if (!accountId) {
|
||||
res.json({
|
||||
status: 'error',
|
||||
error: 'accountId is required',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const client = await getPluggyClient(req);
|
||||
const transactions = await getTransactions(client, accountId, startDate);
|
||||
const account = (await client.fetchAccount(accountId)) as Record<
|
||||
string,
|
||||
unknown
|
||||
>;
|
||||
|
||||
let startingBalance = parseInt(
|
||||
Math.round((account.balance as number) * 100).toString(),
|
||||
);
|
||||
if (account.type === 'CREDIT') {
|
||||
startingBalance = -startingBalance;
|
||||
}
|
||||
const date = getDate(new Date(account.updatedAt as string));
|
||||
|
||||
const balances = [
|
||||
{
|
||||
balanceAmount: {
|
||||
amount: startingBalance,
|
||||
currency: account.currencyCode,
|
||||
},
|
||||
balanceType: 'expected',
|
||||
referenceDate: date,
|
||||
},
|
||||
];
|
||||
|
||||
const all: unknown[] = [];
|
||||
const booked: unknown[] = [];
|
||||
const pending: unknown[] = [];
|
||||
|
||||
for (const trans of transactions) {
|
||||
const transRecord = trans as Record<string, unknown>;
|
||||
const newTrans: Record<string, unknown> = {};
|
||||
|
||||
newTrans.booked = !(transRecord.status === 'PENDING');
|
||||
|
||||
const transactionDate = new Date(transRecord.date as string);
|
||||
|
||||
if (transactionDate < new Date(startDate) && !transRecord.sandbox) {
|
||||
continue;
|
||||
}
|
||||
|
||||
newTrans.date = getDate(transactionDate);
|
||||
newTrans.payeeName = getPayeeName(transRecord);
|
||||
newTrans.notes = transRecord.descriptionRaw || transRecord.description;
|
||||
|
||||
if (account.type === 'CREDIT') {
|
||||
if (transRecord.amountInAccountCurrency) {
|
||||
transRecord.amountInAccountCurrency =
|
||||
(transRecord.amountInAccountCurrency as number) * -1;
|
||||
}
|
||||
|
||||
transRecord.amount = (transRecord.amount as number) * -1;
|
||||
}
|
||||
|
||||
let amountInCurrency =
|
||||
(transRecord.amountInAccountCurrency as number) ??
|
||||
(transRecord.amount as number);
|
||||
amountInCurrency = Math.round(amountInCurrency * 100) / 100;
|
||||
|
||||
newTrans.transactionAmount = {
|
||||
amount: amountInCurrency,
|
||||
currency: transRecord.currencyCode,
|
||||
};
|
||||
|
||||
newTrans.transactionId = transRecord.id;
|
||||
newTrans.sortOrder = transactionDate.getTime();
|
||||
|
||||
delete transRecord.amount;
|
||||
|
||||
const finalTrans = { ...flattenObject(transRecord), ...newTrans };
|
||||
if (newTrans.booked) {
|
||||
booked.push(finalTrans);
|
||||
} else {
|
||||
pending.push(finalTrans);
|
||||
}
|
||||
all.push(finalTrans);
|
||||
}
|
||||
|
||||
const sortFunction = (a: unknown, b: unknown) => {
|
||||
const aRec = a as Record<string, unknown>;
|
||||
const bRec = b as Record<string, unknown>;
|
||||
return (bRec.sortOrder as number) - (aRec.sortOrder as number);
|
||||
};
|
||||
|
||||
const bookedSorted = booked.sort(sortFunction);
|
||||
const pendingSorted = pending.sort(sortFunction);
|
||||
const allSorted = all.sort(sortFunction);
|
||||
|
||||
res.json({
|
||||
status: 'ok',
|
||||
data: {
|
||||
balances,
|
||||
startingBalance,
|
||||
transactions: {
|
||||
all: allSorted,
|
||||
booked: bookedSorted,
|
||||
pending: pendingSorted,
|
||||
},
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('[PLUGGY TRANSACTIONS] Error:', error);
|
||||
|
||||
// Extract Pluggy error message and code if available
|
||||
let pluggyMessage = 'Unknown error';
|
||||
let pluggyCode: string | number | undefined;
|
||||
|
||||
if (error instanceof Error) {
|
||||
pluggyMessage = error.message;
|
||||
|
||||
// Try to parse Pluggy SDK error format from error message
|
||||
try {
|
||||
const errorAny = error as unknown as Record<string, unknown>;
|
||||
if (errorAny.message && typeof errorAny.message === 'string') {
|
||||
pluggyMessage = errorAny.message;
|
||||
}
|
||||
if (errorAny.code !== undefined) {
|
||||
pluggyCode = errorAny.code as string | number;
|
||||
}
|
||||
} catch (e) {
|
||||
// Ignore parse errors
|
||||
}
|
||||
}
|
||||
|
||||
const errorResponse: BankSyncError = {
|
||||
error_type: BankSyncErrorCode.UNKNOWN_ERROR,
|
||||
error_code: BankSyncErrorCode.UNKNOWN_ERROR,
|
||||
status: 'error',
|
||||
reason: pluggyMessage, // Use the Pluggy error message directly
|
||||
};
|
||||
|
||||
// Map HTTP status codes to error types
|
||||
const errorMessageLower = pluggyMessage.toLowerCase();
|
||||
|
||||
if (pluggyCode === 401 || errorMessageLower.includes('401') || errorMessageLower.includes('unauthorized') || errorMessageLower.includes('invalid credentials')) {
|
||||
errorResponse.error_type = BankSyncErrorCode.INVALID_CREDENTIALS;
|
||||
errorResponse.error_code = BankSyncErrorCode.INVALID_CREDENTIALS;
|
||||
} else if (pluggyCode === 403 || errorMessageLower.includes('403') || errorMessageLower.includes('forbidden')) {
|
||||
errorResponse.error_type = BankSyncErrorCode.UNAUTHORIZED;
|
||||
errorResponse.error_code = BankSyncErrorCode.UNAUTHORIZED;
|
||||
} else if (pluggyCode === 404 || errorMessageLower.includes('404') || errorMessageLower.includes('not found')) {
|
||||
errorResponse.error_type = BankSyncErrorCode.ACCOUNT_NOT_FOUND;
|
||||
errorResponse.error_code = BankSyncErrorCode.ACCOUNT_NOT_FOUND;
|
||||
} else if (pluggyCode === 429 || errorMessageLower.includes('429') || errorMessageLower.includes('rate limit')) {
|
||||
errorResponse.error_type = BankSyncErrorCode.RATE_LIMIT;
|
||||
errorResponse.error_code = BankSyncErrorCode.RATE_LIMIT;
|
||||
} else if (pluggyCode === 400 || errorMessageLower.includes('400') || errorMessageLower.includes('bad request')) {
|
||||
errorResponse.error_type = BankSyncErrorCode.INVALID_REQUEST;
|
||||
errorResponse.error_code = BankSyncErrorCode.INVALID_REQUEST;
|
||||
} else if (errorMessageLower.includes('network') || errorMessageLower.includes('connect') || errorMessageLower.includes('econnrefused')) {
|
||||
errorResponse.error_type = BankSyncErrorCode.NETWORK_ERROR;
|
||||
errorResponse.error_code = BankSyncErrorCode.NETWORK_ERROR;
|
||||
} else if ((pluggyCode && typeof pluggyCode === 'number' && pluggyCode >= 500) || errorMessageLower.includes('500') || errorMessageLower.includes('502') || errorMessageLower.includes('503')) {
|
||||
errorResponse.error_type = BankSyncErrorCode.SERVER_ERROR;
|
||||
errorResponse.error_code = BankSyncErrorCode.SERVER_ERROR;
|
||||
}
|
||||
|
||||
errorResponse.details = {
|
||||
originalError: pluggyMessage,
|
||||
pluggyCode: pluggyCode,
|
||||
};
|
||||
|
||||
res.json({
|
||||
status: 'ok',
|
||||
data: errorResponse,
|
||||
});
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
// Helper functions
|
||||
async function getTransactions(
|
||||
client: PluggyClient,
|
||||
accountId: string,
|
||||
startDate: string,
|
||||
): Promise<unknown[]> {
|
||||
let transactions: unknown[] = [];
|
||||
let result = await getTransactionsByAccountId(
|
||||
client,
|
||||
accountId,
|
||||
startDate,
|
||||
500,
|
||||
1,
|
||||
);
|
||||
transactions = transactions.concat(result.results);
|
||||
const totalPages = result.totalPages;
|
||||
let currentPage = result.page;
|
||||
|
||||
while (currentPage !== totalPages) {
|
||||
result = await getTransactionsByAccountId(
|
||||
client,
|
||||
accountId,
|
||||
startDate,
|
||||
500,
|
||||
currentPage + 1,
|
||||
);
|
||||
transactions = transactions.concat(result.results);
|
||||
currentPage = result.page;
|
||||
}
|
||||
|
||||
return transactions;
|
||||
}
|
||||
|
||||
async function getTransactionsByAccountId(
|
||||
client: PluggyClient,
|
||||
accountId: string,
|
||||
startDate: string,
|
||||
pageSize: number,
|
||||
page: number,
|
||||
): Promise<{ results: unknown[]; totalPages: number; page: number }> {
|
||||
const account = (await client.fetchAccount(accountId)) as Record<
|
||||
string,
|
||||
unknown
|
||||
>;
|
||||
|
||||
// Sandbox account handling
|
||||
const sandboxAccount = account.owner === 'John Doe';
|
||||
const fromDate = sandboxAccount ? '2000-01-01' : startDate;
|
||||
|
||||
const transactions = await client.fetchTransactions(accountId, {
|
||||
from: fromDate,
|
||||
pageSize,
|
||||
page,
|
||||
});
|
||||
|
||||
if (sandboxAccount) {
|
||||
const mappedResults = transactions.results.map(
|
||||
(t: Record<string, unknown>) => ({
|
||||
...t,
|
||||
sandbox: true,
|
||||
}),
|
||||
);
|
||||
transactions.results =
|
||||
mappedResults as unknown as typeof transactions.results;
|
||||
}
|
||||
|
||||
return transactions;
|
||||
}
|
||||
|
||||
function getDate(date: Date): string {
|
||||
return date.toISOString().split('T')[0];
|
||||
}
|
||||
|
||||
function flattenObject(
|
||||
obj: Record<string, unknown>,
|
||||
prefix = '',
|
||||
): Record<string, unknown> {
|
||||
const result: Record<string, unknown> = {};
|
||||
|
||||
for (const [key, value] of Object.entries(obj)) {
|
||||
const newKey = prefix ? `${prefix}.${key}` : key;
|
||||
|
||||
if (value === null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (typeof value === 'object' && value !== null && !Array.isArray(value)) {
|
||||
Object.assign(
|
||||
result,
|
||||
flattenObject(value as Record<string, unknown>, newKey),
|
||||
);
|
||||
} else {
|
||||
result[newKey] = value;
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
function getPayeeName(trans: Record<string, unknown>): string {
|
||||
const merchant = trans.merchant as Record<string, string> | undefined;
|
||||
if (merchant && (merchant.name || merchant.businessName)) {
|
||||
return merchant.name || merchant.businessName || '';
|
||||
}
|
||||
|
||||
const paymentData = trans.paymentData as
|
||||
| Record<string, Record<string, unknown>>
|
||||
| undefined;
|
||||
if (paymentData) {
|
||||
const { receiver, payer } = paymentData;
|
||||
|
||||
if (trans.type === 'DEBIT' && receiver) {
|
||||
const receiverData = receiver as Record<string, unknown>;
|
||||
const docNum = receiverData.documentNumber as
|
||||
| Record<string, string>
|
||||
| undefined;
|
||||
return (receiverData.name as string) || docNum?.value || '';
|
||||
}
|
||||
|
||||
if (trans.type === 'CREDIT' && payer) {
|
||||
const payerData = payer as Record<string, unknown>;
|
||||
const docNum = payerData.documentNumber as
|
||||
| Record<string, string>
|
||||
| undefined;
|
||||
return (payerData.name as string) || docNum?.value || '';
|
||||
}
|
||||
}
|
||||
|
||||
return '';
|
||||
}
|
||||
|
||||
console.log('Pluggy.ai Bank Sync Plugin loaded');
|
||||
43
packages/bank-sync-plugin-pluggy.ai/src/manifest.ts
Normal file
43
packages/bank-sync-plugin-pluggy.ai/src/manifest.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
import { PluginManifest } from '@actual-app/plugins-core-sync-server';
|
||||
|
||||
export const manifest: PluginManifest = {
|
||||
name: 'pluggy-bank-sync',
|
||||
version: '0.0.1',
|
||||
description: 'Pluggy.ai bank synchronization plugin for Actual Budget',
|
||||
entry: 'index.js',
|
||||
author: 'Actual Budget Team',
|
||||
license: 'MIT',
|
||||
routes: [
|
||||
{
|
||||
path: '/status',
|
||||
methods: ['POST'],
|
||||
auth: 'authenticated',
|
||||
description: 'Check Pluggy.ai configuration status',
|
||||
},
|
||||
{
|
||||
path: '/accounts',
|
||||
methods: ['POST'],
|
||||
auth: 'authenticated',
|
||||
description: 'Fetch accounts from Pluggy.ai',
|
||||
},
|
||||
{
|
||||
path: '/transactions',
|
||||
methods: ['POST'],
|
||||
auth: 'authenticated',
|
||||
description: 'Fetch transactions from Pluggy.ai',
|
||||
},
|
||||
],
|
||||
bankSync: {
|
||||
enabled: true,
|
||||
displayName: 'Pluggy.ai',
|
||||
description: 'Connect your bank accounts via Pluggy.ai',
|
||||
requiresAuth: true,
|
||||
endpoints: {
|
||||
status: '/status',
|
||||
accounts: '/accounts',
|
||||
transactions: '/transactions',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export default manifest;
|
||||
28
packages/bank-sync-plugin-pluggy.ai/tsconfig.json
Normal file
28
packages/bank-sync-plugin-pluggy.ai/tsconfig.json
Normal file
@@ -0,0 +1,28 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"module": "ES2020",
|
||||
"lib": ["ES2020"],
|
||||
"outDir": "./dist",
|
||||
"rootDir": "./src",
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"moduleResolution": "node",
|
||||
"resolveJsonModule": true,
|
||||
"noImplicitAny": true,
|
||||
"strictNullChecks": true,
|
||||
"strictFunctionTypes": true,
|
||||
"strictBindCallApply": true,
|
||||
"strictPropertyInitialization": true,
|
||||
"noImplicitThis": true,
|
||||
"alwaysStrict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noImplicitReturns": true,
|
||||
"noFallthroughCasesInSwitch": true
|
||||
},
|
||||
"include": ["src/**/*"],
|
||||
"exclude": ["node_modules", "dist"]
|
||||
}
|
||||
4
packages/bank-sync-plugin-simplefin/.gitignore
vendored
Normal file
4
packages/bank-sync-plugin-simplefin/.gitignore
vendored
Normal file
@@ -0,0 +1,4 @@
|
||||
dist/
|
||||
node_modules/
|
||||
*.zip
|
||||
*.log
|
||||
159
packages/bank-sync-plugin-simplefin/README.md
Normal file
159
packages/bank-sync-plugin-simplefin/README.md
Normal file
@@ -0,0 +1,159 @@
|
||||
# SimpleFIN Bank Sync Plugin
|
||||
|
||||
A bank synchronization plugin for Actual Budget that connects to financial institutions via SimpleFIN.
|
||||
|
||||
## Overview
|
||||
|
||||
This plugin enables Actual Budget to sync bank account data and transactions through the SimpleFIN API. SimpleFIN provides a unified interface to connect with various financial institutions.
|
||||
|
||||
## Features
|
||||
|
||||
- Account discovery and synchronization
|
||||
- Transaction import with proper categorization
|
||||
- Support for pending and posted transactions
|
||||
- Balance information retrieval
|
||||
- Error handling for connection issues
|
||||
|
||||
## Installation
|
||||
|
||||
1. Build the plugin:
|
||||
```bash
|
||||
npm run build
|
||||
```
|
||||
|
||||
2. Install the plugin to your sync-server:
|
||||
```bash
|
||||
npm run install:plugin
|
||||
```
|
||||
|
||||
3. Restart your sync-server to load the plugin.
|
||||
|
||||
## Configuration
|
||||
|
||||
The plugin requires a SimpleFIN access token to authenticate with the SimpleFIN API.
|
||||
|
||||
### Getting a SimpleFIN Token
|
||||
|
||||
1. Visit [SimpleFIN Bridge](https://bridge.simplefin.org/auth/login)
|
||||
2. Sign up for an account
|
||||
3. Connect your financial institutions
|
||||
4. Generate an access token
|
||||
|
||||
### Plugin Setup
|
||||
|
||||
Once the plugin is installed, configure it in Actual Budget by providing your SimpleFIN token when prompted during the bank connection setup.
|
||||
|
||||
## API Endpoints
|
||||
|
||||
### POST /status
|
||||
Check if the plugin is configured with valid credentials.
|
||||
|
||||
**Response:**
|
||||
```json
|
||||
{
|
||||
"status": "ok",
|
||||
"data": {
|
||||
"configured": true
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### POST /accounts
|
||||
Fetch available accounts from connected financial institutions.
|
||||
|
||||
**Request Body:**
|
||||
```json
|
||||
{
|
||||
"token": "your-simplefin-token" // optional, will be saved if provided
|
||||
}
|
||||
```
|
||||
|
||||
**Response:**
|
||||
```json
|
||||
{
|
||||
"status": "ok",
|
||||
"data": {
|
||||
"accounts": [
|
||||
{
|
||||
"account_id": "123456789",
|
||||
"name": "Checking Account",
|
||||
"institution": "Bank Name",
|
||||
"balance": 1234.56,
|
||||
"mask": "6789",
|
||||
"official_name": "Premium Checking",
|
||||
"orgDomain": "bank.com",
|
||||
"orgId": "BANK123"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### POST /transactions
|
||||
Fetch transactions for specific accounts within a date range.
|
||||
|
||||
**Request Body:**
|
||||
```json
|
||||
{
|
||||
"accountId": "123456789",
|
||||
"startDate": "2024-01-01"
|
||||
}
|
||||
```
|
||||
|
||||
**Response:**
|
||||
```json
|
||||
{
|
||||
"status": "ok",
|
||||
"data": {
|
||||
"balances": [
|
||||
{
|
||||
"balanceAmount": {
|
||||
"amount": "1234.56",
|
||||
"currency": "USD"
|
||||
},
|
||||
"balanceType": "expected",
|
||||
"referenceDate": "2024-01-15"
|
||||
}
|
||||
],
|
||||
"startingBalance": 123456,
|
||||
"transactions": {
|
||||
"all": [...],
|
||||
"booked": [...],
|
||||
"pending": [...]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Error Handling
|
||||
|
||||
The plugin provides detailed error messages for various failure scenarios:
|
||||
|
||||
- `INVALID_ACCESS_TOKEN`: Invalid or expired SimpleFIN token
|
||||
- `SERVER_DOWN`: Communication issues with SimpleFIN
|
||||
- `ACCOUNT_MISSING`: Specified account not found
|
||||
- `ACCOUNT_NEEDS_ATTENTION`: Account requires attention on SimpleFIN Bridge
|
||||
|
||||
## Development
|
||||
|
||||
### Building
|
||||
|
||||
```bash
|
||||
npm run build # Full build (compile + bundle + manifest + zip)
|
||||
npm run build:compile # TypeScript compilation only
|
||||
npm run build:bundle # Bundle with dependencies
|
||||
npm run build:manifest # Generate manifest.json
|
||||
npm run build:zip # Create distribution zip
|
||||
```
|
||||
|
||||
### Testing
|
||||
|
||||
The plugin integrates with Actual Budget's existing test infrastructure. Run tests from the monorepo root:
|
||||
|
||||
```bash
|
||||
yarn test
|
||||
```
|
||||
|
||||
## License
|
||||
|
||||
MIT
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user