Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8dc3aa557c | ||
|
|
316f5c1def | ||
|
|
d80e791547 | ||
|
|
b37e9b6449 | ||
|
|
87b82d6f2d | ||
|
|
ce56822a69 | ||
|
|
364eac4812 | ||
|
|
1cf711e4fa | ||
|
|
b08c4e0079 | ||
|
|
8208cb2b39 | ||
|
|
eed0aed508 | ||
|
|
7477713f93 | ||
|
|
f99e3d1e92 | ||
|
|
b2052ff205 | ||
|
|
fb5fac0621 | ||
|
|
a9462c61b3 | ||
|
|
d8c7263456 | ||
|
|
b38c8ffbf8 | ||
|
|
bcede2fc71 | ||
|
|
6dad3dbc8f | ||
|
|
2eacd98c1d | ||
|
|
ca51caeed9 | ||
|
|
a93c292682 | ||
|
|
e6c502cd7a | ||
|
|
4d1226ffdc | ||
|
|
de795fdcf3 | ||
|
|
04857ba913 | ||
|
|
237345bf90 | ||
|
|
bdfd0b8f2c | ||
|
|
3e8f6614fa | ||
|
|
7b64353aba | ||
|
|
3b73bda108 | ||
|
|
74abe3b2bb | ||
|
|
f3449814dc | ||
|
|
e13ca7c116 | ||
|
|
f8f157db56 | ||
|
|
c2fd17e856 | ||
|
|
decf1cb94d | ||
|
|
eeb79e7d10 | ||
|
|
dc04d495f5 | ||
|
|
bddf7ff452 | ||
|
|
f55dd02e39 | ||
|
|
e95bb85c8b | ||
|
|
e60f7c791e | ||
|
|
6b1aa99b3d | ||
|
|
7e36e61036 | ||
|
|
a3073ded0b | ||
|
|
0c429cfe26 | ||
|
|
cdf53e782f | ||
|
|
22f448e665 | ||
|
|
e1c647a53f | ||
|
|
46f59a6cd5 | ||
|
|
597fea74fb | ||
|
|
019f5f5329 | ||
|
|
681c25ae7c | ||
|
|
840471a3e2 | ||
|
|
94da7d87f1 | ||
|
|
3665089f5b | ||
|
|
c159b94f6e | ||
|
|
e965b1d585 | ||
|
|
c5ec84991a | ||
|
|
1fb766e2b9 | ||
|
|
d67c5b1c3e | ||
|
|
db2512e857 | ||
|
|
fcb0406c30 | ||
|
|
e37ba06818 | ||
|
|
afe76076ee | ||
|
|
8f342a8830 | ||
|
|
2286e6c639 | ||
|
|
ed7b69645d | ||
|
|
a8dd93d5b0 | ||
|
|
23adf59a2e | ||
|
|
7a17e93496 | ||
|
|
db636d2b0b | ||
|
|
b7037e80b8 | ||
|
|
e7bd738821 | ||
|
|
d280a4a350 | ||
|
|
352184f1e6 | ||
|
|
a96707197d | ||
|
|
4de07e0d58 | ||
|
|
5c074ec4a2 | ||
|
|
36bd979bbe | ||
|
|
6a86ea9b74 | ||
|
|
3df533dfd0 | ||
|
|
6675dc7761 | ||
|
|
2fa2831fc2 | ||
|
|
99f775c39d | ||
|
|
77c2d8ad45 | ||
|
|
9837e3650c | ||
|
|
de29524dd8 | ||
|
|
2b88926d7f | ||
|
|
06366981a6 | ||
|
|
ebd30d2d9f | ||
|
|
2e5c56c007 | ||
|
|
5fae575e4f | ||
|
|
a519a6f8ea | ||
|
|
88135755dc | ||
|
|
26ea5d4679 | ||
|
|
bd793e9325 | ||
|
|
4a3420b50d | ||
|
|
062fde9f2a | ||
|
|
e8e237ded3 | ||
|
|
0efca1f325 | ||
|
|
b2a737f822 | ||
|
|
5683b2f366 | ||
|
|
65755f40bf | ||
|
|
6c4d1ce6cf | ||
|
|
4c02df75fa | ||
|
|
fec47dcfb7 | ||
|
|
da67ea6af5 | ||
|
|
93002f84e9 | ||
|
|
5e6ccf6295 | ||
|
|
a05fd639e8 | ||
|
|
33094b14b0 | ||
|
|
141445378d | ||
|
|
fe95dad65d | ||
|
|
bf04ca908a | ||
|
|
08b9d23c2c | ||
|
|
f068433bea | ||
|
|
b54670c6ab | ||
|
|
a8c0e4bbc1 | ||
|
|
563bde5394 | ||
|
|
c878610603 | ||
|
|
67d847dbc3 | ||
|
|
e4c355b651 | ||
|
|
9f03958056 | ||
|
|
02b73cccb2 | ||
|
|
f4d6d80baa | ||
|
|
c5e71b8ff6 | ||
|
|
6cf4e52de8 | ||
|
|
32b592832a | ||
|
|
125abb40b1 | ||
|
|
e755454265 | ||
|
|
a17dc3a6f6 | ||
|
|
589a2580a7 | ||
|
|
2843d87b00 | ||
|
|
bfbced5395 | ||
|
|
00fd6b6ef3 | ||
|
|
436bef1ab1 | ||
|
|
6b68bf4664 | ||
|
|
2400fc95d6 | ||
|
|
983563d197 | ||
|
|
68bc275045 | ||
|
|
1a49411d68 | ||
|
|
e5a847b475 | ||
|
|
042a104f8a | ||
|
|
7597cd8577 | ||
|
|
efd556c247 | ||
|
|
9dcd7acfe1 | ||
|
|
4be7c99895 | ||
|
|
5ade47a225 | ||
|
|
38a7cbd808 | ||
|
|
4e716443cc | ||
|
|
a76eb3b67e | ||
|
|
134b2458e4 | ||
|
|
b58d920bbe | ||
|
|
12dbec8d79 | ||
|
|
b4a6197f31 | ||
|
|
13a85e9b75 | ||
|
|
f4f156f045 | ||
|
|
82bf272c89 | ||
|
|
54a4bc2fed | ||
|
|
45e4e25629 | ||
|
|
372f8f1639 | ||
|
|
fd34e6cc43 | ||
|
|
40f00012f2 | ||
|
|
02fb4f64bb | ||
|
|
5a6e5f12ce | ||
|
|
5a9800b8e6 | ||
|
|
be3f1af59a | ||
|
|
b9790c6c75 | ||
|
|
bea6c316fa | ||
|
|
76345c2173 | ||
|
|
4113edfa8e | ||
|
|
39dc01ed2e | ||
|
|
a3d024803f | ||
|
|
d87d07396d | ||
|
|
9da12ae6c4 | ||
|
|
08815c736f | ||
|
|
9e033025de | ||
|
|
a20d5ccc56 | ||
|
|
67da2b38ee | ||
|
|
cbd370bcd9 | ||
|
|
07baf92f60 | ||
|
|
f7a0cf7bec | ||
|
|
48033894fb | ||
|
|
4e0d3d155a | ||
|
|
28344f8d34 | ||
|
|
59494a352d | ||
|
|
8f277846fa | ||
|
|
9fbd92f1db | ||
|
|
46f450e996 | ||
|
|
3fcdeba567 | ||
|
|
bc4c4bac34 | ||
|
|
0da4500e58 | ||
|
|
862595edc1 | ||
|
|
3b056de641 | ||
|
|
1b91e7bfd3 | ||
|
|
093b57d05b | ||
|
|
fb58c9f765 | ||
|
|
928ba40abd | ||
|
|
ee7fe33e72 | ||
|
|
8ea87fd6d9 | ||
|
|
71521672f7 | ||
|
|
0a45c5e7d1 | ||
|
|
b2d737078c | ||
|
|
99b4f8e832 | ||
|
|
75a5d73412 | ||
|
|
dc0574735e | ||
|
|
2d8c3ff4a8 | ||
|
|
d88944f424 | ||
|
|
b9bcb9fe1e | ||
|
|
0769d232f4 | ||
|
|
86232d04b6 | ||
|
|
aa7d80c68f | ||
|
|
f98f09c2d1 | ||
|
|
c46a5b9446 | ||
|
|
40dc33bca8 | ||
|
|
155b282066 | ||
|
|
6dbbef7208 | ||
|
|
5cdfadaab0 | ||
|
|
5e8ec89662 | ||
|
|
fa79c46aad | ||
|
|
7e2961126f | ||
|
|
509570fe20 | ||
|
|
67a81cc4a1 | ||
|
|
9523536f1e | ||
|
|
8a4d8625bb | ||
|
|
f13b122afe | ||
|
|
2ea3d34a82 | ||
|
|
c9f35ae8b0 | ||
|
|
6168fb737c | ||
|
|
9420600db4 | ||
|
|
c98408809c | ||
|
|
90e85c2986 | ||
|
|
eea6dcd9c2 | ||
|
|
c527550158 | ||
|
|
dae81155d6 | ||
|
|
c80c4836bb | ||
|
|
849a3805af | ||
|
|
08c2849a9b | ||
|
|
b1006efa33 | ||
|
|
b389f3e052 | ||
|
|
0f7339e78f | ||
|
|
dee8846a80 | ||
|
|
1130030754 | ||
|
|
f10bcede6e | ||
|
|
926b3fcd75 | ||
|
|
bdb2e1dc98 | ||
|
|
3942b0c9f6 | ||
|
|
cc93fcc1dd | ||
|
|
10c270c4fe | ||
|
|
9979001936 | ||
|
|
479d15a1c9 | ||
|
|
8d200f78ee | ||
|
|
15819c426d | ||
|
|
7751bf032e | ||
|
|
d3c3c60876 | ||
|
|
195ffd9cd9 | ||
|
|
eefb71744c | ||
|
|
f0fcbcb0da | ||
|
|
79d7872ea0 | ||
|
|
ee3002d26a | ||
|
|
7644d7a2dc | ||
|
|
e456fa5e70 | ||
|
|
2f0a5795ea | ||
|
|
10dd69856e | ||
|
|
5a2504e57b | ||
|
|
4e5d0a2faa | ||
|
|
333143f19c | ||
|
|
5f09bb4041 | ||
|
|
02800b95e8 | ||
|
|
d2d2f0c1f1 | ||
|
|
1cdb120658 | ||
|
|
cc2a351581 | ||
|
|
43d7d3eb1b | ||
|
|
9fc9168a8b | ||
|
|
0df4842d38 | ||
|
|
4f76b7b5a8 | ||
|
|
d7cd1c4e25 | ||
|
|
c1cae9a9d2 | ||
|
|
3272bc9108 | ||
|
|
1373c7364b | ||
|
|
f551431618 | ||
|
|
57d53dd263 | ||
|
|
80067f544d | ||
|
|
491407ff5b | ||
|
|
5d675e1c89 | ||
|
|
81baee7a3e | ||
|
|
2664fb50aa | ||
|
|
0f14daaefd | ||
|
|
6ab117bdf8 | ||
|
|
46f007812a | ||
|
|
f8d66e94d4 | ||
|
|
3bc055fda7 | ||
|
|
a4a80f671b | ||
|
|
4cf90ed351 | ||
|
|
bebcaab69f | ||
|
|
e0c5da3911 | ||
|
|
72b0e6b241 | ||
|
|
625ef96434 | ||
|
|
9ceaeb2134 | ||
|
|
6fd98b12db | ||
|
|
1a65dd8c2c | ||
|
|
3e6fd15059 | ||
|
|
b6fe30e997 | ||
|
|
fb66dbf464 | ||
|
|
da74f715f6 | ||
|
|
f70950647e | ||
|
|
8db0946727 | ||
|
|
ab586f994f | ||
|
|
b349028b0e | ||
|
|
2d2538661f | ||
|
|
9979a342ed | ||
|
|
204f533b68 | ||
|
|
d69aa597d7 | ||
|
|
d670a6e90a | ||
|
|
0750359675 | ||
|
|
b1699f89a9 | ||
|
|
4ba4c2948b | ||
|
|
c1736ae23d | ||
|
|
93f045a0ba | ||
|
|
e14287795f | ||
|
|
3abcf556ad | ||
|
|
0ab3eb59d9 | ||
|
|
0458f7b16e | ||
|
|
9bad877047 | ||
|
|
1047cb1958 | ||
|
|
1cd018b275 | ||
|
|
28f85992ff | ||
|
|
04c8bece6b | ||
|
|
e5ae7960a8 | ||
|
|
24b003c9ff | ||
|
|
730ddb9b6f | ||
|
|
957ec7577e | ||
|
|
03666bcb96 | ||
|
|
b9cdd81538 | ||
|
|
a6ee4b9144 | ||
|
|
ac58fe7645 | ||
|
|
7c27d8f60a | ||
|
|
8286681636 | ||
|
|
c6696c0736 | ||
|
|
7f5ed27bb8 | ||
|
|
04f8ac11de | ||
|
|
13297d69e0 | ||
|
|
ae082b8ee5 | ||
|
|
2fa649ec32 | ||
|
|
a0c7ea43c6 | ||
|
|
6ff5a10211 | ||
|
|
3c502b312d | ||
|
|
e7dc0dbaf0 | ||
|
|
24373a109e | ||
|
|
bbd5aa35bf | ||
|
|
9b27a471a7 | ||
|
|
c942839905 | ||
|
|
23dcafa973 | ||
|
|
1b11dca467 | ||
|
|
da628cd565 | ||
|
|
b891cbf0a1 | ||
|
|
cacc6beb05 | ||
|
|
62a965f8b7 | ||
|
|
556780de24 | ||
|
|
2612e4df9f | ||
|
|
0b8f9982fc | ||
|
|
45bcd222f5 | ||
|
|
28e6e01b38 | ||
|
|
0264c2259d | ||
|
|
66c73e3108 | ||
|
|
1ac36860d8 | ||
|
|
77d90eb047 | ||
|
|
228d884a1f | ||
|
|
b02b792e8c | ||
|
|
88ec20d789 | ||
|
|
f7b5813cad | ||
|
|
84f264c433 | ||
|
|
4597e4610a | ||
|
|
a9ddeaee43 | ||
|
|
f65738cc2e | ||
|
|
c46ed18f2d | ||
|
|
e8199b8228 | ||
|
|
91ab7423b7 | ||
|
|
936f5355f7 | ||
|
|
36e2fd91eb | ||
|
|
666913db3a | ||
|
|
5256127069 | ||
|
|
3ae8ce92b5 | ||
|
|
8dff03932a | ||
|
|
fb97479954 | ||
|
|
f10ff88fde | ||
|
|
34ece000cb | ||
|
|
917966129b | ||
|
|
7cb0ddc38e | ||
|
|
03ac2e2b4e | ||
|
|
7d94708dad | ||
|
|
7f8550645b | ||
|
|
71349f475f | ||
|
|
4c2c727af9 | ||
|
|
94b00ccc05 | ||
|
|
3c8fdbb8a2 | ||
|
|
633e48b07f | ||
|
|
ce8c23f978 | ||
|
|
32f7812e3d | ||
|
|
84a1563162 | ||
|
|
fd58f6d903 | ||
|
|
59f900dd08 | ||
|
|
3a0f48d8a8 | ||
|
|
6051add542 | ||
|
|
dcc6599d12 | ||
|
|
57e51bf5b1 | ||
|
|
e4c89fef74 | ||
|
|
9ef6a9c99f | ||
|
|
642760f9e0 | ||
|
|
b0639efa54 | ||
|
|
d0ae998f11 | ||
|
|
4fbf25c403 | ||
|
|
9e7c28c47c | ||
|
|
17805e3e82 | ||
|
|
0c652de424 | ||
|
|
a5662f9ea3 | ||
|
|
6663acca54 | ||
|
|
c750a7dc18 | ||
|
|
79a1fa127f | ||
|
|
fe5cf26134 | ||
|
|
b926dd956f | ||
|
|
c2f610c85e | ||
|
|
7b243df47d | ||
|
|
8d0fa86062 | ||
|
|
1a2300e914 | ||
|
|
3596d54bbd | ||
|
|
e1eaa51e99 | ||
|
|
52a47b1a0e | ||
|
|
f4e1e79abd | ||
|
|
7925ddf748 | ||
|
|
c1465bddfe | ||
|
|
a298eeaf2d | ||
|
|
a7f6474f7f | ||
|
|
3134d31777 | ||
|
|
51084bb038 | ||
|
|
4d9cc0b893 | ||
|
|
0b2762a757 | ||
|
|
2f13f6cf5e | ||
|
|
cfc8230fe9 | ||
|
|
649b86a584 | ||
|
|
71b70b54f5 | ||
|
|
f17b2e6fbe | ||
|
|
570272b702 | ||
|
|
2d7b3d04a1 | ||
|
|
0fbc224d9f | ||
|
|
13e01eb7e2 | ||
|
|
07467c6cbd | ||
|
|
ed1de8a522 | ||
|
|
f0e734c538 | ||
|
|
9f11223a92 | ||
|
|
1809b10096 | ||
|
|
a92240c5c7 | ||
|
|
a140535f27 | ||
|
|
282f50171d | ||
|
|
2be9e78ddc | ||
|
|
ac3833738e | ||
|
|
6f3bc4f278 | ||
|
|
8ef0c15349 | ||
|
|
57019e26f6 | ||
|
|
f6b73d7e4f | ||
|
|
aa09c2319d | ||
|
|
2fcf6df0e6 | ||
|
|
53e1b1eb8b | ||
|
|
bb178fdc1f | ||
|
|
8886b32412 | ||
|
|
5ec8a6d402 | ||
|
|
94cc97ee0b | ||
|
|
effb37357d | ||
|
|
ef42432418 | ||
|
|
28629bd8a2 | ||
|
|
fd44d6376e | ||
|
|
13253ccfb5 | ||
|
|
06b338faba | ||
|
|
b28b8e88f7 | ||
|
|
135ec97f34 | ||
|
|
3cd1df5e9a | ||
|
|
a3f457b7d1 | ||
|
|
9bc832b274 | ||
|
|
866fa0c11b | ||
|
|
96e5f36c4e | ||
|
|
42c55c660b | ||
|
|
7fdaa1d4b7 | ||
|
|
412886227b | ||
|
|
7d74d0b4bf | ||
|
|
86a260a69d | ||
|
|
018beb589d | ||
|
|
bdc6fc8cb4 | ||
|
|
6d62691db4 | ||
|
|
b6ffc34b25 | ||
|
|
de1ca75747 | ||
|
|
c3fb501da8 | ||
|
|
04f9446b5f | ||
|
|
844fd89b18 | ||
|
|
71e6c00e01 | ||
|
|
64b708bb8d | ||
|
|
96473555b7 | ||
|
|
ee00b34f9b | ||
|
|
886e83c8b4 | ||
|
|
16a53b7064 | ||
|
|
d73d0f0d2f | ||
|
|
a862cbb612 | ||
|
|
9bcf595271 | ||
|
|
6e32723f5e | ||
|
|
cc14009440 | ||
|
|
cd7c831e4a | ||
|
|
db73a34be6 | ||
|
|
77293573cd | ||
|
|
c69fa3f1bb | ||
|
|
5d438e610f | ||
|
|
bd6f1811ef | ||
|
|
6fa99e5055 | ||
|
|
9733506605 | ||
|
|
ef8b2a3383 | ||
|
|
a2071cbe47 | ||
|
|
c87f9e2ead | ||
|
|
72b6de53eb | ||
|
|
d6d2b76183 | ||
|
|
a78d6785b9 | ||
|
|
39677c8405 | ||
|
|
4d1d834b8a | ||
|
|
07709b3b4f | ||
|
|
dd4e61daed | ||
|
|
54165bbd77 | ||
|
|
414b358463 | ||
|
|
a9afeb9bbe | ||
|
|
f75703567b | ||
|
|
572ca51316 | ||
|
|
0d2062997c | ||
|
|
bc388f4e12 | ||
|
|
1424326de5 | ||
|
|
afc6cef622 | ||
|
|
6f842a3de7 | ||
|
|
d3462cd040 | ||
|
|
d7a48c5228 | ||
|
|
964f38d36d | ||
|
|
2c865681c6 | ||
|
|
3bc2aaeaa1 | ||
|
|
5f5d4d1378 | ||
|
|
d0ed4e4df0 | ||
|
|
6ca34f4055 | ||
|
|
4598d5b2ca | ||
|
|
bb1d58b5a6 | ||
|
|
03472b3502 | ||
|
|
9ee70bf5f2 | ||
|
|
8f678f24f9 | ||
|
|
a6cba06617 | ||
|
|
a9ddeb2ef2 | ||
|
|
8358fe5072 | ||
|
|
cb665c5f51 | ||
|
|
7af06bdfd1 | ||
|
|
68efbf3c93 | ||
|
|
0462db86be | ||
|
|
0137057f59 | ||
|
|
479dde5e2b | ||
|
|
ef3ce487ea | ||
|
|
155ca19e26 | ||
|
|
624586aa96 | ||
|
|
852f8cf3a2 | ||
|
|
6e8b00a6ab | ||
|
|
63de317493 | ||
|
|
5ad413dcbb | ||
|
|
3ae308794b | ||
|
|
956cb86684 | ||
|
|
1a77f3ac09 | ||
|
|
bde420d1ff | ||
|
|
476bf8315a | ||
|
|
1ceddcbaca | ||
|
|
17e6276ae3 | ||
|
|
f84488161e | ||
|
|
9b74f23950 | ||
|
|
3fb8c83606 | ||
|
|
4e26211265 | ||
|
|
55daca626a | ||
|
|
ef88681ed2 | ||
|
|
1c9d9719ec | ||
|
|
1482f24428 | ||
|
|
4ec9e8c908 | ||
|
|
ce685c1632 | ||
|
|
1fc27200b8 | ||
|
|
23265c6a99 | ||
|
|
4347c3aa7c | ||
|
|
3898ebd5aa | ||
|
|
c3e4f87a3e | ||
|
|
a24326c960 | ||
|
|
d109acd64a | ||
|
|
31d9c0db2d | ||
|
|
7f85c95b69 | ||
|
|
cbf392ba03 | ||
|
|
3457ec8689 | ||
|
|
391f92221d | ||
|
|
7e7eb073fd | ||
|
|
fc16343456 | ||
|
|
8c97a81177 | ||
|
|
d930b84f40 | ||
|
|
55fd98cfcb | ||
|
|
d85823a4e9 | ||
|
|
742b6834f7 | ||
|
|
0e1b9266ec | ||
|
|
a8e2d1bb42 | ||
|
|
63018bae84 | ||
|
|
a227763937 | ||
|
|
c895dc3a43 | ||
|
|
d2120d8b18 | ||
|
|
b777ab96ac | ||
|
|
0a3f0bf9b8 | ||
|
|
dd3015f153 | ||
|
|
55e051da0d | ||
|
|
2f73ff304a | ||
|
|
def5ada66d | ||
|
|
ed4cdf68f9 | ||
|
|
21893bb583 | ||
|
|
8453086472 | ||
|
|
6ffcd06606 | ||
|
|
470802fb04 | ||
|
|
2cd16aaeaa | ||
|
|
ce57c1a4fb | ||
|
|
41e2dd281e | ||
|
|
16187c7fdc | ||
|
|
cc1e0f5943 | ||
|
|
19a4f227ee | ||
|
|
c8e17ad53d | ||
|
|
bed10b0952 | ||
|
|
63809a8acf | ||
|
|
435038ab00 | ||
|
|
07aff5db5b | ||
|
|
8ed78e6110 | ||
|
|
2dabb0cd4d | ||
|
|
cfd0f8959e | ||
|
|
4df6d640b8 | ||
|
|
a77c47b6e1 | ||
|
|
a4217859a2 | ||
|
|
20c6273b55 | ||
|
|
f043816c7b | ||
|
|
b461110ea3 | ||
|
|
bba5fb11f6 | ||
|
|
45bc0c7864 | ||
|
|
6286b52627 | ||
|
|
8619727278 | ||
|
|
f81108e4a7 | ||
|
|
16877cb494 | ||
|
|
421b644f08 | ||
|
|
6bda357eaa | ||
|
|
5ec4f7fc75 | ||
|
|
251b0ea595 | ||
|
|
75447025d3 | ||
|
|
3c96f077d5 | ||
|
|
e793d69f8c | ||
|
|
186364a902 | ||
|
|
1399adfce3 | ||
|
|
0a56d12eff | ||
|
|
6358cbd293 | ||
|
|
941aa7cb13 | ||
|
|
6a2c9e386f | ||
|
|
0f8e30cf20 | ||
|
|
da8ca89e17 | ||
|
|
99f5657cb2 | ||
|
|
b9920efe1a | ||
|
|
bf1b227995 | ||
|
|
b7dcf47afc | ||
|
|
c63810a604 | ||
|
|
c8370bae3a | ||
|
|
e5236c2318 | ||
|
|
64981e63e2 | ||
|
|
60a254a7c6 | ||
|
|
a624e51f9d | ||
|
|
95f29ace2e | ||
|
|
90958fb5df | ||
|
|
96c3f3385c | ||
|
|
59ee241c3d | ||
|
|
3d3761ae78 | ||
|
|
a9a8b2ada3 | ||
|
|
ef23141d7f | ||
|
|
d9d2bfb932 | ||
|
|
5abae0d05b | ||
|
|
2142ff29b0 | ||
|
|
70ecdce3a8 | ||
|
|
18cd843fac | ||
|
|
fcd3b64b05 | ||
|
|
ea49e462f9 | ||
|
|
b23b3c7c33 | ||
|
|
44d040fadd | ||
|
|
c160276fde | ||
|
|
42784a1fca | ||
|
|
ab9a711b3e | ||
|
|
615549fc77 | ||
|
|
9d5c04b0e4 | ||
|
|
72e560759e | ||
|
|
c413dfecaf | ||
|
|
db8072a835 | ||
|
|
1da6df36da | ||
|
|
3e77ea4e5c | ||
|
|
8fca92b893 | ||
|
|
698d38af57 | ||
|
|
461789d803 | ||
|
|
25dd6c55e0 | ||
|
|
85e5f429ac | ||
|
|
4f6029ba0b | ||
|
|
5d6088fa1f | ||
|
|
2d905af784 | ||
|
|
9e15da127c | ||
|
|
f85fad8486 | ||
|
|
1962a39a00 | ||
|
|
3f1ce9d59a | ||
|
|
61712fe51d | ||
|
|
b0a7bf731a | ||
|
|
92c4a7771f | ||
|
|
23152b7dcf | ||
|
|
024cefc52e | ||
|
|
524d52e14b | ||
|
|
c813fe5f36 | ||
|
|
e2145f546a | ||
|
|
908912f342 | ||
|
|
d32a7defed | ||
|
|
e70e98343a | ||
|
|
f98923e44c | ||
|
|
b580acfb17 | ||
|
|
b840d94483 | ||
|
|
302bd8f0cd | ||
|
|
39b52202d1 | ||
|
|
a1fae01723 | ||
|
|
1298bbcd8e | ||
|
|
7876bad7b3 | ||
|
|
09992a68e3 | ||
|
|
d688277325 | ||
|
|
0922ec61f4 | ||
|
|
be99a8cb43 | ||
|
|
9309014cd6 | ||
|
|
5a056ae341 | ||
|
|
739918ec9f | ||
|
|
015425af84 | ||
|
|
5a8a4461e5 | ||
|
|
855df75d0c | ||
|
|
a577512c4b | ||
|
|
537835ebf9 | ||
|
|
bd96c96cce | ||
|
|
f480b093a0 | ||
|
|
614bd17121 | ||
|
|
bb7a7e3f0a | ||
|
|
3ddf59e4b2 | ||
|
|
a22af1b8b0 | ||
|
|
66900a6c3c | ||
|
|
d6268b8411 | ||
|
|
ed7cf5b909 | ||
|
|
96764e40b3 | ||
|
|
156671d4a4 | ||
|
|
84c523e2b4 | ||
|
|
bab82ff1fe | ||
|
|
efe51bf9e7 | ||
|
|
6ace5ad61d | ||
|
|
335b303185 | ||
|
|
8cae187755 | ||
|
|
2f490d48d8 | ||
|
|
bb3b92716c | ||
|
|
70beda2f2a | ||
|
|
f201ac5af4 | ||
|
|
715672d5ae | ||
|
|
47c3931584 | ||
|
|
a40c4ed965 | ||
|
|
2f2fb3ae68 | ||
|
|
d28e7dba0e | ||
|
|
47657fe915 | ||
|
|
beeda511d1 | ||
|
|
97bc9fae20 | ||
|
|
9761b3d389 | ||
|
|
cf52340d04 | ||
|
|
5c140f9a4f | ||
|
|
c1dd8cadd8 | ||
|
|
d24eafa642 | ||
|
|
1292913b28 | ||
|
|
4564d1c69c | ||
|
|
6c6308a53e | ||
|
|
039b822396 | ||
|
|
f10173dfd5 | ||
|
|
a158775c7d | ||
|
|
2486cb37fb | ||
|
|
5f255d1181 | ||
|
|
021d50d6b7 | ||
|
|
fcd8c212e4 | ||
|
|
f0d356e0c4 | ||
|
|
28687fca44 | ||
|
|
d10d2833ec | ||
|
|
09764e903b | ||
|
|
44c89bea5a | ||
|
|
bf527483a3 | ||
|
|
9fc01b3120 | ||
|
|
ae479fbe9b | ||
|
|
e64c226ad7 | ||
|
|
81497fc746 | ||
|
|
90b7f3e6be | ||
|
|
fa12d791ec | ||
|
|
f429fa7d79 | ||
|
|
fb286f0d66 | ||
|
|
f46427f20b | ||
|
|
4348b37d51 | ||
|
|
3d0e87fba4 | ||
|
|
1a75e49df8 | ||
|
|
04b0600d5e | ||
|
|
974e08cb78 | ||
|
|
920cd8e4c6 | ||
|
|
1e92a02bc2 | ||
|
|
c755f9c879 | ||
|
|
00f1a1a7a9 | ||
|
|
a9ce2bb865 | ||
|
|
55c24860b2 | ||
|
|
95b31db15c | ||
|
|
18b1a86c0a | ||
|
|
61c7d957bb |
@@ -0,0 +1,13 @@
|
||||
# These are supported funding model platforms
|
||||
|
||||
github: [11cafe]
|
||||
patreon: # Replace with a single Patreon username
|
||||
open_collective: # Replace with a single Open Collective username
|
||||
ko_fi: # Replace with a single Ko-fi username
|
||||
tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel
|
||||
community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry
|
||||
liberapay: # Replace with a single Liberapay username
|
||||
issuehunt: # Replace with a single IssueHunt username
|
||||
lfx_crowdfunding: # Replace with a single LFX Crowdfunding project-name e.g., cloud-foundry
|
||||
polar: # Replace with a single Polar username
|
||||
custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2']
|
||||
+1
-2
@@ -1,6 +1,5 @@
|
||||
dist
|
||||
__pycache__
|
||||
dist
|
||||
hash
|
||||
.env
|
||||
# Logs
|
||||
logs
|
||||
|
||||
@@ -0,0 +1,674 @@
|
||||
GNU GENERAL PUBLIC LICENSE
|
||||
Version 3, 29 June 2007
|
||||
|
||||
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
|
||||
Everyone is permitted to copy and distribute verbatim copies
|
||||
of this license document, but changing it is not allowed.
|
||||
|
||||
Preamble
|
||||
|
||||
The GNU General Public License is a free, copyleft license for
|
||||
software and other kinds of works.
|
||||
|
||||
The licenses for most software and other practical works are designed
|
||||
to take away your freedom to share and change the works. By contrast,
|
||||
the GNU General Public License is intended to guarantee your freedom to
|
||||
share and change all versions of a program--to make sure it remains free
|
||||
software for all its users. We, the Free Software Foundation, use the
|
||||
GNU General Public License for most of our software; it applies also to
|
||||
any other work released this way by its authors. You can apply it to
|
||||
your programs, too.
|
||||
|
||||
When we speak of free software, we are referring to freedom, not
|
||||
price. Our General Public Licenses are designed to make sure that you
|
||||
have the freedom to distribute copies of free software (and charge for
|
||||
them if you wish), that you receive source code or can get it if you
|
||||
want it, that you can change the software or use pieces of it in new
|
||||
free programs, and that you know you can do these things.
|
||||
|
||||
To protect your rights, we need to prevent others from denying you
|
||||
these rights or asking you to surrender the rights. Therefore, you have
|
||||
certain responsibilities if you distribute copies of the software, or if
|
||||
you modify it: responsibilities to respect the freedom of others.
|
||||
|
||||
For example, if you distribute copies of such a program, whether
|
||||
gratis or for a fee, you must pass on to the recipients the same
|
||||
freedoms that you received. You must make sure that they, too, receive
|
||||
or can get the source code. And you must show them these terms so they
|
||||
know their rights.
|
||||
|
||||
Developers that use the GNU GPL protect your rights with two steps:
|
||||
(1) assert copyright on the software, and (2) offer you this License
|
||||
giving you legal permission to copy, distribute and/or modify it.
|
||||
|
||||
For the developers' and authors' protection, the GPL clearly explains
|
||||
that there is no warranty for this free software. For both users' and
|
||||
authors' sake, the GPL requires that modified versions be marked as
|
||||
changed, so that their problems will not be attributed erroneously to
|
||||
authors of previous versions.
|
||||
|
||||
Some devices are designed to deny users access to install or run
|
||||
modified versions of the software inside them, although the manufacturer
|
||||
can do so. This is fundamentally incompatible with the aim of
|
||||
protecting users' freedom to change the software. The systematic
|
||||
pattern of such abuse occurs in the area of products for individuals to
|
||||
use, which is precisely where it is most unacceptable. Therefore, we
|
||||
have designed this version of the GPL to prohibit the practice for those
|
||||
products. If such problems arise substantially in other domains, we
|
||||
stand ready to extend this provision to those domains in future versions
|
||||
of the GPL, as needed to protect the freedom of users.
|
||||
|
||||
Finally, every program is threatened constantly by software patents.
|
||||
States should not allow patents to restrict development and use of
|
||||
software on general-purpose computers, but in those that do, we wish to
|
||||
avoid the special danger that patents applied to a free program could
|
||||
make it effectively proprietary. To prevent this, the GPL assures that
|
||||
patents cannot be used to render the program non-free.
|
||||
|
||||
The precise terms and conditions for copying, distribution and
|
||||
modification follow.
|
||||
|
||||
TERMS AND CONDITIONS
|
||||
|
||||
0. Definitions.
|
||||
|
||||
"This License" refers to version 3 of the GNU General Public License.
|
||||
|
||||
"Copyright" also means copyright-like laws that apply to other kinds of
|
||||
works, such as semiconductor masks.
|
||||
|
||||
"The Program" refers to any copyrightable work licensed under this
|
||||
License. Each licensee is addressed as "you". "Licensees" and
|
||||
"recipients" may be individuals or organizations.
|
||||
|
||||
To "modify" a work means to copy from or adapt all or part of the work
|
||||
in a fashion requiring copyright permission, other than the making of an
|
||||
exact copy. The resulting work is called a "modified version" of the
|
||||
earlier work or a work "based on" the earlier work.
|
||||
|
||||
A "covered work" means either the unmodified Program or a work based
|
||||
on the Program.
|
||||
|
||||
To "propagate" a work means to do anything with it that, without
|
||||
permission, would make you directly or secondarily liable for
|
||||
infringement under applicable copyright law, except executing it on a
|
||||
computer or modifying a private copy. Propagation includes copying,
|
||||
distribution (with or without modification), making available to the
|
||||
public, and in some countries other activities as well.
|
||||
|
||||
To "convey" a work means any kind of propagation that enables other
|
||||
parties to make or receive copies. Mere interaction with a user through
|
||||
a computer network, with no transfer of a copy, is not conveying.
|
||||
|
||||
An interactive user interface displays "Appropriate Legal Notices"
|
||||
to the extent that it includes a convenient and prominently visible
|
||||
feature that (1) displays an appropriate copyright notice, and (2)
|
||||
tells the user that there is no warranty for the work (except to the
|
||||
extent that warranties are provided), that licensees may convey the
|
||||
work under this License, and how to view a copy of this License. If
|
||||
the interface presents a list of user commands or options, such as a
|
||||
menu, a prominent item in the list meets this criterion.
|
||||
|
||||
1. Source Code.
|
||||
|
||||
The "source code" for a work means the preferred form of the work
|
||||
for making modifications to it. "Object code" means any non-source
|
||||
form of a work.
|
||||
|
||||
A "Standard Interface" means an interface that either is an official
|
||||
standard defined by a recognized standards body, or, in the case of
|
||||
interfaces specified for a particular programming language, one that
|
||||
is widely used among developers working in that language.
|
||||
|
||||
The "System Libraries" of an executable work include anything, other
|
||||
than the work as a whole, that (a) is included in the normal form of
|
||||
packaging a Major Component, but which is not part of that Major
|
||||
Component, and (b) serves only to enable use of the work with that
|
||||
Major Component, or to implement a Standard Interface for which an
|
||||
implementation is available to the public in source code form. A
|
||||
"Major Component", in this context, means a major essential component
|
||||
(kernel, window system, and so on) of the specific operating system
|
||||
(if any) on which the executable work runs, or a compiler used to
|
||||
produce the work, or an object code interpreter used to run it.
|
||||
|
||||
The "Corresponding Source" for a work in object code form means all
|
||||
the source code needed to generate, install, and (for an executable
|
||||
work) run the object code and to modify the work, including scripts to
|
||||
control those activities. However, it does not include the work's
|
||||
System Libraries, or general-purpose tools or generally available free
|
||||
programs which are used unmodified in performing those activities but
|
||||
which are not part of the work. For example, Corresponding Source
|
||||
includes interface definition files associated with source files for
|
||||
the work, and the source code for shared libraries and dynamically
|
||||
linked subprograms that the work is specifically designed to require,
|
||||
such as by intimate data communication or control flow between those
|
||||
subprograms and other parts of the work.
|
||||
|
||||
The Corresponding Source need not include anything that users
|
||||
can regenerate automatically from other parts of the Corresponding
|
||||
Source.
|
||||
|
||||
The Corresponding Source for a work in source code form is that
|
||||
same work.
|
||||
|
||||
2. Basic Permissions.
|
||||
|
||||
All rights granted under this License are granted for the term of
|
||||
copyright on the Program, and are irrevocable provided the stated
|
||||
conditions are met. This License explicitly affirms your unlimited
|
||||
permission to run the unmodified Program. The output from running a
|
||||
covered work is covered by this License only if the output, given its
|
||||
content, constitutes a covered work. This License acknowledges your
|
||||
rights of fair use or other equivalent, as provided by copyright law.
|
||||
|
||||
You may make, run and propagate covered works that you do not
|
||||
convey, without conditions so long as your license otherwise remains
|
||||
in force. You may convey covered works to others for the sole purpose
|
||||
of having them make modifications exclusively for you, or provide you
|
||||
with facilities for running those works, provided that you comply with
|
||||
the terms of this License in conveying all material for which you do
|
||||
not control copyright. Those thus making or running the covered works
|
||||
for you must do so exclusively on your behalf, under your direction
|
||||
and control, on terms that prohibit them from making any copies of
|
||||
your copyrighted material outside their relationship with you.
|
||||
|
||||
Conveying under any other circumstances is permitted solely under
|
||||
the conditions stated below. Sublicensing is not allowed; section 10
|
||||
makes it unnecessary.
|
||||
|
||||
3. Protecting Users' Legal Rights From Anti-Circumvention Law.
|
||||
|
||||
No covered work shall be deemed part of an effective technological
|
||||
measure under any applicable law fulfilling obligations under article
|
||||
11 of the WIPO copyright treaty adopted on 20 December 1996, or
|
||||
similar laws prohibiting or restricting circumvention of such
|
||||
measures.
|
||||
|
||||
When you convey a covered work, you waive any legal power to forbid
|
||||
circumvention of technological measures to the extent such circumvention
|
||||
is effected by exercising rights under this License with respect to
|
||||
the covered work, and you disclaim any intention to limit operation or
|
||||
modification of the work as a means of enforcing, against the work's
|
||||
users, your or third parties' legal rights to forbid circumvention of
|
||||
technological measures.
|
||||
|
||||
4. Conveying Verbatim Copies.
|
||||
|
||||
You may convey verbatim copies of the Program's source code as you
|
||||
receive it, in any medium, provided that you conspicuously and
|
||||
appropriately publish on each copy an appropriate copyright notice;
|
||||
keep intact all notices stating that this License and any
|
||||
non-permissive terms added in accord with section 7 apply to the code;
|
||||
keep intact all notices of the absence of any warranty; and give all
|
||||
recipients a copy of this License along with the Program.
|
||||
|
||||
You may charge any price or no price for each copy that you convey,
|
||||
and you may offer support or warranty protection for a fee.
|
||||
|
||||
5. Conveying Modified Source Versions.
|
||||
|
||||
You may convey a work based on the Program, or the modifications to
|
||||
produce it from the Program, in the form of source code under the
|
||||
terms of section 4, provided that you also meet all of these conditions:
|
||||
|
||||
a) The work must carry prominent notices stating that you modified
|
||||
it, and giving a relevant date.
|
||||
|
||||
b) The work must carry prominent notices stating that it is
|
||||
released under this License and any conditions added under section
|
||||
7. This requirement modifies the requirement in section 4 to
|
||||
"keep intact all notices".
|
||||
|
||||
c) You must license the entire work, as a whole, under this
|
||||
License to anyone who comes into possession of a copy. This
|
||||
License will therefore apply, along with any applicable section 7
|
||||
additional terms, to the whole of the work, and all its parts,
|
||||
regardless of how they are packaged. This License gives no
|
||||
permission to license the work in any other way, but it does not
|
||||
invalidate such permission if you have separately received it.
|
||||
|
||||
d) If the work has interactive user interfaces, each must display
|
||||
Appropriate Legal Notices; however, if the Program has interactive
|
||||
interfaces that do not display Appropriate Legal Notices, your
|
||||
work need not make them do so.
|
||||
|
||||
A compilation of a covered work with other separate and independent
|
||||
works, which are not by their nature extensions of the covered work,
|
||||
and which are not combined with it such as to form a larger program,
|
||||
in or on a volume of a storage or distribution medium, is called an
|
||||
"aggregate" if the compilation and its resulting copyright are not
|
||||
used to limit the access or legal rights of the compilation's users
|
||||
beyond what the individual works permit. Inclusion of a covered work
|
||||
in an aggregate does not cause this License to apply to the other
|
||||
parts of the aggregate.
|
||||
|
||||
6. Conveying Non-Source Forms.
|
||||
|
||||
You may convey a covered work in object code form under the terms
|
||||
of sections 4 and 5, provided that you also convey the
|
||||
machine-readable Corresponding Source under the terms of this License,
|
||||
in one of these ways:
|
||||
|
||||
a) Convey the object code in, or embodied in, a physical product
|
||||
(including a physical distribution medium), accompanied by the
|
||||
Corresponding Source fixed on a durable physical medium
|
||||
customarily used for software interchange.
|
||||
|
||||
b) Convey the object code in, or embodied in, a physical product
|
||||
(including a physical distribution medium), accompanied by a
|
||||
written offer, valid for at least three years and valid for as
|
||||
long as you offer spare parts or customer support for that product
|
||||
model, to give anyone who possesses the object code either (1) a
|
||||
copy of the Corresponding Source for all the software in the
|
||||
product that is covered by this License, on a durable physical
|
||||
medium customarily used for software interchange, for a price no
|
||||
more than your reasonable cost of physically performing this
|
||||
conveying of source, or (2) access to copy the
|
||||
Corresponding Source from a network server at no charge.
|
||||
|
||||
c) Convey individual copies of the object code with a copy of the
|
||||
written offer to provide the Corresponding Source. This
|
||||
alternative is allowed only occasionally and noncommercially, and
|
||||
only if you received the object code with such an offer, in accord
|
||||
with subsection 6b.
|
||||
|
||||
d) Convey the object code by offering access from a designated
|
||||
place (gratis or for a charge), and offer equivalent access to the
|
||||
Corresponding Source in the same way through the same place at no
|
||||
further charge. You need not require recipients to copy the
|
||||
Corresponding Source along with the object code. If the place to
|
||||
copy the object code is a network server, the Corresponding Source
|
||||
may be on a different server (operated by you or a third party)
|
||||
that supports equivalent copying facilities, provided you maintain
|
||||
clear directions next to the object code saying where to find the
|
||||
Corresponding Source. Regardless of what server hosts the
|
||||
Corresponding Source, you remain obligated to ensure that it is
|
||||
available for as long as needed to satisfy these requirements.
|
||||
|
||||
e) Convey the object code using peer-to-peer transmission, provided
|
||||
you inform other peers where the object code and Corresponding
|
||||
Source of the work are being offered to the general public at no
|
||||
charge under subsection 6d.
|
||||
|
||||
A separable portion of the object code, whose source code is excluded
|
||||
from the Corresponding Source as a System Library, need not be
|
||||
included in conveying the object code work.
|
||||
|
||||
A "User Product" is either (1) a "consumer product", which means any
|
||||
tangible personal property which is normally used for personal, family,
|
||||
or household purposes, or (2) anything designed or sold for incorporation
|
||||
into a dwelling. In determining whether a product is a consumer product,
|
||||
doubtful cases shall be resolved in favor of coverage. For a particular
|
||||
product received by a particular user, "normally used" refers to a
|
||||
typical or common use of that class of product, regardless of the status
|
||||
of the particular user or of the way in which the particular user
|
||||
actually uses, or expects or is expected to use, the product. A product
|
||||
is a consumer product regardless of whether the product has substantial
|
||||
commercial, industrial or non-consumer uses, unless such uses represent
|
||||
the only significant mode of use of the product.
|
||||
|
||||
"Installation Information" for a User Product means any methods,
|
||||
procedures, authorization keys, or other information required to install
|
||||
and execute modified versions of a covered work in that User Product from
|
||||
a modified version of its Corresponding Source. The information must
|
||||
suffice to ensure that the continued functioning of the modified object
|
||||
code is in no case prevented or interfered with solely because
|
||||
modification has been made.
|
||||
|
||||
If you convey an object code work under this section in, or with, or
|
||||
specifically for use in, a User Product, and the conveying occurs as
|
||||
part of a transaction in which the right of possession and use of the
|
||||
User Product is transferred to the recipient in perpetuity or for a
|
||||
fixed term (regardless of how the transaction is characterized), the
|
||||
Corresponding Source conveyed under this section must be accompanied
|
||||
by the Installation Information. But this requirement does not apply
|
||||
if neither you nor any third party retains the ability to install
|
||||
modified object code on the User Product (for example, the work has
|
||||
been installed in ROM).
|
||||
|
||||
The requirement to provide Installation Information does not include a
|
||||
requirement to continue to provide support service, warranty, or updates
|
||||
for a work that has been modified or installed by the recipient, or for
|
||||
the User Product in which it has been modified or installed. Access to a
|
||||
network may be denied when the modification itself materially and
|
||||
adversely affects the operation of the network or violates the rules and
|
||||
protocols for communication across the network.
|
||||
|
||||
Corresponding Source conveyed, and Installation Information provided,
|
||||
in accord with this section must be in a format that is publicly
|
||||
documented (and with an implementation available to the public in
|
||||
source code form), and must require no special password or key for
|
||||
unpacking, reading or copying.
|
||||
|
||||
7. Additional Terms.
|
||||
|
||||
"Additional permissions" are terms that supplement the terms of this
|
||||
License by making exceptions from one or more of its conditions.
|
||||
Additional permissions that are applicable to the entire Program shall
|
||||
be treated as though they were included in this License, to the extent
|
||||
that they are valid under applicable law. If additional permissions
|
||||
apply only to part of the Program, that part may be used separately
|
||||
under those permissions, but the entire Program remains governed by
|
||||
this License without regard to the additional permissions.
|
||||
|
||||
When you convey a copy of a covered work, you may at your option
|
||||
remove any additional permissions from that copy, or from any part of
|
||||
it. (Additional permissions may be written to require their own
|
||||
removal in certain cases when you modify the work.) You may place
|
||||
additional permissions on material, added by you to a covered work,
|
||||
for which you have or can give appropriate copyright permission.
|
||||
|
||||
Notwithstanding any other provision of this License, for material you
|
||||
add to a covered work, you may (if authorized by the copyright holders of
|
||||
that material) supplement the terms of this License with terms:
|
||||
|
||||
a) Disclaiming warranty or limiting liability differently from the
|
||||
terms of sections 15 and 16 of this License; or
|
||||
|
||||
b) Requiring preservation of specified reasonable legal notices or
|
||||
author attributions in that material or in the Appropriate Legal
|
||||
Notices displayed by works containing it; or
|
||||
|
||||
c) Prohibiting misrepresentation of the origin of that material, or
|
||||
requiring that modified versions of such material be marked in
|
||||
reasonable ways as different from the original version; or
|
||||
|
||||
d) Limiting the use for publicity purposes of names of licensors or
|
||||
authors of the material; or
|
||||
|
||||
e) Declining to grant rights under trademark law for use of some
|
||||
trade names, trademarks, or service marks; or
|
||||
|
||||
f) Requiring indemnification of licensors and authors of that
|
||||
material by anyone who conveys the material (or modified versions of
|
||||
it) with contractual assumptions of liability to the recipient, for
|
||||
any liability that these contractual assumptions directly impose on
|
||||
those licensors and authors.
|
||||
|
||||
All other non-permissive additional terms are considered "further
|
||||
restrictions" within the meaning of section 10. If the Program as you
|
||||
received it, or any part of it, contains a notice stating that it is
|
||||
governed by this License along with a term that is a further
|
||||
restriction, you may remove that term. If a license document contains
|
||||
a further restriction but permits relicensing or conveying under this
|
||||
License, you may add to a covered work material governed by the terms
|
||||
of that license document, provided that the further restriction does
|
||||
not survive such relicensing or conveying.
|
||||
|
||||
If you add terms to a covered work in accord with this section, you
|
||||
must place, in the relevant source files, a statement of the
|
||||
additional terms that apply to those files, or a notice indicating
|
||||
where to find the applicable terms.
|
||||
|
||||
Additional terms, permissive or non-permissive, may be stated in the
|
||||
form of a separately written license, or stated as exceptions;
|
||||
the above requirements apply either way.
|
||||
|
||||
8. Termination.
|
||||
|
||||
You may not propagate or modify a covered work except as expressly
|
||||
provided under this License. Any attempt otherwise to propagate or
|
||||
modify it is void, and will automatically terminate your rights under
|
||||
this License (including any patent licenses granted under the third
|
||||
paragraph of section 11).
|
||||
|
||||
However, if you cease all violation of this License, then your
|
||||
license from a particular copyright holder is reinstated (a)
|
||||
provisionally, unless and until the copyright holder explicitly and
|
||||
finally terminates your license, and (b) permanently, if the copyright
|
||||
holder fails to notify you of the violation by some reasonable means
|
||||
prior to 60 days after the cessation.
|
||||
|
||||
Moreover, your license from a particular copyright holder is
|
||||
reinstated permanently if the copyright holder notifies you of the
|
||||
violation by some reasonable means, this is the first time you have
|
||||
received notice of violation of this License (for any work) from that
|
||||
copyright holder, and you cure the violation prior to 30 days after
|
||||
your receipt of the notice.
|
||||
|
||||
Termination of your rights under this section does not terminate the
|
||||
licenses of parties who have received copies or rights from you under
|
||||
this License. If your rights have been terminated and not permanently
|
||||
reinstated, you do not qualify to receive new licenses for the same
|
||||
material under section 10.
|
||||
|
||||
9. Acceptance Not Required for Having Copies.
|
||||
|
||||
You are not required to accept this License in order to receive or
|
||||
run a copy of the Program. Ancillary propagation of a covered work
|
||||
occurring solely as a consequence of using peer-to-peer transmission
|
||||
to receive a copy likewise does not require acceptance. However,
|
||||
nothing other than this License grants you permission to propagate or
|
||||
modify any covered work. These actions infringe copyright if you do
|
||||
not accept this License. Therefore, by modifying or propagating a
|
||||
covered work, you indicate your acceptance of this License to do so.
|
||||
|
||||
10. Automatic Licensing of Downstream Recipients.
|
||||
|
||||
Each time you convey a covered work, the recipient automatically
|
||||
receives a license from the original licensors, to run, modify and
|
||||
propagate that work, subject to this License. You are not responsible
|
||||
for enforcing compliance by third parties with this License.
|
||||
|
||||
An "entity transaction" is a transaction transferring control of an
|
||||
organization, or substantially all assets of one, or subdividing an
|
||||
organization, or merging organizations. If propagation of a covered
|
||||
work results from an entity transaction, each party to that
|
||||
transaction who receives a copy of the work also receives whatever
|
||||
licenses to the work the party's predecessor in interest had or could
|
||||
give under the previous paragraph, plus a right to possession of the
|
||||
Corresponding Source of the work from the predecessor in interest, if
|
||||
the predecessor has it or can get it with reasonable efforts.
|
||||
|
||||
You may not impose any further restrictions on the exercise of the
|
||||
rights granted or affirmed under this License. For example, you may
|
||||
not impose a license fee, royalty, or other charge for exercise of
|
||||
rights granted under this License, and you may not initiate litigation
|
||||
(including a cross-claim or counterclaim in a lawsuit) alleging that
|
||||
any patent claim is infringed by making, using, selling, offering for
|
||||
sale, or importing the Program or any portion of it.
|
||||
|
||||
11. Patents.
|
||||
|
||||
A "contributor" is a copyright holder who authorizes use under this
|
||||
License of the Program or a work on which the Program is based. The
|
||||
work thus licensed is called the contributor's "contributor version".
|
||||
|
||||
A contributor's "essential patent claims" are all patent claims
|
||||
owned or controlled by the contributor, whether already acquired or
|
||||
hereafter acquired, that would be infringed by some manner, permitted
|
||||
by this License, of making, using, or selling its contributor version,
|
||||
but do not include claims that would be infringed only as a
|
||||
consequence of further modification of the contributor version. For
|
||||
purposes of this definition, "control" includes the right to grant
|
||||
patent sublicenses in a manner consistent with the requirements of
|
||||
this License.
|
||||
|
||||
Each contributor grants you a non-exclusive, worldwide, royalty-free
|
||||
patent license under the contributor's essential patent claims, to
|
||||
make, use, sell, offer for sale, import and otherwise run, modify and
|
||||
propagate the contents of its contributor version.
|
||||
|
||||
In the following three paragraphs, a "patent license" is any express
|
||||
agreement or commitment, however denominated, not to enforce a patent
|
||||
(such as an express permission to practice a patent or covenant not to
|
||||
sue for patent infringement). To "grant" such a patent license to a
|
||||
party means to make such an agreement or commitment not to enforce a
|
||||
patent against the party.
|
||||
|
||||
If you convey a covered work, knowingly relying on a patent license,
|
||||
and the Corresponding Source of the work is not available for anyone
|
||||
to copy, free of charge and under the terms of this License, through a
|
||||
publicly available network server or other readily accessible means,
|
||||
then you must either (1) cause the Corresponding Source to be so
|
||||
available, or (2) arrange to deprive yourself of the benefit of the
|
||||
patent license for this particular work, or (3) arrange, in a manner
|
||||
consistent with the requirements of this License, to extend the patent
|
||||
license to downstream recipients. "Knowingly relying" means you have
|
||||
actual knowledge that, but for the patent license, your conveying the
|
||||
covered work in a country, or your recipient's use of the covered work
|
||||
in a country, would infringe one or more identifiable patents in that
|
||||
country that you have reason to believe are valid.
|
||||
|
||||
If, pursuant to or in connection with a single transaction or
|
||||
arrangement, you convey, or propagate by procuring conveyance of, a
|
||||
covered work, and grant a patent license to some of the parties
|
||||
receiving the covered work authorizing them to use, propagate, modify
|
||||
or convey a specific copy of the covered work, then the patent license
|
||||
you grant is automatically extended to all recipients of the covered
|
||||
work and works based on it.
|
||||
|
||||
A patent license is "discriminatory" if it does not include within
|
||||
the scope of its coverage, prohibits the exercise of, or is
|
||||
conditioned on the non-exercise of one or more of the rights that are
|
||||
specifically granted under this License. You may not convey a covered
|
||||
work if you are a party to an arrangement with a third party that is
|
||||
in the business of distributing software, under which you make payment
|
||||
to the third party based on the extent of your activity of conveying
|
||||
the work, and under which the third party grants, to any of the
|
||||
parties who would receive the covered work from you, a discriminatory
|
||||
patent license (a) in connection with copies of the covered work
|
||||
conveyed by you (or copies made from those copies), or (b) primarily
|
||||
for and in connection with specific products or compilations that
|
||||
contain the covered work, unless you entered into that arrangement,
|
||||
or that patent license was granted, prior to 28 March 2007.
|
||||
|
||||
Nothing in this License shall be construed as excluding or limiting
|
||||
any implied license or other defenses to infringement that may
|
||||
otherwise be available to you under applicable patent law.
|
||||
|
||||
12. No Surrender of Others' Freedom.
|
||||
|
||||
If conditions are imposed on you (whether by court order, agreement or
|
||||
otherwise) that contradict the conditions of this License, they do not
|
||||
excuse you from the conditions of this License. If you cannot convey a
|
||||
covered work so as to satisfy simultaneously your obligations under this
|
||||
License and any other pertinent obligations, then as a consequence you may
|
||||
not convey it at all. For example, if you agree to terms that obligate you
|
||||
to collect a royalty for further conveying from those to whom you convey
|
||||
the Program, the only way you could satisfy both those terms and this
|
||||
License would be to refrain entirely from conveying the Program.
|
||||
|
||||
13. Use with the GNU Affero General Public License.
|
||||
|
||||
Notwithstanding any other provision of this License, you have
|
||||
permission to link or combine any covered work with a work licensed
|
||||
under version 3 of the GNU Affero General Public License into a single
|
||||
combined work, and to convey the resulting work. The terms of this
|
||||
License will continue to apply to the part which is the covered work,
|
||||
but the special requirements of the GNU Affero General Public License,
|
||||
section 13, concerning interaction through a network will apply to the
|
||||
combination as such.
|
||||
|
||||
14. Revised Versions of this License.
|
||||
|
||||
The Free Software Foundation may publish revised and/or new versions of
|
||||
the GNU General Public License from time to time. Such new versions will
|
||||
be similar in spirit to the present version, but may differ in detail to
|
||||
address new problems or concerns.
|
||||
|
||||
Each version is given a distinguishing version number. If the
|
||||
Program specifies that a certain numbered version of the GNU General
|
||||
Public License "or any later version" applies to it, you have the
|
||||
option of following the terms and conditions either of that numbered
|
||||
version or of any later version published by the Free Software
|
||||
Foundation. If the Program does not specify a version number of the
|
||||
GNU General Public License, you may choose any version ever published
|
||||
by the Free Software Foundation.
|
||||
|
||||
If the Program specifies that a proxy can decide which future
|
||||
versions of the GNU General Public License can be used, that proxy's
|
||||
public statement of acceptance of a version permanently authorizes you
|
||||
to choose that version for the Program.
|
||||
|
||||
Later license versions may give you additional or different
|
||||
permissions. However, no additional obligations are imposed on any
|
||||
author or copyright holder as a result of your choosing to follow a
|
||||
later version.
|
||||
|
||||
15. Disclaimer of Warranty.
|
||||
|
||||
THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
|
||||
APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
|
||||
HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
|
||||
OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
|
||||
THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
|
||||
PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
|
||||
IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
|
||||
ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
|
||||
|
||||
16. Limitation of Liability.
|
||||
|
||||
IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
|
||||
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
|
||||
THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
|
||||
GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
|
||||
USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
|
||||
DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
|
||||
PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
|
||||
EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
|
||||
SUCH DAMAGES.
|
||||
|
||||
17. Interpretation of Sections 15 and 16.
|
||||
|
||||
If the disclaimer of warranty and limitation of liability provided
|
||||
above cannot be given local legal effect according to their terms,
|
||||
reviewing courts shall apply local law that most closely approximates
|
||||
an absolute waiver of all civil liability in connection with the
|
||||
Program, unless a warranty or assumption of liability accompanies a
|
||||
copy of the Program in return for a fee.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
|
||||
How to Apply These Terms to Your New Programs
|
||||
|
||||
If you develop a new program, and you want it to be of the greatest
|
||||
possible use to the public, the best way to achieve this is to make it
|
||||
free software which everyone can redistribute and change under these terms.
|
||||
|
||||
To do so, attach the following notices to the program. It is safest
|
||||
to attach them to the start of each source file to most effectively
|
||||
state the exclusion of warranty; and each file should have at least
|
||||
the "copyright" line and a pointer to where the full notice is found.
|
||||
|
||||
<one line to give the program's name and a brief idea of what it does.>
|
||||
Copyright (C) <year> <name of author>
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
Also add information on how to contact you by electronic and paper mail.
|
||||
|
||||
If the program does terminal interaction, make it output a short
|
||||
notice like this when it starts in an interactive mode:
|
||||
|
||||
<program> Copyright (C) <year> <name of author>
|
||||
This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
|
||||
This is free software, and you are welcome to redistribute it
|
||||
under certain conditions; type `show c' for details.
|
||||
|
||||
The hypothetical commands `show w' and `show c' should show the appropriate
|
||||
parts of the General Public License. Of course, your program's commands
|
||||
might be different; for a GUI interface, you would use an "about box".
|
||||
|
||||
You should also get your employer (if you work as a programmer) or school,
|
||||
if any, to sign a "copyright disclaimer" for the program, if necessary.
|
||||
For more information on this, and how to apply and follow the GNU GPL, see
|
||||
<https://www.gnu.org/licenses/>.
|
||||
|
||||
The GNU General Public License does not permit incorporating your program
|
||||
into proprietary programs. If your program is a subroutine library, you
|
||||
may consider it more useful to permit linking proprietary applications with
|
||||
the library. If this is what you want to do, use the GNU Lesser General
|
||||
Public License instead of this License. But first, please read
|
||||
<https://www.gnu.org/licenses/why-not-lgpl.html>.
|
||||
@@ -1,14 +1,17 @@
|
||||
# ☕️ ComfyUI Workspace Manager - Comfyspace
|
||||
|
||||
A ComfyUI workflow management extension to **organize and manage all your workflows and generated images in one place**. Seamlessly switch between workflows, track version history and image generation history within a single workspace. We are reimagining the way to manage all your resources as a whole, workflows, models and custom nodes.
|
||||
A ComfyUI workflow and model manager extension to **organize and manage all your workflows, models and generated images in one place**. Seamlessly switch between workflows, track version history and image generation history, 1 click install models from Civit ai, browse/update your installed models
|
||||
|
||||
If you have questions or suggestions, please [join our Discord](https://discord.gg/HHvfEurv2Z)!
|
||||
If you have questions or suggestions, please [join our Discord](https://discord.gg/HHvfEurv2Z)!
|
||||
|
||||
<a href="https://discord.gg/HHvfEurv2Z">
|
||||
<img src="https://animeartmagazine.com/wp-content/uploads/2022/05/discord-button600.webp" alt="Join Discord" width="120"/>
|
||||
</a>
|
||||
[New!] we're building a comfyui custom nodes library wiki like Pypi: https://www.nodecafe.org/
|
||||
welcome any feedbacks!
|
||||
|
||||
🔴‼️ If you updated or cloned this repo between March 14-17, 2024, please update it again to latest verison, there were some bugs that may cause you lose changes in Auto Save mode.
|
||||
|
||||
‼️ [3-23-2024]**search and install models from civitai is not working in previous versions**, because civitai changed something in their api. **Please update to the latest version** if you are seeing blank screen in Install Models dialog
|
||||
|
||||
<img width="610" alt="Screenshot 2024-03-08 at 6 41 18 PM" src="https://github.com/11cafe/comfyui-workspace-manager/assets/18367033/30455eb1-3d12-4930-bb81-28b33148fbaa">
|
||||
|
||||
## Installation
|
||||
|
||||
@@ -29,36 +32,72 @@ and restart your ComfyUI.
|
||||
It is recommended to do git clone than downloading zip folder, because you can get latest updates by `git pull`!
|
||||
|
||||
## Features
|
||||
### 📣Update 01/03/2024 - Gallery and cover images
|
||||
Every image/video you generate will be saved in the gallery of the current workflow. You can set any image in the gallery as the cover photo of the workflow
|
||||
|
||||
<img width="400" alt="Screenshot 2024-01-03 at 9 05 01 PM" src="https://github.com/11cafe/comfyui-workspace-manager/assets/18367033/879ea664-c06b-4abb-b4f0-a9305ade3bc9">
|
||||
<img width="394" alt="Screenshot 2024-01-03 at 7 35 20 PM" src="https://github.com/11cafe/comfyui-workspace-manager/assets/18367033/ae818652-8680-48a2-bdd3-bd02cb20ad16">
|
||||
**Workflows manager**
|
||||
|
||||
### 📣Update 12/29/2023 - Manual save, version history
|
||||
**Manually save** your workflow by either click the 💾 floppy disk icon button or Shift+S shortcut (customize this shortcut in Settings)
|
||||
Everytime you save, it will create a new record in **Version History**. You can go back and switch versions in version history like git.
|
||||
|
||||
<img width="400" alt="Screenshot 2023-12-29 at 9 59 57 PM" src="https://github.com/11cafe/comfyui-workspace-manager/assets/18367033/88c82672-ebf2-4feb-bf5b-74b447843c2c">
|
||||
|
||||
- 🔁Effortlessly switch between different workflows in your workspace.
|
||||
- 🔁Switch between different workflows easily. 1click open workflow in multiple browser tabs.
|
||||
- 🤏**Drag and drop** to insert subworkflows into current flow.
|
||||
- Create and name workflows
|
||||
- 🗂️Organize workflows with folders, 🏷️tags
|
||||
- 📂Saves all your workflows in a single folder (by default under `/ComfyUI/my_workflows`), customize this location in Settings
|
||||
- 🖼️Gallery and cover images: Every image/video you generate will be saved in the gallery of the current workflow. You can set any image in the gallery as the cover photo of the workflow
|
||||
- 🕛**Version control** of workflow, never lose any changes (Everytime you press save, it will log a new version in history. you can easily revert back to any versions like git)
|
||||
- **Bulk import** workflows into your workspace
|
||||
- 📑Quickly duplicate flow in right-click menu
|
||||
- 🗂️Organize workflows with folders, tags.
|
||||
- 📂Save and sync all your workflows in a local folder (by default under `/ComfyUI/my_workflows` customize in Settings)
|
||||
- **Save workflow by pressing the 💾 button, or ⌨Shift+S shortcut** (customize in Settings).
|
||||
- Every time you save, it will create a new record in **Version History**. You can easily revert to any version.
|
||||
- **Enable/disable autosave workflow** (please disable autosave if you are experiencing UI lagging)
|
||||
- You can move files using OS File Explorer and it will be **two-way synced** with workspace.
|
||||
- 🕛**Version control** of workflow, create/switch versions
|
||||
- Bulk import workflows into your workspace
|
||||
- 1click download all your workflows into a .zip
|
||||
|
||||
**🔮Models manager**:
|
||||
|
||||
- 1click install models from civitai to `models/` subfolder of the matching model type
|
||||
- Browse installed models with thumbnail image
|
||||
- drag and drop to insert "load model_type" node into workflow
|
||||
|
||||
**🖼️Image Gallery**: Every image/video you generate will be saved in the gallery of the current workflow. You can set any image in the gallery as the cover photo of the workflow
|
||||
|
||||
- Dark/light mode
|
||||
- cloud sync & backup workspace so you will never lose your data (Upcoming!)
|
||||
- One-click share workflow (Upcoming!)
|
||||
- [beta!]One-click share workflow
|
||||
|
||||
### Where are my workflow and workspace data stored?
|
||||
Recommend using Chrome browser for the best experience. Safari doesn't work well. Edge or firefox should be fine too.
|
||||
|
||||
🍺 All your workflows are now output to a folder in `/ComfyUI/my_workflows` ⚠️Note: this is a ONE-WAY sync folder by default, that will only reflect changes made from your ComfyUI workspace browser; If you add some files manually using your OS file system (e.g. Finder in MacOS, File Explorer in Windows), workspace manager will not be able to pickup those changes. You need to use the "Import" button in files side bar to add new files to workspace manager.
|
||||
[中文版教程](https://www.uisdc.com/workspace-manager) [Roadmap](https://github.com/orgs/11cafe/projects/1/views/1)
|
||||
|
||||
Our internal db data is stored in your disk under `/ComfyUI/custom_nodes/comfyui-workspace-manager/db/` We also store a backup copy of your db data in your browser cache in case you lose your workspace data when deleting comfyui-workspace-manager and reinstall. Press F12 or Inspect in right click browser menu, go to Application -> IndexedDB -> WorkspaceDB to see your data
|
||||
# <<<<<<< HEAD
|
||||
|
||||
🚧🔴 Drag and drop to insert subworkflow is not working. I have debugged for many hours but just can't make it work perfectly. If anyone is interested here is the code:
|
||||
https://github.com/11cafe/comfyui-workspace-manager/blob/d2d2f0c1f1ace35236e73fa5074e9feb83342773/ui/src/utils.tsx#L192
|
||||
I would really appreciate it if someone can find a solution to insert a subworkflow to current canvas given the json object.
|
||||
|
||||
### 🦄v2.0 beta: Two-way sync with local folder, select model install path, cloud sync workflows
|
||||
|
||||
> > > > > > > dev
|
||||
|
||||
**🦩v2.1 New gallery coming soon! Inspect image metadata, search image by prompt, model/lora used**
|
||||
|
||||
<img width="700" alt="Screenshot 2024-03-23 at 12 58 38 AM" src="https://github.com/11cafe/comfyui-workspace-manager/assets/18367033/5bac9793-7491-4131-b850-037094d03823">
|
||||
|
||||
Control+P quick spotlight search (like VSCode)
|
||||
|
||||
<img width="500" alt="Screenshot 2024-03-27 at 3 49 35 PM" src="https://github.com/11cafe/comfyui-workspace-manager/assets/18367033/47c14904-180c-4c52-a644-223d0936c9fa">
|
||||
|
||||
**🦄v2.0 Two-way sync with local folder, select model install path, cloud sync workflows**
|
||||
|
||||
- Two-way sync with selected local folder so you can move files using File Explore or Finder to sync them with workspace!
|
||||
- **Models manager support external models path like Automatic1111**! Choose models downloading folder from added external paths.
|
||||
- Add models thumbnails image by putting a image file with the same name in models folder like A1111
|
||||
- One click sharing to cloud to backup your workflows to cloud
|
||||
v2.0 demo: https://youtu.be/7Hht_BMe844
|
||||
|
||||
**v1.7 ComfyUI model manager, view your installed models, install models from civit ai, open workflow in multiple browser tabs**
|
||||
|
||||
<img width="700" alt="Screenshot 2024-02-02 at 5 51 13 PM" src="https://github.com/11cafe/comfyui-workspace-manager/assets/18367033/86dc9007-c45c-4b7b-b74a-76ddc93c89a8">
|
||||
|
||||
<img width="400" alt="Screenshot 2024-01-23 at 12 27 34 AM" src="https://github.com/11cafe/comfyui-workspace-manager/assets/18367033/3b543024-3a6b-4fca-9980-e535d83e206e">
|
||||
|
||||
<img width="200" alt="Screenshot 2024-01-23 at 12 26 56 AM" src="https://github.com/11cafe/comfyui-workspace-manager/assets/18367033/d29d0831-a15a-4597-acf6-facee4dbc76e">
|
||||
|
||||
<img width="460" alt="gallery" src="https://github.com/11cafe/comfyui-workspace-manager/assets/18367033/2b7886c9-2d4a-46c6-adac-429720f74f11">
|
||||
|
||||
switch between flows:
|
||||
|
||||
@@ -68,32 +107,42 @@ switch between flows:
|
||||
|
||||
<img src="https://github.com/11cafe/comfyui-workspace-manager/assets/18367033/7540c45b-be51-4428-8558-96adcc1024c8" width="480">
|
||||
|
||||
Choose the folder location where you want to save your workflows .json files in Settings:
|
||||
|
||||
<img width="400" alt="Screenshot 2024-01-02 at 10 22 45 PM" src="https://github.com/11cafe/comfyui-workspace-manager/assets/18367033/4751b096-0886-4a32-95b1-87c7ce5b3787">
|
||||
|
||||
|
||||
Bulk import flows:
|
||||
|
||||
<img width="400" alt="Screenshot 2024-01-02 at 10 22 45 PM" src="https://github.com/11cafe/comfyui-workspace-manager/assets/18367033/884c8852-73c7-41d6-9179-7fb1fcea9d04">
|
||||
|
||||
|
||||
|
||||
add tags to flow:
|
||||
add tags to flow:
|
||||
|
||||
<img width="400" alt="Screenshot 2023-12-13 at 1 54 04 AM" src="https://github.com/11cafe/comfyui-workspace-manager/assets/18367033/6bf4d22b-f52a-4d23-8882-4a31162616f3">
|
||||
|
||||
Workflow operations: Manual Save, Download, Discard unsaved changes:
|
||||
Workflow operations: Manual Save, Download, Discard unsaved changes:
|
||||
|
||||
<img width="500" alt="Screenshot 2023-12-29 at 9 59 57 PM" src="https://github.com/11cafe/comfyui-workspace-manager/assets/18367033/88c82672-ebf2-4feb-bf5b-74b447843c2c">
|
||||
|
||||
Version history:
|
||||
### How do I save my workflow?
|
||||
|
||||
<img width="387" alt="Screenshot 2023-12-29 at 11 17 37 PM" src="https://github.com/11cafe/comfyui-workspace-manager/assets/18367033/ac8c52d7-c339-46a0-8682-ed03b0cab75d">
|
||||
Save your workflow by either clicking the 💾 floppy disk icon button or ⌨**Shift+S** shortcut (customize this shortcut in Settings)
|
||||
Every time you save, it will create a new record in **Version History**. You can go back and switch versions in version history like git.
|
||||
|
||||
duplicate flow by right click menu option:
|
||||
<img width="385" alt="Screenshot 2023-12-29 at 9 58 26 PM" src="https://github.com/11cafe/comfyui-workspace-manager/assets/18367033/b74c4405-8232-4e39-b995-3b4877d27c9e">
|
||||
<img width="350" alt="Screenshot 2024-01-23 at 12 22 22 AM" src="https://github.com/11cafe/comfyui-workspace-manager/assets/18367033/cc4eb504-6a65-44af-ad7f-d4a8b4ac7b9a">
|
||||
<img width="300" alt="Screenshot 2024-01-23 at 12 23 21 AM" src="https://github.com/11cafe/comfyui-workspace-manager/assets/18367033/041c2da8-d8cf-46b7-b9d8-45b415e764f8">
|
||||
|
||||
By default, We also enabled auto-saving your current workflow as a cache in case you forgot to save it manually. It may affect UI performance, if you experience UI lagging, ‼️‼️**you can disable auto-saving in Settings:**
|
||||
|
||||
<img width="297" alt="Screenshot 2024-01-23 at 12 37 37 AM" src="https://github.com/11cafe/comfyui-workspace-manager/assets/18367033/9ca0a63e-ddae-4436-a374-6ef0276c23fb">
|
||||
<img width="402" alt="Screenshot 2024-01-22 at 2 33 00 PM" src="https://github.com/11cafe/comfyui-workspace-manager/assets/18367033/061a8463-f7e6-4985-9516-8117d7a5edd5">
|
||||
|
||||
### Where are my workflow and workspace data stored?
|
||||
|
||||
🗂️All your workflows are saved in a folder in `/ComfyUI/my_workflows`, you can change this path in settings.
|
||||
|
||||
<img width="360" alt="Screenshot 2024-01-02 at 10 22 45 PM" src="https://github.com/11cafe/comfyui-workspace-manager/assets/18367033/4751b096-0886-4a32-95b1-87c7ce5b3787">
|
||||
|
||||
Other metadata (versions, gallery images) is primarily stored in your browser cache (IndexDB) and backedup in your disk under `/ComfyUI/custom_nodes/comfyui-workspace-manager/db/`. Press F12 or Inspect in right click browser menu, go to Application -> IndexedDB -> comfyui_workspace_db to see your data (workflows, version history, gallery, etc.)
|
||||
|
||||
**If you switch browser, your will not see your previous metadata (version history, gallery images) in your new browser**
|
||||
|
||||
<img width="607" alt="303892585-c04ca874-03f0-4569-9a40-d8453dd9b031" src="https://github.com/11cafe/comfyui-workspace-manager/assets/18367033/17097e17-743d-499f-8f86-0eb1193d0e46">
|
||||
|
||||
**Looking for colaborators and coworkers** to develop this ComfyUI project management tool. If you know React, Python or ML model deployment, please reach out to weixuanfu01@gmail.com We want to make it easy to **manage, share and deploy** ComfyUI project.
|
||||
|
||||
@@ -114,7 +163,37 @@ duplicate flow by right click menu option:
|
||||
Right now you need to have python and GPU server up to run ComfyUI or Automatic111, that’s really painful for people who do not own a GPU. You should be able to run UI independently without paying for GPU💰.
|
||||
➡️ Share and deploy your workflow to cloud in 1click and other people can easily run in browser using cloud GPU with no setup. It should be as easy as running Google Docs or Figma.
|
||||
|
||||
## Beta Preview
|
||||
|
||||
We're constantly pushing new features in beta branch. Here is how to switch to beta branch to receive latest feature updates pre-launch! It requires you have some basic knowledge about command line and git.
|
||||
|
||||
- Make sure you have [git installed](https://git-scm.com/downloads)
|
||||
- Open your terminal, go to the ComfyUI folder (where you do `python main.py`)
|
||||
- in ComfyUI root directory, `cd custom_nodes && cd comfyui-workspace-manager`
|
||||
- inside custom_nodes/comfyui-workspace-manager, do `git fetch origin beta && git checkout beta`, you should see something like:
|
||||
|
||||
```
|
||||
From https://github.com/11cafe/comfyui-workspace-manager
|
||||
* branch beta -> FETCH_HEAD
|
||||
Switched to branch 'beta'
|
||||
Your branch is up to date with 'origin/beta'.
|
||||
```
|
||||
|
||||
- Restart ComfyUI python server by `python main.py`
|
||||
|
||||
‼️ Be careful that beta branch can be unstable, so please please make sure you have backups for all your workflows! **Before switching to beta, please make a copy of these folders somewhere:**
|
||||
|
||||
`ComfyUI/custom_nodes/comfyui-workspace-manager/db`
|
||||
or the entire `ComfyUI/custom_nodes/comfyui-workspace-manager/` folder
|
||||
|
||||
if anything goes wrong, you can always paste back these folders. Also, you should have your /my_workflows folder (workspace save directory) in safe place always
|
||||
|
||||
To revert back to main:
|
||||
|
||||
- inside custom_nodes/comfyui-workspace-manager, do `git checkout main`, then restart your python server and you are back into main branch!
|
||||
|
||||
## Credits
|
||||
|
||||
ComfyUI: https://github.com/comfyanonymous/ComfyUI
|
||||
|
||||
ComfyUI Browser: https://github.com/talesofai/comfyui-browser (Media display code for reference)
|
||||
@@ -143,3 +222,22 @@ cd custom_nodes && git clone https://github.com/11cafe/comfyui-workspace-manager
|
||||
5. run ComfyUI server
|
||||
inside `/ComfyUI`
|
||||
do `python main.py` or `python3 main.py` depending on your version
|
||||
|
||||
#### IndexDB trouble shooting
|
||||
|
||||
If you see indexdb version mismatch error in console that looks like:
|
||||
|
||||
```
|
||||
VersionError: The requested version (10) is less than the existing version (30)."
|
||||
name
|
||||
:
|
||||
"DatabaseClosedError"
|
||||
```
|
||||
|
||||
please delete indexdb manually F12 Dev tools -> Application -> IndexDB -> WorkspaceManagerDB
|
||||
<img width="460" alt="Screenshot 2024-01-21 at 11 21 55 PM" src="https://github.com/11cafe/comfyui-workspace-manager/assets/18367033/adcde47f-c620-42ad-9367-e16e5be92be1">
|
||||
|
||||
and delete localstorage.WORKSPACE_INDEXDB_BACKFILL
|
||||
<img width="460" alt="Screenshot 2024-01-21 at 11 29 02 PM" src="https://github.com/11cafe/comfyui-workspace-manager/assets/18367033/f3c17034-c863-481a-8771-8e7e87bcee07">
|
||||
|
||||
only do this if you are a developer
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
# ☕️ ComfyUI Workspace Manager - Comfyspace
|
||||
|
||||
This is a document specially prepared for developers, explaining some special development details.Installation
|
||||
|
||||
## Clean up indexdb if it's in bad state
|
||||
|
||||
Make sure you have .json files under `ComfyUI/comfyui-workspace-manager/db`, so your indexdb data (version history, gallery image data) can be recovered after deleting
|
||||
|
||||
<img width="318" alt="Screenshot 2024-03-12 at 12 35 24 PM" src="https://github.com/11cafe/comfyui-workspace-manager/assets/18367033/722a96ca-82a3-4126-83fd-2951de0a18cb">
|
||||
|
||||
1. F12 -> Application -> IndexedDB -> delete current indexdb
|
||||
|
||||
<img width="731" alt="Screenshot 2024-03-12 at 12 30 48 PM" src="https://github.com/11cafe/comfyui-workspace-manager/assets/18367033/4c4f0f6a-e402-4fd5-94cd-b00ff6f2a96f">
|
||||
|
||||
3. If you want to recover your data like version history, gallery image data, F12 -> Application -> Local Storage -> delete WORKSPACE_INDEXDB_BACKFILL key in localstorage so your indexdb can be backedup (you don't have to do this if you only need workflows .json data)
|
||||
|
||||
<img width="723" alt="Screenshot 2024-03-12 at 12 33 08 PM" src="https://github.com/11cafe/comfyui-workspace-manager/assets/18367033/c9ca969d-fdee-4c1e-bfc5-c646ce241fd8">
|
||||
|
||||
## Install custom git hooks
|
||||
|
||||
When running the project for the first time, it is recommended that you execute the following command to install our customized git hooks
|
||||
|
||||
```javascript
|
||||
cd ui
|
||||
npm run setupGithooks
|
||||
```
|
||||
|
||||
Current hooks include:
|
||||
|
||||
1. When switching to a non-main/beta branch, additional .gitignore logic is automatically added to ignore the "/dist" folder.
|
||||
|
||||
## How to use Hot Module Replacement
|
||||
|
||||
1. npm run dev starts the project;
|
||||
2. If the dist directory currently exists, please delete the dist directory or keep the dist directory empty;
|
||||
3. Modify ComfyUI/web/index.html and add the following code. It should be noted that the port number in localhost:5173 needs to be consistent with the port number of the vite local service started by npm run dev.
|
||||
|
||||
```javascript
|
||||
import RefreshRuntime from "http://localhost:5173/@react-refresh";
|
||||
RefreshRuntime.injectIntoGlobalHook(window);
|
||||
window.$RefreshReg$ = () => {};
|
||||
window.$RefreshSig$ = () => (type) => type;
|
||||
window.__vite_plugin_react_preamble_installed__ = true;
|
||||
|
||||
const head = document.getElementsByTagName("head")[0];
|
||||
const viteClientScript = document.createElement("script");
|
||||
viteClientScript.src = "http://localhost:5173/@vite/client";
|
||||
viteClientScript.type = "module";
|
||||
head.appendChild(viteClientScript);
|
||||
const workspaceMainScript = document.createElement("script");
|
||||
workspaceMainScript.src = "http://localhost:5173/src/main.tsx";
|
||||
workspaceMainScript.type = "module";
|
||||
head.appendChild(workspaceMainScript);
|
||||
```
|
||||
+60
-180
@@ -1,18 +1,21 @@
|
||||
import asyncio
|
||||
import server
|
||||
from aiohttp import web
|
||||
import aiohttp
|
||||
import requests
|
||||
import folder_paths
|
||||
import shutil
|
||||
import os
|
||||
import sys
|
||||
import threading
|
||||
import subprocess # don't remove this
|
||||
from urllib.parse import urlparse
|
||||
import subprocess
|
||||
import os
|
||||
import json
|
||||
from .version_control import update_version_if_outdated
|
||||
from .service.model_manager.model_installer import download_url_with_wget
|
||||
from .service.model_manager.model_list import get_model_list
|
||||
from .service.media_service import *
|
||||
from .service.file_sync_service import *
|
||||
from .service.db_service import get_my_workflows_dir
|
||||
from .service.node_service import *
|
||||
|
||||
WEB_DIRECTORY = "entry"
|
||||
NODE_CLASS_MAPPINGS = {}
|
||||
@@ -24,85 +27,15 @@ workspace_path = os.path.join(os.path.dirname(__file__))
|
||||
comfy_path = os.path.dirname(folder_paths.__file__)
|
||||
db_dir_path = os.path.join(workspace_path, "db")
|
||||
|
||||
|
||||
workspace_app = web.Application()
|
||||
workspace_app.add_routes([
|
||||
web.static("/", os.path.join(workspace_path, 'dist/workspace_web')),
|
||||
])
|
||||
server.PromptServer.instance.app.add_subapp("/workspace_web/", workspace_app)
|
||||
|
||||
@server.PromptServer.instance.routes.post("/workspace/save_db")
|
||||
async def save_db(request):
|
||||
# Extract parameters from the request
|
||||
data = await request.json()
|
||||
table = data['table']
|
||||
json_data = data['json']
|
||||
|
||||
file_name = f'{db_dir_path}/{table}.json'
|
||||
if not os.path.exists(db_dir_path):
|
||||
os.makedirs(db_dir_path)
|
||||
|
||||
# Write the JSON data to the specified file
|
||||
with open(file_name, 'w') as file:
|
||||
file.write(json.dumps(json_data, indent=4))
|
||||
|
||||
return web.Response(text=f"JSON saved to {file_name}")
|
||||
|
||||
|
||||
def read_table(table):
|
||||
if not table:
|
||||
return None
|
||||
file_name = f'{db_dir_path}/{table}.json'
|
||||
if not os.path.exists(file_name):
|
||||
return None
|
||||
|
||||
with open(file_name, 'r') as file:
|
||||
data = json.load(file)
|
||||
return data
|
||||
|
||||
|
||||
@server.PromptServer.instance.routes.get("/workspace/get_db")
|
||||
async def get_workspace(request):
|
||||
# Extract the table parameter from the query string
|
||||
table = request.query.get('table')
|
||||
data = read_table(table)
|
||||
return web.json_response(data)
|
||||
dist_path = os.path.join(workspace_path, 'dist/workspace_web')
|
||||
if os.path.exists(dist_path):
|
||||
server.PromptServer.instance.app.add_routes([
|
||||
web.static('/workspace_web/', dist_path),
|
||||
])
|
||||
|
||||
BACKUP_DIR = os.path.join(workspace_path, "backup")
|
||||
MAX_BACKUP_FILES = 20
|
||||
|
||||
|
||||
@server.PromptServer.instance.routes.post("/workspace/save_backup")
|
||||
async def save_backup(request):
|
||||
try:
|
||||
data = await request.json()
|
||||
file_path = data.get('file_path')
|
||||
json_str = data.get('json_str')
|
||||
|
||||
file_path = os.path.join(BACKUP_DIR, file_path)
|
||||
if not file_path or not json_str:
|
||||
return web.Response(text=json.dumps({"error": "file_path and json_str are required"}), status=400)
|
||||
directory = os.path.dirname(file_path)
|
||||
# Create the directory if it does not exist
|
||||
os.makedirs(directory, exist_ok=True)
|
||||
|
||||
with open(file_path, 'w') as file:
|
||||
file.write(json_str)
|
||||
|
||||
# Check the number of files in the directory after writing the new file
|
||||
files = [f for f in os.listdir(directory) if os.path.isfile(
|
||||
os.path.join(directory, f))]
|
||||
if len(files) > MAX_BACKUP_FILES:
|
||||
# Find the oldest file (smallest filename)
|
||||
oldest_file = min(files, key=lambda x: x)
|
||||
# Delete the oldest file
|
||||
os.remove(os.path.join(directory, oldest_file))
|
||||
|
||||
return web.Response(text=json.dumps({"message": "File saved successfully"}), status=200)
|
||||
except Exception as e:
|
||||
return web.Response(text=json.dumps({"error": str(e)}), status=500)
|
||||
|
||||
|
||||
@server.PromptServer.instance.routes.post("/workspace/list_backup")
|
||||
async def list_backup(request):
|
||||
try:
|
||||
@@ -148,31 +81,35 @@ async def get_system_dir(request):
|
||||
|
||||
return web.Response(text=json.dumps({"dir_path": dir_path, "dir_contents": dir_contents}), content_type='application/json')
|
||||
except Exception as e:
|
||||
print('🔴🔴Error',e)
|
||||
return web.Response(text=json.dumps({"error": str(e)}), status=500)
|
||||
|
||||
|
||||
def get_my_workflows_dir():
|
||||
data = read_table('userSettings')
|
||||
if (data):
|
||||
records = json.loads(data)
|
||||
curDir = records['myWorkflowsDir'] if records else None
|
||||
if curDir:
|
||||
return curDir
|
||||
return os.path.join(comfy_path, 'my_workflows')
|
||||
|
||||
@server.PromptServer.instance.routes.get("/workspace/get_my_workflows_dir")
|
||||
async def fetch_my_workflows_dir(request):
|
||||
try:
|
||||
dir = get_my_workflows_dir()
|
||||
return web.Response(text=json.dumps({"path": dir, "os": sys.platform}), content_type='application/json')
|
||||
except Exception as e:
|
||||
return web.Response(text=json.dumps({"error": str(e)}), status=500)
|
||||
|
||||
@server.PromptServer.instance.routes.post("/workspace/update_file")
|
||||
async def update_file(request):
|
||||
data = await request.json()
|
||||
file_path = data['file_path']
|
||||
json_str = data['json_str']
|
||||
my_workflows_dir = get_my_workflows_dir()
|
||||
full_path = os.path.join(my_workflows_dir, file_path)
|
||||
# Create the directory if it doesn't exist
|
||||
os.makedirs(os.path.dirname(full_path), exist_ok=True)
|
||||
|
||||
with open(full_path, 'w', encoding='utf-8') as file:
|
||||
file.write(json_str)
|
||||
def write_json_to_file(json_str):
|
||||
my_workflows_dir = get_my_workflows_dir()
|
||||
full_path = os.path.join(my_workflows_dir, file_path)
|
||||
print(f"Writing to file: {full_path}")
|
||||
# Create the directory if it doesn't exist
|
||||
os.makedirs(os.path.dirname(full_path), exist_ok=True)
|
||||
with open(full_path, 'w', encoding='utf-8') as file:
|
||||
file.write(json_str)
|
||||
|
||||
# Offload the file update to a separate thread
|
||||
await asyncio.to_thread(write_json_to_file, json_str)
|
||||
return web.Response(text="File updated successfully")
|
||||
|
||||
|
||||
@@ -181,22 +118,30 @@ async def delete_file(request):
|
||||
data = await request.json()
|
||||
file_path = data['file_path']
|
||||
delete_empty_folder = data['deleteEmptyFolder']
|
||||
my_workflows_dir = get_my_workflows_dir()
|
||||
full_path = os.path.join(my_workflows_dir, file_path)
|
||||
|
||||
if os.path.exists(full_path):
|
||||
os.remove(full_path)
|
||||
def sync_delete_file(file_path, delete_empty_folder):
|
||||
my_workflows_dir = get_my_workflows_dir()
|
||||
full_path = os.path.join(my_workflows_dir, file_path)
|
||||
|
||||
# Check if the directory is empty after deleting the file
|
||||
directory = os.path.dirname(full_path)
|
||||
if delete_empty_folder and not os.listdir(directory):
|
||||
# If the directory is empty, remove the directory
|
||||
os.rmdir(directory)
|
||||
return web.Response(text="File and empty directory deleted successfully")
|
||||
if os.path.exists(full_path):
|
||||
os.remove(full_path)
|
||||
directory = os.path.dirname(full_path)
|
||||
if delete_empty_folder and not os.listdir(directory):
|
||||
# If the directory is empty, remove the directory
|
||||
os.rmdir(directory)
|
||||
return "File and empty directory deleted successfully"
|
||||
else:
|
||||
return "File deleted successfully"
|
||||
else:
|
||||
return web.Response(text="File deleted successfully")
|
||||
return "File not found"
|
||||
|
||||
# Run the synchronous file operation in a separate thread
|
||||
response_text = await asyncio.to_thread(sync_delete_file, file_path, delete_empty_folder)
|
||||
|
||||
if response_text == "File not found":
|
||||
return web.Response(text=response_text, status=404)
|
||||
else:
|
||||
return web.Response(text="File not found", status=404)
|
||||
return web.Response(text=response_text)
|
||||
|
||||
|
||||
@server.PromptServer.instance.routes.post("/workspace/rename_file")
|
||||
@@ -211,37 +156,6 @@ async def rename_file(request):
|
||||
else:
|
||||
return web.Response(text="File not found", status=404)
|
||||
|
||||
image_extensions = ['.jpg', '.jpeg', '.png', '.gif', '.webp']
|
||||
video_extensions = ['.mp4', '.mov', '.avi', '.webm', '.mkv']
|
||||
@server.PromptServer.instance.routes.get("/workspace/view_media")
|
||||
async def api_view_file(request):
|
||||
filename = request.query.get("filename", None)
|
||||
if not filename:
|
||||
return web.Response(status=404)
|
||||
|
||||
output_path = folder_paths.get_output_directory()
|
||||
file_path = os.path.join(output_path, filename)
|
||||
|
||||
if not os.path.exists(file_path):
|
||||
return web.Response(status=200)
|
||||
|
||||
with open(file_path, 'rb') as f:
|
||||
media_file = f.read()
|
||||
|
||||
content_type = 'application/json'
|
||||
file_extension = os.path.splitext(filename)[1].lower()
|
||||
if file_extension in image_extensions:
|
||||
content_type = f'image/{file_extension[1:]}'
|
||||
if file_extension in video_extensions:
|
||||
content_type = f'video/{file_extension[1:]}'
|
||||
|
||||
return web.Response(
|
||||
body=media_file,
|
||||
content_type=content_type,
|
||||
headers={"Content-Disposition": f"filename=\"{filename}\""}
|
||||
)
|
||||
|
||||
|
||||
@server.PromptServer.instance.routes.post("/workspace/open_workflow_file_browser")
|
||||
async def open_workflow_file_browser(request):
|
||||
my_workflows_dir = get_my_workflows_dir()
|
||||
@@ -256,48 +170,14 @@ async def open_workflow_file_browser(request):
|
||||
except Exception as e:
|
||||
return web.Response(text=json.dumps({"error": str(e)}), status=500)
|
||||
|
||||
@server.PromptServer.instance.routes.post("/workspace/delete_folder")
|
||||
async def delete_folder(request):
|
||||
data = await request.json()
|
||||
folder_path = data['folder_path']
|
||||
|
||||
def file_handle(name, file, existFlowIds, fileList):
|
||||
json_data = json.load(file)
|
||||
fileInfo = {
|
||||
'json': json.dumps(json_data),
|
||||
'name': '.'.join(name.split('.')[:-1])
|
||||
}
|
||||
if 'extra' in json_data and 'workspace_info' in json_data['extra'] and 'id' in json_data['extra']['workspace_info']:
|
||||
if json_data['extra']['workspace_info']['id'] not in existFlowIds:
|
||||
fileList.append(fileInfo)
|
||||
if os.path.exists(folder_path):
|
||||
shutil.rmtree(folder_path)
|
||||
return web.Response(text="Successfully deleted folder: {folder_path}")
|
||||
else:
|
||||
fileList.append(fileInfo)
|
||||
|
||||
# Scan all files and subfolders in the local save directory.
|
||||
# For files, compare the extra.workspace_info.id in the json format file with the flow of the current DB to determine whether it is a flow that needs to be added;
|
||||
# For subfolders, scan the json files in the subfolder and use the same processing method as the file to determine whether it is a flow that needs to be added;
|
||||
@server.PromptServer.instance.routes.post("/workspace/scan_local_new_files")
|
||||
async def scan_local_new_files(request):
|
||||
reqJson = await request.json()
|
||||
path = reqJson['path']
|
||||
existFlowIds = reqJson['existFlowIds']
|
||||
|
||||
fileList = []
|
||||
folderList = []
|
||||
|
||||
for item in os.listdir(path):
|
||||
item_path = os.path.join(path, item)
|
||||
if os.path.isfile(item_path) and item_path.endswith('.json'):
|
||||
with open(item_path, 'r') as f:
|
||||
file_handle(item, f, existFlowIds, fileList)
|
||||
|
||||
elif os.path.isdir(item_path):
|
||||
folder = {
|
||||
'name': item,
|
||||
'list': []
|
||||
}
|
||||
for sub_item in os.listdir(item_path):
|
||||
sub_item_path = os.path.join(item_path, sub_item)
|
||||
if os.path.isfile(sub_item_path) and sub_item_path.endswith('.json'):
|
||||
with open(sub_item_path, 'r') as f:
|
||||
file_handle(sub_item, f, existFlowIds, folder['list'])
|
||||
|
||||
if len(folder['list']) > 0:
|
||||
folderList.append(folder)
|
||||
return web.Response(text=json.dumps({'fileList': fileList, 'folderList': folderList}), content_type='application/json')
|
||||
return web.Response(text="folder not found: {folder_path}", status=404)
|
||||
|
||||
Vendored
-7
File diff suppressed because one or more lines are too long
@@ -1 +0,0 @@
|
||||
import{r as o,j as t,B as e}from"./input.js";import{app as r}from"/scripts/app.js";function n({}){return o.useEffect(()=>{const s=r.graph.serialize();console.log(s)},[]),t.jsx(e,{size:"sm","aria-label":"missing models",px:2,children:"Install Missing Models"})}export{n as default};
|
||||
@@ -1,7 +0,0 @@
|
||||
import{f as u,r as i,j as e,b as d,e as x,B as f,R as S,_ as T}from"./input.js";import{G as E}from"./chunk-JARCRF6W-n_XCkAFM.js";import{B as g,O as W,T as L,a6 as I}from"./App-ZBQ9LoZK.js";import{app as m}from"/scripts/app.js";import"/scripts/api.js";var j=u(function(s,r){const{spacing:o="0.5rem",spacingX:a,spacingY:p,children:c,justify:n,direction:k,align:_,className:M,shouldWrapChildren:h,...y}=s,b=i.useMemo(()=>h?i.Children.map(c,(v,w)=>e.jsx(l,{children:v},w)):c,[c,h]);return e.jsx(d.div,{ref:r,className:x("chakra-wrap",M),...y,children:e.jsx(d.ul,{className:"chakra-wrap__list",__css:{display:"flex",flexWrap:"wrap",justifyContent:n,alignItems:_,flexDirection:k,listStyleType:"none",gap:o,columnGap:a,rowGap:p,padding:"0"},children:b})})});j.displayName="Wrap";var l=u(function(s,r){const{className:o,...a}=s;return e.jsx(d.li,{ref:r,__css:{display:"flex",alignItems:"flex-start"},className:x("chakra-wrap__listitem",o),...a})});l.displayName="WrapItem";function R({selectedModel:t,setSelectedModel:s,modelTypeList:r}){const o=a=>{s(a)};return e.jsx(j,{children:r.map(a=>e.jsx(l,{children:e.jsx(f,{colorScheme:"teal",variant:t===a?"solid":"outline",onClick:()=>o(a),size:"sm",children:a})}))})}const C="https://image.civitai.com/xG1nkqKTMzGDvpLrqFT7WA/27fd7433-cb0a-4a87-88c1-21ccb2b1a842/width=450/00060-881622046.jpeg";function z({list:t}){return e.jsx(E,{templateColumns:"repeat(3, 1fr)",gap:1,marginTop:2,children:t.map(s=>e.jsx(l,{children:e.jsxs(g,{position:"relative",borderRadius:4,children:[e.jsx(W,{src:C,boxSize:"100%",objectFit:"cover",borderRadius:4}),e.jsx(L,{position:"absolute",bottom:"0",left:"0",right:"0",bg:"rgba(0, 0, 0, 0.5)",color:"white",textAlign:"center",p:"0",fontSize:12,children:s})]},s)}))})}function N({onClose:t}){const[s,r]=i.useState("checkpoints"),[o,a]=i.useState(["checkpoints","clip","clip_vision","configs","controlnet","diffusers","embeddings","gligen","hypernetworks","loras"]);i.useState(["checkpoints","checkpoints2","checkpoints3","checkpoints4","checkpoints5","checkpoints6","checkpoints7","checkpoints8","checkpoints9","checkpoints10"]);const[p,c]=i.useState([]);i.useEffect(()=>{},[s]),i.useEffect(()=>(m.canvasEl.addEventListener("click",t),()=>{m.canvasEl.removeEventListener("click",t)}),[]);const n=440;return e.jsx(g,{style:{width:n},children:e.jsxs(I,{width:n,height:"100vh",pl:4,pr:5,pt:4,gap:4,position:"absolute",top:0,left:0,shadow:"xl",zIndex:1e3,overflow:"auto",children:[e.jsx(R,{modelTypeList:o,setSelectedModel:r,selectedModel:s}),e.jsx(z,{list:p})]})})}S.lazy(()=>T(()=>import("./InstallMissingModelsButton-Icn58Ii5.js"),__vite__mapDeps([0,1])));function q({}){const[t,s]=i.useState(!1);return e.jsxs(e.Fragment,{children:[e.jsx(f,{size:"sm",variant:"outline",colorScheme:"teal","aria-label":"My models",onClick:()=>s(!0),px:2,children:"My models"}),t&&e.jsx(N,{onClose:()=>s(!1)})]})}export{q as default};
|
||||
function __vite__mapDeps(indexes) {
|
||||
if (!__vite__mapDeps.viteFileDeps) {
|
||||
__vite__mapDeps.viteFileDeps = ["workspace_web/InstallMissingModelsButton-Icn58Ii5.js","workspace_web/input.js"]
|
||||
}
|
||||
return indexes.map((i) => __vite__mapDeps.viteFileDeps[i])
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
@@ -1 +0,0 @@
|
||||
import{f as G,j as R,b as c}from"./input.js";var C=G(function(o,r){const{templateAreas:a,gap:s,rowGap:t,columnGap:e,column:i,row:d,autoFlow:l,autoRows:m,templateRows:p,autoColumns:u,templateColumns:n,...g}=o,w={display:"grid",gridTemplateAreas:a,gridGap:s,gridRowGap:t,gridColumnGap:e,gridAutoColumns:u,gridColumn:i,gridRow:d,gridAutoFlow:l,gridAutoRows:m,gridTemplateRows:p,gridTemplateColumns:n};return R.jsx(c.div,{ref:r,__css:w,...g})});C.displayName="Grid";export{C as G};
|
||||
Vendored
-687
File diff suppressed because one or more lines are too long
+2
-1
@@ -2,6 +2,7 @@ const targetNode = document.body;
|
||||
|
||||
// Options for the observer (which mutations to observe)
|
||||
const config = { childList: true, subtree: true };
|
||||
const api_base = window.location.pathname.split("/").slice(0, -1).join("/");
|
||||
|
||||
// @ts-ignore
|
||||
const callback = function (mutationsList, observer) {
|
||||
@@ -11,7 +12,7 @@ const callback = function (mutationsList, observer) {
|
||||
mutation.addedNodes.forEach((node) => {
|
||||
if (node.nodeName === "CANVAS") {
|
||||
// @ts-ignore
|
||||
import("/workspace_web/input.js");
|
||||
import(api_base + "/workspace_web/input.js");
|
||||
observer.disconnect();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
const fs = require('fs');
|
||||
const { exec } = require('child_process');
|
||||
const path = require('path');
|
||||
|
||||
const files = [
|
||||
{ path: '../.git/info/exclude.dist', content: '/dist' },
|
||||
{
|
||||
path: '../.git/hooks/post-checkout',
|
||||
content: `
|
||||
#!/bin/sh
|
||||
|
||||
# Get the current branch name
|
||||
BRANCH_NAME=$(git rev-parse --symbolic-full-name --abbrev-ref HEAD)
|
||||
|
||||
# Remove previously set exclude files
|
||||
rm -f .git/info/exclude
|
||||
|
||||
# When not in the beta/main branch, add additional files that need to be ignored
|
||||
if [ "$BRANCH_NAME" != "beta" ] && [ "$BRANCH_NAME" != "main" ]; then
|
||||
cp .git/info/exclude.dist .git/info/exclude
|
||||
# elif [ "$BRANCH_NAME" = "xxx" ]; then
|
||||
# cp .git/info/exclude.xxx .git/info/exclude
|
||||
fi`,
|
||||
},
|
||||
];
|
||||
|
||||
function createFile(filePath, content) {
|
||||
fs.writeFile(filePath, content, (err) => {
|
||||
if (err) {
|
||||
console.error(`An error occurred while creating file ${filePath}:`, err);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function executeCommand(command, workingDirectory = '../') {
|
||||
exec(command, { cwd: path.resolve(__dirname, workingDirectory) }, (error, stdout, stderr) => {
|
||||
if (error) {
|
||||
console.error(`An error occurred while executing command "${command}":`, error);
|
||||
return;
|
||||
}
|
||||
if (stderr) {
|
||||
console.error(`Command error "${stderr}"`);
|
||||
return;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function main() {
|
||||
files.forEach((file) => {
|
||||
createFile(file.path, file.content);
|
||||
});
|
||||
executeCommand('git config advice.ignoredHook false');
|
||||
executeCommand('chmod +x .git/hooks/post-checkout');
|
||||
console.log('Custom git hooks installation completed')
|
||||
}
|
||||
|
||||
main();
|
||||
@@ -0,0 +1,72 @@
|
||||
|
||||
import server
|
||||
import os
|
||||
import folder_paths
|
||||
import json
|
||||
import asyncio
|
||||
from aiohttp import web
|
||||
from .model_manager.model_preview import get_thumbnail_for_image_file
|
||||
|
||||
workspace_path = os.path.join(os.path.dirname(os.path.dirname(__file__)))
|
||||
comfy_path = os.path.dirname(folder_paths.__file__)
|
||||
db_dir_path = os.path.join(workspace_path, "db")
|
||||
DEFAULT_USER = "guest"
|
||||
|
||||
@server.PromptServer.instance.routes.post("/workspace/save_db")
|
||||
async def save_db(request):
|
||||
# Extract parameters from the request
|
||||
data = await request.json()
|
||||
table = data['table']
|
||||
json_data = data['json']
|
||||
|
||||
file_name = f'{db_dir_path}/{table}.json'
|
||||
# Offload file writing to a separate thread
|
||||
def write_json_string_to_db(file_name, json_data):
|
||||
if not os.path.exists(db_dir_path):
|
||||
os.makedirs(db_dir_path)
|
||||
# Write the JSON data to the specified file
|
||||
with open(file_name, 'w') as file:
|
||||
file.write(json.dumps(json_data, indent=4))
|
||||
await asyncio.to_thread(write_json_string_to_db, file_name, json_data)
|
||||
return web.Response(text=f"JSON saved to {file_name}")
|
||||
|
||||
def read_table(table):
|
||||
if not table:
|
||||
return None
|
||||
file_name = f'{db_dir_path}/{table}.json'
|
||||
if not os.path.exists(file_name):
|
||||
return None
|
||||
|
||||
try:
|
||||
with open(file_name, 'r', encoding='utf-8') as file:
|
||||
data = json.load(file)
|
||||
return data
|
||||
except json.JSONDecodeError as e:
|
||||
return None
|
||||
|
||||
|
||||
@server.PromptServer.instance.routes.get("/workspace/get_db")
|
||||
async def get_workspace(request):
|
||||
# Extract the table parameter from the query string
|
||||
table = request.query.get('table')
|
||||
data = await asyncio.to_thread(read_table, table)
|
||||
return web.json_response(data)
|
||||
|
||||
def get_my_workflows_dir():
|
||||
data = read_table('userSettings')
|
||||
if (data):
|
||||
records = json.loads(data)
|
||||
|
||||
if DEFAULT_USER in records and 'myWorkflowsDir' in records[DEFAULT_USER]:
|
||||
curDir = records[DEFAULT_USER]['myWorkflowsDir']
|
||||
elif 'myWorkflowsDir' in records:
|
||||
curDir = records['myWorkflowsDir']
|
||||
|
||||
# this is to be compatible of a bug that a dict is stored in userSettings.myWorkflowsDir
|
||||
# should not be needed once all users refresh their settings
|
||||
if not isinstance(curDir, str):
|
||||
curDir = curDir.get('path', None)
|
||||
|
||||
if curDir and os.path.exists(curDir):
|
||||
return curDir
|
||||
return os.path.join(comfy_path, 'my_workflows')
|
||||
@@ -0,0 +1,357 @@
|
||||
import asyncio
|
||||
import folder_paths
|
||||
from datetime import datetime
|
||||
import platform
|
||||
import tempfile
|
||||
import server
|
||||
import os
|
||||
from aiohttp import web
|
||||
import json
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
from .twoway_sync_folder_service import *
|
||||
import shutil
|
||||
from .scan_my_workflows_folder import *
|
||||
|
||||
@server.PromptServer.instance.routes.post('/workspace/file/save')
|
||||
async def save_file(request):
|
||||
reqJson = await request.json()
|
||||
data = await asyncio.to_thread(save_file_sync, reqJson)
|
||||
return web.json_response(data, content_type='application/json')
|
||||
|
||||
def save_file_sync(reqJson):
|
||||
current_path = reqJson.get('path')
|
||||
new_json_data_str = reqJson.get('json')
|
||||
current_path = os.path.join(get_my_workflows_dir(), current_path)
|
||||
try:
|
||||
new_json_data = json.loads(new_json_data_str)
|
||||
except json.JSONDecodeError:
|
||||
return { "error": "New JSON data is not valid JSON."}
|
||||
expected_id = new_json_data.get('extra', {}).get('workspace_info', {}).get('id', None)
|
||||
try:
|
||||
with open(current_path, 'r', encoding='utf-8') as file:
|
||||
current_json_data = json.load(file)
|
||||
# Extract the id from the current file's content
|
||||
current_id = current_json_data.get('extra', {}).get('workspace_info', {}).get('id', None)
|
||||
|
||||
# Compare the current id with the expected id
|
||||
if current_id is not None and current_id != expected_id:
|
||||
return {"error": "Mismatching workspace_info.id."}
|
||||
except FileNotFoundError:
|
||||
return {"error": f"File not found in path {current_path}"}
|
||||
except Exception as e:
|
||||
return { "error": str(e)}
|
||||
|
||||
# If checks pass, write the new JSON data to the file
|
||||
try:
|
||||
with open(current_path, 'w', encoding='utf-8') as file:
|
||||
file.write(new_json_data_str)
|
||||
return {"success": True}
|
||||
except Exception as e:
|
||||
return { "error": str(e)}
|
||||
|
||||
@server.PromptServer.instance.routes.post('/workspace/file/rename')
|
||||
async def rename_file(request):
|
||||
reqJson = await request.json()
|
||||
data = await asyncio.to_thread(rename_file_sync, reqJson)
|
||||
return web.json_response(data, content_type='application/json')
|
||||
|
||||
def rename_file_sync(reqJson):
|
||||
current_path = Path(get_my_workflows_dir()) / reqJson['path']
|
||||
new_name = reqJson.get('newName')
|
||||
try:
|
||||
new_path = current_path.parent / new_name
|
||||
Path(current_path).rename(new_path)
|
||||
return {"success": True}
|
||||
except Exception as e:
|
||||
return {"success": False, "error": str(e)}
|
||||
|
||||
@server.PromptServer.instance.routes.post('/workspace/file/move')
|
||||
async def move_file(request):
|
||||
reqJson = await request.json()
|
||||
data = await asyncio.to_thread(move_file_sync, reqJson)
|
||||
return web.json_response(data, content_type='application/json')
|
||||
|
||||
def move_file_sync(reqJson):
|
||||
current_path = reqJson.get('path')
|
||||
new_path = reqJson.get('newParentPath')
|
||||
current_path = os.path.join(get_my_workflows_dir(), current_path)
|
||||
new_path = os.path.join(get_my_workflows_dir(), new_path)
|
||||
try:
|
||||
shutil.move(current_path, new_path)
|
||||
return {"success": True}
|
||||
except Exception as e:
|
||||
return {"success": False, "error": str(e)}
|
||||
|
||||
@server.PromptServer.instance.routes.post('/workspace/create_workflow_file')
|
||||
async def create_workflow_file(request):
|
||||
reqJson = await request.json()
|
||||
data = await asyncio.to_thread(create_workflow_file, reqJson)
|
||||
return web.json_response(data, content_type='application/json')
|
||||
def create_workflow_file(reqJson):
|
||||
try:
|
||||
parentFolderPath = reqJson.get('parentFolderPath')
|
||||
name = reqJson.get('name')
|
||||
workflow_json = reqJson.get('json')
|
||||
|
||||
if not name or workflow_json is None:
|
||||
print('🔴Create workflow missing necessary data')
|
||||
return {} # Missing necessary data
|
||||
abs_path = Path(get_my_workflows_dir()) / parentFolderPath
|
||||
# Create the directory if it does not exist
|
||||
abs_path.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Ensuring the file name is unique
|
||||
base_name, extension = os.path.splitext(name)
|
||||
if extension != '.json': # Ensure the extension is .json
|
||||
extension = '.json'
|
||||
new_file_name = base_name + extension
|
||||
counter = 1
|
||||
while (abs_path / new_file_name).exists():
|
||||
new_file_name = f"{base_name}_{counter}{extension}"
|
||||
counter += 1
|
||||
|
||||
new_file_path = abs_path / new_file_name
|
||||
# Writing JSON data to the file
|
||||
with open(new_file_path, 'w', encoding='utf-8') as file:
|
||||
file.write(workflow_json)
|
||||
new_base_name,ext = os.path.splitext(new_file_name)
|
||||
return {"name": new_base_name}
|
||||
except Exception as e:
|
||||
print(f"Failed to create workflow file: {e}")
|
||||
return {} # In case of any error
|
||||
|
||||
def read_workflow_file(path, id):
|
||||
abs_path = os.path.join(get_my_workflows_dir(), path)
|
||||
create_time, update_time = getFileCreateTime(abs_path)
|
||||
with open(abs_path, 'r', encoding='utf-8') as f:
|
||||
json_data = json.load(f)
|
||||
if 'extra' in json_data and 'workspace_info' in json_data['extra'] and 'id' in json_data['extra']['workspace_info']:
|
||||
workflow_id = json_data['extra']['workspace_info']['id']
|
||||
if workflow_id == id:
|
||||
return {"json": json_data, "createTime": create_time, "updateTime": update_time}
|
||||
return {"error": "Workflow ID doesn't match"}
|
||||
return {"error": "No workspace_info.id found in the file"}
|
||||
|
||||
|
||||
def check_and_update_workflow_id(file_path, seen_ids):
|
||||
with open(file_path, "r", encoding="utf-8") as f:
|
||||
json_data = json.load(f)
|
||||
if (
|
||||
"extra" in json_data
|
||||
and "workspace_info" in json_data["extra"]
|
||||
and "id" in json_data["extra"]["workspace_info"]
|
||||
):
|
||||
workflow_id = json_data["extra"]["workspace_info"]["id"]
|
||||
create_time, update_time = getFileCreateTime(file_path)
|
||||
if workflow_id in seen_ids:
|
||||
if create_time > seen_ids[workflow_id]["time"]:
|
||||
# Update the current file
|
||||
json_data["extra"]["workspace_info"]["id"] = str(uuid.uuid4())
|
||||
with open(file_path, "w", encoding="utf-8") as f:
|
||||
json.dump(json_data, f)
|
||||
else:
|
||||
# Update the previously seen file
|
||||
old_file_path = seen_ids[workflow_id]["path"]
|
||||
with open(old_file_path, "r", encoding="utf-8") as f:
|
||||
old_json_data = json.load(f)
|
||||
old_json_data["extra"]["workspace_info"]["id"] = str(uuid.uuid4())
|
||||
with open(old_file_path, "w", encoding="utf-8") as f:
|
||||
json.dump(old_json_data, f)
|
||||
seen_ids[workflow_id] = {
|
||||
"time": create_time,
|
||||
"path": file_path,
|
||||
}
|
||||
|
||||
|
||||
def dedupe_workflow_ids():
|
||||
abs_path = get_my_workflows_dir()
|
||||
seen_ids = {}
|
||||
for root, dirs, files in os.walk(abs_path):
|
||||
for file in files:
|
||||
if not file.endswith(".json"):
|
||||
continue
|
||||
file_path = os.path.join(root, file)
|
||||
check_and_update_workflow_id(file_path, seen_ids)
|
||||
|
||||
@server.PromptServer.instance.routes.get('/workspace/deduplicate_workflow_ids')
|
||||
async def deduplicate_workflow_ids(request):
|
||||
await asyncio.to_thread(dedupe_workflow_ids)
|
||||
return web.json_response(text="success", content_type='application/json')
|
||||
|
||||
@server.PromptServer.instance.routes.post('/workspace/get_workflow_file')
|
||||
async def get_workflow_file(request):
|
||||
reqJson = await request.json()
|
||||
path = reqJson.get('path')
|
||||
id = reqJson.get('id')
|
||||
|
||||
data = await asyncio.to_thread(read_workflow_file, path, id)
|
||||
|
||||
return web.json_response(data, content_type='application/json')
|
||||
|
||||
|
||||
# Scan .json and folders in the given path
|
||||
@server.PromptServer.instance.routes.post("/workspace/scan_my_workflows_files")
|
||||
async def scan_local_new_files(request):
|
||||
reqJson = await request.json()
|
||||
path = reqJson['path']
|
||||
|
||||
fileList = await asyncio.to_thread(folder_handle, path)
|
||||
return web.Response(text=json.dumps(fileList), content_type='application/json')
|
||||
|
||||
def folder_handle(path):
|
||||
fileList = []
|
||||
for item in os.listdir(path):
|
||||
try:
|
||||
item_path = os.path.join(path, item)
|
||||
if os.path.isfile(item_path) and item_path.endswith('.json'):
|
||||
with open(item_path, 'r', encoding='utf-8') as f:
|
||||
file_handle(item, f, fileList, item_path)
|
||||
|
||||
elif os.path.isdir(item_path):
|
||||
createTime, updateTime = getFileCreateTime(item_path)
|
||||
fileList.append({
|
||||
'name': item,
|
||||
'type': 'folder',
|
||||
'createTime': createTime,
|
||||
'updateTime': updateTime
|
||||
})
|
||||
except Exception as e:
|
||||
print(f"Error scanning file {item}: {e}")
|
||||
return fileList
|
||||
|
||||
def file_handle(name, file, fileList, file_path):
|
||||
json_data = json.load(file)
|
||||
createTime, updateTime = getFileCreateTime(file_path)
|
||||
if 'extra' in json_data and 'workspace_info' in json_data['extra'] and 'id' in json_data['extra']['workspace_info']:
|
||||
workflow_id = json_data['extra']['workspace_info']['id']
|
||||
else:
|
||||
# If ID does not exist, generate a new UUID and add it to the JSON data
|
||||
new_id = str(uuid.uuid4())
|
||||
json_data.setdefault('extra', {}).setdefault('workspace_info', {})['id'] = new_id
|
||||
with open(file_path, 'w', encoding='utf-8') as file:
|
||||
json.dump(json_data, file, ensure_ascii=False, indent=4)
|
||||
|
||||
fileInfo = {
|
||||
'json': json.dumps(json_data),
|
||||
'name': name,
|
||||
'type': "workflow",
|
||||
'id': json_data['extra']['workspace_info']['id'],
|
||||
'createTime': createTime,
|
||||
'updateTime': updateTime
|
||||
}
|
||||
fileList.append(fileInfo)
|
||||
|
||||
@server.PromptServer.instance.routes.post('/workspace/file/gen_unique_name')
|
||||
async def generate_unique_file_name(request):
|
||||
reqJson = await request.json()
|
||||
data = await asyncio.to_thread(generate_unique_file_name_sync, reqJson)
|
||||
return web.json_response(data, content_type='application/json')
|
||||
|
||||
def generate_unique_file_name_sync(reqJson):
|
||||
file_path = reqJson.get('path')
|
||||
if not file_path:
|
||||
return {"success": False, "error": "File path is required"}
|
||||
|
||||
path = Path(get_my_workflows_dir()) / file_path
|
||||
if not path.parent.is_dir():
|
||||
return {"success": False, "error": "Directory of the provided path does not exist"}
|
||||
|
||||
original_name = path.stem
|
||||
extension = path.suffix
|
||||
directory = path.parent
|
||||
counter = 1
|
||||
new_name = f"{original_name}{extension}"
|
||||
|
||||
while (directory / new_name).exists():
|
||||
new_name = f"{original_name}{counter}{extension}"
|
||||
counter += 1
|
||||
|
||||
return {"success": True, "uniqueName": new_name}
|
||||
|
||||
@server.PromptServer.instance.routes.post('/workspace/file/count_files')
|
||||
async def count_files(request):
|
||||
reqJson = await request.json()
|
||||
data = await asyncio.to_thread(count_files_sync, reqJson)
|
||||
return web.json_response(data, content_type='application/json')
|
||||
|
||||
def count_files_sync(reqJson):
|
||||
directory_path = reqJson.get('path')
|
||||
if not directory_path:
|
||||
return {"success": False, "error": "Directory path is required"}
|
||||
|
||||
path = Path(get_my_workflows_dir()) / directory_path
|
||||
if not path.is_dir():
|
||||
return {"success": False, "error": "Provided path is not a directory"}
|
||||
file_count = 0
|
||||
for file in path.rglob('*.json'): # Only consider .json files
|
||||
if file.is_file():
|
||||
file_count += 1
|
||||
return {"success": True, "count": file_count}
|
||||
# Begin: Copy all files to the new directory when switching the save directory
|
||||
@server.PromptServer.instance.routes.post('/workspace/copy_json_files')
|
||||
async def copy_json_files(request):
|
||||
reqJson = await request.json()
|
||||
src_dir = reqJson['src']
|
||||
dest_dir = reqJson['dst']
|
||||
try:
|
||||
for item in os.listdir(src_dir):
|
||||
src_item_path = os.path.join(src_dir, item)
|
||||
dest_item_path = os.path.join(dest_dir, item)
|
||||
|
||||
if os.path.isdir(src_item_path):
|
||||
dest_item_path = await get_unique_dir_name(dest_item_path)
|
||||
await asyncio.to_thread(shutil.copytree, src_item_path, dest_item_path)
|
||||
elif item.endswith('.json'):
|
||||
dest_item_path = await get_unique_file_name(dest_item_path)
|
||||
await asyncio.to_thread(shutil.copy2, src_item_path, dest_item_path)
|
||||
|
||||
return web.json_response({"success": True}, content_type='application/json')
|
||||
except Exception as e:
|
||||
return web.json_response({"success": False, "error": str(e)}, content_type='application/json')
|
||||
|
||||
async def get_unique_file_name(path):
|
||||
base, extension = os.path.splitext(path)
|
||||
counter = 1
|
||||
while os.path.exists(path):
|
||||
path = f"{base}_{counter}{extension}"
|
||||
counter += 1
|
||||
return path
|
||||
|
||||
async def get_unique_dir_name(path):
|
||||
original_path = path
|
||||
counter = 1
|
||||
while os.path.exists(path):
|
||||
path = f"{original_path}_{counter}"
|
||||
counter += 1
|
||||
return path
|
||||
# End: Copy all files to the new directory when switching the save directory
|
||||
|
||||
@server.PromptServer.instance.routes.post('/workspace/images/save')
|
||||
async def save_images(request):
|
||||
reader = await request.multipart()
|
||||
subfolder = "workspace_manager" # 默认子文件夹,实际上你可能会从表单中动态获取
|
||||
output_path = folder_paths.get_output_directory()
|
||||
safe_dir = Path(os.path.join(output_path, subfolder))
|
||||
if not safe_dir.exists():
|
||||
safe_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# 用于存储保存的文件路径
|
||||
files_saved = []
|
||||
# 处理multipart/form-data中的每一部分
|
||||
async for part in reader:
|
||||
if part.name.startswith('images'):
|
||||
filename = part.filename
|
||||
if not filename:
|
||||
continue
|
||||
file_path = safe_dir / filename
|
||||
loop = asyncio.get_running_loop()
|
||||
# 异步写入文件
|
||||
with open(file_path, 'wb') as f:
|
||||
while True:
|
||||
chunk = await part.read_chunk() # 默认8192 bytes
|
||||
if not chunk:
|
||||
break
|
||||
await loop.run_in_executor(None, f.write, chunk)
|
||||
files_saved.append(str(file_path))
|
||||
return web.Response(text=json.dumps({"imgPaths": files_saved}), content_type='application/json')
|
||||
@@ -0,0 +1,101 @@
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import server
|
||||
from aiohttp import FormData, web, ClientSession
|
||||
import folder_paths
|
||||
import os
|
||||
from .model_manager.model_preview import get_thumbnail_for_image_file
|
||||
|
||||
image_extensions = ['.jpg', '.jpeg', '.png', '.gif', '.webp']
|
||||
video_extensions = ['.mp4', '.mov', '.avi', '.webm', '.mkv']
|
||||
API_URL = 'https://www.nodecafe.org/api/image/upload'
|
||||
def view_media(filename, isPreview = False, isInput = False):
|
||||
if not filename:
|
||||
return web.Response(status=404)
|
||||
|
||||
# validation for security: prevent accessing arbitrary path
|
||||
if filename[0] == '/' or '..' in filename:
|
||||
return web.Response(status=400)
|
||||
|
||||
path = folder_paths.get_input_directory() if isInput else folder_paths.get_output_directory()
|
||||
file_path = os.path.join(path, filename)
|
||||
|
||||
if not os.path.exists(file_path):
|
||||
return web.Response(status=404)
|
||||
|
||||
file_extension = os.path.splitext(filename)[1].lower()
|
||||
if isPreview and file_extension in image_extensions:
|
||||
return web.json_response(
|
||||
body=get_thumbnail_for_image_file(file_path),
|
||||
content_type='image/jpeg',
|
||||
headers={"Content-Disposition": f"filename=\"{filename}.jpg\""}
|
||||
)
|
||||
|
||||
with open(file_path, 'rb') as f:
|
||||
media_file = f.read()
|
||||
|
||||
content_type = 'application/json'
|
||||
|
||||
if file_extension in image_extensions:
|
||||
content_type = f'image/{file_extension[1:]}'
|
||||
if file_extension in video_extensions:
|
||||
content_type = f'video/{file_extension[1:]}'
|
||||
|
||||
return web.Response(
|
||||
body=media_file,
|
||||
content_type=content_type,
|
||||
headers={"Content-Disposition": f"filename=\"{filename}\""}
|
||||
)
|
||||
|
||||
@server.PromptServer.instance.routes.get("/workspace/preview_media")
|
||||
async def preview_file(request):
|
||||
filename = request.query.get("filename", None)
|
||||
return await asyncio.to_thread(view_media, filename, True)
|
||||
|
||||
@server.PromptServer.instance.routes.get("/workspace/view_media")
|
||||
async def view_file(request):
|
||||
filename = request.query.get("filename", None)
|
||||
isPreview = request.query.get("isPreview", False)
|
||||
isInput = request.query.get("isInput", False)
|
||||
return await asyncio.to_thread(view_media, filename, isPreview, isInput)
|
||||
|
||||
@server.PromptServer.instance.routes.post("/workspace/upload_image") # Handle POST requests
|
||||
async def upload_image_handler(request):
|
||||
data = await request.json()
|
||||
image_paths = data.get("images")
|
||||
if not image_paths:
|
||||
return web.Response(status=400, text="Images parameter is required and should be a list of image paths.")
|
||||
|
||||
# Initialize FormData for multipart upload
|
||||
multipart_data = FormData()
|
||||
files_not_found = []
|
||||
|
||||
# Iterate over image paths to add them to FormData
|
||||
for image_path in image_paths:
|
||||
abs_path = os.path.join(folder_paths.get_input_directory(), image_path)
|
||||
|
||||
if not os.path.exists(abs_path):
|
||||
files_not_found.append(image_path)
|
||||
continue # Skip this file and proceed with the next
|
||||
|
||||
with open(abs_path, 'rb') as img:
|
||||
multipart_data.add_field('file', img.read(),
|
||||
filename=os.path.basename(image_path),
|
||||
content_type='application/octet-stream') # Adjust MIME type as necessary
|
||||
|
||||
# Handle case where one or more files were not found
|
||||
if files_not_found:
|
||||
return web.Response(status=404, text=f"Image file(s) not found: {', '.join(files_not_found)}")
|
||||
|
||||
try:
|
||||
async with ClientSession() as session:
|
||||
async with session.post(API_URL, data=multipart_data) as response:
|
||||
if response.status != 200:
|
||||
text = await response.text()
|
||||
return web.Response(status=response.status, text=text)
|
||||
|
||||
result = await response.json()
|
||||
return web.Response(text=json.dumps(result), content_type='application/json')
|
||||
except Exception as e:
|
||||
return web.Response(status=500, text=f"Error forwarding the images to the Next.js API: {e}")
|
||||
@@ -0,0 +1,142 @@
|
||||
import asyncio
|
||||
import json
|
||||
import server
|
||||
from aiohttp import web
|
||||
import nodes
|
||||
import ast
|
||||
import inspect
|
||||
|
||||
async def validate_prompt_input_in_list():
|
||||
obj_class = nodes.NODE_CLASS_MAPPINGS[class_type]
|
||||
class_inputs = obj_class.INPUT_TYPES()
|
||||
required_inputs = class_inputs['required']
|
||||
|
||||
errors = []
|
||||
valid = True
|
||||
|
||||
validate_function_inputs = []
|
||||
if hasattr(obj_class, "VALIDATE_INPUTS"):
|
||||
validate_function_inputs = inspect.getfullargspec(obj_class.VALIDATE_INPUTS).args
|
||||
|
||||
for x in required_inputs:
|
||||
val = inputs[x]
|
||||
info = required_inputs[x]
|
||||
type_input = info[0]
|
||||
if not isinstance(val, list):
|
||||
if x not in validate_function_inputs:
|
||||
if isinstance(type_input, list):
|
||||
if val not in type_input:
|
||||
input_config = info
|
||||
list_info = ""
|
||||
|
||||
# Don't send back gigantic lists like if they're lots of
|
||||
# scanned model filepaths
|
||||
if len(type_input) > 20:
|
||||
list_info = f"(list of length {len(type_input)})"
|
||||
input_config = None
|
||||
else:
|
||||
list_info = str(type_input)
|
||||
|
||||
error = {
|
||||
"type": "value_not_in_list",
|
||||
"message": "Value not in list",
|
||||
"details": f"{x}: '{val}' not in {list_info}",
|
||||
"extra_info": {
|
||||
"input_name": x,
|
||||
"input_config": input_config,
|
||||
"received_value": val,
|
||||
}
|
||||
}
|
||||
errors.append(error)
|
||||
continue
|
||||
|
||||
@server.PromptServer.instance.routes.post("/model_manager/find_missing_models")
|
||||
async def find_missing_models(request):
|
||||
json_data = await request.json()
|
||||
workflow = json_data['workflow']
|
||||
|
||||
# def missing_nodes_in_workflow():
|
||||
models_nodes = {}
|
||||
for node in workflow['nodes']:
|
||||
try:
|
||||
node_type = node['type']
|
||||
models_dir= extract_folder_paths_arguments_with_key(node_type)
|
||||
if(models_dir):
|
||||
models_nodes[node['id']] = {
|
||||
'type': node_type,
|
||||
'models_dir': models_dir
|
||||
}
|
||||
print('find_missing_models node_type', node_type, models_dir)
|
||||
except Exception as e:
|
||||
print('find_missing_models exception', e)
|
||||
continue
|
||||
# await asyncio.to_thread(missing_nodes_in_workflow, file_name, json_data)
|
||||
|
||||
# return web.Response(json=models_nodes)
|
||||
return web.Response(text=json.dumps(models_nodes), status=500)
|
||||
|
||||
def extract_folder_paths_argument(node_type: str):
|
||||
class_def = nodes.NODE_CLASS_MAPPINGS[node_type]
|
||||
source = inspect.getsource(class_def)
|
||||
tree = ast.parse(source)
|
||||
|
||||
for node in ast.walk(tree):
|
||||
# Check for the INPUT_TYPES method
|
||||
if isinstance(node, ast.FunctionDef) and node.name == 'INPUT_TYPES':
|
||||
for sub_node in ast.walk(node):
|
||||
# Check for a call to folder_paths.get_filename_list
|
||||
if isinstance(sub_node, ast.Call) and hasattr(sub_node.func, 'attr'):
|
||||
if sub_node.func.attr == 'get_filename_list':
|
||||
# Extract the argument passed to the function
|
||||
if sub_node.args:
|
||||
return sub_node.args[0].s # Assuming it's a string argument
|
||||
return None
|
||||
|
||||
def extract_folder_paths_arguments_with_key(node_type: str):
|
||||
class_def = nodes.NODE_CLASS_MAPPINGS[node_type]
|
||||
source = inspect.getsource(class_def)
|
||||
tree = ast.parse(source)
|
||||
|
||||
arguments = {}
|
||||
|
||||
for node in ast.walk(tree):
|
||||
if isinstance(node, ast.FunctionDef) and node.name == 'INPUT_TYPES':
|
||||
for sub_node in ast.walk(node):
|
||||
# Check if the node is a dictionary
|
||||
if isinstance(sub_node, ast.Dict):
|
||||
for key, value in zip(sub_node.keys, sub_node.values):
|
||||
# Check if the value is a call to folder_paths.get_filename_list
|
||||
if isinstance(value, ast.Tuple) and value.elts:
|
||||
call_node = value.elts[0]
|
||||
if isinstance(call_node, ast.Call) and hasattr(call_node.func, 'attr'):
|
||||
if call_node.func.attr == 'get_filename_list':
|
||||
if call_node.args:
|
||||
key_name = key.s # Extract the key name
|
||||
# print('key_name', key_name)
|
||||
folder_name = call_node.args[0].s # Extract the argument
|
||||
# print('folder_name', folder_name)
|
||||
arguments[key_name] = folder_name
|
||||
return arguments
|
||||
|
||||
def find_folder_paths_arguments(class_def):
|
||||
source = inspect.getsource(class_def)
|
||||
tree = ast.parse(source)
|
||||
|
||||
arguments = {}
|
||||
|
||||
for node in ast.walk(tree):
|
||||
if isinstance(node, ast.FunctionDef) and node.name == 'INPUT_TYPES':
|
||||
for sub_node in ast.walk(node):
|
||||
if isinstance(sub_node, ast.Call) and hasattr(sub_node.func, 'attr'):
|
||||
if sub_node.func.attr == 'get_filename_list':
|
||||
if sub_node.args:
|
||||
# This will get the name of the key under which the call is made
|
||||
parent = next(ast.iter_child_nodes(sub_node.parent))
|
||||
if isinstance(parent, ast.Str):
|
||||
key_name = parent.s
|
||||
print('key_name', key_name)
|
||||
arguments[key_name] = sub_node.args[0].s
|
||||
print('arguments[key_name]', arguments[key_name])
|
||||
|
||||
return arguments
|
||||
|
||||
@@ -6,14 +6,21 @@ import sys
|
||||
from aiohttp import web
|
||||
import folder_paths
|
||||
import urllib.request
|
||||
import threading
|
||||
import server
|
||||
import server
|
||||
import time
|
||||
from .missing_models import find_missing_models # type: ignore noqa
|
||||
from .model_list import start_populate_file_hash_dict, save_file_hash
|
||||
|
||||
comfy_path = os.path.dirname(folder_paths.__file__)
|
||||
|
||||
|
||||
def download_url_with_wget(url, save_path):
|
||||
print(f"Downloading {url} to {save_path} ...")
|
||||
if not is_wget_installed():
|
||||
print("wget is not installed. Please install wget or use a different download method.")
|
||||
print(
|
||||
"wget is not installed. Please install wget or use a different download method."
|
||||
)
|
||||
return False
|
||||
|
||||
try:
|
||||
@@ -23,7 +30,7 @@ def download_url_with_wget(url, save_path):
|
||||
|
||||
while True:
|
||||
output = process.stderr.readline()
|
||||
if process.poll() is not None and output == b'':
|
||||
if process.poll() is not None and output == b"":
|
||||
break
|
||||
if output:
|
||||
progress = parse_wget_output(output.decode())
|
||||
@@ -41,156 +48,205 @@ def download_url_with_wget(url, save_path):
|
||||
print("\nDownload completed successfully.")
|
||||
return True
|
||||
|
||||
|
||||
def is_wget_installed():
|
||||
try:
|
||||
subprocess.run("wget --version", shell=True, check=True, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
||||
subprocess.run(
|
||||
"wget --version",
|
||||
shell=True,
|
||||
check=True,
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL,
|
||||
)
|
||||
return True
|
||||
except subprocess.CalledProcessError:
|
||||
return False
|
||||
|
||||
|
||||
def parse_wget_output(output):
|
||||
"""
|
||||
Parse wget output to extract and return the download progress.
|
||||
"""
|
||||
match = re.search(r'\d+%|\d+K \d+Kb/s', output)
|
||||
match = re.search(r"\d+%|\d+K \d+Kb/s", output)
|
||||
return match.group(0) if match else ""
|
||||
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
import asyncio
|
||||
|
||||
# Create a dictionary to store download tasks
|
||||
download_tasks = []
|
||||
# Create a lock object to synchronize access to the dictionary
|
||||
download_tasks_lock = threading.Lock()
|
||||
# Create a thread that will execute the download tasks
|
||||
download_thread = None
|
||||
# Create a variable to store the path of the download that needs to be canceled
|
||||
cancel_path = None
|
||||
|
||||
|
||||
def download_worker():
|
||||
while True:
|
||||
task = None
|
||||
with download_tasks_lock:
|
||||
# Get a task from the list
|
||||
if download_tasks:
|
||||
task = download_tasks.pop(0)
|
||||
if task is not None:
|
||||
# Execute the download task and update the download progress
|
||||
download_url_with_agent(
|
||||
url=task["url"],
|
||||
save_path=task["save_path"],
|
||||
file_name=task["filename"],
|
||||
file_hash=task.get("file_hash"),
|
||||
force_filename=task.get("force_filename", False),
|
||||
)
|
||||
# calculate newly downloaded models' hash
|
||||
start_populate_file_hash_dict()
|
||||
|
||||
else:
|
||||
# No more tasks, break the loop
|
||||
send_ws("download_progress", [])
|
||||
break
|
||||
|
||||
|
||||
@server.PromptServer.instance.routes.post("/model_manager/install_model")
|
||||
async def install_model(request):
|
||||
global download_thread
|
||||
|
||||
json_data = await request.json()
|
||||
url = json_data['url']
|
||||
save_path = get_model_path(json_data)
|
||||
with download_tasks_lock:
|
||||
# Add the task to the list
|
||||
download_tasks.append(json_data)
|
||||
|
||||
# Define the wrapper function for the executor
|
||||
def download_wrapper():
|
||||
return download_url_with_agent(url, save_path)
|
||||
# If the previous thread is not active, start a new one
|
||||
if download_thread is None or not download_thread.is_alive():
|
||||
download_thread = threading.Thread(target=download_worker)
|
||||
download_thread.daemon = True
|
||||
download_thread.start()
|
||||
|
||||
# Run the download function in a separate thread
|
||||
with ThreadPoolExecutor() as executor:
|
||||
loop = asyncio.get_running_loop()
|
||||
result = await loop.run_in_executor(executor, download_wrapper)
|
||||
|
||||
return web.Response(text=f"Download complete. {save_path}" if result else "Download failed.")
|
||||
return web.Response(
|
||||
text=f"Downloading {json_data['url']} to {json_data['save_path']} ..."
|
||||
)
|
||||
|
||||
|
||||
@server.PromptServer.instance.routes.post("/model_manager/install_model_stream")
|
||||
async def install_model_stream(request):
|
||||
@server.PromptServer.instance.routes.post("/model_manager/cancel_installation")
|
||||
async def cancel_installation(request):
|
||||
global download_thread, cancel_path
|
||||
|
||||
json_data = await request.json()
|
||||
model_path = get_model_path(json_data)
|
||||
save_path = json_data["save_path"]
|
||||
is_canceled = False
|
||||
with download_tasks_lock:
|
||||
# Remove the task from the list
|
||||
for task in download_tasks:
|
||||
if task["filename"] == save_path:
|
||||
download_tasks.remove(task)
|
||||
is_canceled = True
|
||||
break
|
||||
print(download_tasks)
|
||||
|
||||
response = web.StreamResponse()
|
||||
response.headers['Content-Type'] = 'text/plain'
|
||||
await response.prepare(request)
|
||||
print(f"🖌️Model Manager: Installing model '{json_data['name']}' into '{model_path}' ...")
|
||||
async def progress_callback(progress):
|
||||
progress_message = f"Installing {json_data['name']}: {progress:.2f}%\n"
|
||||
await response.write(progress_message.encode('utf-8'))
|
||||
# if the download is not in task list, the download is in progress, cancel it
|
||||
if not is_canceled:
|
||||
cancel_path = save_path
|
||||
|
||||
try:
|
||||
if model_path is not None:
|
||||
result = await download_url_with_agent(json_data['url'], model_path, progress_callback)
|
||||
if result:
|
||||
await response.write(b"Installation complete.\n")
|
||||
else:
|
||||
await response.write(b"Installation failed.\n")
|
||||
else:
|
||||
print(f"Model installation error: invalid model type - {json_data['type']}")
|
||||
await response.write(b"Installation failed: Invalid model type.\n")
|
||||
return web.Response(text=f"Canceled download to {save_path} ...")
|
||||
|
||||
except Exception as e:
|
||||
print(f"[ERROR] {e}", file=sys.stderr)
|
||||
await response.write(f"Installation failed: {e}\n".encode('utf-8'))
|
||||
|
||||
await response.write_eof()
|
||||
return response
|
||||
|
||||
def download_url_with_agent(url, save_path, progress_callback=None):
|
||||
print('download_url_with_agent', url, save_path)
|
||||
|
||||
temp_save_path = save_path + ".temp" # Temporary file
|
||||
def download_url_with_agent(url, save_path, file_name, file_hash, force_filename=False):
|
||||
print("download_url_with_agent", url, save_path)
|
||||
global cancel_path
|
||||
|
||||
try:
|
||||
headers = {
|
||||
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/58.0.3029.110 Safari/537.3'}
|
||||
print(f"Downloading {url} to {temp_save_path} ...")
|
||||
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/58.0.3029.110 Safari/537.3"
|
||||
}
|
||||
print(f"Downloading {url} to {save_path} ...")
|
||||
|
||||
req = urllib.request.Request(url, headers=headers)
|
||||
with urllib.request.urlopen(req) as response:
|
||||
file_size = int(response.headers.get('content-length', 0))
|
||||
if response.status != 200:
|
||||
raise Exception(f"Request returned status code {response.status}")
|
||||
|
||||
file_size = int(response.headers.get("content-length", 0))
|
||||
print(f"File size: {file_size} bytes")
|
||||
content_disposition = response.headers.get("Content-Disposition")
|
||||
if not force_filename and content_disposition:
|
||||
pattern = re.compile(r'filename="([^"]+)"')
|
||||
match = pattern.search(content_disposition)
|
||||
if match:
|
||||
file_name = match.group(1)
|
||||
file_path = os.path.join(get_model_dir(save_path), file_name)
|
||||
if file_hash is not None:
|
||||
save_file_hash(file_path, file_hash)
|
||||
|
||||
temp_file_path = file_path + ".temp" # Temporary file
|
||||
chunk_size = 1024 # 1KB per chunk
|
||||
downloaded = 0
|
||||
|
||||
if not os.path.exists(os.path.dirname(temp_save_path)):
|
||||
os.makedirs(os.path.dirname(temp_save_path))
|
||||
if file_size == 0:
|
||||
html = response.read().decode()
|
||||
title = re.search(r"<title>(.*?)</title>", html)[0]
|
||||
if "Sign in" in title:
|
||||
raise ValueError("You need to add API key to download this model.")
|
||||
else:
|
||||
raise ValueError(f"Download failed. {title}")
|
||||
|
||||
with open(temp_save_path, 'wb') as f:
|
||||
if not os.path.exists(os.path.dirname(temp_file_path)):
|
||||
os.makedirs(os.path.dirname(temp_file_path))
|
||||
|
||||
with open(temp_file_path, "wb") as f:
|
||||
while True:
|
||||
if cancel_path == file_path:
|
||||
break
|
||||
chunk = response.read(chunk_size)
|
||||
if not chunk:
|
||||
break
|
||||
f.write(chunk)
|
||||
downloaded += len(chunk)
|
||||
progress = (downloaded / file_size) * 100
|
||||
print(f'\rProgress: {progress:.2f}%', end='')
|
||||
print(f"\rProgress: {progress:.2f}%", end="")
|
||||
send_download_status({"save_path": file_path, "progress": progress})
|
||||
|
||||
# if progress_callback:
|
||||
# progress_callback(progress)
|
||||
if cancel_path == file_path:
|
||||
print(f"\nDownload to {save_path} is canceled.")
|
||||
if os.path.exists(temp_file_path):
|
||||
os.remove(temp_file_path)
|
||||
cancel_path = None
|
||||
return False
|
||||
|
||||
# Rename temp file to final filename after successful download
|
||||
os.rename(temp_save_path, save_path)
|
||||
os.rename(temp_file_path, file_path)
|
||||
|
||||
except Exception as e:
|
||||
print(f"\nDownload error: {url} / {e}", file=sys.stderr)
|
||||
if os.path.exists(temp_save_path):
|
||||
os.remove(temp_save_path) # Clean up the temporary file in case of failure
|
||||
send_ws("download_error", f"{url} / {e}")
|
||||
if os.path.exists(temp_file_path):
|
||||
os.remove(temp_file_path) # Clean up the temporary file in case of failure
|
||||
return False
|
||||
|
||||
print("\nDownload complete. File saved as:", save_path)
|
||||
return True
|
||||
|
||||
def get_model_dir(data):
|
||||
if data['save_path'] != 'default':
|
||||
if '..' in data['save_path'] or data['save_path'].startswith('/'):
|
||||
print(f"[WARN] '{data['save_path']}' is not allowed path. So it will be saved into 'models/etc'.")
|
||||
base_model = "etc"
|
||||
else:
|
||||
if data['save_path'].startswith("custom_nodes"):
|
||||
base_model = os.path.join(comfy_path, data['save_path'])
|
||||
else:
|
||||
base_model = os.path.join(folder_paths.models_dir, data['save_path'])
|
||||
def get_model_dir(save_path):
|
||||
if os.path.isabs(save_path):
|
||||
return save_path
|
||||
else:
|
||||
model_type = data['type']
|
||||
if model_type == "checkpoints":
|
||||
base_model = folder_paths.folder_names_and_paths["checkpoints"][0][0]
|
||||
elif model_type == "unclip":
|
||||
base_model = folder_paths.folder_names_and_paths["checkpoints"][0][0]
|
||||
elif model_type == "VAE":
|
||||
base_model = folder_paths.folder_names_and_paths["vae"][0][0]
|
||||
elif model_type == "lora":
|
||||
base_model = folder_paths.folder_names_and_paths["loras"][0][0]
|
||||
elif model_type == "T2I-Adapter":
|
||||
base_model = folder_paths.folder_names_and_paths["controlnet"][0][0]
|
||||
elif model_type == "T2I-Style":
|
||||
base_model = folder_paths.folder_names_and_paths["controlnet"][0][0]
|
||||
elif model_type == "controlnet":
|
||||
base_model = folder_paths.folder_names_and_paths["controlnet"][0][0]
|
||||
elif model_type == "clip_vision":
|
||||
base_model = folder_paths.folder_names_and_paths["clip_vision"][0][0]
|
||||
elif model_type == "gligen":
|
||||
base_model = folder_paths.folder_names_and_paths["gligen"][0][0]
|
||||
elif model_type == "upscale":
|
||||
base_model = folder_paths.folder_names_and_paths["upscale_models"][0][0]
|
||||
elif model_type == "embeddings":
|
||||
base_model = folder_paths.folder_names_and_paths["embeddings"][0][0]
|
||||
else:
|
||||
base_model = "etc"
|
||||
|
||||
return base_model
|
||||
return folder_paths.folder_names_and_paths[save_path][0][0]
|
||||
|
||||
|
||||
def get_model_path(data):
|
||||
base_model = get_model_dir(data)
|
||||
return os.path.join(base_model, data['filename'])
|
||||
last_call_time = 0
|
||||
def send_download_status(data):
|
||||
global last_call_time
|
||||
current_time = time.time()
|
||||
if current_time - last_call_time < 0.1:
|
||||
return
|
||||
last_call_time = time.time()
|
||||
with download_tasks_lock:
|
||||
progress_list = [data]
|
||||
for task in download_tasks:
|
||||
progress_list.append({"save_path": task["filename"], "progress": 0})
|
||||
send_ws("download_progress", progress_list)
|
||||
|
||||
|
||||
loop = asyncio.get_event_loop()
|
||||
def send_ws(event, data):
|
||||
asyncio.run_coroutine_threadsafe(
|
||||
server.PromptServer.instance.send(event, data), loop
|
||||
)
|
||||
|
||||
@@ -0,0 +1,145 @@
|
||||
import asyncio
|
||||
import os
|
||||
import folder_paths
|
||||
import server
|
||||
from aiohttp import web
|
||||
import hashlib
|
||||
import shelve
|
||||
import threading
|
||||
import os
|
||||
import urllib.request
|
||||
import json
|
||||
from .model_preview import preview_file
|
||||
|
||||
# The path to the file where file_hash_dict will be saved
|
||||
FILE_HASH_DICT_FOLDER_PATH = os.path.join(os.path.dirname(__file__), "../../hash")
|
||||
FILE_HASH_DICT_PATH = os.path.join(FILE_HASH_DICT_FOLDER_PATH, "file_hash_dict")
|
||||
if not os.path.exists(FILE_HASH_DICT_FOLDER_PATH):
|
||||
os.makedirs(FILE_HASH_DICT_FOLDER_PATH)
|
||||
|
||||
# Early open shelve for better performance
|
||||
with shelve.open(FILE_HASH_DICT_PATH) as file_hash_dict:
|
||||
print(f"Workspace manager - Openning file hash dict")
|
||||
|
||||
file_list = []
|
||||
file_list_lock = threading.Lock()
|
||||
|
||||
# Add a global variable to track if populate_file_hash_dict is done
|
||||
is_populating = False
|
||||
|
||||
|
||||
def save_file_hash(file_path, file_hash):
|
||||
with shelve.open(FILE_HASH_DICT_PATH) as file_hash_dict:
|
||||
file_hash_dict[file_path] = file_hash
|
||||
|
||||
|
||||
def compute_hash(file_path):
|
||||
# sha256 hash of the file
|
||||
sha256_hash = hashlib.sha256()
|
||||
with open(file_path, "rb") as f:
|
||||
# Read and update hash string value in blocks of 4K
|
||||
for byte_block in iter(lambda: f.read(4096), b""):
|
||||
sha256_hash.update(byte_block)
|
||||
return sha256_hash.hexdigest()
|
||||
|
||||
|
||||
def process_file(folder, file):
|
||||
file_path = folder_paths.get_full_path(folder, file)
|
||||
# check if the file hash is already calculated
|
||||
file_hash = None
|
||||
with shelve.open(FILE_HASH_DICT_PATH) as file_hash_dict:
|
||||
if file_path in file_hash_dict:
|
||||
file_hash = file_hash_dict[file_path]
|
||||
[model_name, model_extension] = os.path.splitext(file)
|
||||
return {
|
||||
"model_name": model_name,
|
||||
"model_type": folder,
|
||||
"model_extension": model_extension,
|
||||
"file_hash": file_hash,
|
||||
"preview": preview_file(file_path),
|
||||
"date": os.path.getmtime(file_path),
|
||||
}
|
||||
|
||||
|
||||
def populate_file_hash_dict():
|
||||
global is_populating, file_list
|
||||
local_file_list = []
|
||||
folder_list = dict(folder_paths.folder_names_and_paths)
|
||||
for folder in folder_list:
|
||||
if folder == "configs" or folder == "custom_nodes":
|
||||
continue
|
||||
files = folder_paths.get_filename_list(folder)
|
||||
for file in files:
|
||||
try:
|
||||
local_file_list.append(process_file(folder, file))
|
||||
except Exception as e:
|
||||
print(f"Error processing file: {file} in folder: {folder}. Error: {e}")
|
||||
with file_list_lock:
|
||||
file_list = local_file_list
|
||||
send_ws("model_list", file_list) # send the list without hash to the client
|
||||
# After all files are added, calculate hashes for those that don't have it yet
|
||||
for file in file_list:
|
||||
try:
|
||||
if file["file_hash"] is None:
|
||||
file_path = folder_paths.get_full_path(
|
||||
file["model_type"], file["model_name"] + file["model_extension"]
|
||||
)
|
||||
file_hash = compute_hash(file_path)
|
||||
with shelve.open(FILE_HASH_DICT_PATH) as file_hash_dict:
|
||||
file_hash_dict[file_path] = file_hash
|
||||
with file_list_lock:
|
||||
file["file_hash"] = file_hash
|
||||
send_ws("model_list", file_list) # update the list with the new hash
|
||||
except Exception as e:
|
||||
print(f"Error calculating hash for file: {file}. Error: {e}")
|
||||
# Set populate_done to True when done
|
||||
is_populating = False
|
||||
|
||||
|
||||
def civit_get_model_by_hash(file_hash: str):
|
||||
if file_hash is None:
|
||||
return None
|
||||
try:
|
||||
headers = {
|
||||
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/58.0.3029.110 Safari/537.3"
|
||||
}
|
||||
url = f"https://civitai.com/api/v1/model-versions/by-hash/{file_hash}"
|
||||
req = urllib.request.Request(url, headers=headers)
|
||||
# Making a GET request
|
||||
with urllib.request.urlopen(req) as response:
|
||||
data = response.read()
|
||||
encoding = response.info().get_content_charset("utf-8")
|
||||
result = json.loads(data.decode(encoding))
|
||||
return result
|
||||
except Exception as e:
|
||||
return None
|
||||
|
||||
|
||||
def start_populate_file_hash_dict():
|
||||
global populate_thread, is_populating
|
||||
if is_populating:
|
||||
return
|
||||
is_populating = True
|
||||
populate_thread = threading.Thread(target=populate_file_hash_dict)
|
||||
populate_thread.daemon = True
|
||||
populate_thread.start()
|
||||
|
||||
|
||||
@server.PromptServer.instance.routes.get("/model_manager/get_model_list")
|
||||
def get_model_list(request):
|
||||
start_populate_file_hash_dict()
|
||||
with file_list_lock:
|
||||
return web.json_response(file_list, content_type="application/json")
|
||||
|
||||
|
||||
@server.PromptServer.instance.routes.get("/model_manager/get_folder_list")
|
||||
def get_folder_list(request):
|
||||
dirList = {k: v[0] for k, v in folder_paths.folder_names_and_paths.items()}
|
||||
return web.json_response(dirList, content_type="application/json")
|
||||
|
||||
|
||||
loop = asyncio.get_event_loop()
|
||||
def send_ws(event, data):
|
||||
asyncio.run_coroutine_threadsafe(
|
||||
server.PromptServer.instance.send(event, data), loop
|
||||
)
|
||||
@@ -0,0 +1,46 @@
|
||||
import os
|
||||
from PIL import Image
|
||||
import base64
|
||||
from io import BytesIO
|
||||
from pathlib import Path
|
||||
|
||||
MAX_IMAGE_SIZE = 250
|
||||
|
||||
def preview_file(filename: str):
|
||||
preview_exts = [".jpg", ".png", ".jpeg", ".gif"]
|
||||
preview_exts = [*preview_exts, *[".preview" + x for x in preview_exts]]
|
||||
for ext in preview_exts:
|
||||
try:
|
||||
pathStr = os.path.splitext(filename)[0] + ext
|
||||
path = Path(pathStr).resolve()
|
||||
if os.path.exists(path):
|
||||
# because ComfyUI has extra model path feature
|
||||
# the path might not be relative to the ComfyUI root
|
||||
# so instead of returning the path, we return the image data directly, to avoid security issues
|
||||
bytes = get_thumbnail_for_image_file(path)
|
||||
# Get the base64 string
|
||||
img_base64 = base64.b64encode(bytes).decode()
|
||||
# Return the base64 string
|
||||
return f"data:image/jpeg;base64, {img_base64}"
|
||||
except Exception as e:
|
||||
print(f"Error opening image preview: {e}")
|
||||
return None
|
||||
|
||||
|
||||
def get_thumbnail_for_image_file(file_path: Path):
|
||||
try:
|
||||
with Image.open(file_path) as img:
|
||||
# If the image is too large, resize it
|
||||
if img.width > MAX_IMAGE_SIZE and img.height > MAX_IMAGE_SIZE:
|
||||
# Calculate new width to maintain aspect ratio
|
||||
width = int(img.width * MAX_IMAGE_SIZE / img.height)
|
||||
# Resize the image
|
||||
img = img.resize((width, MAX_IMAGE_SIZE))
|
||||
img = img.convert("RGB")
|
||||
# Save the image to a BytesIO object
|
||||
buffer = BytesIO()
|
||||
img.save(buffer, format="JPEG", quality=85)
|
||||
return buffer.getvalue()
|
||||
except Exception as e:
|
||||
print(f"Error opening image preview: {e}")
|
||||
return None
|
||||
@@ -0,0 +1,192 @@
|
||||
import inspect
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
from nodes import NODE_CLASS_MAPPINGS
|
||||
import server
|
||||
from aiohttp import web
|
||||
BUILT_IN_NODE_TYPES = {
|
||||
"BasicScheduler",
|
||||
"CLIPLoader",
|
||||
"CLIPMergeSimple",
|
||||
"CLIPSave",
|
||||
"CLIPSetLastLayer",
|
||||
"CLIPTextEncode",
|
||||
"CLIPTextEncodeSDXL",
|
||||
"CLIPTextEncodeSDXLRefiner",
|
||||
"CLIPVisionEncode",
|
||||
"CLIPVisionLoader",
|
||||
"Canny",
|
||||
"CheckpointLoader",
|
||||
"CheckpointLoaderSimple",
|
||||
"CheckpointSave",
|
||||
"ConditioningAverage",
|
||||
"ConditioningCombine",
|
||||
"ConditioningConcat",
|
||||
"ConditioningSetArea",
|
||||
"ConditioningSetAreaPercentage",
|
||||
"ConditioningSetAreaStrength",
|
||||
"ConditioningSetMask",
|
||||
"ConditioningSetTimestepRange",
|
||||
"ConditioningZeroOut",
|
||||
"ControlNetApply",
|
||||
"ControlNetApplyAdvanced",
|
||||
"ControlNetLoader",
|
||||
"CropMask",
|
||||
"DiffControlNetLoader",
|
||||
"DiffusersLoader",
|
||||
"DualCLIPLoader",
|
||||
"EmptyImage",
|
||||
"EmptyLatentImage",
|
||||
"ExponentialScheduler",
|
||||
"FeatherMask",
|
||||
"FlipSigmas",
|
||||
"FreeU",
|
||||
"FreeU_V2",
|
||||
"GLIGENLoader",
|
||||
"GLIGENTextBoxApply",
|
||||
"GrowMask",
|
||||
"HyperTile",
|
||||
"HypernetworkLoader",
|
||||
"ImageBatch",
|
||||
"ImageBlend",
|
||||
"ImageBlur",
|
||||
"ImageColorToMask",
|
||||
"ImageCompositeMasked",
|
||||
"ImageCrop",
|
||||
"ImageInvert",
|
||||
"ImageOnlyCheckpointLoader",
|
||||
"ImageOnlyCheckpointSave",
|
||||
"ImagePadForOutpaint",
|
||||
"ImageQuantize",
|
||||
"ImageScale",
|
||||
"ImageScaleBy",
|
||||
"ImageScaleToTotalPixels",
|
||||
"ImageSharpen",
|
||||
"ImageToMask",
|
||||
"ImageUpscaleWithModel",
|
||||
"InpaintModelConditioning",
|
||||
"InvertMask",
|
||||
"JoinImageWithAlpha",
|
||||
"KSampler",
|
||||
"KSamplerAdvanced",
|
||||
"KSamplerSelect",
|
||||
"KarrasScheduler",
|
||||
"LatentAdd",
|
||||
"LatentBatch",
|
||||
"LatentBatchSeedBehavior",
|
||||
"LatentBlend",
|
||||
"LatentComposite",
|
||||
"LatentCompositeMasked",
|
||||
"LatentCrop",
|
||||
"LatentFlip",
|
||||
"LatentFromBatch",
|
||||
"LatentInterpolate",
|
||||
"LatentMultiply",
|
||||
"LatentRotate",
|
||||
"LatentSubtract",
|
||||
"LatentUpscale",
|
||||
"LatentUpscaleBy",
|
||||
"LoadImage",
|
||||
"LoadImageMask",
|
||||
"LoadLatent",
|
||||
"LoraLoader",
|
||||
"LoraLoaderModelOnly",
|
||||
"MaskComposite",
|
||||
"MaskToImage",
|
||||
"ModelMergeAdd",
|
||||
"ModelMergeBlocks",
|
||||
"ModelMergeSimple",
|
||||
"ModelMergeSubtract",
|
||||
"ModelSamplingContinuousEDM",
|
||||
"ModelSamplingDiscrete",
|
||||
"PatchModelAddDownscale",
|
||||
"PerpNeg",
|
||||
"PhotoMakerEncode",
|
||||
"PhotoMakerLoader",
|
||||
"PolyexponentialScheduler",
|
||||
"PorterDuffImageComposite",
|
||||
"PreviewImage",
|
||||
"RebatchImages",
|
||||
"RebatchLatents",
|
||||
"RepeatImageBatch",
|
||||
"RepeatLatentBatch",
|
||||
"RescaleCFG",
|
||||
"SDTurboScheduler",
|
||||
"SD_4XUpscale_Conditioning",
|
||||
"SVD_img2vid_Conditioning",
|
||||
"SamplerCustom",
|
||||
"SamplerDPMPP_2M_SDE",
|
||||
"SamplerDPMPP_SDE",
|
||||
"SaveAnimatedPNG",
|
||||
"SaveAnimatedWEBP",
|
||||
"SaveImage",
|
||||
"SaveLatent",
|
||||
"SelfAttentionGuidance",
|
||||
"SetLatentNoiseMask",
|
||||
"SolidMask",
|
||||
"SplitImageWithAlpha",
|
||||
"SplitSigmas",
|
||||
"StableZero123_Conditioning",
|
||||
"StableZero123_Conditioning_Batched",
|
||||
"StyleModelApply",
|
||||
"StyleModelLoader",
|
||||
"TomePatchModel",
|
||||
"UNETLoader",
|
||||
"UpscaleModelLoader",
|
||||
"VAEDecode",
|
||||
"VAEDecodeTiled",
|
||||
"VAEEncode",
|
||||
"VAEEncodeForInpaint",
|
||||
"VAEEncodeTiled",
|
||||
"VAELoader",
|
||||
"VAESave",
|
||||
"VPScheduler",
|
||||
"VideoLinearCFGGuidance",
|
||||
"unCLIPCheckpointLoader",
|
||||
"unCLIPConditioning"
|
||||
}
|
||||
|
||||
def get_git_repo(node_type: str):
|
||||
if node_type not in NODE_CLASS_MAPPINGS:
|
||||
return None
|
||||
if node_type in BUILT_IN_NODE_TYPES:
|
||||
return None
|
||||
|
||||
cls = NODE_CLASS_MAPPINGS[node_type]
|
||||
source_file = inspect.getfile(cls)
|
||||
directory = os.path.dirname(source_file)
|
||||
|
||||
# Attempt to get the remote repository URL directly
|
||||
try:
|
||||
git_repo = subprocess.check_output(["git", "-C", directory, "config", "--get", "remote.origin.url"], text=True).strip()
|
||||
except subprocess.CalledProcessError:
|
||||
return None
|
||||
|
||||
# Attempt to get the current commit hash
|
||||
try:
|
||||
commit_hash = subprocess.check_output(["git", "-C", directory, "rev-parse", "HEAD"], text=True).strip()
|
||||
except subprocess.CalledProcessError:
|
||||
return None
|
||||
if git_repo.endswith(".git"):
|
||||
git_repo = git_repo[:-4]
|
||||
username = git_repo.split("/")[-2]
|
||||
repo_name = git_repo.split("/")[-1]
|
||||
return {"gitRepo": f"{username}/{repo_name}", "commitHash": commit_hash}
|
||||
|
||||
@server.PromptServer.instance.routes.post("/workspace/fetch_node_repos") # Handle POST requests
|
||||
async def fetch_node_repos(request):
|
||||
data = await request.json()
|
||||
nodetypes = data.get("nodes")
|
||||
if not nodetypes:
|
||||
return web.Response(status=400, text="NodeTypes parameter is required and should be a list of node types.")
|
||||
repos_mapping = {}
|
||||
for nodetype in nodetypes:
|
||||
try:
|
||||
repo = get_git_repo(nodetype)
|
||||
if repo:
|
||||
repos_mapping[nodetype] = repo
|
||||
except Exception as e:
|
||||
print(f"Error fetching repo for {nodetype}: {e}")
|
||||
return web.Response(text=json.dumps(repos_mapping), content_type='application/json')
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
import platform
|
||||
from aiohttp import web
|
||||
import asyncio
|
||||
import json
|
||||
import os
|
||||
import glob
|
||||
from threading import Lock
|
||||
import server
|
||||
import uuid
|
||||
from .db_service import get_my_workflows_dir
|
||||
|
||||
@server.PromptServer.instance.routes.post('/workspace/file/scan_my_workflows_folder')
|
||||
async def scan_my_workflows_files(request):
|
||||
reqJson = await request.json()
|
||||
path = reqJson['path']
|
||||
path = os.path.join(get_my_workflows_dir(), path)
|
||||
recursive = reqJson.get('recursive', False)
|
||||
metaInfoOnly = reqJson.get('metaInfoOnly', False)
|
||||
|
||||
fileList = await asyncio.to_thread(folder_handle, path, recursive, metaInfoOnly)
|
||||
return web.Response(text=json.dumps(fileList), content_type='application/json')
|
||||
|
||||
def folder_handle(path, recursive, metaInfoOnly, fileList=None):
|
||||
if fileList is None:
|
||||
fileList = []
|
||||
for item in os.listdir(path):
|
||||
item_path = os.path.join(path, item)
|
||||
try:
|
||||
if os.path.isfile(item_path) and item_path.endswith('.json'):
|
||||
file_handle(item, fileList, item_path, metaInfoOnly)
|
||||
|
||||
elif os.path.isdir(item_path):
|
||||
createTime, updateTime = getFileCreateTime(item_path)
|
||||
fileList.append({
|
||||
'name': item,
|
||||
'type': 'folder',
|
||||
'createTime': createTime,
|
||||
'updateTime': updateTime
|
||||
})
|
||||
# Recursively scan if recursive is True
|
||||
if recursive:
|
||||
folder_handle(item_path, recursive, metaInfoOnly, fileList)
|
||||
except Exception as e:
|
||||
print(f"Error scanning item {item}: {e}")
|
||||
return fileList
|
||||
|
||||
def file_handle(name, fileList, file_path, metaInfoOnly):
|
||||
with open(file_path, 'r', encoding='utf-8') as f:
|
||||
json_data = json.load(f)
|
||||
|
||||
createTime, updateTime = getFileCreateTime(file_path)
|
||||
workspace_info = json_data.get('extra', {}).get('workspace_info', {})
|
||||
workflow_id = workspace_info.get('id', str(uuid.uuid4()))
|
||||
# Update JSON data with new ID if needed and write back to file
|
||||
if 'id' not in workspace_info:
|
||||
if 'extra' not in json_data:
|
||||
json_data['extra'] = {}
|
||||
if 'workspace_info' not in json_data['extra']:
|
||||
json_data['extra']['workspace_info'] = {}
|
||||
json_data['extra']['workspace_info']['id'] = workflow_id
|
||||
with open(file_path, 'w', encoding='utf-8') as f:
|
||||
json.dump(json_data, f, ensure_ascii=False, indent=4)
|
||||
|
||||
fileInfo = {
|
||||
'name': name,
|
||||
'type': "workflow",
|
||||
'id': workflow_id,
|
||||
'createTime': createTime,
|
||||
'updateTime': updateTime
|
||||
}
|
||||
|
||||
if not metaInfoOnly:
|
||||
fileInfo['json'] = json.dumps(json_data)
|
||||
|
||||
fileList.append(fileInfo)
|
||||
|
||||
def getFileCreateTime(path):
|
||||
# Cross-platform compatibility for creation time
|
||||
file_stats = os.stat(path)
|
||||
if platform.system() == 'Windows':
|
||||
createTime = int(file_stats.st_ctime * 1000)
|
||||
else: # macOS and potentially others
|
||||
createTime = int(getattr(file_stats, 'st_birthtime', file_stats.st_ctime) * 1000)
|
||||
|
||||
updateTime = int(file_stats.st_mtime * 1000)
|
||||
return createTime, updateTime
|
||||
@@ -0,0 +1,71 @@
|
||||
import asyncio
|
||||
import shutil
|
||||
from aiohttp import web
|
||||
import os
|
||||
from pathlib import Path
|
||||
import server
|
||||
from .db_service import get_my_workflows_dir
|
||||
|
||||
@server.PromptServer.instance.routes.post('/workspace/folder/create')
|
||||
async def create_folder(request):
|
||||
reqJson = await request.json()
|
||||
data = await asyncio.to_thread(create_folder_sync, reqJson)
|
||||
return web.json_response(data, content_type='application/json')
|
||||
|
||||
def create_folder_sync(reqJson):
|
||||
folder_path = reqJson.get('path')
|
||||
folder_path = os.path.join(get_my_workflows_dir(), folder_path)
|
||||
try:
|
||||
Path(folder_path).mkdir(parents=True, exist_ok=True)
|
||||
return {"success": True}
|
||||
except Exception as e:
|
||||
return {"success": False, "error": str(e)}
|
||||
|
||||
@server.PromptServer.instance.routes.post('/workspace/folder/delete')
|
||||
async def delete_folder(request):
|
||||
reqJson = await request.json()
|
||||
data = await asyncio.to_thread(delete_folder_sync, reqJson)
|
||||
return web.json_response(data, content_type='application/json')
|
||||
|
||||
def delete_folder_sync(reqJson):
|
||||
folder_path = reqJson.get('path')
|
||||
folder_path = os.path.join(get_my_workflows_dir(), folder_path)
|
||||
try:
|
||||
shutil.rmtree(folder_path)
|
||||
return {"success": True}
|
||||
except Exception as e:
|
||||
return {"success": False, "error": str(e)}
|
||||
|
||||
@server.PromptServer.instance.routes.post('/workspace/folder/rename')
|
||||
async def rename_folder(request):
|
||||
reqJson = await request.json()
|
||||
data = await asyncio.to_thread(rename_folder_sync, reqJson)
|
||||
return web.json_response(data, content_type='application/json')
|
||||
|
||||
def rename_folder_sync(reqJson):
|
||||
current_path = reqJson.get('absPath')
|
||||
current_path = os.path.join(get_my_workflows_dir(), current_path)
|
||||
new_name = reqJson.get('newName')
|
||||
try:
|
||||
new_path = Path(current_path).parent / new_name
|
||||
Path(current_path).rename(new_path)
|
||||
return {"success": True}
|
||||
except Exception as e:
|
||||
return {"success": False, "error": str(e)}
|
||||
|
||||
@server.PromptServer.instance.routes.post('/workspace/folder/move')
|
||||
async def move_folder(request):
|
||||
reqJson = await request.json()
|
||||
data = await asyncio.to_thread(move_folder_sync, reqJson)
|
||||
return web.json_response(data, content_type='application/json')
|
||||
|
||||
def move_folder_sync(reqJson):
|
||||
current_path = reqJson.get('folder')
|
||||
current_path = os.path.join(get_my_workflows_dir(), current_path)
|
||||
new_path = os.path.join(get_my_workflows_dir(), reqJson.get('newParentPath',""))
|
||||
try:
|
||||
shutil.move(current_path, new_path)
|
||||
return {"success": True}
|
||||
except Exception as e:
|
||||
return {"success": False, "error": str(e)}
|
||||
|
||||
+3
-1
@@ -5,14 +5,16 @@ module.exports = {
|
||||
'eslint:recommended',
|
||||
'plugin:@typescript-eslint/recommended',
|
||||
'plugin:react-hooks/recommended',
|
||||
'prettier',
|
||||
],
|
||||
ignorePatterns: ['dist', '.eslintrc.cjs'],
|
||||
parser: '@typescript-eslint/parser',
|
||||
plugins: ['react-refresh'],
|
||||
plugins: ['react-refresh', '@typescript-eslint', 'prettier'],
|
||||
rules: {
|
||||
'react-refresh/only-export-components': [
|
||||
'warn',
|
||||
{ allowConstantExport: true },
|
||||
],
|
||||
'prettier/prettier': ['error'],
|
||||
},
|
||||
}
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"bracketSpacing": true,
|
||||
"htmlWhitespaceSensitivity": "css",
|
||||
"insertPragma": false,
|
||||
"jsxBracketSameLine": false,
|
||||
"printWidth": 80,
|
||||
"proseWrap": "always",
|
||||
"quoteProps": "as-needed",
|
||||
"requirePragma": false,
|
||||
"semi": true,
|
||||
"tabWidth": 2,
|
||||
"trailingComma": "all",
|
||||
"useTabs": false,
|
||||
"endOfLine": "auto"
|
||||
}
|
||||
Generated
+218
-32
@@ -15,11 +15,11 @@
|
||||
"chakra-react-select": "^4.7.6",
|
||||
"dexie": "^3.2.4",
|
||||
"dexie-react-hooks": "^1.1.7",
|
||||
"framer-motion": "^10.16.12",
|
||||
"fuse.js": "^7.0.0",
|
||||
"jszip": "^3.10.1",
|
||||
"nanoid": "^5.0.6",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"uuid": "^9.0.1"
|
||||
"react-dom": "^18.2.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^18.2.37",
|
||||
@@ -29,8 +29,11 @@
|
||||
"@typescript-eslint/parser": "^6.10.0",
|
||||
"@vitejs/plugin-react": "^4.2.0",
|
||||
"eslint": "^8.53.0",
|
||||
"eslint-config-prettier": "^9.1.0",
|
||||
"eslint-plugin-prettier": "^5.1.3",
|
||||
"eslint-plugin-react-hooks": "^4.6.0",
|
||||
"eslint-plugin-react-refresh": "^0.4.4",
|
||||
"prettier": "^3.2.4",
|
||||
"rollup-plugin-watch": "^1.0.4",
|
||||
"typescript": "^5.2.2",
|
||||
"vite": "^5.0.0"
|
||||
@@ -2278,6 +2281,18 @@
|
||||
"node": ">= 8"
|
||||
}
|
||||
},
|
||||
"node_modules/@pkgr/core": {
|
||||
"version": "0.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@pkgr/core/-/core-0.1.1.tgz",
|
||||
"integrity": "sha512-cq8o4cWH0ibXh9VGi5P20Tu9XF/0fFXl9EUinr9QfTM7a7p0oTA4iJRCQWppXR1Pg8dSM0UCItCkPwsk9qWWYA==",
|
||||
"dev": true,
|
||||
"engines": {
|
||||
"node": "^12.20.0 || ^14.18.0 || >=16.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/unts"
|
||||
}
|
||||
},
|
||||
"node_modules/@popperjs/core": {
|
||||
"version": "2.11.8",
|
||||
"resolved": "https://registry.npmjs.org/@popperjs/core/-/core-2.11.8.tgz",
|
||||
@@ -3352,6 +3367,48 @@
|
||||
"url": "https://opencollective.com/eslint"
|
||||
}
|
||||
},
|
||||
"node_modules/eslint-config-prettier": {
|
||||
"version": "9.1.0",
|
||||
"resolved": "https://registry.npmjs.org/eslint-config-prettier/-/eslint-config-prettier-9.1.0.tgz",
|
||||
"integrity": "sha512-NSWl5BFQWEPi1j4TjVNItzYV7dZXZ+wP6I6ZhrBGpChQhZRUaElihE9uRRkcbRnNb76UMKDF3r+WTmNcGPKsqw==",
|
||||
"dev": true,
|
||||
"bin": {
|
||||
"eslint-config-prettier": "bin/cli.js"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"eslint": ">=7.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/eslint-plugin-prettier": {
|
||||
"version": "5.1.3",
|
||||
"resolved": "https://registry.npmjs.org/eslint-plugin-prettier/-/eslint-plugin-prettier-5.1.3.tgz",
|
||||
"integrity": "sha512-C9GCVAs4Eq7ZC/XFQHITLiHJxQngdtraXaM+LoUFoFp/lHNl2Zn8f3WQbe9HvTBBQ9YnKFB0/2Ajdqwo5D1EAw==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"prettier-linter-helpers": "^1.0.0",
|
||||
"synckit": "^0.8.6"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^14.18.0 || >=16.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/eslint-plugin-prettier"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/eslint": ">=8.0.0",
|
||||
"eslint": ">=8.0.0",
|
||||
"eslint-config-prettier": "*",
|
||||
"prettier": ">=3.0.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/eslint": {
|
||||
"optional": true
|
||||
},
|
||||
"eslint-config-prettier": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/eslint-plugin-react-hooks": {
|
||||
"version": "4.6.0",
|
||||
"resolved": "https://registry.npmjs.org/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-4.6.0.tgz",
|
||||
@@ -3569,6 +3626,12 @@
|
||||
"integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==",
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/fast-diff": {
|
||||
"version": "1.3.0",
|
||||
"resolved": "https://registry.npmjs.org/fast-diff/-/fast-diff-1.3.0.tgz",
|
||||
"integrity": "sha512-VxPP4NqbUjj6MaAOafWeUn2cXWLcCtljklUtZf0Ind4XQ+QPtmA0b18zZy0jIQx+ExRVCR/ZQpBmik5lXshNsw==",
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/fast-glob": {
|
||||
"version": "3.3.2",
|
||||
"resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.2.tgz",
|
||||
@@ -3698,6 +3761,7 @@
|
||||
"version": "10.16.12",
|
||||
"resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-10.16.12.tgz",
|
||||
"integrity": "sha512-w7Yzx0OzQ5Uh6uNkxaX+4TuAPuOKz3haSbjmHpdrqDpGuCJCpq6YP9Dy7JJWdZ6mJjndrg3Ao3vUwDajKNikCA==",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"tslib": "^2.4.0"
|
||||
},
|
||||
@@ -3722,6 +3786,7 @@
|
||||
"resolved": "https://registry.npmjs.org/@emotion/is-prop-valid/-/is-prop-valid-0.8.8.tgz",
|
||||
"integrity": "sha512-u5WtneEAr5IDG2Wv65yhunPSMLIpuKsbuOktRojfrEiEvRyC85LgPMZI63cr7NUqT8ZIGdSVg8ZKGxIug4lXcA==",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@emotion/memoize": "0.7.4"
|
||||
}
|
||||
@@ -3730,7 +3795,8 @@
|
||||
"version": "0.7.4",
|
||||
"resolved": "https://registry.npmjs.org/@emotion/memoize/-/memoize-0.7.4.tgz",
|
||||
"integrity": "sha512-Ja/Vfqe3HpuzRsG1oBtWTHk2PGZ7GR+2Vz5iYGelAw8dx32K0y7PjVuxK6z1nMpZOqAFsRUPCkK1YjJ56qJlgw==",
|
||||
"optional": true
|
||||
"optional": true,
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/framesync": {
|
||||
"version": "6.1.2",
|
||||
@@ -3773,6 +3839,14 @@
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/fuse.js": {
|
||||
"version": "7.0.0",
|
||||
"resolved": "https://registry.npmjs.org/fuse.js/-/fuse.js-7.0.0.tgz",
|
||||
"integrity": "sha512-14F4hBIxqKvD4Zz/XjDc3y94mNZN6pRv3U13Udo0lNLCWRBUsrMv2xwcF/y/Z5sV6+FQW+/ow68cHpm4sunt8Q==",
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/gensync": {
|
||||
"version": "1.0.0-beta.2",
|
||||
"resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz",
|
||||
@@ -4213,10 +4287,9 @@
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/nanoid": {
|
||||
"version": "3.3.7",
|
||||
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.7.tgz",
|
||||
"integrity": "sha512-eSRppjcPIatRIMC1U6UngP8XFcz8MQWGQdt1MTBQ7NaAmvXDfvNxbvWV3x2y6CdEUciCSsDHDQZbhYaB8QEo2g==",
|
||||
"dev": true,
|
||||
"version": "5.0.6",
|
||||
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-5.0.6.tgz",
|
||||
"integrity": "sha512-rRq0eMHoGZxlvaFOUdK1Ev83Bd1IgzzR+WJ3IbDJ7QOSdAxYjlurSPqFs9s4lJg29RT6nPwizFtJhQS6V5xgiA==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
@@ -4224,10 +4297,10 @@
|
||||
}
|
||||
],
|
||||
"bin": {
|
||||
"nanoid": "bin/nanoid.cjs"
|
||||
"nanoid": "bin/nanoid.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
|
||||
"node": "^18 || >=20"
|
||||
}
|
||||
},
|
||||
"node_modules/natural-compare": {
|
||||
@@ -4425,6 +4498,24 @@
|
||||
"node": "^10 || ^12 || >=14"
|
||||
}
|
||||
},
|
||||
"node_modules/postcss/node_modules/nanoid": {
|
||||
"version": "3.3.7",
|
||||
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.7.tgz",
|
||||
"integrity": "sha512-eSRppjcPIatRIMC1U6UngP8XFcz8MQWGQdt1MTBQ7NaAmvXDfvNxbvWV3x2y6CdEUciCSsDHDQZbhYaB8QEo2g==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ai"
|
||||
}
|
||||
],
|
||||
"bin": {
|
||||
"nanoid": "bin/nanoid.cjs"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/prelude-ls": {
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz",
|
||||
@@ -4434,6 +4525,33 @@
|
||||
"node": ">= 0.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/prettier": {
|
||||
"version": "3.2.4",
|
||||
"resolved": "https://registry.npmjs.org/prettier/-/prettier-3.2.4.tgz",
|
||||
"integrity": "sha512-FWu1oLHKCrtpO1ypU6J0SbK2d9Ckwysq6bHj/uaCP26DxrPpppCLQRGVuqAxSTvhF00AcvDRyYrLNW7ocBhFFQ==",
|
||||
"dev": true,
|
||||
"bin": {
|
||||
"prettier": "bin/prettier.cjs"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/prettier/prettier?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/prettier-linter-helpers": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/prettier-linter-helpers/-/prettier-linter-helpers-1.0.0.tgz",
|
||||
"integrity": "sha512-GbK2cP9nraSSUF9N2XwUwqfzlAFlMNYYl+ShE/V+H8a9uNl/oUqB1w2EL54Jh0OlyRSd8RfWYJ3coVS4TROP2w==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"fast-diff": "^1.1.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/process-nextick-args": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmmirror.com/process-nextick-args/-/process-nextick-args-2.0.1.tgz",
|
||||
@@ -4946,6 +5064,22 @@
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/synckit": {
|
||||
"version": "0.8.8",
|
||||
"resolved": "https://registry.npmjs.org/synckit/-/synckit-0.8.8.tgz",
|
||||
"integrity": "sha512-HwOKAP7Wc5aRGYdKH+dw0PRRpbO841v2DENBtjnR5HFWoiNByAl7vrx3p0G/rCyYXQsrxqtX48TImFtPcIHSpQ==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"@pkgr/core": "^0.1.0",
|
||||
"tslib": "^2.6.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^14.18.0 || >=16.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/unts"
|
||||
}
|
||||
},
|
||||
"node_modules/text-table": {
|
||||
"version": "0.2.0",
|
||||
"resolved": "https://registry.npmjs.org/text-table/-/text-table-0.2.0.tgz",
|
||||
@@ -5134,18 +5268,6 @@
|
||||
"resolved": "https://registry.npmmirror.com/util-deprecate/-/util-deprecate-1.0.2.tgz",
|
||||
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="
|
||||
},
|
||||
"node_modules/uuid": {
|
||||
"version": "9.0.1",
|
||||
"resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.1.tgz",
|
||||
"integrity": "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==",
|
||||
"funding": [
|
||||
"https://github.com/sponsors/broofa",
|
||||
"https://github.com/sponsors/ctavan"
|
||||
],
|
||||
"bin": {
|
||||
"uuid": "dist/bin/uuid"
|
||||
}
|
||||
},
|
||||
"node_modules/vite": {
|
||||
"version": "5.0.2",
|
||||
"resolved": "https://registry.npmjs.org/vite/-/vite-5.0.2.tgz",
|
||||
@@ -6848,6 +6970,12 @@
|
||||
"fastq": "^1.6.0"
|
||||
}
|
||||
},
|
||||
"@pkgr/core": {
|
||||
"version": "0.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@pkgr/core/-/core-0.1.1.tgz",
|
||||
"integrity": "sha512-cq8o4cWH0ibXh9VGi5P20Tu9XF/0fFXl9EUinr9QfTM7a7p0oTA4iJRCQWppXR1Pg8dSM0UCItCkPwsk9qWWYA==",
|
||||
"dev": true
|
||||
},
|
||||
"@popperjs/core": {
|
||||
"version": "2.11.8",
|
||||
"resolved": "https://registry.npmjs.org/@popperjs/core/-/core-2.11.8.tgz",
|
||||
@@ -7663,6 +7791,23 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"eslint-config-prettier": {
|
||||
"version": "9.1.0",
|
||||
"resolved": "https://registry.npmjs.org/eslint-config-prettier/-/eslint-config-prettier-9.1.0.tgz",
|
||||
"integrity": "sha512-NSWl5BFQWEPi1j4TjVNItzYV7dZXZ+wP6I6ZhrBGpChQhZRUaElihE9uRRkcbRnNb76UMKDF3r+WTmNcGPKsqw==",
|
||||
"dev": true,
|
||||
"requires": {}
|
||||
},
|
||||
"eslint-plugin-prettier": {
|
||||
"version": "5.1.3",
|
||||
"resolved": "https://registry.npmjs.org/eslint-plugin-prettier/-/eslint-plugin-prettier-5.1.3.tgz",
|
||||
"integrity": "sha512-C9GCVAs4Eq7ZC/XFQHITLiHJxQngdtraXaM+LoUFoFp/lHNl2Zn8f3WQbe9HvTBBQ9YnKFB0/2Ajdqwo5D1EAw==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"prettier-linter-helpers": "^1.0.0",
|
||||
"synckit": "^0.8.6"
|
||||
}
|
||||
},
|
||||
"eslint-plugin-react-hooks": {
|
||||
"version": "4.6.0",
|
||||
"resolved": "https://registry.npmjs.org/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-4.6.0.tgz",
|
||||
@@ -7746,6 +7891,12 @@
|
||||
"integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==",
|
||||
"dev": true
|
||||
},
|
||||
"fast-diff": {
|
||||
"version": "1.3.0",
|
||||
"resolved": "https://registry.npmjs.org/fast-diff/-/fast-diff-1.3.0.tgz",
|
||||
"integrity": "sha512-VxPP4NqbUjj6MaAOafWeUn2cXWLcCtljklUtZf0Ind4XQ+QPtmA0b18zZy0jIQx+ExRVCR/ZQpBmik5lXshNsw==",
|
||||
"dev": true
|
||||
},
|
||||
"fast-glob": {
|
||||
"version": "3.3.2",
|
||||
"resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.2.tgz",
|
||||
@@ -7853,6 +8004,7 @@
|
||||
"version": "10.16.12",
|
||||
"resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-10.16.12.tgz",
|
||||
"integrity": "sha512-w7Yzx0OzQ5Uh6uNkxaX+4TuAPuOKz3haSbjmHpdrqDpGuCJCpq6YP9Dy7JJWdZ6mJjndrg3Ao3vUwDajKNikCA==",
|
||||
"peer": true,
|
||||
"requires": {
|
||||
"@emotion/is-prop-valid": "^0.8.2",
|
||||
"tslib": "^2.4.0"
|
||||
@@ -7863,6 +8015,7 @@
|
||||
"resolved": "https://registry.npmjs.org/@emotion/is-prop-valid/-/is-prop-valid-0.8.8.tgz",
|
||||
"integrity": "sha512-u5WtneEAr5IDG2Wv65yhunPSMLIpuKsbuOktRojfrEiEvRyC85LgPMZI63cr7NUqT8ZIGdSVg8ZKGxIug4lXcA==",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"requires": {
|
||||
"@emotion/memoize": "0.7.4"
|
||||
}
|
||||
@@ -7871,7 +8024,8 @@
|
||||
"version": "0.7.4",
|
||||
"resolved": "https://registry.npmjs.org/@emotion/memoize/-/memoize-0.7.4.tgz",
|
||||
"integrity": "sha512-Ja/Vfqe3HpuzRsG1oBtWTHk2PGZ7GR+2Vz5iYGelAw8dx32K0y7PjVuxK6z1nMpZOqAFsRUPCkK1YjJ56qJlgw==",
|
||||
"optional": true
|
||||
"optional": true,
|
||||
"peer": true
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -7908,6 +8062,11 @@
|
||||
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
|
||||
"integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="
|
||||
},
|
||||
"fuse.js": {
|
||||
"version": "7.0.0",
|
||||
"resolved": "https://registry.npmjs.org/fuse.js/-/fuse.js-7.0.0.tgz",
|
||||
"integrity": "sha512-14F4hBIxqKvD4Zz/XjDc3y94mNZN6pRv3U13Udo0lNLCWRBUsrMv2xwcF/y/Z5sV6+FQW+/ow68cHpm4sunt8Q=="
|
||||
},
|
||||
"gensync": {
|
||||
"version": "1.0.0-beta.2",
|
||||
"resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz",
|
||||
@@ -8255,10 +8414,9 @@
|
||||
"dev": true
|
||||
},
|
||||
"nanoid": {
|
||||
"version": "3.3.7",
|
||||
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.7.tgz",
|
||||
"integrity": "sha512-eSRppjcPIatRIMC1U6UngP8XFcz8MQWGQdt1MTBQ7NaAmvXDfvNxbvWV3x2y6CdEUciCSsDHDQZbhYaB8QEo2g==",
|
||||
"dev": true
|
||||
"version": "5.0.6",
|
||||
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-5.0.6.tgz",
|
||||
"integrity": "sha512-rRq0eMHoGZxlvaFOUdK1Ev83Bd1IgzzR+WJ3IbDJ7QOSdAxYjlurSPqFs9s4lJg29RT6nPwizFtJhQS6V5xgiA=="
|
||||
},
|
||||
"natural-compare": {
|
||||
"version": "1.4.0",
|
||||
@@ -8391,6 +8549,14 @@
|
||||
"nanoid": "^3.3.6",
|
||||
"picocolors": "^1.0.0",
|
||||
"source-map-js": "^1.0.2"
|
||||
},
|
||||
"dependencies": {
|
||||
"nanoid": {
|
||||
"version": "3.3.7",
|
||||
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.7.tgz",
|
||||
"integrity": "sha512-eSRppjcPIatRIMC1U6UngP8XFcz8MQWGQdt1MTBQ7NaAmvXDfvNxbvWV3x2y6CdEUciCSsDHDQZbhYaB8QEo2g==",
|
||||
"dev": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"prelude-ls": {
|
||||
@@ -8399,6 +8565,21 @@
|
||||
"integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==",
|
||||
"dev": true
|
||||
},
|
||||
"prettier": {
|
||||
"version": "3.2.4",
|
||||
"resolved": "https://registry.npmjs.org/prettier/-/prettier-3.2.4.tgz",
|
||||
"integrity": "sha512-FWu1oLHKCrtpO1ypU6J0SbK2d9Ckwysq6bHj/uaCP26DxrPpppCLQRGVuqAxSTvhF00AcvDRyYrLNW7ocBhFFQ==",
|
||||
"dev": true
|
||||
},
|
||||
"prettier-linter-helpers": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/prettier-linter-helpers/-/prettier-linter-helpers-1.0.0.tgz",
|
||||
"integrity": "sha512-GbK2cP9nraSSUF9N2XwUwqfzlAFlMNYYl+ShE/V+H8a9uNl/oUqB1w2EL54Jh0OlyRSd8RfWYJ3coVS4TROP2w==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"fast-diff": "^1.1.2"
|
||||
}
|
||||
},
|
||||
"process-nextick-args": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmmirror.com/process-nextick-args/-/process-nextick-args-2.0.1.tgz",
|
||||
@@ -8743,6 +8924,16 @@
|
||||
"resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz",
|
||||
"integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w=="
|
||||
},
|
||||
"synckit": {
|
||||
"version": "0.8.8",
|
||||
"resolved": "https://registry.npmjs.org/synckit/-/synckit-0.8.8.tgz",
|
||||
"integrity": "sha512-HwOKAP7Wc5aRGYdKH+dw0PRRpbO841v2DENBtjnR5HFWoiNByAl7vrx3p0G/rCyYXQsrxqtX48TImFtPcIHSpQ==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"@pkgr/core": "^0.1.0",
|
||||
"tslib": "^2.6.2"
|
||||
}
|
||||
},
|
||||
"text-table": {
|
||||
"version": "0.2.0",
|
||||
"resolved": "https://registry.npmjs.org/text-table/-/text-table-0.2.0.tgz",
|
||||
@@ -8853,11 +9044,6 @@
|
||||
"resolved": "https://registry.npmmirror.com/util-deprecate/-/util-deprecate-1.0.2.tgz",
|
||||
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="
|
||||
},
|
||||
"uuid": {
|
||||
"version": "9.0.1",
|
||||
"resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.1.tgz",
|
||||
"integrity": "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA=="
|
||||
},
|
||||
"vite": {
|
||||
"version": "5.0.2",
|
||||
"resolved": "https://registry.npmjs.org/vite/-/vite-5.0.2.tgz",
|
||||
|
||||
+8
-4
@@ -7,7 +7,8 @@
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0",
|
||||
"preview": "vite preview"
|
||||
"preview": "vite preview",
|
||||
"setupGithooks": "node ../scripts/setupGithooks"
|
||||
},
|
||||
"dependencies": {
|
||||
"@chakra-ui/react": "^2.8.2",
|
||||
@@ -17,11 +18,11 @@
|
||||
"chakra-react-select": "^4.7.6",
|
||||
"dexie": "^3.2.4",
|
||||
"dexie-react-hooks": "^1.1.7",
|
||||
"framer-motion": "^10.16.12",
|
||||
"fuse.js": "^7.0.0",
|
||||
"jszip": "^3.10.1",
|
||||
"nanoid": "^5.0.6",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"uuid": "^9.0.1"
|
||||
"react-dom": "^18.2.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^18.2.37",
|
||||
@@ -31,8 +32,11 @@
|
||||
"@typescript-eslint/parser": "^6.10.0",
|
||||
"@vitejs/plugin-react": "^4.2.0",
|
||||
"eslint": "^8.53.0",
|
||||
"eslint-config-prettier": "^9.1.0",
|
||||
"eslint-plugin-prettier": "^5.1.3",
|
||||
"eslint-plugin-react-hooks": "^4.6.0",
|
||||
"eslint-plugin-react-refresh": "^0.4.4",
|
||||
"prettier": "^3.2.4",
|
||||
"rollup-plugin-watch": "^1.0.4",
|
||||
"typescript": "^5.2.2",
|
||||
"vite": "^5.0.0"
|
||||
|
||||
+90
-27
@@ -1,6 +1,8 @@
|
||||
import { Table } from "./db-tables/WorkspaceDB";
|
||||
import type { ModelsListRespItem } from "./model-manager/types";
|
||||
|
||||
export async function getDB(table: Table): Promise<string | undefined> {
|
||||
console.warn("[workspace deprecated] getDB is deprecated", table);
|
||||
try {
|
||||
const response = await fetch(`/workspace/get_db?table=${table}`);
|
||||
if (!response.ok) {
|
||||
@@ -15,8 +17,6 @@ export async function getDB(table: Table): Promise<string | undefined> {
|
||||
}
|
||||
|
||||
export async function saveDB(table: Table, jsonData: string) {
|
||||
// const tableBackupFile = table + "/" + Date.now() + ".json";
|
||||
// saveBackup(tableBackupFile, jsonData);
|
||||
try {
|
||||
const response = await fetch("/workspace/save_db", {
|
||||
method: "POST",
|
||||
@@ -71,25 +71,6 @@ export async function deleteFile(file_path: string, deleteEmptyFolder = false) {
|
||||
}
|
||||
}
|
||||
|
||||
export async function saveBackup(file_path: string, jsonData: string) {
|
||||
try {
|
||||
const response = await fetch("/workspace/save_backup", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
file_path: file_path,
|
||||
json_str: jsonData,
|
||||
}),
|
||||
});
|
||||
const result = await response.text();
|
||||
return result;
|
||||
} catch (error) {
|
||||
console.error("Error saving workspace backup:", error);
|
||||
}
|
||||
}
|
||||
|
||||
export async function listBackup(dir: string) {
|
||||
try {
|
||||
const response = await fetch("/workspace/list_backup", {
|
||||
@@ -116,7 +97,7 @@ export async function getSystemDir(root?: string) {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
absolute_dir: root,
|
||||
absolute_dir: root ?? "",
|
||||
}),
|
||||
});
|
||||
const result = await response.json();
|
||||
@@ -141,21 +122,103 @@ export async function openWorkflowsFolder() {
|
||||
}
|
||||
}
|
||||
|
||||
export async function scanLocalNewFiles(path: string, existFlowIds: string[]) {
|
||||
export async function fetchMyWorkflowsDir() {
|
||||
const resp = await fetch("/workspace/get_my_workflows_dir");
|
||||
const res = (await resp.json()) as {
|
||||
path?: string;
|
||||
error?: string;
|
||||
os: "win32" | "darwin" | "linux";
|
||||
};
|
||||
if (res.error) {
|
||||
alert(`Failed to fetch my workflows path: ${res.error}`);
|
||||
}
|
||||
return res;
|
||||
}
|
||||
|
||||
export async function getAllModelsList() {
|
||||
try {
|
||||
const response = await fetch("/workspace/scan_local_new_files", {
|
||||
const response = await fetch("/model_manager/get_model_list", {
|
||||
method: "GET",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
});
|
||||
const result = await response.json();
|
||||
return result as Array<ModelsListRespItem & { date: number }>;
|
||||
} catch (error) {
|
||||
console.error("Error get all models list:", error);
|
||||
}
|
||||
}
|
||||
|
||||
export async function getAllFoldersList() {
|
||||
try {
|
||||
const response = await fetch("/model_manager/get_folder_list", {
|
||||
method: "GET",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
});
|
||||
const result = await response.json();
|
||||
return result as Record<string, string[]>;
|
||||
} catch (error) {
|
||||
console.error("Error get all models list:", error);
|
||||
}
|
||||
}
|
||||
|
||||
export async function deleteLocalDiskFolder(folderPath: string) {
|
||||
try {
|
||||
const response = await fetch("/workspace/delete_folder", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
path,
|
||||
existFlowIds
|
||||
folder_path: folderPath,
|
||||
}),
|
||||
});
|
||||
const result = await response.text();
|
||||
return result;
|
||||
} catch (error) {
|
||||
console.error("Error move file:", error);
|
||||
}
|
||||
}
|
||||
|
||||
export async function cancelDownload(savePath: string) {
|
||||
try {
|
||||
const response = await fetch("/model_manager/cancel_installation", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
save_path: savePath,
|
||||
}),
|
||||
});
|
||||
const result = await response.text();
|
||||
return result;
|
||||
} catch (error) {
|
||||
console.error("Error move file:", error);
|
||||
}
|
||||
}
|
||||
|
||||
export async function copyFlowsToNewDirectory(
|
||||
sourceDir: string,
|
||||
dstDir: string,
|
||||
) {
|
||||
try {
|
||||
const response = await fetch("/workspace/copy_json_files", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
src: sourceDir,
|
||||
dst: dstDir,
|
||||
}),
|
||||
});
|
||||
const result = await response.json();
|
||||
return result;
|
||||
} catch (error) {
|
||||
console.error("Error scan local new files:", error);
|
||||
console.error("Error copy flows to new directory:", error);
|
||||
}
|
||||
}
|
||||
|
||||
+363
-211
@@ -1,229 +1,354 @@
|
||||
import { Suspense, useCallback, useEffect, useRef, useState } from "react";
|
||||
import {
|
||||
Suspense,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useRef,
|
||||
useState,
|
||||
DragEvent,
|
||||
} from "react";
|
||||
// @ts-ignore
|
||||
import { app } from "/scripts/app.js";
|
||||
// @ts-ignore
|
||||
import { api } from "/scripts/api.js";
|
||||
import { ComfyExtension, ComfyObjectInfo } from "./types/comfy";
|
||||
import { Box, Portal } from "@chakra-ui/react";
|
||||
import { Box, Portal, useToast } from "@chakra-ui/react";
|
||||
import {
|
||||
createFlow,
|
||||
getWorkflow,
|
||||
loadDBs,
|
||||
updateFlow,
|
||||
workspace,
|
||||
workflowsTable,
|
||||
userSettingsTable,
|
||||
changelogsTable,
|
||||
mediaTable,
|
||||
listWorkflows,
|
||||
workflowVersionsTable,
|
||||
} from "./db-tables/WorkspaceDB";
|
||||
import { defaultGraph } from "./defaultGraph";
|
||||
import { WorkspaceContext } from "./WorkspaceContext";
|
||||
import {
|
||||
Route,
|
||||
syncNewFlowOfLocalDisk,
|
||||
getFileUrl,
|
||||
matchSaveWorkflowShortcut,
|
||||
validateOrSaveAllJsonFileMyWorkflows,
|
||||
getWorkflowIdInUrlHash,
|
||||
generateUrlHashWithFlowId,
|
||||
openWorkflowInNewTab,
|
||||
} from "./utils";
|
||||
import GalleryModal from "./gallery/GalleryModal";
|
||||
import { Topbar } from "./topbar/Topbar";
|
||||
import { authTokenListener, pullAuthTokenCloseIfExist } from "./auth/authUtils";
|
||||
import { PanelPosition } from "./types/dbTypes";
|
||||
import { Workflow, WorkflowVersion } from "./types/dbTypes";
|
||||
import { useDialog } from "./components/AlertDialogProvider";
|
||||
import React from "react";
|
||||
const RecentFilesDrawer = React.lazy(
|
||||
() => import("./RecentFilesDrawer/RecentFilesDrawer")
|
||||
() => import("./RecentFilesDrawer/RecentFilesDrawer"),
|
||||
);
|
||||
import { scanLocalNewFiles } from "./Api";
|
||||
const GalleryModal = React.lazy(() => import("./gallery/GalleryModal"));
|
||||
import { IconExternalLink } from "@tabler/icons-react";
|
||||
import { DRAWER_Z_INDEX, UPGRADE_TO_2WAY_SYNC_KEY } from "./const";
|
||||
import ServerEventListener from "./model-manager/hooks/ServerEventListener";
|
||||
import { nanoid } from "nanoid";
|
||||
import { WorkspaceRoute } from "./types/types";
|
||||
import { useStateRef } from "./customHooks/useStateRef";
|
||||
import { indexdb } from "./db-tables/indexdb";
|
||||
import EnableTwowaySyncConfirm from "./settings/EnableTwowaySyncConfirm";
|
||||
import { deepJsonDiffCheck } from "./utils/deepJsonDiffCheck";
|
||||
import {
|
||||
OPEN_TAB_EVENT,
|
||||
tabDataManager,
|
||||
} from "./topbar/multipleTabs/TabDataManager";
|
||||
|
||||
export default function App() {
|
||||
const nodeDefs = useRef<Record<string, ComfyObjectInfo>>({});
|
||||
const [curFlowName, setCurFlowName] = useState<string | null>(null);
|
||||
const [route, setRoute] = useState<Route>("root");
|
||||
const [route, setRoute] = useState<WorkspaceRoute>("root");
|
||||
const [loadingDB, setLoadingDB] = useState(true);
|
||||
const [flowID, setFlowID] = useState<string | null>(null);
|
||||
const curFlowID = useRef<string | null>(null);
|
||||
const [positionStyle, setPositionStyle] = useState<PanelPosition>();
|
||||
|
||||
const [isDirty, setIsDirty] = useState(false);
|
||||
const workspaceContainerRef = useRef(null);
|
||||
const { showDialog } = useDialog();
|
||||
const [loadChild, setLoadChild] = useState(false);
|
||||
const saveCurWorkflow = useCallback(() => {
|
||||
if (curFlowID.current) {
|
||||
const graphJson = JSON.stringify(app.graph.serialize());
|
||||
updateFlow(curFlowID.current, {
|
||||
lastSavedJson: graphJson,
|
||||
json: graphJson,
|
||||
});
|
||||
|
||||
changelogsTable?.create({
|
||||
workflowID: curFlowID.current,
|
||||
json: graphJson,
|
||||
});
|
||||
const developmentEnvLoadFirst = useRef(false);
|
||||
const toast = useToast();
|
||||
const [curVersion, setCurVersion] = useStateRef<WorkflowVersion | null>(null);
|
||||
const saveCurWorkflow = useCallback(async (newGraphJson?: string) => {
|
||||
const flowId = workflowsTable?.curWorkflow?.id;
|
||||
if (flowId) {
|
||||
if (workflowsTable?.curWorkflow?.saveLock) {
|
||||
toast({
|
||||
title: "The workflow is locked and cannot be saved",
|
||||
status: "warning",
|
||||
duration: 3000,
|
||||
});
|
||||
return;
|
||||
}
|
||||
!newGraphJson && setIsDirty(false);
|
||||
const graphJson = newGraphJson || JSON.stringify(app.graph.serialize());
|
||||
await Promise.all([
|
||||
workflowsTable?.updateFlow(flowId, {
|
||||
json: graphJson,
|
||||
}),
|
||||
changelogsTable?.create({
|
||||
workflowID: flowId,
|
||||
json: graphJson,
|
||||
isAutoSave: false,
|
||||
}),
|
||||
]);
|
||||
userSettingsTable?.settings?.autoSave &&
|
||||
toast({
|
||||
title: "Saved",
|
||||
status: "success",
|
||||
duration: 1000,
|
||||
isClosable: true,
|
||||
});
|
||||
}
|
||||
}, []);
|
||||
const discardUnsavedChanges = () => {
|
||||
|
||||
const discardUnsavedChanges = async () => {
|
||||
if (userSettingsTable?.settings?.autoSave) {
|
||||
alert("You cannot discard unsaved changes when auto save is enabled");
|
||||
return;
|
||||
}
|
||||
const userInput = confirm(
|
||||
"Are you sure you want to discard unsaved changes? This will revert current workflow to your last saved version. You will lose all changes made since your last save."
|
||||
"Are you sure you want to discard unsaved changes? This will revert current workflow to your last saved version. You will lose all changes made since your last save.",
|
||||
);
|
||||
|
||||
if (userInput) {
|
||||
// User clicked OK
|
||||
if (curFlowID.current) {
|
||||
const flow = getWorkflow(curFlowID.current);
|
||||
if (flow && flow.lastSavedJson) {
|
||||
app.loadGraphData(JSON.parse(flow.lastSavedJson));
|
||||
}
|
||||
const curID = workflowsTable?.curWorkflow?.id;
|
||||
if (curID == null) return;
|
||||
const lastSavedJson = (await workflowsTable?.get(curID))?.json;
|
||||
if (lastSavedJson) {
|
||||
workflowsTable?.updateCurWorkflow({
|
||||
...workflowsTable.curWorkflow!,
|
||||
json: lastSavedJson,
|
||||
});
|
||||
await app.loadGraphData(JSON.parse(lastSavedJson));
|
||||
setIsDirty(false);
|
||||
} else {
|
||||
alert("Error: No last saved version found");
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const setCurFlowID = (id: string) => {
|
||||
curFlowID.current = id;
|
||||
const setCurFlowIDAndName = (workflow: Workflow | null) => {
|
||||
// curID null is when you deleted current workflow
|
||||
const id = workflow?.id ?? null;
|
||||
workflowsTable?.updateCurWorkflow(workflow);
|
||||
setFlowID(id);
|
||||
const workflow = getWorkflow(id);
|
||||
if (workflow) {
|
||||
if (id == null) {
|
||||
document.title = "ComfyUI";
|
||||
window.location.hash = "";
|
||||
localStorage.removeItem("curFlowID");
|
||||
return;
|
||||
}
|
||||
if (getWorkflowIdInUrlHash()) {
|
||||
const newUrlHash = generateUrlHashWithFlowId(id);
|
||||
window.location.hash = newUrlHash;
|
||||
document.title = workflow!.name + " - ComfyUI";
|
||||
} else {
|
||||
localStorage.setItem("curFlowID", id);
|
||||
// localStorage.setItem("comfy_workspace_workflow", workflow.json);
|
||||
document.title = "ComfyUI - " + workflow!.name;
|
||||
}
|
||||
if (workflow) {
|
||||
workflowsTable?.updateLastOpenedTime(workflow.id);
|
||||
}
|
||||
};
|
||||
|
||||
const graphAppSetup = async () => {
|
||||
const ext: ComfyExtension = {
|
||||
// Unique name for the extension
|
||||
name: "WorkspaceManager",
|
||||
async setup(app) {
|
||||
// clean up legacy localStorage
|
||||
localStorage.removeItem("workspace");
|
||||
localStorage.removeItem("comfyspace");
|
||||
},
|
||||
async addCustomNodeDefs(defs) {
|
||||
nodeDefs.current = defs;
|
||||
},
|
||||
// @ts-ignore
|
||||
async afterConfigureGraph() {
|
||||
// to avoid the bug after switching workflow using comfyspace,
|
||||
// immediately refresh browser, resulting in latest workflow not updated
|
||||
localStorage.setItem("workflow", JSON.stringify(app.graph.serialize()));
|
||||
},
|
||||
};
|
||||
app.registerExtension(ext);
|
||||
localStorage.removeItem("workspace");
|
||||
localStorage.removeItem("comfyspace");
|
||||
try {
|
||||
await loadDBs();
|
||||
updatePanelPosition(userSettingsTable?.getSetting("topBarStyle"), false);
|
||||
} catch (error) {
|
||||
console.error("error loading db", error);
|
||||
}
|
||||
setLoadingDB(false);
|
||||
const latest = localStorage.getItem("curFlowID");
|
||||
const latestWf = latest != null ? getWorkflow(latest) : null;
|
||||
|
||||
if (latestWf) {
|
||||
latestWf && localStorage.setItem("workflow", latestWf.json);
|
||||
setCurFlowID(latestWf.id ?? null);
|
||||
setCurFlowName(latestWf.name ?? null);
|
||||
let latestWfID = null;
|
||||
latestWfID = getWorkflowIdInUrlHash();
|
||||
if (latestWfID == null) {
|
||||
latestWfID = localStorage.getItem("curFlowID");
|
||||
}
|
||||
await validateOrSaveAllJsonFileMyWorkflows();
|
||||
|
||||
if (userSettingsTable?.getSetting("twoWaySync")) {
|
||||
// Scan all files and subfolders in the local storage directory, compare and find the data that needs to be added in the DB, and perform the new operation
|
||||
const myWorkflowsDir = userSettingsTable?.getSetting("myWorkflowsDir");
|
||||
const existFlowIds = listWorkflows().map((flow) => flow.id);
|
||||
const { fileList, folderList } = await scanLocalNewFiles(
|
||||
myWorkflowsDir!,
|
||||
existFlowIds
|
||||
);
|
||||
await syncNewFlowOfLocalDisk(fileList, folderList);
|
||||
if (latestWfID) {
|
||||
loadWorkflowIDImpl(latestWfID!, null, true);
|
||||
}
|
||||
fetch("/workspace/deduplicate_workflow_ids");
|
||||
const twoway = await userSettingsTable?.getSetting("twoWaySync");
|
||||
!twoway &&
|
||||
indexdb.cache.get(UPGRADE_TO_2WAY_SYNC_KEY).then(async (value) => {
|
||||
if (value?.value !== "true") {
|
||||
const workflowsCount = await indexdb.workflows.count();
|
||||
if (workflowsCount == 0) {
|
||||
// empty workflows, enable 2way sync
|
||||
await userSettingsTable?.upsert({ twoWaySync: true });
|
||||
await indexdb.cache.put({
|
||||
id: UPGRADE_TO_2WAY_SYNC_KEY,
|
||||
value: "true",
|
||||
});
|
||||
return;
|
||||
}
|
||||
const myWorkflowsDir =
|
||||
await userSettingsTable?.getSetting("myWorkflowsDir");
|
||||
showDialog(
|
||||
<EnableTwowaySyncConfirm
|
||||
myWorkflowsDir={myWorkflowsDir ?? "undefined"}
|
||||
/>,
|
||||
[
|
||||
{
|
||||
label: "I have downloaded all my workflows and ready to enable",
|
||||
onClick: async () => {
|
||||
await userSettingsTable?.upsert({ twoWaySync: true });
|
||||
if (await userSettingsTable?.getSetting("twoWaySync")) {
|
||||
indexdb.cache.put({
|
||||
id: UPGRADE_TO_2WAY_SYNC_KEY,
|
||||
value: "true",
|
||||
});
|
||||
location.reload();
|
||||
}
|
||||
},
|
||||
colorScheme: "red",
|
||||
},
|
||||
],
|
||||
{
|
||||
closeOnOverlayClick: false,
|
||||
},
|
||||
);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
graphAppSetup();
|
||||
setLoadChild(true);
|
||||
setInterval(() => {
|
||||
const autoSaveEnabled = userSettingsTable?.getSetting("autoSave") ?? true;
|
||||
|
||||
if (curFlowID.current != null && autoSaveEnabled) {
|
||||
// autosave workflow if enabled
|
||||
const graphJson = JSON.stringify(app.graph.serialize());
|
||||
graphJson != null &&
|
||||
updateFlow(curFlowID.current, {
|
||||
json: graphJson,
|
||||
});
|
||||
}
|
||||
setIsDirty(checkIsDirty());
|
||||
}, 1000);
|
||||
pullAuthTokenCloseIfExist();
|
||||
}, []);
|
||||
|
||||
const checkIsDirty = () => {
|
||||
if (curFlowID.current != null) {
|
||||
const graphJson = app.graph.serialize() ?? {};
|
||||
const curWorkflow = curFlowID.current
|
||||
? getWorkflow(curFlowID.current)
|
||||
: null;
|
||||
let lastSaved = {};
|
||||
try {
|
||||
lastSaved = curWorkflow?.lastSavedJson
|
||||
? JSON.parse(curWorkflow?.lastSavedJson)
|
||||
: {};
|
||||
} catch (e) {
|
||||
console.error("error parsing json", e);
|
||||
}
|
||||
const equal = JSON.stringify(graphJson) === JSON.stringify(lastSaved);
|
||||
return !equal;
|
||||
if (workflowsTable?.curWorkflow?.id == null) return false;
|
||||
const curflow = workflowsTable?.curWorkflow;
|
||||
if (curflow == null) return false;
|
||||
if (curflow.json == null) return true;
|
||||
const graphJson = app.graph.serialize() ?? {};
|
||||
let lastSaved = {};
|
||||
try {
|
||||
lastSaved = curflow?.json ? JSON.parse(curflow?.json) : {};
|
||||
} catch (e) {
|
||||
console.error("error parsing json", e);
|
||||
}
|
||||
return false;
|
||||
const equal = deepJsonDiffCheck(graphJson, lastSaved);
|
||||
return !equal;
|
||||
};
|
||||
const loadWorkflowIDImpl = (id: string) => {
|
||||
|
||||
const loadWorkflowIDImpl = async (
|
||||
id: string,
|
||||
versionID?: string | null,
|
||||
isInit?: boolean,
|
||||
) => {
|
||||
if (app.graph == null) {
|
||||
console.error("app.graph is null cannot load workflow");
|
||||
return;
|
||||
}
|
||||
setCurFlowID(id);
|
||||
const flow = getWorkflow(id);
|
||||
const flow = await workflowsTable?.get(id);
|
||||
// If the currently loaded flow does not exist, you need to clear the URL hash and localStorage to avoid popping up another prompt that the flow does not exist when refreshing the page.
|
||||
if (flow == null) {
|
||||
alert("Error: Workflow not found! id: " + id);
|
||||
setCurFlowIDAndName(null);
|
||||
return;
|
||||
}
|
||||
const version = versionID
|
||||
? await workflowVersionsTable?.get(versionID)
|
||||
: null;
|
||||
|
||||
setCurVersion(version ?? null);
|
||||
|
||||
const tabIndex = tabDataManager.tabs.findIndex((tab) => tab.id === id);
|
||||
if (tabIndex !== -1) {
|
||||
tabDataManager.changeActiveTab(tabIndex);
|
||||
if (version) {
|
||||
tabDataManager.updateTabData(tabIndex, { json: version.json });
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
app.ui.dialog.close();
|
||||
app.loadGraphData(JSON.parse(flow.json));
|
||||
setCurFlowName(flow.name);
|
||||
setRoute("root");
|
||||
isDirty && setIsDirty(false);
|
||||
|
||||
const newTabInfo = {
|
||||
id: flow.id,
|
||||
name: flow.name,
|
||||
json: version ? version.json : flow.json,
|
||||
isDirty: false,
|
||||
};
|
||||
|
||||
/**
|
||||
* During initialization,
|
||||
* because the event listener of OPEN_TAB_EVENT has not been registered yet,
|
||||
* the newly added tab will be invalid,
|
||||
* so the tab is added manually during initialization.
|
||||
*/
|
||||
if (isInit) {
|
||||
tabDataManager.addTabData(newTabInfo);
|
||||
app.loadGraphData(JSON.parse(newTabInfo.json));
|
||||
setCurFlowIDAndName({
|
||||
...flow,
|
||||
json: newTabInfo.json,
|
||||
});
|
||||
} else {
|
||||
document.dispatchEvent(
|
||||
new CustomEvent(OPEN_TAB_EVENT, {
|
||||
detail: {
|
||||
tabInfo: newTabInfo,
|
||||
},
|
||||
}),
|
||||
);
|
||||
}
|
||||
};
|
||||
const loadWorkflowID = (id: string) => {
|
||||
const autoSaveEnabled = userSettingsTable?.getSetting("autoSave") ?? true;
|
||||
if (autoSaveEnabled || !isDirty) {
|
||||
loadWorkflowIDImpl(id);
|
||||
|
||||
const loadWorkflowID = async (
|
||||
id: string | null,
|
||||
versionID?: string | null,
|
||||
) => {
|
||||
// No current workflow, id is null when you deleted current workflow
|
||||
if (id === null) {
|
||||
setCurFlowIDAndName(null);
|
||||
app.graph.clear();
|
||||
return;
|
||||
}
|
||||
showDialog(`Do you want to save the current workflow, ${curFlowName}?`, [
|
||||
|
||||
loadWorkflowIDImpl(id, versionID);
|
||||
};
|
||||
|
||||
const showSaveOrDiscardCurWorkflowDialog = async (newIDToLoad?: string) => {
|
||||
const buttons = [
|
||||
newIDToLoad
|
||||
? {
|
||||
label: "Open in new tab",
|
||||
icon: <IconExternalLink />,
|
||||
onClick: () => {
|
||||
openWorkflowInNewTab(newIDToLoad);
|
||||
},
|
||||
}
|
||||
: null,
|
||||
{
|
||||
label: "Save",
|
||||
colorScheme: "teal",
|
||||
onClick: () => {
|
||||
saveCurWorkflow();
|
||||
loadWorkflowIDImpl(id);
|
||||
onClick: async () => {
|
||||
await saveCurWorkflow();
|
||||
newIDToLoad && loadWorkflowIDImpl(newIDToLoad);
|
||||
},
|
||||
},
|
||||
{
|
||||
label: "Discard",
|
||||
colorScheme: "red",
|
||||
onClick: () => {
|
||||
loadWorkflowIDImpl(id);
|
||||
},
|
||||
];
|
||||
buttons.push({
|
||||
label: "Discard",
|
||||
colorScheme: "red",
|
||||
onClick: async () => {
|
||||
newIDToLoad && loadWorkflowIDImpl(newIDToLoad);
|
||||
},
|
||||
]);
|
||||
});
|
||||
showDialog(
|
||||
`Please save or discard your changes to "${workflowsTable?.curWorkflow?.name}" before leaving, or your changes will be lost.`,
|
||||
buttons,
|
||||
);
|
||||
};
|
||||
const loadNewWorkflow = (input?: { json?: string; name?: string }) => {
|
||||
|
||||
const loadNewWorkflow = async (input?: { json?: string; name?: string }) => {
|
||||
const jsonStr = input?.json ?? JSON.stringify(defaultGraph);
|
||||
const flow = createFlow({ json: jsonStr, name: input?.name });
|
||||
loadWorkflowID(flow.id);
|
||||
const flow = await workflowsTable?.createFlow({
|
||||
json: jsonStr,
|
||||
name: input?.name,
|
||||
});
|
||||
flow && (await loadWorkflowID(flow.id));
|
||||
setRoute("root");
|
||||
};
|
||||
|
||||
const loadFilePath = async (
|
||||
relativePath: string,
|
||||
overwriteCurrent: boolean = false
|
||||
overwriteCurrent: boolean = false,
|
||||
) => {
|
||||
const fileName = relativePath.split("/").pop() ?? relativePath;
|
||||
if (!overwriteCurrent) {
|
||||
@@ -240,60 +365,31 @@ export default function App() {
|
||||
setRoute("root");
|
||||
};
|
||||
|
||||
const onDuplicateWorkflow = (workflowID: string, newFlowName?: string) => {
|
||||
if (workspace == null) {
|
||||
return;
|
||||
}
|
||||
const workflow = workspace[workflowID];
|
||||
const onDuplicateWorkflow = async (
|
||||
workflowID: string,
|
||||
newFlowName?: string,
|
||||
) => {
|
||||
const workflow = await workflowsTable?.get(workflowID);
|
||||
if (workflow == null) {
|
||||
return;
|
||||
}
|
||||
const flow = createFlow({
|
||||
const flow = await workflowsTable?.createFlow({
|
||||
json: workflow.json,
|
||||
name: newFlowName || workflow.name,
|
||||
parentFolderID: workflow.parentFolderID,
|
||||
tags: workflow.tags,
|
||||
});
|
||||
loadWorkflowID(flow.id);
|
||||
flow && (await loadWorkflowID(flow.id));
|
||||
};
|
||||
|
||||
const updatePanelPosition = useCallback(
|
||||
(position?: PanelPosition, needUpdateDB: boolean = false) => {
|
||||
const { top: curTop = 0, left: curLeft = 0 } = positionStyle || {};
|
||||
let { top = 0, left = 0 } = position ?? {};
|
||||
top += curTop;
|
||||
left += curLeft;
|
||||
const clientWidth = document.documentElement.clientWidth;
|
||||
const clientHeight = document.documentElement.clientHeight;
|
||||
const panelElement = document.getElementById("workspaceManagerPanel");
|
||||
const offsetWidth = panelElement?.offsetWidth || 392;
|
||||
|
||||
if (top + 36 > clientHeight) top = clientHeight - 36;
|
||||
if (left + offsetWidth >= clientWidth) left = clientWidth - offsetWidth;
|
||||
|
||||
setPositionStyle({ top: Math.max(0, top), left: Math.max(0, left) });
|
||||
|
||||
needUpdateDB &&
|
||||
userSettingsTable?.upsert({
|
||||
topBarStyle: { top, left },
|
||||
});
|
||||
},
|
||||
[positionStyle]
|
||||
);
|
||||
|
||||
const shortcutListener = (event: KeyboardEvent) => {
|
||||
if (matchSaveWorkflowShortcut(event)) {
|
||||
saveCurWorkflow();
|
||||
}
|
||||
};
|
||||
const onExecutedCreateMedia = useCallback((image: any) => {
|
||||
if (curFlowID.current == null) return;
|
||||
if (workflowsTable?.curWorkflow?.id == null) return;
|
||||
let path = image.filename;
|
||||
if (image.subfolder != null && image.subfolder !== "") {
|
||||
path = image.subfolder + "/" + path;
|
||||
}
|
||||
mediaTable?.create({
|
||||
workflowID: curFlowID.current,
|
||||
workflowID: workflowsTable?.curWorkflow?.id,
|
||||
localPath: path,
|
||||
});
|
||||
}, []);
|
||||
@@ -303,63 +399,117 @@ export default function App() {
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
window.addEventListener("keydown", shortcutListener);
|
||||
window.addEventListener("message", authTokenListener);
|
||||
/**
|
||||
* because we have turned on strict mode, useEffect will be executed twice in strict mode in the development environment.
|
||||
* and graphAppSetup contains DB related operations, repeated execution will bring some bad results.
|
||||
* so in development environment mode, the first execution is skipped.
|
||||
*/
|
||||
if (
|
||||
// @ts-ignore
|
||||
process.env.NODE_ENV === "development" &&
|
||||
!developmentEnvLoadFirst.current
|
||||
) {
|
||||
developmentEnvLoadFirst.current = true;
|
||||
return;
|
||||
}
|
||||
graphAppSetup();
|
||||
setLoadChild(true);
|
||||
|
||||
const fileInput = document.getElementById(
|
||||
"comfy-file-input"
|
||||
"comfy-file-input",
|
||||
) as HTMLInputElement;
|
||||
const fileInputListener = () => {
|
||||
const fileInputListener = async () => {
|
||||
if (fileInput && fileInput.files && fileInput.files.length > 0) {
|
||||
const flow = createFlow({
|
||||
// @ts-ignore
|
||||
const newFlow: Workflow = {
|
||||
id: nanoid(),
|
||||
name: fileInput.files[0].name,
|
||||
json: JSON.stringify(defaultGraph),
|
||||
});
|
||||
setCurFlowID(flow.id);
|
||||
setCurFlowName(flow.name ?? "Unknown name");
|
||||
updateTime: Date.now(),
|
||||
createTime: Date.now(),
|
||||
};
|
||||
setCurFlowIDAndName(newFlow);
|
||||
const res = await workflowsTable?.createFlow(newFlow);
|
||||
res &&
|
||||
tabDataManager.updateTabData(tabDataManager.activeIndex, {
|
||||
id: res.id,
|
||||
name: res.name,
|
||||
});
|
||||
}
|
||||
};
|
||||
fileInput?.addEventListener("change", fileInputListener);
|
||||
|
||||
const handleBeforeUnload = (e: BeforeUnloadEvent) => {
|
||||
const autoSaveEnabled = userSettingsTable?.getSetting("autoSave") ?? true;
|
||||
const autoSaveEnabled = userSettingsTable?.settings?.autoSave ?? true;
|
||||
if (workflowsTable?.curWorkflow?.saveLock) return;
|
||||
const isDirty = checkIsDirty();
|
||||
|
||||
if (!autoSaveEnabled && checkIsDirty()) {
|
||||
if (!autoSaveEnabled && isDirty) {
|
||||
e.preventDefault(); // For modern browsers
|
||||
e.returnValue = "You have unsaved changes"; // For older browsers
|
||||
e.returnValue = "You have unsaved changes!"; // For older browsers
|
||||
showSaveOrDiscardCurWorkflowDialog();
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener("beforeunload", handleBeforeUnload);
|
||||
|
||||
api.addEventListener("executed", (e: any) => {
|
||||
const handleExecuted = (e: any) => {
|
||||
e.detail?.output?.images?.forEach(
|
||||
(im: { filename: string; subfolder: string; type: string }) => {
|
||||
if (im.type === "output") {
|
||||
onExecutedCreateMedia(im);
|
||||
}
|
||||
}
|
||||
},
|
||||
);
|
||||
e.detail?.output?.gifs?.forEach(
|
||||
(im: { filename: string; subfolder: string; type: string }) => {
|
||||
if (im.type === "output") {
|
||||
onExecutedCreateMedia(im);
|
||||
}
|
||||
}
|
||||
},
|
||||
);
|
||||
});
|
||||
};
|
||||
api.addEventListener("executed", handleExecuted);
|
||||
|
||||
const handleDrop = async (event: DragEvent) => {
|
||||
const fileName = event.dataTransfer?.files[0]?.name;
|
||||
const n = app.dragOverNode;
|
||||
if (n && n.onDragDrop) {
|
||||
return;
|
||||
}
|
||||
const overwriteFlow =
|
||||
(await userSettingsTable?.getSetting(
|
||||
"overwriteCurWorkflowWhenDroppingFileToCanvas",
|
||||
)) ?? false;
|
||||
if (!overwriteFlow && fileName) {
|
||||
const newFlow: Workflow = {
|
||||
id: nanoid(),
|
||||
name: fileName,
|
||||
json: JSON.stringify(defaultGraph),
|
||||
updateTime: Date.now(),
|
||||
createTime: Date.now(),
|
||||
};
|
||||
setCurFlowIDAndName(newFlow);
|
||||
const res = await workflowsTable?.createFlow(newFlow);
|
||||
res &&
|
||||
tabDataManager.updateTabData(tabDataManager.activeIndex, {
|
||||
id: res.id,
|
||||
name: res.name,
|
||||
});
|
||||
}
|
||||
};
|
||||
app.canvasEl.addEventListener("drop", handleDrop);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener("message", authTokenListener);
|
||||
window.removeEventListener("keydown", shortcutListener);
|
||||
window.removeEventListener("change", fileInputListener);
|
||||
window.removeEventListener("beforeunload", handleBeforeUnload);
|
||||
window.removeEventListener("executed", handleExecuted);
|
||||
app.canvasEl.removeEventListener("drop", handleDrop);
|
||||
};
|
||||
}, []);
|
||||
|
||||
if (loadingDB || !positionStyle) {
|
||||
if (loadingDB) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<WorkspaceContext.Provider
|
||||
value={{
|
||||
@@ -369,30 +519,29 @@ export default function App() {
|
||||
discardUnsavedChanges: discardUnsavedChanges,
|
||||
saveCurWorkflow: saveCurWorkflow,
|
||||
isDirty: isDirty,
|
||||
setIsDirty: setIsDirty,
|
||||
loadNewWorkflow: loadNewWorkflow,
|
||||
loadFilePath: loadFilePath,
|
||||
setRoute: setRoute,
|
||||
route: route,
|
||||
curVersion: curVersion,
|
||||
setCurVersion: setCurVersion,
|
||||
setCurFlowIDAndName: setCurFlowIDAndName,
|
||||
}}
|
||||
>
|
||||
<div ref={workspaceContainerRef} className="workspace_manager">
|
||||
<Portal containerRef={workspaceContainerRef}>
|
||||
<Box
|
||||
style={{
|
||||
width: "100vh",
|
||||
position: "absolute",
|
||||
top: 0,
|
||||
right: 0,
|
||||
left: 0,
|
||||
lineHeight: "24px",
|
||||
}}
|
||||
zIndex={1000}
|
||||
zIndex={DRAWER_Z_INDEX}
|
||||
draggable={false}
|
||||
>
|
||||
<Topbar
|
||||
curFlowName={curFlowName}
|
||||
setCurFlowName={setCurFlowName}
|
||||
updatePanelPosition={updatePanelPosition}
|
||||
positionStyle={positionStyle}
|
||||
/>
|
||||
<Topbar />
|
||||
{loadChild && route === "recentFlows" && (
|
||||
<Suspense>
|
||||
<RecentFilesDrawer
|
||||
@@ -405,9 +554,12 @@ export default function App() {
|
||||
</Suspense>
|
||||
)}
|
||||
{route === "gallery" && (
|
||||
<GalleryModal onclose={() => setRoute("root")} />
|
||||
<Suspense>
|
||||
<GalleryModal onclose={() => setRoute("root")} />
|
||||
</Suspense>
|
||||
)}
|
||||
</Box>
|
||||
<ServerEventListener />
|
||||
</Portal>
|
||||
</div>
|
||||
</WorkspaceContext.Provider>
|
||||
|
||||
@@ -6,6 +6,7 @@ const vhPolyfill = css`
|
||||
:root,
|
||||
:host {
|
||||
--chakra-vh: 100vh;
|
||||
--toast-z-index: 17000000;
|
||||
}
|
||||
|
||||
@supports (height: -webkit-fill-available) {
|
||||
|
||||
@@ -9,13 +9,14 @@ import {
|
||||
PopoverHeader,
|
||||
Input,
|
||||
HStack,
|
||||
IconButton,
|
||||
MenuItem,
|
||||
} from "@chakra-ui/react";
|
||||
import { useEffect, useState, useContext } from "react";
|
||||
import { Tag, Workflow, tagsTable, updateFlow } from "../db-tables/WorkspaceDB";
|
||||
import { useState, useContext } from "react";
|
||||
import { tagsTable, workflowsTable } from "../db-tables/WorkspaceDB";
|
||||
import { IconPlus, IconTag } from "@tabler/icons-react";
|
||||
import { MultiValue, Select } from "chakra-react-select";
|
||||
import { RecentFilesContext } from "../WorkspaceContext";
|
||||
import { Tag, Workflow } from "../types/dbTypes";
|
||||
|
||||
type Props = {
|
||||
workflow: Workflow;
|
||||
@@ -29,19 +30,25 @@ export default function AddTagToWorkflowPopover({ workflow }: Props) {
|
||||
value: t,
|
||||
label: t,
|
||||
})) ?? [];
|
||||
|
||||
const [selectedTags, setSelectedTags] =
|
||||
useState<MultiValue<{ value: string; label: string }>>(initialTags);
|
||||
useEffect(() => {
|
||||
tagsTable && setAllTags(tagsTable.listAll() ?? []);
|
||||
}, []);
|
||||
useEffect(() => {
|
||||
setSelectedTags(
|
||||
workflow.tags?.map((t) => ({
|
||||
value: t,
|
||||
label: t,
|
||||
})) ?? []
|
||||
);
|
||||
}, [workflow.tags]);
|
||||
|
||||
const addTag = async () => {
|
||||
await tagsTable?.put({
|
||||
name: newTagName,
|
||||
workflowIDs: [],
|
||||
updateTime: Date.now(),
|
||||
});
|
||||
await tagsTable?.listAll().then((tags) => setAllTags(tags ?? []));
|
||||
setNewTagName("");
|
||||
};
|
||||
|
||||
const onOpen = async () => {
|
||||
const tags = await tagsTable?.listAll();
|
||||
setAllTags(tags ?? []);
|
||||
};
|
||||
|
||||
if (tagsTable == null) {
|
||||
alert("Error: TagsTable is not loaded");
|
||||
return null;
|
||||
@@ -53,14 +60,9 @@ export default function AddTagToWorkflowPopover({ workflow }: Props) {
|
||||
const maxTagMenuHeight = 37 * 5;
|
||||
|
||||
return (
|
||||
<Popover isLazy={true}>
|
||||
<Popover isLazy={true} onOpen={onOpen} closeOnBlur={false} placement="auto">
|
||||
<PopoverTrigger>
|
||||
<IconButton
|
||||
aria-label="Delete confirm"
|
||||
size={"sm"}
|
||||
variant="ghost"
|
||||
icon={<IconTag color={"#718096"} />}
|
||||
/>
|
||||
<MenuItem icon={<IconTag />}>Add Tag</MenuItem>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent>
|
||||
<PopoverArrow />
|
||||
@@ -75,20 +77,20 @@ export default function AddTagToWorkflowPopover({ workflow }: Props) {
|
||||
options={tagOptions}
|
||||
menuIsOpen={true}
|
||||
value={selectedTags}
|
||||
onChange={(selected) => {
|
||||
onChange={async (selected) => {
|
||||
setSelectedTags(selected);
|
||||
updateFlow(workflow.id, {
|
||||
await workflowsTable?.updateMetaInfo(workflow.id, {
|
||||
tags: selected.map((s) => s.value),
|
||||
});
|
||||
onRefreshFilesList && onRefreshFilesList();
|
||||
await onRefreshFilesList?.();
|
||||
}}
|
||||
chakraStyles={{
|
||||
dropdownIndicator: (provided, state) => ({
|
||||
dropdownIndicator: (provided) => ({
|
||||
...provided,
|
||||
p: 0,
|
||||
w: "30px",
|
||||
}),
|
||||
menuList: (provided, state) => ({
|
||||
menuList: (provided) => ({
|
||||
...provided,
|
||||
shadow: "none",
|
||||
pt: 0,
|
||||
@@ -110,7 +112,7 @@ export default function AddTagToWorkflowPopover({ workflow }: Props) {
|
||||
variant={"flushed"}
|
||||
value={newTagName}
|
||||
onChange={(e) => {
|
||||
setNewTagName(e.target.value);
|
||||
setNewTagName(e.target.value.trim());
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
@@ -121,11 +123,7 @@ export default function AddTagToWorkflowPopover({ workflow }: Props) {
|
||||
size={"xs"}
|
||||
px={5}
|
||||
isDisabled={newTagName.length === 0}
|
||||
onClick={() => {
|
||||
tagsTable?.upsert(newTagName);
|
||||
setAllTags(tagsTable?.listAll() ?? []);
|
||||
setNewTagName("");
|
||||
}}
|
||||
onClick={addTag}
|
||||
>
|
||||
New Tag
|
||||
</Button>
|
||||
|
||||
@@ -1,57 +1,94 @@
|
||||
import { HStack, useColorMode, Text, Stack } from "@chakra-ui/react";
|
||||
import {
|
||||
Workflow,
|
||||
foldersTable,
|
||||
isFolder,
|
||||
listFolderContent,
|
||||
updateFlow,
|
||||
workflowsTable,
|
||||
} from "../db-tables/WorkspaceDB";
|
||||
import { useState, memo, useContext, useEffect, MouseEvent } from "react";
|
||||
import {
|
||||
useState,
|
||||
memo,
|
||||
useContext,
|
||||
useEffect,
|
||||
MouseEvent,
|
||||
DragEvent,
|
||||
} from "react";
|
||||
import { IconChevronDown, IconChevronRight } from "@tabler/icons-react";
|
||||
import { RecentFilesContext } from "../WorkspaceContext";
|
||||
import WorkflowListItem from "./WorkflowListItem";
|
||||
import FilesListFolderItemRightClickMenu from "./FilesListFolderItemRightClickMenu";
|
||||
import { ESortTypes, sortTypeLocalStorageKey } from "./types";
|
||||
import { Folder } from "../types/dbTypes";
|
||||
import { Folder, Workflow } from "../types/dbTypes";
|
||||
import ItemsList from "./ItemsList";
|
||||
|
||||
type Props = {
|
||||
folder: Folder;
|
||||
};
|
||||
export default memo(function FilesListFolderItem({ folder }: Props) {
|
||||
const [isActive, setIsActive] = useState(false);
|
||||
const [isCollapsed, setIsCollapsed] = useState(folder.isCollapse ?? false);
|
||||
const [children, setChildren] = useState<Array<Folder | Workflow>>(() =>
|
||||
listFolderContent(folder.id)
|
||||
);
|
||||
const [isCollapsed, setIsCollapsed] = useState(folder.isCollapse ?? true);
|
||||
const [children, setChildren] = useState<Array<Folder | Workflow>>([]);
|
||||
const [menuPosition, setMenuPosition] = useState({ x: 0, y: 0 });
|
||||
const [isMenuOpen, setIsMenuOpen] = useState(false);
|
||||
const { colorMode } = useColorMode();
|
||||
const { draggingFile, refreshFolderStamp, onRefreshFilesList } =
|
||||
useContext(RecentFilesContext);
|
||||
const {
|
||||
draggingFile,
|
||||
setDraggingFile,
|
||||
refreshFolderStamp,
|
||||
onRefreshFilesList,
|
||||
} = useContext(RecentFilesContext);
|
||||
const activeStyle =
|
||||
colorMode === "light"
|
||||
? { backgroundColor: "#E2E8F0" }
|
||||
: { backgroundColor: "#4A5568" };
|
||||
|
||||
useEffect(() => {
|
||||
setChildren(
|
||||
listFolderContent(
|
||||
if (isCollapsed) return;
|
||||
workflowsTable
|
||||
?.listFolderContent(
|
||||
folder.id,
|
||||
window.localStorage.getItem(sortTypeLocalStorageKey) as ESortTypes
|
||||
window.localStorage.getItem(sortTypeLocalStorageKey) as ESortTypes,
|
||||
)
|
||||
);
|
||||
}, [folder.id, refreshFolderStamp]);
|
||||
.then((child) => {
|
||||
setChildren(child);
|
||||
});
|
||||
}, [folder.id, refreshFolderStamp, isCollapsed]);
|
||||
|
||||
const handleContextMenu = (event: MouseEvent<HTMLDivElement>) => {
|
||||
event.preventDefault();
|
||||
setMenuPosition({ x: event.clientX, y: event.clientY });
|
||||
setIsMenuOpen(true);
|
||||
};
|
||||
const handleDrop = async (e: DragEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
if (!draggingFile) return setIsActive(false);
|
||||
if (isFolder(draggingFile)) {
|
||||
if (draggingFile.id === folder.id) return setIsActive(false);
|
||||
await foldersTable?.update(draggingFile.id, {
|
||||
parentFolderID: folder.id,
|
||||
});
|
||||
} else if (!isFolder(draggingFile)) {
|
||||
await workflowsTable?.updateFolder(draggingFile.id, {
|
||||
parentFolderID: folder.id,
|
||||
});
|
||||
}
|
||||
await onRefreshFilesList?.();
|
||||
setIsActive(false);
|
||||
};
|
||||
useEffect(() => {
|
||||
if (folder.isCollapse === isCollapsed) return;
|
||||
foldersTable?.update({
|
||||
id: folder.id,
|
||||
if (!!folder.isCollapse === isCollapsed) return;
|
||||
foldersTable?.update(folder.id, {
|
||||
isCollapse: isCollapsed,
|
||||
});
|
||||
}, [isCollapsed]);
|
||||
useEffect(() => {
|
||||
const cur = workflowsTable?.curWorkflow;
|
||||
const curWorkflowPath = cur?.parentFolderID + "/";
|
||||
const folderPath = folder.id + "/";
|
||||
const childreHasCurworkflow = curWorkflowPath.startsWith(folderPath);
|
||||
if (childreHasCurworkflow) {
|
||||
setIsCollapsed(false);
|
||||
}
|
||||
}, []);
|
||||
return (
|
||||
<Stack>
|
||||
<HStack
|
||||
@@ -62,24 +99,22 @@ export default memo(function FilesListFolderItem({ folder }: Props) {
|
||||
setIsCollapsed(!isCollapsed);
|
||||
}}
|
||||
onContextMenu={handleContextMenu}
|
||||
draggable="true"
|
||||
onDragOver={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setIsActive(true);
|
||||
}}
|
||||
onDragLeave={() => {
|
||||
setIsActive(false);
|
||||
}}
|
||||
onDrop={() => {
|
||||
if (draggingFile && !isFolder(draggingFile)) {
|
||||
updateFlow(draggingFile.id, {
|
||||
parentFolderID: folder.id,
|
||||
});
|
||||
onRefreshFilesList && onRefreshFilesList();
|
||||
}
|
||||
onDragLeave={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setIsActive(false);
|
||||
}}
|
||||
onDragStart={() => setDraggingFile?.(folder)}
|
||||
onDrop={handleDrop}
|
||||
_hover={activeStyle}
|
||||
style={isActive || isMenuOpen ? activeStyle : undefined}
|
||||
className="droppable"
|
||||
>
|
||||
<HStack gap={1}>
|
||||
{isCollapsed ? (
|
||||
@@ -97,23 +132,16 @@ export default memo(function FilesListFolderItem({ folder }: Props) {
|
||||
</svg>
|
||||
</HStack>
|
||||
<Text>{folder.name}</Text>
|
||||
|
||||
<FilesListFolderItemRightClickMenu
|
||||
isopen={isMenuOpen}
|
||||
menuPosition={menuPosition}
|
||||
onClose={() => setIsMenuOpen(false)}
|
||||
folder={folder}
|
||||
/>
|
||||
</HStack>
|
||||
<FilesListFolderItemRightClickMenu
|
||||
isopen={isMenuOpen}
|
||||
menuPosition={menuPosition}
|
||||
onClose={() => setIsMenuOpen(false)}
|
||||
folder={folder}
|
||||
/>
|
||||
{!isCollapsed && (
|
||||
<Stack ml={4} gap={0}>
|
||||
{children.map((file) => {
|
||||
if (isFolder(file)) {
|
||||
return <FilesListFolderItem folder={file} />;
|
||||
} else {
|
||||
return <WorkflowListItem workflow={file} />;
|
||||
}
|
||||
})}
|
||||
<ItemsList items={children} />
|
||||
</Stack>
|
||||
)}
|
||||
</Stack>
|
||||
|
||||
@@ -1,29 +1,19 @@
|
||||
import {
|
||||
Box,
|
||||
Menu,
|
||||
MenuList,
|
||||
MenuItem,
|
||||
Popover,
|
||||
PopoverTrigger,
|
||||
PopoverContent,
|
||||
PopoverCloseButton,
|
||||
PopoverBody,
|
||||
Button,
|
||||
PopoverArrow,
|
||||
Text,
|
||||
} from "@chakra-ui/react";
|
||||
import { MouseEvent, useContext, useRef, useState } from "react";
|
||||
import { Box, Menu, MenuList, MenuItem } from "@chakra-ui/react";
|
||||
import { MouseEvent, useContext, useState } from "react";
|
||||
import { RecentFilesContext } from "../WorkspaceContext";
|
||||
import { foldersTable } from "../db-tables/WorkspaceDB";
|
||||
import { workflowsTable, foldersTable } from "../db-tables/WorkspaceDB";
|
||||
import EditFolderNameModal from "../components/EditFolderName";
|
||||
import {
|
||||
IconFileImport,
|
||||
IconFolderPlus,
|
||||
IconPencil,
|
||||
IconPlus,
|
||||
IconTrash,
|
||||
} from "@tabler/icons-react";
|
||||
import { Folder } from "../types/dbTypes";
|
||||
import { EFlowOperationType, Folder } from "../types/dbTypes";
|
||||
import ImportFlowsFileInput from "./ImportFlowsFileInput";
|
||||
import { defaultGraph } from "../defaultGraph";
|
||||
import { useDialog } from "../components/AlertDialogProvider";
|
||||
import { getFileCountInFolder } from "../db-tables/DiskFileUtils";
|
||||
|
||||
type Props = {
|
||||
menuPosition: { x: number; y: number };
|
||||
@@ -38,22 +28,65 @@ export default function FilesListFolderItemRightClickMenu({
|
||||
onClose,
|
||||
}: Props) {
|
||||
const [isRenameOpen, setIsRenameOpen] = useState(false);
|
||||
const [isDeleteOpen, setIsDeleteOpen] = useState(false);
|
||||
const { onRefreshFilesList } = useContext(RecentFilesContext);
|
||||
const onClickNewFolder = (e: MouseEvent<HTMLButtonElement>) => {
|
||||
const { showDialog } = useDialog();
|
||||
|
||||
const onClickNewFolder = async (e: MouseEvent<HTMLButtonElement>) => {
|
||||
e.stopPropagation();
|
||||
foldersTable?.create({
|
||||
name: "New folder",
|
||||
await foldersTable?.create({
|
||||
name: "New Folder",
|
||||
parentFolderID: folder.id,
|
||||
});
|
||||
onRefreshFilesList && onRefreshFilesList();
|
||||
};
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const onClickNewFile = async (e: MouseEvent<HTMLButtonElement>) => {
|
||||
e.stopPropagation();
|
||||
await workflowsTable?.createFlow({
|
||||
json: JSON.stringify(defaultGraph),
|
||||
parentFolderID: folder.id,
|
||||
});
|
||||
onRefreshFilesList && onRefreshFilesList();
|
||||
};
|
||||
|
||||
const openDeleteConfirm = async (e: MouseEvent<HTMLButtonElement>) => {
|
||||
e.stopPropagation();
|
||||
const fileCount = await getFileCountInFolder(folder.id);
|
||||
showDialog(
|
||||
`Do you want to delete the ${fileCount} files inside the folder too?`,
|
||||
[
|
||||
// {
|
||||
// label: "Keep and move to root directory",
|
||||
// colorScheme: "teal",
|
||||
// onClick: () => {
|
||||
// onDelete(EFlowOperationType.MOVE_TO_ROOT_FOLDER);
|
||||
// },
|
||||
// },
|
||||
{
|
||||
label: "Delete all files",
|
||||
colorScheme: "red",
|
||||
onClick: () => {
|
||||
onDelete(EFlowOperationType.DELETE);
|
||||
},
|
||||
},
|
||||
],
|
||||
);
|
||||
};
|
||||
|
||||
const onDelete = async (operationType: EFlowOperationType) => {
|
||||
await foldersTable?.deleteFolder(folder.id, operationType);
|
||||
onClose();
|
||||
onRefreshFilesList && onRefreshFilesList();
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Box position="absolute" top={menuPosition.y} left={menuPosition.x}>
|
||||
<Menu isOpen={isopen} onClose={onClose} isLazy>
|
||||
<Menu
|
||||
isOpen={isopen}
|
||||
onClose={onClose}
|
||||
isLazy
|
||||
lazyBehavior="keepMounted"
|
||||
>
|
||||
<MenuList>
|
||||
<MenuItem
|
||||
icon={<IconPencil size={19} />}
|
||||
@@ -67,7 +100,7 @@ export default function FilesListFolderItemRightClickMenu({
|
||||
</MenuItem>
|
||||
<MenuItem
|
||||
icon={<IconTrash size={19} />}
|
||||
onClick={() => setIsDeleteOpen(true)}
|
||||
onClick={openDeleteConfirm}
|
||||
>
|
||||
Delete
|
||||
</MenuItem>
|
||||
@@ -77,67 +110,23 @@ export default function FilesListFolderItemRightClickMenu({
|
||||
>
|
||||
New folder
|
||||
</MenuItem>
|
||||
<MenuItem
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
fileInputRef.current?.click();
|
||||
}}
|
||||
icon={<IconFileImport size={19} />}
|
||||
>
|
||||
Import workflows
|
||||
<MenuItem onClick={onClickNewFile} icon={<IconPlus size={19} />}>
|
||||
New file
|
||||
</MenuItem>
|
||||
<ImportFlowsFileInput
|
||||
parentFolderID={folder.id}
|
||||
onlyImportFile={true}
|
||||
/>
|
||||
</MenuList>
|
||||
</Menu>
|
||||
</Box>
|
||||
<ImportFlowsFileInput
|
||||
parentFolderID={folder.id}
|
||||
fileInputRef={fileInputRef}
|
||||
/>
|
||||
|
||||
{isRenameOpen && (
|
||||
<EditFolderNameModal
|
||||
folder={folder}
|
||||
onclose={() => setIsRenameOpen(false)}
|
||||
/>
|
||||
)}
|
||||
{isDeleteOpen && (
|
||||
<Popover
|
||||
returnFocusOnClose={false}
|
||||
isOpen={true}
|
||||
onClose={() => setIsDeleteOpen(false)}
|
||||
// placement="right"
|
||||
closeOnBlur={false}
|
||||
>
|
||||
<PopoverTrigger>
|
||||
<div></div>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent>
|
||||
<PopoverArrow />
|
||||
<PopoverCloseButton />
|
||||
<PopoverBody textAlign={"left"}>
|
||||
<Text mb={2}>
|
||||
Are you sure you want to delete this folder,
|
||||
<b> {folder.name}</b>?
|
||||
</Text>
|
||||
<Text color={"GrayText"} mb={5}>
|
||||
This will NOT delete any files in the folder. The files will be
|
||||
moved to the root folder.
|
||||
</Text>
|
||||
<Button
|
||||
colorScheme="red"
|
||||
size={"sm"}
|
||||
onClick={() => {
|
||||
setIsDeleteOpen(false);
|
||||
onClose();
|
||||
foldersTable?.delete(folder.id);
|
||||
onRefreshFilesList && onRefreshFilesList();
|
||||
}}
|
||||
>
|
||||
Yes, delete
|
||||
</Button>
|
||||
</PopoverBody>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,27 +0,0 @@
|
||||
import { Button } from "@chakra-ui/react";
|
||||
import { IconFileImport } from "@tabler/icons-react";
|
||||
import { useRef } from "react";
|
||||
import ImportFlowsFileInput from "./ImportFlowsFileInput";
|
||||
|
||||
interface Props {
|
||||
parentFolderID?: string;
|
||||
}
|
||||
export default function ImportFileButton({}: Props) {
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
return (
|
||||
<Button
|
||||
leftIcon={<IconFileImport size={18} />}
|
||||
variant="outline"
|
||||
iconSpacing={1}
|
||||
size={"sm"}
|
||||
colorScheme="teal"
|
||||
onClick={() => {
|
||||
fileInputRef.current?.click();
|
||||
}}
|
||||
>
|
||||
Import
|
||||
<ImportFlowsFileInput fileInputRef={fileInputRef} />
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
@@ -1,78 +1,247 @@
|
||||
import { ChangeEvent, useContext, useRef } from "react";
|
||||
import { batchCreateFlows } from "../db-tables/WorkspaceDB";
|
||||
import { ChangeEvent, useContext, useRef, useState } from "react";
|
||||
import { Button, Menu, MenuButton, MenuList, MenuItem } from "@chakra-ui/react";
|
||||
import { IconFile, IconFileImport, IconFolder } from "@tabler/icons-react";
|
||||
import { workflowsTable, foldersTable } from "../db-tables/WorkspaceDB";
|
||||
import { RecentFilesContext } from "../WorkspaceContext";
|
||||
import { getPngMetadata } from "../utils";
|
||||
import { ImportWorkflow } from "./types";
|
||||
import { getPngMetadata } from "../utils/mediaMetadataUtils";
|
||||
import { ImportWorkflow, importMenuItemList } from "./types";
|
||||
|
||||
interface WorkflowFile {
|
||||
name: string;
|
||||
json: string;
|
||||
}
|
||||
|
||||
interface TreeNode {
|
||||
name: string;
|
||||
isFile: boolean;
|
||||
file?: WorkflowFile;
|
||||
children?: TreeNode[];
|
||||
parentFolderID?: string;
|
||||
}
|
||||
|
||||
interface HTMLInputDirectoryElement extends HTMLInputElement {
|
||||
webkitdirectory: boolean;
|
||||
}
|
||||
interface Props {
|
||||
fileInputRef: React.RefObject<HTMLInputElement>;
|
||||
onlyImportFile?: boolean;
|
||||
parentFolderID?: string;
|
||||
}
|
||||
export default function ImportFlowsFileInput({
|
||||
parentFolderID,
|
||||
fileInputRef,
|
||||
onlyImportFile = false,
|
||||
}: Props) {
|
||||
const { onRefreshFilesList } = useContext(RecentFilesContext);
|
||||
const [importType, setImportType] = useState("file"); // file/folder
|
||||
const fileInputRef = useRef<HTMLInputDirectoryElement>(null);
|
||||
|
||||
const onClick = (type: string) => {
|
||||
setImportType(type);
|
||||
if (type === "folder") {
|
||||
fileInputRef.current!.setAttribute("webkitdirectory", "");
|
||||
} else {
|
||||
fileInputRef.current!.removeAttribute("webkitdirectory");
|
||||
}
|
||||
fileInputRef.current?.click();
|
||||
};
|
||||
|
||||
const parseFile = async (file: File): Promise<WorkflowFile | null> => {
|
||||
if (file.type === "image/png") {
|
||||
const pngInfo = await getPngMetadata(file);
|
||||
// @ts-expect-error
|
||||
const flowJson = pngInfo?.workflow;
|
||||
if (flowJson) {
|
||||
return {
|
||||
json: flowJson,
|
||||
name: file.name.replace(".png", ""),
|
||||
};
|
||||
}
|
||||
} else if (
|
||||
file.type === "application/json" ||
|
||||
file.name?.endsWith(".json")
|
||||
) {
|
||||
return await new Promise((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
reader.onload = (e) => {
|
||||
try {
|
||||
const result = e.target?.result;
|
||||
if (typeof result === "string") {
|
||||
resolve({
|
||||
json: result,
|
||||
name: file.name.replace(".json", ""),
|
||||
});
|
||||
} else {
|
||||
reject(new Error("File content is not a string"));
|
||||
}
|
||||
} catch (error) {
|
||||
reject(error);
|
||||
}
|
||||
};
|
||||
reader.onerror = reject;
|
||||
reader.readAsText(file);
|
||||
});
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const createFileAndFolderFormTree = async (treeNode: TreeNode) => {
|
||||
// Traverse the folders in the tree through queue breadth first, creating folders and files under the current folder in sequence;
|
||||
const queue = [treeNode];
|
||||
while (queue.length > 0) {
|
||||
const currentNode = queue.shift();
|
||||
|
||||
if (currentNode && !currentNode.isFile) {
|
||||
const folder = await foldersTable?.create({
|
||||
name: currentNode.name,
|
||||
parentFolderID: currentNode.parentFolderID,
|
||||
});
|
||||
if (currentNode.children && currentNode.children.length) {
|
||||
const fileList: WorkflowFile[] = [];
|
||||
currentNode.children.forEach((child) => {
|
||||
if (child.isFile && child.file) {
|
||||
fileList.push(child.file);
|
||||
} else {
|
||||
child.parentFolderID = folder?.id;
|
||||
queue.push(child);
|
||||
}
|
||||
});
|
||||
if (fileList.length) {
|
||||
await workflowsTable?.batchCreateFlows(
|
||||
fileList,
|
||||
undefined,
|
||||
folder?.id,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
onRefreshFilesList?.();
|
||||
};
|
||||
|
||||
const handleFileChange = async (event: ChangeEvent<HTMLInputElement>) => {
|
||||
const files = event.target.files;
|
||||
if (!files) return;
|
||||
const parsedFileList: ImportWorkflow[] = [];
|
||||
for (const file of files) {
|
||||
if (file.type === "image/png") {
|
||||
const pngInfo = await getPngMetadata(file);
|
||||
// @ts-expect-error
|
||||
const flowJson = pngInfo?.workflow;
|
||||
if (flowJson) {
|
||||
parsedFileList.push({
|
||||
json: flowJson,
|
||||
name: file.name.replace(".png", ""),
|
||||
});
|
||||
}
|
||||
} else if (
|
||||
file.type === "application/json" ||
|
||||
file.name?.endsWith(".json")
|
||||
) {
|
||||
await new Promise((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
reader.onload = (e) => {
|
||||
try {
|
||||
const result = e.target?.result;
|
||||
if (typeof result === "string") {
|
||||
parsedFileList.push({
|
||||
json: result,
|
||||
name: file.name.replace(".json", ""),
|
||||
});
|
||||
resolve("");
|
||||
} else {
|
||||
reject(new Error("File content is not a string"));
|
||||
}
|
||||
} catch (error) {
|
||||
reject(error);
|
||||
}
|
||||
};
|
||||
reader.onerror = reject;
|
||||
reader.readAsText(file);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (parsedFileList.length) {
|
||||
await batchCreateFlows(parsedFileList, undefined, parentFolderID);
|
||||
onRefreshFilesList && onRefreshFilesList();
|
||||
if (importType === "folder") {
|
||||
// Create a root node based on the selected directory
|
||||
const rootNode: TreeNode = {
|
||||
name: files[0].webkitRelativePath.split("/")[0],
|
||||
isFile: false,
|
||||
children: [],
|
||||
};
|
||||
|
||||
// Create nested tree file structure from file path
|
||||
for (const file of files) {
|
||||
if (file.name !== ".DS_Store") {
|
||||
const splitPath = file.webkitRelativePath.split("/");
|
||||
let currentLevel = rootNode.children ?? [];
|
||||
|
||||
// Start traversing the second part of the path
|
||||
for (let index = 1; index < splitPath.length; index++) {
|
||||
const part = splitPath[index];
|
||||
let existingNode = currentLevel.find(
|
||||
(child) => child.name === part,
|
||||
);
|
||||
|
||||
if (!existingNode) {
|
||||
existingNode = {
|
||||
name: part,
|
||||
isFile: index === splitPath.length - 1,
|
||||
children: index === splitPath.length - 1 ? undefined : [],
|
||||
};
|
||||
|
||||
if (existingNode.isFile) {
|
||||
const parseFileResult = await parseFile(file);
|
||||
if (parseFileResult) {
|
||||
existingNode.file = parseFileResult;
|
||||
}
|
||||
}
|
||||
currentLevel.push(existingNode);
|
||||
}
|
||||
|
||||
if (existingNode && !existingNode.isFile) {
|
||||
currentLevel = existingNode.children!;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
createFileAndFolderFormTree(rootNode);
|
||||
} else {
|
||||
const parsedFileList: ImportWorkflow[] = [];
|
||||
|
||||
for (const file of files) {
|
||||
const parseFileResult = await parseFile(file);
|
||||
if (parseFileResult) {
|
||||
parsedFileList.push(parseFileResult);
|
||||
}
|
||||
}
|
||||
if (parsedFileList.length) {
|
||||
await workflowsTable?.batchCreateFlows(
|
||||
parsedFileList,
|
||||
undefined,
|
||||
parentFolderID,
|
||||
);
|
||||
onRefreshFilesList && onRefreshFilesList();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<input
|
||||
style={{ display: "none" }}
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept=".json,image/png"
|
||||
multiple
|
||||
onChange={handleFileChange}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
}}
|
||||
/>
|
||||
<>
|
||||
{onlyImportFile ? (
|
||||
<MenuItem
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onClick("file");
|
||||
}}
|
||||
icon={<IconFileImport size={19} />}
|
||||
>
|
||||
Import workflows
|
||||
</MenuItem>
|
||||
) : (
|
||||
<Menu closeOnSelect={true}>
|
||||
<MenuButton
|
||||
as={Button}
|
||||
leftIcon={<IconFileImport size={18} />}
|
||||
variant="outline"
|
||||
iconSpacing={1}
|
||||
size={"sm"}
|
||||
colorScheme="teal"
|
||||
>
|
||||
Import
|
||||
</MenuButton>
|
||||
<MenuList>
|
||||
{importMenuItemList.map((item, index) => (
|
||||
<MenuItem
|
||||
key={index}
|
||||
onClick={() => {
|
||||
onClick(item.type);
|
||||
}}
|
||||
iconSpacing={2}
|
||||
icon={
|
||||
item.type === "folder" ? (
|
||||
<IconFolder size={18} />
|
||||
) : (
|
||||
<IconFile size={18} />
|
||||
)
|
||||
}
|
||||
>
|
||||
{item.title}
|
||||
</MenuItem>
|
||||
))}
|
||||
</MenuList>
|
||||
</Menu>
|
||||
)}
|
||||
<input
|
||||
style={{ display: "none" }}
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept=".json,image/png"
|
||||
multiple
|
||||
onChange={handleFileChange}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,97 +1,18 @@
|
||||
// @ts-nocheck
|
||||
|
||||
// these code are extracted and combined from LGraph.prototype.configure and LGCanvas.pasteFromClipboard
|
||||
export function insertWorkflowToCanvas2(json: string, insertPos?: number[]) {
|
||||
const clipboard_info = JSON.parse(json);
|
||||
const nodes = clipboard_info.nodes;
|
||||
// LiteGraph.use_uuids = true;
|
||||
|
||||
// calculate top-left node, could work without this processing but using diff with last node pos :: clipboard_info.nodes[clipboard_info.nodes.length-1].pos
|
||||
var posMin = false;
|
||||
var posMinIndexes = false;
|
||||
for (var i = 0; i < clipboard_info.nodes.length; ++i) {
|
||||
if (posMin) {
|
||||
if (posMin[0] > clipboard_info.nodes[i].pos[0]) {
|
||||
posMin[0] = clipboard_info.nodes[i].pos[0];
|
||||
posMinIndexes[0] = i;
|
||||
}
|
||||
if (posMin[1] > clipboard_info.nodes[i].pos[1]) {
|
||||
posMin[1] = clipboard_info.nodes[i].pos[1];
|
||||
posMinIndexes[1] = i;
|
||||
}
|
||||
} else {
|
||||
posMin = [clipboard_info.nodes[i].pos[0], clipboard_info.nodes[i].pos[1]];
|
||||
posMinIndexes = [i, i];
|
||||
}
|
||||
}
|
||||
var copy_nodes = {};
|
||||
|
||||
for (var i = 0; i < nodes.length; ++i) {
|
||||
var node_data = nodes[i];
|
||||
var newnode = LiteGraph.createNode(node_data.type);
|
||||
if (!newnode) {
|
||||
// missing custom node type
|
||||
if (LiteGraph.debug) {
|
||||
console.log("Node not found or has errors: " + node_data.type);
|
||||
}
|
||||
//in case of error we create a replacement node to avoid losing info
|
||||
newnode = new LGraphNode();
|
||||
newnode.last_serialization = node_data;
|
||||
newnode.has_errors = true;
|
||||
}
|
||||
if (newnode) {
|
||||
newnode.configure(node_data);
|
||||
//paste in last known mouse position
|
||||
let canvasPos = insertPos
|
||||
? [insertPos[0] - 11, insertPos[1] - 11]
|
||||
: app.canvas.graph_mouse;
|
||||
newnode.pos[0] += canvasPos[0] - posMin[0]; //+= 5;
|
||||
newnode.pos[1] += canvasPos[1] - posMin[1]; //+= 5;
|
||||
|
||||
app.graph.add(newnode, { doProcessChange: false });
|
||||
|
||||
copy_nodes[node_data.id] = newnode;
|
||||
}
|
||||
}
|
||||
//create links
|
||||
for (var i = 0; i < clipboard_info.links.length; ++i) {
|
||||
var link_info = clipboard_info.links[i];
|
||||
const origin_node_slot = link_info[2];
|
||||
const target_node_slot = link_info[4];
|
||||
let target_node = copy_nodes[link_info[3]];
|
||||
// this is avoid disconnect in LGraphNode.prototype.connect (line 4295), causing insert same workflow again and it disconnects prior links:
|
||||
// if there is something already plugged there, disconnect
|
||||
// .... if (target_node.inputs[target_slot] && target_node.inputs[target_slot].link != null) { ....
|
||||
target_node.inputs[target_node_slot] &&
|
||||
target_node.inputs[target_node_slot].link != null;
|
||||
let origin_node = copy_nodes[link_info[1]];
|
||||
if (origin_node && target_node)
|
||||
origin_node.connect(origin_node_slot, target_node, target_node_slot);
|
||||
else {
|
||||
console.error(
|
||||
"Nodes missing on pasting",
|
||||
"origin node",
|
||||
origin_node?.serialize(),
|
||||
"target node",
|
||||
target_node.serialize()
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function insertWorkflowToCanvas3(json: string, insertPos?: number[]) {
|
||||
const clipboard_info = JSON.parse(json);
|
||||
const old_nodes = clipboard_info.nodes;
|
||||
const old_links = clipboard_info.links;
|
||||
const LLink = LiteGraph.LLink;
|
||||
// LiteGraph.use_uuids = true;
|
||||
let old_new_node_ids_map: Record<string, string> = {};
|
||||
const old_new_node_ids_map: Record<string, string> = {};
|
||||
let last_node_id = app.graph.last_node_id;
|
||||
for (let i = 0; i < old_nodes.length; i++) {
|
||||
last_node_id++;
|
||||
old_new_node_ids_map[old_nodes[i].id] = last_node_id;
|
||||
}
|
||||
let old_new_link_ids_map: Record<string, string> = {};
|
||||
const old_new_link_ids_map: Record<string, string> = {};
|
||||
let last_link_id = app.graph.last_link_id;
|
||||
for (let i = 0; i < clipboard_info.links.length; i++) {
|
||||
last_link_id++;
|
||||
@@ -99,15 +20,15 @@ export function insertWorkflowToCanvas3(json: string, insertPos?: number[]) {
|
||||
}
|
||||
// decode links
|
||||
if (old_links && old_links.constructor === Array) {
|
||||
var links = [];
|
||||
const links = [];
|
||||
for (var i = 0; i < old_links.length; ++i) {
|
||||
const old_link = old_links[i];
|
||||
let new_link = [...old_link];
|
||||
const new_link = [...old_link];
|
||||
new_link[0] = old_new_link_ids_map[old_link[0]];
|
||||
new_link[1] = old_new_node_ids_map[old_link[1]];
|
||||
new_link[3] = old_new_node_ids_map[old_link[3]];
|
||||
// new_links.push(new_link);
|
||||
var llink = new LLink();
|
||||
const llink = new LLink();
|
||||
llink.configure(new_link);
|
||||
links[llink.id] = llink;
|
||||
app.graph.links[llink.id] = llink;
|
||||
@@ -115,8 +36,8 @@ export function insertWorkflowToCanvas3(json: string, insertPos?: number[]) {
|
||||
app.graph.last_link_id = last_link_id;
|
||||
}
|
||||
// calculate top-left node, could work without this processing but using diff with last node pos :: clipboard_info.nodes[clipboard_info.nodes.length-1].pos
|
||||
var posMin = false;
|
||||
var posMinIndexes = false;
|
||||
let posMin = false;
|
||||
let posMinIndexes = false;
|
||||
for (var i = 0; i < clipboard_info.nodes.length; ++i) {
|
||||
if (posMin) {
|
||||
if (posMin[0] > clipboard_info.nodes[i].pos[0]) {
|
||||
@@ -132,11 +53,11 @@ export function insertWorkflowToCanvas3(json: string, insertPos?: number[]) {
|
||||
posMinIndexes = [i, i];
|
||||
}
|
||||
}
|
||||
var newNodes = [];
|
||||
var newNodesInfo = {};
|
||||
const newNodes = [];
|
||||
const newNodesInfo = {};
|
||||
for (var i = 0; i < old_nodes.length; ++i) {
|
||||
var node_data = old_nodes[i];
|
||||
var new_node_data = { ...node_data };
|
||||
const node_data = old_nodes[i];
|
||||
const new_node_data = { ...node_data };
|
||||
new_node_data.id = old_new_node_ids_map[node_data.id];
|
||||
new_node_data.inputs?.forEach((input) => {
|
||||
input.link = old_new_link_ids_map[input.link];
|
||||
@@ -146,13 +67,13 @@ export function insertWorkflowToCanvas3(json: string, insertPos?: number[]) {
|
||||
});
|
||||
|
||||
//paste in last known mouse position
|
||||
let canvasPos = insertPos
|
||||
const canvasPos = insertPos
|
||||
? [insertPos[0] - 11, insertPos[1] - 11]
|
||||
: app.canvas.graph_mouse;
|
||||
new_node_data.pos[0] += canvasPos[0] - posMin[0]; //+= 5;
|
||||
new_node_data.pos[1] += canvasPos[1] - posMin[1]; //+= 5;
|
||||
|
||||
var newnode = LiteGraph.createNode(new_node_data.type);
|
||||
let newnode = LiteGraph.createNode(new_node_data.type);
|
||||
if (!newnode) {
|
||||
// missing custom node type
|
||||
if (LiteGraph.debug) {
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
import { DragEvent, useContext, useEffect, useState } from "react";
|
||||
import { RecentFilesContext } from "../WorkspaceContext";
|
||||
import {
|
||||
foldersTable,
|
||||
isFolder,
|
||||
workflowsTable,
|
||||
} from "../db-tables/WorkspaceDB";
|
||||
import { Folder, Workflow } from "../types/dbTypes";
|
||||
import FilesListFolderItem from "./FilesListFolderItem";
|
||||
import WorkflowListItem from "./WorkflowListItem";
|
||||
import { Box } from "@chakra-ui/react";
|
||||
import { userSettingsTable } from "../db-tables/WorkspaceDB";
|
||||
|
||||
export default function ItemsList({
|
||||
items,
|
||||
}: {
|
||||
items: Array<Folder | Workflow>;
|
||||
}) {
|
||||
const [folderOnTop, setFolderOnTop] = useState(false);
|
||||
const folders = items.filter(isFolder);
|
||||
const workflows = items.filter((item): item is Workflow => !isFolder(item));
|
||||
const parentFolderID = workflows[0]?.parentFolderID;
|
||||
|
||||
const [isDraggingOver, setIsDraggingOver] = useState(false);
|
||||
const { onRefreshFilesList, draggingFile, refreshFolderStamp } =
|
||||
useContext(RecentFilesContext);
|
||||
|
||||
const handleDrop = async (e: DragEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
if (!draggingFile) return setIsDraggingOver(false);
|
||||
if (isFolder(draggingFile)) {
|
||||
if (draggingFile.id === parentFolderID) return setIsDraggingOver(false);
|
||||
await foldersTable?.update(draggingFile.id, {
|
||||
parentFolderID: parentFolderID,
|
||||
});
|
||||
} else {
|
||||
await workflowsTable?.updateFolder(draggingFile.id, {
|
||||
parentFolderID: parentFolderID,
|
||||
});
|
||||
}
|
||||
await onRefreshFilesList?.();
|
||||
setIsDraggingOver(false);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
userSettingsTable?.getSetting("foldersOnTop").then((res) => {
|
||||
setFolderOnTop(res ?? false);
|
||||
});
|
||||
}, [refreshFolderStamp]);
|
||||
|
||||
return (
|
||||
<>
|
||||
{folderOnTop &&
|
||||
folders.map((folder) => (
|
||||
<FilesListFolderItem key={folder.id} folder={folder} />
|
||||
))}
|
||||
<Box
|
||||
border={
|
||||
isDraggingOver ? "2px dashed #718096" : "2px dashed transparent"
|
||||
}
|
||||
onDragOver={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setIsDraggingOver(true);
|
||||
}}
|
||||
onDragLeave={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setIsDraggingOver(false);
|
||||
}}
|
||||
onDrop={handleDrop}
|
||||
className="droppable"
|
||||
>
|
||||
{folderOnTop
|
||||
? workflows.map((workflow) => (
|
||||
<WorkflowListItem key={workflow.id} workflow={workflow} />
|
||||
))
|
||||
: items.map((n) => {
|
||||
if (isFolder(n)) {
|
||||
return <FilesListFolderItem folder={n} key={n.id} />;
|
||||
}
|
||||
return <WorkflowListItem key={n.id} workflow={n} />;
|
||||
})}
|
||||
</Box>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,7 +1,5 @@
|
||||
import {
|
||||
Button,
|
||||
HStack,
|
||||
useDisclosure,
|
||||
Modal,
|
||||
ModalOverlay,
|
||||
ModalContent,
|
||||
@@ -12,11 +10,19 @@ import {
|
||||
IconButton,
|
||||
} from "@chakra-ui/react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { Tag, Workflow, tagsTable, updateFlow } from "../db-tables/WorkspaceDB";
|
||||
import { IconSettings, IconTrash } from "@tabler/icons-react";
|
||||
import { tagsTable } from "../db-tables/WorkspaceDB";
|
||||
import { IconTrash } from "@tabler/icons-react";
|
||||
import { Tag } from "../types/dbTypes";
|
||||
|
||||
export default function ManageTagsModal({ onclose }: { onclose: () => void }) {
|
||||
const [allTags, setAllTags] = useState<Tag[]>(tagsTable?.listAll() ?? []);
|
||||
const [allTags, setAllTags] = useState<Tag[]>([]);
|
||||
const loadTags = async () => {
|
||||
const tags = await tagsTable?.listAll();
|
||||
setAllTags(tags ?? []);
|
||||
};
|
||||
useEffect(() => {
|
||||
loadTags();
|
||||
}, []);
|
||||
return (
|
||||
<Modal isOpen={true} onClose={onclose}>
|
||||
<ModalOverlay />
|
||||
@@ -28,9 +34,9 @@ export default function ManageTagsModal({ onclose }: { onclose: () => void }) {
|
||||
<HStack>
|
||||
<ChakraTag>{tag.name}</ChakraTag>
|
||||
<IconButton
|
||||
onClick={() => {
|
||||
tagsTable?.delete(tag.name);
|
||||
setAllTags(tagsTable?.listAll() ?? []);
|
||||
onClick={async () => {
|
||||
await tagsTable?.delete(tag.name);
|
||||
loadTags();
|
||||
}}
|
||||
aria-label="delete-tag"
|
||||
colorScheme="red"
|
||||
|
||||
@@ -0,0 +1,140 @@
|
||||
import { ChangeEvent, useContext, useRef, useState } from "react";
|
||||
import {
|
||||
IconButton,
|
||||
Menu,
|
||||
MenuButton,
|
||||
MenuItem,
|
||||
MenuList,
|
||||
Tooltip,
|
||||
useToast,
|
||||
} from "@chakra-ui/react";
|
||||
import {
|
||||
IconCopy,
|
||||
IconDotsVertical,
|
||||
IconExternalLink,
|
||||
IconLock,
|
||||
IconLockOpen,
|
||||
IconUpload,
|
||||
} from "@tabler/icons-react";
|
||||
import AddTagToWorkflowPopover from "./AddTagToWorkflowPopover";
|
||||
import { Workflow } from "../types/dbTypes";
|
||||
import { mediaTable, workflowsTable } from "../db-tables/WorkspaceDB";
|
||||
import { WorkspaceContext } from "../WorkspaceContext";
|
||||
import { openWorkflowInNewTab } from "../utils";
|
||||
|
||||
type Props = {
|
||||
workflow: Workflow;
|
||||
};
|
||||
export default function MoreActionMenu({ workflow }: Props) {
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const toast = useToast();
|
||||
const { onDuplicateWorkflow } = useContext(WorkspaceContext);
|
||||
|
||||
const [isLocked, setIsLocked] = useState(workflow.saveLock ?? false);
|
||||
|
||||
const onLockChange = async () => {
|
||||
const newLocked = !isLocked!;
|
||||
setIsLocked(newLocked);
|
||||
await workflowsTable?.updateMetaInfo(workflow.id, {
|
||||
saveLock: newLocked,
|
||||
});
|
||||
toast({
|
||||
title: `${newLocked ? "Locked" : "Unlocked"} successfully`,
|
||||
status: "success",
|
||||
duration: 2000,
|
||||
});
|
||||
};
|
||||
|
||||
const handleFileChange = async (event: ChangeEvent<HTMLInputElement>) => {
|
||||
const files = event.target.files;
|
||||
if (!files) return;
|
||||
|
||||
const body = new FormData();
|
||||
|
||||
Array.from(files).forEach((file, index) => {
|
||||
body.append(`images[${index}]`, file, file.name);
|
||||
});
|
||||
body.append("subfolder", "workspace_manager");
|
||||
await fetch("/workspace/images/save", {
|
||||
method: "POST",
|
||||
body,
|
||||
});
|
||||
Array.from(files).forEach(async (file) => {
|
||||
const localPath = `workspace_manager/${file.name}`;
|
||||
mediaTable?.create({
|
||||
workflowID: workflow.id,
|
||||
localPath: localPath,
|
||||
});
|
||||
workflowsTable?.updateMetaInfo(workflow.id, {
|
||||
coverMediaPath: localPath,
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Menu isLazy closeOnSelect={false} placement="right" gutter={0}>
|
||||
<MenuButton
|
||||
border={0}
|
||||
as={IconButton}
|
||||
aria-label="Options"
|
||||
icon={<IconDotsVertical size={20} />}
|
||||
size={"sm"}
|
||||
variant="outline"
|
||||
/>
|
||||
<MenuList zIndex={101}>
|
||||
<MenuItem
|
||||
icon={<IconCopy />}
|
||||
onClick={() =>
|
||||
onDuplicateWorkflow && onDuplicateWorkflow(workflow.id)
|
||||
}
|
||||
>
|
||||
Duplicate
|
||||
</MenuItem>
|
||||
<MenuItem
|
||||
icon={<IconExternalLink size={20} />}
|
||||
onClick={() => openWorkflowInNewTab(workflow.id)}
|
||||
>
|
||||
Open in new tab
|
||||
</MenuItem>
|
||||
{/* <AddTagToWorkflowPopover workflow={workflow} /> */}
|
||||
<Tooltip
|
||||
hasArrow
|
||||
label={
|
||||
isLocked
|
||||
? "Unlock workflow, allow modifying"
|
||||
: "Lock workflow, prevent modifying"
|
||||
}
|
||||
placement="bottom"
|
||||
>
|
||||
<MenuItem
|
||||
icon={isLocked ? <IconLock /> : <IconLockOpen />}
|
||||
onClick={onLockChange}
|
||||
>
|
||||
{isLocked ? "Unlock" : "Lock"}
|
||||
</MenuItem>
|
||||
</Tooltip>
|
||||
<MenuItem
|
||||
icon={<IconUpload />}
|
||||
onClick={() => {
|
||||
fileInputRef.current?.click();
|
||||
}}
|
||||
>
|
||||
Upload Thumbnail Image
|
||||
</MenuItem>
|
||||
</MenuList>
|
||||
</Menu>
|
||||
<input
|
||||
style={{ display: "none" }}
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept=".json,image/png"
|
||||
multiple
|
||||
onChange={handleFileChange}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,15 +1,9 @@
|
||||
import { Checkbox, HStack, IconButton, Text, Tooltip } from "@chakra-ui/react";
|
||||
import {
|
||||
IconListCheck,
|
||||
IconX,
|
||||
IconFileExport,
|
||||
IconDownload,
|
||||
} from "@tabler/icons-react";
|
||||
import { IconListCheck, IconX, IconDownload } from "@tabler/icons-react";
|
||||
import DeleteConfirm from "../components/DeleteConfirm";
|
||||
import { ChangeEvent } from "react";
|
||||
import { batchDeleteFlow, listWorkflows } from "../db-tables/WorkspaceDB";
|
||||
import JSZip from "JSZip";
|
||||
import { formatTimestamp } from "../utils";
|
||||
import { workflowsTable } from "../db-tables/WorkspaceDB";
|
||||
import { downloadWorkflowsZip } from "../utils/downloadWorkflowsZip";
|
||||
|
||||
type Props = {
|
||||
multipleState: boolean;
|
||||
@@ -28,24 +22,9 @@ export default function MultipleSelectionOperation(props: Props) {
|
||||
batchOperationCallback,
|
||||
} = props;
|
||||
|
||||
const batchExport = () => {
|
||||
const selectedList = listWorkflows().filter((flow) =>
|
||||
selectedKeys.includes(flow.id)
|
||||
);
|
||||
const exportName = `ComfyUI workflow ${formatTimestamp(Date.now())}`;
|
||||
const zip = new JSZip();
|
||||
const folder = zip.folder(exportName);
|
||||
selectedList.forEach((flow) => {
|
||||
folder && folder.file(`${flow.name}.json`, flow.json);
|
||||
});
|
||||
zip.generateAsync({ type: "blob" }).then(function (content) {
|
||||
const a = document.createElement("a");
|
||||
a.href = window.URL.createObjectURL(content);
|
||||
a.download = `${exportName}.zip`;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
});
|
||||
const batchExport = async () => {
|
||||
const selectedList = (await workflowsTable?.batchQuery(selectedKeys)) ?? [];
|
||||
downloadWorkflowsZip(selectedList);
|
||||
};
|
||||
|
||||
const notChecked = selectedKeys.length === 0;
|
||||
@@ -75,8 +54,8 @@ export default function MultipleSelectionOperation(props: Props) {
|
||||
variant="solid"
|
||||
promptMessage={`Are you sure you want to delete these ${selectedKeys.length} checked workflows?`}
|
||||
tooltipText="Delete selected"
|
||||
onDelete={() => {
|
||||
batchDeleteFlow(selectedKeys);
|
||||
onDelete={async () => {
|
||||
await workflowsTable?.batchDeleteFlow(selectedKeys);
|
||||
batchOperationCallback("batchDelete");
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
import { Button, HStack, IconButton } from "@chakra-ui/react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { Tag } from "../types/dbTypes";
|
||||
import { tagsTable } from "../db-tables/WorkspaceDB";
|
||||
import { IconChevronDown, IconChevronUp, IconX } from "@tabler/icons-react";
|
||||
const MAX_TAGS_TO_SHOW = 6;
|
||||
|
||||
interface Props {
|
||||
selectedTag?: string;
|
||||
setSelectedTag: (tag?: string) => void;
|
||||
}
|
||||
export default function MyTagsRow({ setSelectedTag, selectedTag }: Props) {
|
||||
const [showAllTags, setShowAllTags] = useState(false);
|
||||
|
||||
const [tags, setTags] = useState<Tag[]>([]);
|
||||
useEffect(() => {
|
||||
tagsTable?.listAll().then((tags) => {
|
||||
setTags(tags);
|
||||
});
|
||||
}, []);
|
||||
return (
|
||||
<HStack spacing={2} wrap={"wrap"} mb={0}>
|
||||
{selectedTag != null && (
|
||||
<IconButton
|
||||
aria-label="Close"
|
||||
size={"sm"}
|
||||
icon={<IconX />}
|
||||
onClick={() => {
|
||||
setSelectedTag(undefined);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{tags.slice(0, showAllTags ? undefined : MAX_TAGS_TO_SHOW).map((tag) => (
|
||||
<Button
|
||||
key={tag.name}
|
||||
variant="solid"
|
||||
width={"auto"}
|
||||
flexShrink={0}
|
||||
size={"sm"}
|
||||
borderRadius={15}
|
||||
py={4}
|
||||
onClick={() => {
|
||||
setSelectedTag(tag.name);
|
||||
}}
|
||||
isActive={selectedTag === tag.name}
|
||||
>
|
||||
{tag.name}
|
||||
</Button>
|
||||
))}
|
||||
{(tags.length ?? 0) > MAX_TAGS_TO_SHOW && (
|
||||
<IconButton
|
||||
aria-label="Show-all-tags"
|
||||
size={"sm"}
|
||||
icon={showAllTags ? <IconChevronUp /> : <IconChevronDown />}
|
||||
onClick={() => setShowAllTags(!showAllTags)}
|
||||
/>
|
||||
)}
|
||||
</HStack>
|
||||
);
|
||||
}
|
||||
@@ -13,42 +13,34 @@ import {
|
||||
Flex,
|
||||
Tooltip,
|
||||
} from "@chakra-ui/react";
|
||||
import { useEffect, useState, useRef, useCallback } from "react";
|
||||
import { useEffect, useState, useRef, useCallback, useContext } from "react";
|
||||
import {
|
||||
Workflow,
|
||||
deleteFlow,
|
||||
workflowsTable,
|
||||
isFolder,
|
||||
listWorkflows,
|
||||
listFolderContent,
|
||||
tagsTable,
|
||||
workspace,
|
||||
foldersTable,
|
||||
} from "../db-tables/WorkspaceDB";
|
||||
import {
|
||||
IconChevronDown,
|
||||
IconChevronUp,
|
||||
IconFolderPlus,
|
||||
IconPlus,
|
||||
IconX,
|
||||
IconFolder,
|
||||
} from "@tabler/icons-react";
|
||||
import { RecentFilesContext } from "../WorkspaceContext";
|
||||
import { RecentFilesContext, WorkspaceContext } from "../WorkspaceContext";
|
||||
import RecentFilesDrawerMenu from "./RecentFilesDrawerMenu";
|
||||
import { sortFileItem } from "../utils";
|
||||
import WorkflowListItem from "./WorkflowListItem";
|
||||
import { insertWorkflowToCanvas, sortFileItem } from "../utils";
|
||||
import MultipleSelectionOperation from "./MultipleSelectionOperation";
|
||||
import { ESortTypes, sortTypeLocalStorageKey } from "./types";
|
||||
// @ts-ignore
|
||||
// @ts-expect-error ComfyUI import
|
||||
import { app } from "/scripts/app.js";
|
||||
import { insertWorkflowToCanvas3 } from "./InsertWorkflowToCanvas";
|
||||
import FilesListFolderItem from "./FilesListFolderItem";
|
||||
import { useDebounce } from "../customHooks/useDebounce";
|
||||
import SearchInput from "../components/SearchInput";
|
||||
import { openWorkflowsFolder } from "../Api";
|
||||
import { Folder } from "../types/dbTypes";
|
||||
import ImportFileButton from "./ImportFileButton";
|
||||
import { Folder, Workflow } from "../types/dbTypes";
|
||||
import MyTagsRow from "./MyTagsRow";
|
||||
import ImportFlowsFileInput from "./ImportFlowsFileInput";
|
||||
import ItemsList from "./ItemsList";
|
||||
import { DRAWER_Z_INDEX } from "../const";
|
||||
|
||||
const MAX_TAGS_TO_SHOW = 6;
|
||||
type Props = {
|
||||
onClose: () => void;
|
||||
onClickNewFlow: () => void;
|
||||
@@ -58,11 +50,8 @@ export default function RecentFilesDrawer({ onClose, onClickNewFlow }: Props) {
|
||||
const [currentRenderingData, setCurrentRenderingData] = useState<
|
||||
Array<Folder | Workflow>
|
||||
>([]);
|
||||
const aloneFlowsAndFoldersRef = useRef<Array<Folder | Workflow>>([]);
|
||||
const allFlowsRef = useRef<Array<Workflow>>([]);
|
||||
|
||||
const [selectedTag, setSelectedTag] = useState<string>();
|
||||
const [showAllTags, setShowAllTags] = useState(false);
|
||||
const [multipleState, setMultipleState] = useState(false);
|
||||
const [selectedKeys, setSelectedKeys] = useState<string[]>([]);
|
||||
const [refreshFolderStamp, setRefreshFolderStamp] = useState(0);
|
||||
@@ -70,45 +59,39 @@ export default function RecentFilesDrawer({ onClose, onClickNewFlow }: Props) {
|
||||
const debounceSearchValue = useDebounce(searchValue, 400);
|
||||
const draggingWorkflowID = useRef<string | null>(null);
|
||||
const [draggingFile, setDraggingFile] = useState<Workflow | Folder | null>(
|
||||
null
|
||||
null,
|
||||
);
|
||||
const sortTypeRef = useRef<ESortTypes>(
|
||||
(window.localStorage.getItem(sortTypeLocalStorageKey) as ESortTypes) ??
|
||||
ESortTypes.RECENTLY_MODIFIED
|
||||
ESortTypes.RECENTLY_MODIFIED,
|
||||
);
|
||||
const isFilter = !!selectedTag || !!debounceSearchValue.length;
|
||||
|
||||
const loadLatestWorkflows = () => {
|
||||
const all = listFolderContent();
|
||||
aloneFlowsAndFoldersRef.current = all;
|
||||
allFlowsRef.current = listWorkflows();
|
||||
filterFlows();
|
||||
const loadLatestWorkflows = async () => {
|
||||
const curFolderContent = (await workflowsTable?.listFolderContent()) ?? [];
|
||||
setCurrentRenderingData(
|
||||
sortFileItem(curFolderContent, sortTypeRef.current),
|
||||
);
|
||||
setRefreshFolderStamp(Date.now());
|
||||
allFlowsRef.current = (await workflowsTable?.listAll()) ?? [];
|
||||
};
|
||||
|
||||
const isFilter = !!selectedTag || !!debounceSearchValue;
|
||||
|
||||
const filterFlows = () => {
|
||||
if (isFilter) {
|
||||
const filterResult = allFlowsRef.current.filter((flow) => {
|
||||
let tagFlag = true;
|
||||
let keywordFlag = true;
|
||||
if (selectedTag) {
|
||||
tagFlag = !!flow.tags?.includes(selectedTag);
|
||||
}
|
||||
if (debounceSearchValue) {
|
||||
keywordFlag = !!flow.name
|
||||
.toLocaleLowerCase()
|
||||
.includes(debounceSearchValue.toLocaleLowerCase());
|
||||
}
|
||||
const filterResult = allFlowsRef.current.filter((flow) => {
|
||||
let tagFlag = true;
|
||||
let keywordFlag = true;
|
||||
if (selectedTag) {
|
||||
tagFlag = !!flow.tags?.includes(selectedTag);
|
||||
}
|
||||
if (debounceSearchValue) {
|
||||
keywordFlag = !!flow.name
|
||||
.toLocaleLowerCase()
|
||||
.includes(debounceSearchValue.toLocaleLowerCase());
|
||||
}
|
||||
|
||||
return tagFlag && keywordFlag;
|
||||
});
|
||||
setCurrentRenderingData(sortFileItem(filterResult, sortTypeRef.current));
|
||||
} else {
|
||||
setCurrentRenderingData(
|
||||
sortFileItem(aloneFlowsAndFoldersRef.current, sortTypeRef.current)
|
||||
);
|
||||
}
|
||||
return tagFlag && keywordFlag;
|
||||
});
|
||||
setCurrentRenderingData(sortFileItem(filterResult, sortTypeRef.current));
|
||||
};
|
||||
|
||||
const onSort = (type: ESortTypes) => {
|
||||
@@ -119,26 +102,29 @@ export default function RecentFilesDrawer({ onClose, onClickNewFlow }: Props) {
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
filterFlows();
|
||||
if (isFilter) {
|
||||
filterFlows();
|
||||
} else {
|
||||
loadLatestWorkflows();
|
||||
}
|
||||
}, [debounceSearchValue, selectedTag]);
|
||||
|
||||
const onDelete = useCallback(
|
||||
(id: string) => {
|
||||
deleteFlow(id);
|
||||
loadLatestWorkflows();
|
||||
async (id: string) => {
|
||||
await workflowsTable?.deleteFlow(id);
|
||||
await loadLatestWorkflows();
|
||||
},
|
||||
[selectedTag, debounceSearchValue]
|
||||
[selectedTag, debounceSearchValue],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
loadLatestWorkflows();
|
||||
const handleDrop = (e: any) => {
|
||||
workspace &&
|
||||
draggingWorkflowID.current &&
|
||||
insertWorkflowToCanvas3(workspace[draggingWorkflowID.current].json, [
|
||||
e.canvasX,
|
||||
e.canvasY,
|
||||
]);
|
||||
const handleDrop = async (e: { canvasX: number; canvasY: number }) => {
|
||||
if (draggingWorkflowID.current) {
|
||||
const flow = await workflowsTable?.get(draggingWorkflowID.current);
|
||||
flow &&
|
||||
flow.json &&
|
||||
insertWorkflowToCanvas(flow.json, [e.canvasX, e.canvasY]);
|
||||
}
|
||||
};
|
||||
|
||||
app.canvasEl.addEventListener("drop", handleDrop);
|
||||
@@ -171,7 +157,7 @@ export default function RecentFilesDrawer({ onClose, onClickNewFlow }: Props) {
|
||||
return;
|
||||
case "selectAll":
|
||||
setSelectedKeys(
|
||||
value ? allFlowsRef.current.map((flow) => flow.id) : []
|
||||
value ? allFlowsRef.current.map((flow) => flow.id) : [],
|
||||
);
|
||||
return;
|
||||
}
|
||||
@@ -188,7 +174,7 @@ export default function RecentFilesDrawer({ onClose, onClickNewFlow }: Props) {
|
||||
setSearchValue(newValue);
|
||||
};
|
||||
|
||||
const DRAWER_WIDTH = 440;
|
||||
const DRAWER_WIDTH = 450;
|
||||
return (
|
||||
<RecentFilesContext.Provider
|
||||
value={{
|
||||
@@ -207,28 +193,28 @@ export default function RecentFilesDrawer({ onClose, onClickNewFlow }: Props) {
|
||||
<Card
|
||||
width={DRAWER_WIDTH}
|
||||
height={"100vh"}
|
||||
pl={4}
|
||||
pr={5}
|
||||
pl={2}
|
||||
pr={2}
|
||||
pt={4}
|
||||
gap={4}
|
||||
position={"absolute"}
|
||||
top={0}
|
||||
left={0}
|
||||
shadow={"xl"}
|
||||
zIndex={1000}
|
||||
zIndex={DRAWER_Z_INDEX}
|
||||
// boxShadow={"rgba(200, 200, 200, 0.4) 1px 4px 8px 1px"}
|
||||
>
|
||||
<Flex alignItems={"center"} justifyContent={"space-between"}>
|
||||
<HStack gap={4}>
|
||||
<HStack gap={2}>
|
||||
<Text fontSize={19} fontWeight={600}>
|
||||
☕️Workspace
|
||||
🦄Workspace
|
||||
</Text>
|
||||
{/* <Tooltip label="Login to share and sync your workflows to cloud">
|
||||
<Link onClick={openCognitoPopup}>Login</Link>
|
||||
</Tooltip> */}
|
||||
</HStack>
|
||||
<ImportFileButton />
|
||||
<ImportFlowsFileInput />
|
||||
<Tooltip label="Open workspace save directory">
|
||||
<IconButton
|
||||
aria-label="Open workspace save directory"
|
||||
@@ -256,45 +242,10 @@ export default function RecentFilesDrawer({ onClose, onClickNewFlow }: Props) {
|
||||
</HStack>
|
||||
</Flex>
|
||||
<Flex direction="column" h="calc(100% - 64px)">
|
||||
<HStack spacing={2} wrap={"wrap"} mb={0}>
|
||||
{selectedTag != null && (
|
||||
<IconButton
|
||||
aria-label="Close"
|
||||
size={"sm"}
|
||||
icon={<IconX />}
|
||||
onClick={() => {
|
||||
setSelectedTag(undefined);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{tagsTable
|
||||
?.listAll()
|
||||
.slice(0, showAllTags ? undefined : MAX_TAGS_TO_SHOW)
|
||||
.map((tag) => (
|
||||
<Button
|
||||
variant="solid"
|
||||
width={"auto"}
|
||||
flexShrink={0}
|
||||
size={"sm"}
|
||||
borderRadius={15}
|
||||
py={4}
|
||||
onClick={() => {
|
||||
setSelectedTag(tag.name);
|
||||
}}
|
||||
isActive={selectedTag === tag.name}
|
||||
>
|
||||
{tag.name}
|
||||
</Button>
|
||||
))}
|
||||
{(tagsTable?.listAll().length ?? 0) > MAX_TAGS_TO_SHOW && (
|
||||
<IconButton
|
||||
aria-label="Show-all-tags"
|
||||
size={"sm"}
|
||||
icon={showAllTags ? <IconChevronUp /> : <IconChevronDown />}
|
||||
onClick={() => setShowAllTags(!showAllTags)}
|
||||
/>
|
||||
)}
|
||||
</HStack>
|
||||
<MyTagsRow
|
||||
selectedTag={selectedTag}
|
||||
setSelectedTag={setSelectedTag}
|
||||
/>
|
||||
<HStack mb={2} mt={2} p={0} justifyContent="space-between">
|
||||
<HStack>
|
||||
{allFlowsRef.current.length > 0 ? (
|
||||
@@ -319,8 +270,8 @@ export default function RecentFilesDrawer({ onClose, onClickNewFlow }: Props) {
|
||||
size={"sm"}
|
||||
variant={"outline"}
|
||||
icon={<IconFolderPlus size={21} />}
|
||||
onClick={() => {
|
||||
foldersTable?.create({
|
||||
onClick={async () => {
|
||||
await foldersTable?.create({
|
||||
name: "New Folder",
|
||||
});
|
||||
loadLatestWorkflows();
|
||||
@@ -349,7 +300,12 @@ export default function RecentFilesDrawer({ onClose, onClickNewFlow }: Props) {
|
||||
type="radio"
|
||||
onChange={(type) => onSort(type as ESortTypes)}
|
||||
>
|
||||
{Object.values(ESortTypes).map((sortType, index) => (
|
||||
{[
|
||||
ESortTypes.RECENTLY_MODIFIED,
|
||||
ESortTypes.OLDEST_MODIFIED,
|
||||
ESortTypes.AZ,
|
||||
ESortTypes.ZA,
|
||||
].map((sortType, index) => (
|
||||
<MenuItemOption key={index} value={sortType}>
|
||||
{sortType}
|
||||
</MenuItemOption>
|
||||
@@ -362,13 +318,17 @@ export default function RecentFilesDrawer({ onClose, onClickNewFlow }: Props) {
|
||||
searchValue={searchValue}
|
||||
onUpdateSearchValue={onUpdateSearchValue}
|
||||
/>
|
||||
<Flex overflowY={"auto"} overflowX={"hidden"} direction="column">
|
||||
{currentRenderingData.map((n) => {
|
||||
if (isFolder(n)) {
|
||||
return <FilesListFolderItem folder={n} key={n.id} />;
|
||||
}
|
||||
return <WorkflowListItem key={n.id} workflow={n} />;
|
||||
})}
|
||||
<Flex
|
||||
overflowY={"auto"}
|
||||
overflowX={"hidden"}
|
||||
direction="column"
|
||||
style={{
|
||||
overflowY: "scroll",
|
||||
scrollbarWidth: "none", // For Firefox
|
||||
msOverflowStyle: "none", // For Internet Explorer and Edge
|
||||
}}
|
||||
>
|
||||
<ItemsList items={currentRenderingData} />
|
||||
</Flex>
|
||||
</Flex>
|
||||
</Card>
|
||||
|
||||
@@ -5,33 +5,26 @@ import {
|
||||
Text,
|
||||
Checkbox,
|
||||
Flex,
|
||||
Image,
|
||||
Stack,
|
||||
} from "@chakra-ui/react";
|
||||
import { Workflow, isFolder, updateFlow } from "../db-tables/WorkspaceDB";
|
||||
import { formatTimestamp, isImageFormat } from "../utils";
|
||||
import AddTagToWorkflowPopover from "./AddTagToWorkflowPopover";
|
||||
import { useState, memo, ChangeEvent, useContext } from "react";
|
||||
import WorkflowListItemRightClickMenu from "./WorkflowListItemRightClickMenu";
|
||||
import DeleteConfirm from "../components/DeleteConfirm";
|
||||
import { IconLock } from "@tabler/icons-react";
|
||||
import { formatTimestamp } from "../utils";
|
||||
import { memo, ChangeEvent, useContext, useState } from "react";
|
||||
import { RecentFilesContext, WorkspaceContext } from "../WorkspaceContext";
|
||||
import { Workflow } from "../types/dbTypes";
|
||||
import MediaPreview from "../components/MediaPreview";
|
||||
import WorkflowListItemActionButtons from "./WorkflowListItemActionButtons";
|
||||
import { userSettingsTable } from "../db-tables/WorkspaceDB";
|
||||
|
||||
type Props = {
|
||||
workflow: Workflow;
|
||||
};
|
||||
export default function WorkflowListItem({ workflow }: Props) {
|
||||
export default memo(function WorkflowListItem({ workflow }: Props) {
|
||||
const { colorMode } = useColorMode();
|
||||
const [isDraggingOver, setIsDraggingOver] = useState(false);
|
||||
const [menuPosition, setMenuPosition] = useState({ x: 0, y: 0 });
|
||||
const [isMenuOpen, setIsMenuOpen] = useState(false);
|
||||
const {
|
||||
setDraggingFile,
|
||||
isMultiSelecting,
|
||||
onMultiSelectFlow,
|
||||
onDeleteFlow,
|
||||
multiSelectedFlowsID,
|
||||
onRefreshFilesList,
|
||||
draggingFile,
|
||||
} = useContext(RecentFilesContext);
|
||||
const isChecked =
|
||||
multiSelectedFlowsID &&
|
||||
@@ -39,48 +32,19 @@ export default function WorkflowListItem({ workflow }: Props) {
|
||||
multiSelectedFlowsID.includes(workflow.id);
|
||||
const { curFlowID, loadWorkflowID } = useContext(WorkspaceContext);
|
||||
const isSelected = curFlowID === workflow.id;
|
||||
const handleContextMenu = (event: any) => {
|
||||
event.preventDefault();
|
||||
setMenuPosition({ x: event.clientX, y: event.clientY });
|
||||
setIsMenuOpen(true);
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
setIsMenuOpen(false);
|
||||
};
|
||||
const hoverBgColor = colorMode === "light" ? "gray.200" : "#4A5568";
|
||||
|
||||
const [isHovering, setIsHovering] = useState(false);
|
||||
const basicInfoComp = (
|
||||
<Box
|
||||
flexShrink={1}
|
||||
flexGrow={1}
|
||||
onDragOver={(e) => {
|
||||
e.preventDefault();
|
||||
setIsDraggingOver(true);
|
||||
}}
|
||||
onDragLeave={() => {
|
||||
setIsDraggingOver(false);
|
||||
}}
|
||||
onDrop={() => {
|
||||
if (draggingFile && !isFolder(draggingFile)) {
|
||||
updateFlow(draggingFile.id, {
|
||||
parentFolderID: workflow.parentFolderID,
|
||||
});
|
||||
onRefreshFilesList && onRefreshFilesList();
|
||||
}
|
||||
setIsDraggingOver(false);
|
||||
}}
|
||||
backgroundColor={
|
||||
isSelected ? "teal.200" : isMenuOpen ? hoverBgColor : undefined
|
||||
}
|
||||
backgroundColor={isSelected ? "teal.200" : undefined}
|
||||
color={isSelected && !isMultiSelecting ? "#333" : undefined}
|
||||
draggable={!isMultiSelecting}
|
||||
onDragStart={(e) => {
|
||||
setDraggingFile && setDraggingFile(workflow);
|
||||
}}
|
||||
onDragStart={() => setDraggingFile?.(workflow)}
|
||||
borderRadius={6}
|
||||
px={1}
|
||||
py={1}
|
||||
py={"5px"}
|
||||
onClick={() => {
|
||||
!isMultiSelecting && loadWorkflowID(workflow.id);
|
||||
}}
|
||||
@@ -88,46 +52,46 @@ export default function WorkflowListItem({ workflow }: Props) {
|
||||
bg: hoverBgColor,
|
||||
}}
|
||||
>
|
||||
<HStack>
|
||||
{workflow.coverMediaPath != null &&
|
||||
(isImageFormat(workflow.coverMediaPath) ? (
|
||||
<Image
|
||||
borderRadius={3}
|
||||
boxSize="60px"
|
||||
objectFit="cover"
|
||||
src={`/workspace/view_media?filename=${workflow.coverMediaPath}`}
|
||||
alt={workflow.name ?? "workflow cover image"}
|
||||
<HStack flexGrow={1}>
|
||||
{!userSettingsTable?.settings?.hideCoverImage &&
|
||||
(workflow.coverMediaPath?.length || workflow.latestImage?.length) && (
|
||||
<MediaPreview
|
||||
mediaLocalPath={
|
||||
workflow.coverMediaPath ?? workflow.latestImage ?? ""
|
||||
}
|
||||
size={60}
|
||||
hideBrokenImage
|
||||
isPreview
|
||||
/>
|
||||
) : (
|
||||
<video
|
||||
width={60}
|
||||
height={60}
|
||||
src={`/workspace/view_media?filename=${workflow.coverMediaPath}`}
|
||||
muted={true}
|
||||
>
|
||||
<track kind="captions" />
|
||||
</video>
|
||||
))}
|
||||
)}
|
||||
|
||||
<Stack textAlign={"left"} gap={0}>
|
||||
<Text fontWeight={"500"}>{workflow.name ?? "untitled"}</Text>
|
||||
<Text color={"GrayText"} ml={2} fontSize={"sm"}>
|
||||
Updated: {formatTimestamp(workflow.updateTime)}
|
||||
</Text>
|
||||
</Stack>
|
||||
<Box
|
||||
textAlign={"left"}
|
||||
gap={0}
|
||||
justifyContent={"space-between"}
|
||||
flexGrow={1}
|
||||
>
|
||||
<Flex alignItems={"center"}>
|
||||
<Text fontWeight={"500"} noOfLines={1} wordBreak={"break-all"}>
|
||||
{workflow.name ?? "untitled"}
|
||||
</Text>
|
||||
{workflow.saveLock && (
|
||||
<IconLock size={18} style={{ display: "inline-block" }} />
|
||||
)}
|
||||
</Flex>
|
||||
</Box>
|
||||
</HStack>
|
||||
{isDraggingOver && (
|
||||
<Box width={"100%"} mt={2} height={"2px"} backgroundColor={"#4299E1"} />
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
|
||||
return (
|
||||
<HStack
|
||||
w={"100%"}
|
||||
mb={1}
|
||||
justify={"space-between"}
|
||||
onContextMenu={handleContextMenu}
|
||||
borderBottom={"1px solid"}
|
||||
borderColor={colorMode == "dark" ? "gray.900" : "gray.200"}
|
||||
onMouseEnter={() => setIsHovering(true)}
|
||||
onMouseLeave={() => setIsHovering(false)}
|
||||
>
|
||||
{isMultiSelecting ? (
|
||||
<Checkbox
|
||||
@@ -143,24 +107,17 @@ export default function WorkflowListItem({ workflow }: Props) {
|
||||
) : (
|
||||
<>
|
||||
{basicInfoComp}
|
||||
<Flex width={"60px"}>
|
||||
<AddTagToWorkflowPopover workflow={workflow} />
|
||||
<DeleteConfirm
|
||||
promptMessage="Are you sure you want to delete this workflow?"
|
||||
onDelete={() => {
|
||||
onDeleteFlow && onDeleteFlow(workflow.id);
|
||||
}}
|
||||
/>
|
||||
</Flex>
|
||||
<Box width={"fit-content"}>
|
||||
{isHovering ? (
|
||||
<WorkflowListItemActionButtons workflow={workflow} />
|
||||
) : (
|
||||
<Text color={"GrayText"} ml={"2px"} fontSize={"sm"}>
|
||||
{formatTimestamp(workflow.updateTime, false, false, "/")}
|
||||
</Text>
|
||||
)}
|
||||
</Box>
|
||||
</>
|
||||
)}
|
||||
{isMenuOpen && (
|
||||
<WorkflowListItemRightClickMenu
|
||||
menuPosition={menuPosition}
|
||||
onClose={handleClose}
|
||||
workflowID={workflow.id}
|
||||
/>
|
||||
)}
|
||||
</HStack>
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
import { Flex, IconButton, Tooltip } from "@chakra-ui/react";
|
||||
import DeleteConfirm from "../components/DeleteConfirm";
|
||||
import MoreActionMenu from "./MoreActionMenu";
|
||||
import { useContext } from "react";
|
||||
import { RecentFilesContext } from "../WorkspaceContext";
|
||||
import { Workflow } from "../types/dbTypes";
|
||||
|
||||
export default function WorkflowListItemActionButtons({
|
||||
workflow,
|
||||
}: {
|
||||
workflow: Workflow;
|
||||
}) {
|
||||
const { onDeleteFlow } = useContext(RecentFilesContext);
|
||||
return (
|
||||
<Flex justifyContent={"flex-end"}>
|
||||
<DeleteConfirm
|
||||
promptMessage="Are you sure you want to delete this workflow?"
|
||||
onDelete={() => {
|
||||
onDeleteFlow && onDeleteFlow(workflow.id);
|
||||
}}
|
||||
/>
|
||||
<MoreActionMenu workflow={workflow} />
|
||||
</Flex>
|
||||
);
|
||||
}
|
||||
@@ -1,33 +0,0 @@
|
||||
import { Box, Menu, MenuList, MenuItem } from "@chakra-ui/react";
|
||||
import { useContext } from "react";
|
||||
import { WorkspaceContext } from "../WorkspaceContext";
|
||||
|
||||
type Props = {
|
||||
menuPosition: { x: number; y: number };
|
||||
onClose: () => void;
|
||||
workflowID: string;
|
||||
};
|
||||
export default function WorkflowListItemRightClickMenu({
|
||||
menuPosition,
|
||||
workflowID,
|
||||
onClose,
|
||||
}: Props) {
|
||||
const { onDuplicateWorkflow } = useContext(WorkspaceContext);
|
||||
return (
|
||||
<>
|
||||
<Box position="absolute" top={menuPosition.y} left={menuPosition.x}>
|
||||
<Menu isOpen={true} onClose={onClose}>
|
||||
<MenuList>
|
||||
<MenuItem
|
||||
onClick={() =>
|
||||
onDuplicateWorkflow && onDuplicateWorkflow(workflowID)
|
||||
}
|
||||
>
|
||||
Duplicate
|
||||
</MenuItem>
|
||||
</MenuList>
|
||||
</Menu>
|
||||
</Box>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -6,156 +6,22 @@ import {
|
||||
ModalHeader,
|
||||
ModalBody,
|
||||
ModalCloseButton,
|
||||
Text,
|
||||
StackDivider,
|
||||
VStack,
|
||||
Box,
|
||||
Tag,
|
||||
IconButton,
|
||||
Button,
|
||||
Flex,
|
||||
Input,
|
||||
Stack,
|
||||
useToast,
|
||||
SimpleGrid,
|
||||
} from "@chakra-ui/react";
|
||||
import { IconEdit, IconFolder } from "@tabler/icons-react";
|
||||
import { useRef, useState } from "react";
|
||||
import { getSystemDir } from "../Api";
|
||||
import { userSettingsTable } from "../db-tables/WorkspaceDB";
|
||||
import { ShortcutSettings } from "../settings/ShortcutSettings";
|
||||
import { validateOrSaveAllJsonFileMyWorkflows } from "../utils";
|
||||
import AutoSaveSettings from "../settings/AutoSaveSettings";
|
||||
import FolderOnTopSettings from "../settings/FolderOnTopSettings";
|
||||
import TwoWaySyncSettings from "../settings/TwoWaySyncSettings";
|
||||
import CommonCheckboxSettings from "../settings/CommonCheckboxSettings";
|
||||
import SelectMyWorkflowsDir from "../settings/SelectMyWorkflowsDir";
|
||||
import { CommonNumberSetting } from "../settings/CommonNumberSetting";
|
||||
import AutosaveSetting from "../settings/AutosaveSetting";
|
||||
|
||||
export default function WorkspaceSettingsModal({
|
||||
onClose,
|
||||
}: {
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const [isManualEntry, setIsManualEntry] = useState(false);
|
||||
const [isEditDirectory, setIsEditDirectory] = useState(false);
|
||||
const [currentDirectory, setCurrentDirectory] = useState(
|
||||
userSettingsTable?.records.myWorkflowsDir
|
||||
);
|
||||
const [dirPathList, setDirPathList] = useState<string[]>([]);
|
||||
const [subdirectoryList, setSubdirectoryList] = useState<string[]>([]);
|
||||
const [noPermission, setNoPermission] = useState(false);
|
||||
const manualEntryRef = useRef<HTMLInputElement>(null);
|
||||
const toast = useToast();
|
||||
|
||||
const getDir = async (root?: string) => {
|
||||
const {
|
||||
dir_path = "",
|
||||
dir_contents = [],
|
||||
error = "",
|
||||
} = await getSystemDir(root ?? "");
|
||||
if (error.includes("Operation not permitted")) {
|
||||
setNoPermission(true);
|
||||
onCloseEditDirectory();
|
||||
return {
|
||||
isError: true,
|
||||
};
|
||||
} else {
|
||||
return {
|
||||
dirList: dir_contents,
|
||||
dirPath: dir_path,
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
const onOpenEditDirectory = async () => {
|
||||
const { isError = false, dirList = [] } = await getDir(currentDirectory);
|
||||
if (!isError) {
|
||||
setSubdirectoryList(dirList);
|
||||
setIsEditDirectory(true);
|
||||
setDirPathList(
|
||||
(currentDirectory && currentDirectory.split("/").filter((p) => !!p)) ||
|
||||
[]
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const onCloseEditDirectory = () => {
|
||||
setIsEditDirectory(false);
|
||||
setIsManualEntry(false);
|
||||
setDirPathList([]);
|
||||
};
|
||||
|
||||
const onSaveDirectory = async (manualEntry?: string) => {
|
||||
if (manualEntry) {
|
||||
const { error = "" } = await getSystemDir(manualEntry);
|
||||
if (error.includes("Not a directory")) {
|
||||
toast({
|
||||
title:
|
||||
"The directory address you entered is invalid, please re-enter it.",
|
||||
status: "warning",
|
||||
duration: 5000,
|
||||
position: "top",
|
||||
isClosable: true,
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
const newPath = manualEntry ?? `/${dirPathList.join("/")}`;
|
||||
userSettingsTable?.upsert({
|
||||
myWorkflowsDir: newPath,
|
||||
});
|
||||
setCurrentDirectory(userSettingsTable?.getSetting("myWorkflowsDir"));
|
||||
manualEntry && setNoPermission(false);
|
||||
onCloseEditDirectory();
|
||||
// to update /my_workflows files in disk to new location
|
||||
validateOrSaveAllJsonFileMyWorkflows();
|
||||
};
|
||||
|
||||
const onReset = async () => {
|
||||
const { dirPath, dirList } = await getDir("");
|
||||
setDirPathList(dirPath.split("/").filter((p: string) => !!p));
|
||||
setSubdirectoryList(dirList);
|
||||
};
|
||||
|
||||
const onDirJump = async (index: number, nextDir?: string) => {
|
||||
let nextDirPath = "";
|
||||
if (index === -1 && nextDir) {
|
||||
nextDirPath = `/${dirPathList.concat([nextDir]).join("/")}`;
|
||||
} else {
|
||||
nextDirPath = `/${dirPathList.slice(0, index + 1).join("/")}`;
|
||||
}
|
||||
const { isError = false, dirList = [] } = await getDir(nextDirPath);
|
||||
|
||||
if (!isError) {
|
||||
setSubdirectoryList(dirList);
|
||||
setDirPathList(nextDirPath.slice(1)?.split("/"));
|
||||
}
|
||||
};
|
||||
|
||||
const onOpenEnter = () => {
|
||||
setIsEditDirectory(true);
|
||||
setIsManualEntry(true);
|
||||
};
|
||||
|
||||
const enterFolderAddressComp = (
|
||||
<Box textAlign="end" mb={4}>
|
||||
<Input
|
||||
placeholder="Please enter the absolute path directory address"
|
||||
ref={manualEntryRef}
|
||||
mt={2}
|
||||
mb={2}
|
||||
/>
|
||||
<Button
|
||||
colorScheme="teal"
|
||||
mr={3}
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
manualEntryRef?.current?.value &&
|
||||
onSaveDirectory(manualEntryRef?.current?.value);
|
||||
}}
|
||||
>
|
||||
Save
|
||||
</Button>
|
||||
</Box>
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Modal isOpen={true} onClose={onClose} size={"xl"}>
|
||||
@@ -171,150 +37,30 @@ export default function WorkspaceSettingsModal({
|
||||
align="stretch"
|
||||
w="100%"
|
||||
>
|
||||
<Box mb={4}>
|
||||
<Text mb={2} fontWeight={500}>
|
||||
Workspace Save Directory
|
||||
</Text>
|
||||
{!isEditDirectory ? (
|
||||
<Flex>
|
||||
<Tag overflowWrap="anywhere">{currentDirectory}</Tag>
|
||||
<IconButton
|
||||
aria-label="Enter folder address"
|
||||
size={"sm"}
|
||||
ml={2}
|
||||
icon={<IconEdit />}
|
||||
onClick={onOpenEnter}
|
||||
/>
|
||||
<IconButton
|
||||
aria-label="Select folder"
|
||||
size={"sm"}
|
||||
ml={2}
|
||||
icon={<IconFolder />}
|
||||
onClick={onOpenEditDirectory}
|
||||
/>
|
||||
</Flex>
|
||||
) : isManualEntry ? (
|
||||
enterFolderAddressComp
|
||||
) : (
|
||||
<VStack spacing={2} alignItems="start">
|
||||
<Stack spacing="2px" direction="row" wrap="wrap">
|
||||
{dirPathList.map((dir, index) => (
|
||||
<>
|
||||
/
|
||||
<Button
|
||||
colorScheme="teal"
|
||||
variant="link"
|
||||
minW={1}
|
||||
key={`${dir}-${index}`}
|
||||
onClick={() => {
|
||||
onDirJump(index);
|
||||
}}
|
||||
>
|
||||
{dir}
|
||||
</Button>
|
||||
</>
|
||||
))}
|
||||
</Stack>
|
||||
<SimpleGrid
|
||||
columns={2}
|
||||
spacing={3}
|
||||
maxHeight={500}
|
||||
width="100%"
|
||||
overflowY="auto"
|
||||
>
|
||||
{subdirectoryList.map((sub, index) => (
|
||||
<Button
|
||||
key={`${sub}-${index}`}
|
||||
size="sm"
|
||||
leftIcon={
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
fill="#EFC54D"
|
||||
style={{ width: "1.2rem", height: "1.2rem" }}
|
||||
>
|
||||
<path d="M19.5 21a3 3 0 0 0 3-3v-4.5a3 3 0 0 0-3-3h-15a3 3 0 0 0-3 3V18a3 3 0 0 0 3 3h15ZM1.5 10.146V6a3 3 0 0 1 3-3h5.379a2.25 2.25 0 0 1 1.59.659l2.122 2.121c.14.141.331.22.53.22H19.5a3 3 0 0 1 3 3v1.146A4.483 4.483 0 0 0 19.5 9h-15a4.483 4.483 0 0 0-3 1.146Z" />
|
||||
</svg>
|
||||
}
|
||||
justifyContent="start"
|
||||
onClick={() => {
|
||||
onDirJump(-1, sub);
|
||||
}}
|
||||
>
|
||||
{sub}
|
||||
</Button>
|
||||
))}
|
||||
</SimpleGrid>
|
||||
<Box w="100%" textAlign="end">
|
||||
<Button
|
||||
colorScheme="teal"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
onSaveDirectory();
|
||||
}}
|
||||
>
|
||||
Save
|
||||
</Button>
|
||||
<Button size="sm" onClick={onReset} mr={2} ml={2}>
|
||||
Reset
|
||||
</Button>
|
||||
<Button size="sm" onClick={onCloseEditDirectory}>
|
||||
Cancel
|
||||
</Button>
|
||||
</Box>
|
||||
</VStack>
|
||||
)}
|
||||
</Box>
|
||||
<SelectMyWorkflowsDir />
|
||||
<AutosaveSetting />
|
||||
<ShortcutSettings />
|
||||
<AutoSaveSettings />
|
||||
<TwoWaySyncSettings />
|
||||
<FolderOnTopSettings />
|
||||
<CommonCheckboxSettings
|
||||
settingKey="overwriteCurWorkflowWhenDroppingFileToCanvas"
|
||||
text="Overwrite current workflow when drag and drop workflow file to canvas"
|
||||
/>
|
||||
<CommonNumberSetting
|
||||
label={`Maximum number of save change history to store. This does not include
|
||||
versions that you created by "Create Version", which are always stored
|
||||
and has no limit.`}
|
||||
settingKey="maximumChangelogNumber"
|
||||
/>
|
||||
<CommonCheckboxSettings
|
||||
settingKey="hideCoverImage"
|
||||
text="Hide thumbnail image in workflow list"
|
||||
/>
|
||||
</VStack>
|
||||
</HStack>
|
||||
</ModalBody>
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
<Modal
|
||||
isOpen={noPermission}
|
||||
onClose={() => setNoPermission(false)}
|
||||
size={"xl"}
|
||||
>
|
||||
<ModalOverlay />
|
||||
<ModalContent>
|
||||
<ModalHeader>Kind tips</ModalHeader>
|
||||
<ModalCloseButton />
|
||||
<ModalBody>
|
||||
<Box>
|
||||
{navigator.userAgent.indexOf("Mac OS X") !== -1 ? (
|
||||
<Text fontWeight={500}>
|
||||
There is no permission to access the folder. You need to grant
|
||||
full disk access permission to the terminal that starts the
|
||||
ComfyUI program. For details, refer to the
|
||||
<Button
|
||||
colorScheme="teal"
|
||||
variant="link"
|
||||
as="a"
|
||||
ml={2}
|
||||
target="_block"
|
||||
href="https://stackoverflow.com/questions/58479686/permissionerror-errno-1-operation-not-permitted-after-macos
|
||||
-catalina-update"
|
||||
>
|
||||
Document
|
||||
</Button>
|
||||
, or you can manually enter the absolute path of the folder in
|
||||
the input box below to save.
|
||||
</Text>
|
||||
) : (
|
||||
<Text fontWeight={500}>
|
||||
You do not have permission to access the folder. It is
|
||||
recommended that you manually enter the absolute path address
|
||||
of the required folder.
|
||||
</Text>
|
||||
)}
|
||||
{enterFolderAddressComp}
|
||||
</Box>
|
||||
</ModalBody>
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -4,6 +4,10 @@
|
||||
export const sortTypeLocalStorageKey = "CWM_WORKFLOWS_SORT_TYPE";
|
||||
|
||||
export enum ESortTypes {
|
||||
/**
|
||||
* Sort by last opened time, most recently modified first
|
||||
*/
|
||||
RECENTLY_OPENED = "recentlyOpened",
|
||||
/**
|
||||
* Sort by update time, most recently modified first
|
||||
*/
|
||||
@@ -26,3 +30,21 @@ export type ImportWorkflow = {
|
||||
json: string;
|
||||
name?: string;
|
||||
};
|
||||
|
||||
export const importMenuItemList = [
|
||||
{
|
||||
title: "Import Workflows",
|
||||
type: "file",
|
||||
},
|
||||
{
|
||||
title: "Import Folder",
|
||||
type: "folder",
|
||||
},
|
||||
];
|
||||
|
||||
// Solve TS errors caused by webkitdirectory
|
||||
declare module "react" {
|
||||
interface InputHTMLAttributes<T> extends HTMLAttributes<T> {
|
||||
webkitdirectory?: string;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,27 +1,40 @@
|
||||
import { createContext } from "react";
|
||||
import { Workflow } from "./db-tables/WorkspaceDB";
|
||||
import { Route } from "./utils";
|
||||
import { Folder } from "./types/dbTypes";
|
||||
import { Folder, Workflow, WorkflowVersion } from "./types/dbTypes";
|
||||
import { WorkspaceRoute } from "./types/types";
|
||||
export type JsonDiff = {
|
||||
old: Object;
|
||||
new: Object;
|
||||
} | null;
|
||||
|
||||
export const WorkspaceContext = createContext<{
|
||||
curFlowID: string | null;
|
||||
onDuplicateWorkflow?: (flowID: string, newFlowName?: string) => void;
|
||||
loadWorkflowID: (id: string) => void;
|
||||
saveCurWorkflow: () => void;
|
||||
discardUnsavedChanges: () => void;
|
||||
loadWorkflowID: (id: string | null, versionID?: string | null) => void;
|
||||
setIsDirty: (dirty: boolean) => void;
|
||||
saveCurWorkflow: (saveCurWorkflow?: string) => Promise<void>;
|
||||
discardUnsavedChanges: () => Promise<void>;
|
||||
isDirty: boolean;
|
||||
loadNewWorkflow: (input?: { json: string; name?: string }) => void;
|
||||
loadFilePath: (path: string, overwriteCurrent?: boolean) => void;
|
||||
setRoute: (route: Route) => void;
|
||||
setRoute: (route: WorkspaceRoute) => void;
|
||||
route: WorkspaceRoute;
|
||||
curVersion: WorkflowVersion | null;
|
||||
setCurVersion: (version: WorkflowVersion | null) => void;
|
||||
setCurFlowIDAndName: (workflow: Workflow) => void;
|
||||
}>({
|
||||
curFlowID: null,
|
||||
loadWorkflowID: () => {},
|
||||
saveCurWorkflow: () => {},
|
||||
discardUnsavedChanges: () => {},
|
||||
saveCurWorkflow: async () => {},
|
||||
discardUnsavedChanges: async () => {},
|
||||
isDirty: false,
|
||||
loadNewWorkflow: () => {},
|
||||
loadFilePath: () => {},
|
||||
setRoute: () => {},
|
||||
route: "root",
|
||||
curVersion: null,
|
||||
setIsDirty: () => {},
|
||||
setCurVersion: () => {},
|
||||
setCurFlowIDAndName: () => {},
|
||||
});
|
||||
|
||||
export const RecentFilesContext = createContext<{
|
||||
|
||||
@@ -0,0 +1,271 @@
|
||||
import { COMFYSPACE_TRACKING_FIELD_NAME } from "../const";
|
||||
import { indexdb } from "../db-tables/indexdb";
|
||||
import { Workflow } from "../types/dbTypes";
|
||||
import {
|
||||
getWorkflowRelPath,
|
||||
joinRelPath,
|
||||
sanitizeRelPath,
|
||||
} from "../utils/OsPathUtils";
|
||||
|
||||
export namespace TwowaySyncAPI {
|
||||
export async function moveWorkflow(
|
||||
workflow: Workflow,
|
||||
newParentFolderRelPath: string,
|
||||
) {
|
||||
try {
|
||||
const response = await fetch("/workspace/file/move", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
path: getWorkflowRelPath(workflow),
|
||||
newParentPath: newParentFolderRelPath,
|
||||
}),
|
||||
});
|
||||
const result = await response.json();
|
||||
if (result.error) {
|
||||
alert("Error moving file: " + result.error);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error("Error deleting file:", error);
|
||||
}
|
||||
}
|
||||
|
||||
export async function genFileUniqueName(
|
||||
fileName: string,
|
||||
parentFolderID: string | null,
|
||||
): Promise<string | null> {
|
||||
try {
|
||||
const response = await fetch("/workspace/file/gen_unique_name", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
path: joinRelPath(parentFolderID ?? "", fileName),
|
||||
}),
|
||||
});
|
||||
const result = await response.json();
|
||||
if (result.error) {
|
||||
console.error("Error getting unique name:", result.error);
|
||||
return null;
|
||||
}
|
||||
return result.uniqueName;
|
||||
} catch (error) {
|
||||
console.error("Error deleting file:", error);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export async function renameWorkflow(
|
||||
{ parentFolderID, name }: Pick<Workflow, "name" | "parentFolderID">,
|
||||
newName: string,
|
||||
) {
|
||||
try {
|
||||
const response = await fetch("/workspace/file/rename", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
path: joinRelPath(parentFolderID ?? "", name + ".json"),
|
||||
newName: newName,
|
||||
}),
|
||||
});
|
||||
const result = await response.json();
|
||||
if (result.error) {
|
||||
alert("Error rename file: " + result.error);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error("Error deleting file:", error);
|
||||
}
|
||||
}
|
||||
|
||||
interface DuplicatesResponse {
|
||||
duplicates: {
|
||||
[id: string]: Array<{
|
||||
path: string;
|
||||
createTime: number;
|
||||
}>;
|
||||
};
|
||||
}
|
||||
|
||||
export async function saveWorkflow(workflow: Workflow) {
|
||||
const json = workflow.json;
|
||||
const flow = JSON.parse(json);
|
||||
if (flow.extra[COMFYSPACE_TRACKING_FIELD_NAME] == null) {
|
||||
flow.extra[COMFYSPACE_TRACKING_FIELD_NAME] = {};
|
||||
}
|
||||
flow.extra[COMFYSPACE_TRACKING_FIELD_NAME].id = workflow.id;
|
||||
const response = await fetch("/workspace/file/save", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
path: getWorkflowRelPath(workflow),
|
||||
json: JSON.stringify(flow),
|
||||
}),
|
||||
});
|
||||
const result = await response.json();
|
||||
if (result.error) {
|
||||
console.error("Error saving file:", result.error);
|
||||
alert(`"Error saving file: ${result.error}
|
||||
|
||||
👉Please try "Save as" from "Files" dropdown menu
|
||||
`);
|
||||
throw new Error(result.error);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
export async function deleteWorkflow(workflow: Workflow) {
|
||||
try {
|
||||
const response = await fetch("/workspace/delete_file", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
file_path: getWorkflowRelPath(workflow),
|
||||
deleteEmptyFolder: false,
|
||||
}),
|
||||
});
|
||||
const result = await response.text();
|
||||
return result;
|
||||
} catch (error) {
|
||||
alert("Error deleting workflow .json file: " + error);
|
||||
console.error("Error deleting file:", error);
|
||||
}
|
||||
}
|
||||
export async function createWorkflow({
|
||||
parentFolderID,
|
||||
name,
|
||||
json,
|
||||
id,
|
||||
}: Workflow) {
|
||||
let jsonObj: any = JSON.parse(json);
|
||||
if (jsonObj.extra == null) {
|
||||
jsonObj.extra = {};
|
||||
}
|
||||
if (jsonObj.extra[COMFYSPACE_TRACKING_FIELD_NAME] == null) {
|
||||
jsonObj.extra[COMFYSPACE_TRACKING_FIELD_NAME] = {};
|
||||
}
|
||||
jsonObj.extra[COMFYSPACE_TRACKING_FIELD_NAME].id = id; // to avoid overwriting deps
|
||||
const input: { parentFolderPath: string; name: string; json: string } = {
|
||||
parentFolderPath: sanitizeRelPath(parentFolderID ?? ""),
|
||||
name: name,
|
||||
json: JSON.stringify(jsonObj),
|
||||
};
|
||||
|
||||
try {
|
||||
const response = await fetch("/workspace/create_workflow_file", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify(input),
|
||||
});
|
||||
const result: { name?: string } = await response.json();
|
||||
|
||||
result.name &&
|
||||
(await indexdb.workflows?.update(id, {
|
||||
name: result.name,
|
||||
}));
|
||||
return result;
|
||||
} catch (error) {
|
||||
console.error(
|
||||
`Error creating file <${id}> at "${parentFolderID}"`,
|
||||
error,
|
||||
);
|
||||
alert(`Error creating file <${id}> at "${parentFolderID}"`);
|
||||
return {};
|
||||
}
|
||||
}
|
||||
export async function getFile({
|
||||
parentFolderID,
|
||||
name,
|
||||
id,
|
||||
}: {
|
||||
parentFolderID: string | null;
|
||||
id: string;
|
||||
name: string;
|
||||
}): Promise<{
|
||||
json?: Object;
|
||||
error?: string;
|
||||
}> {
|
||||
const relPath = joinRelPath(parentFolderID ?? "", name + ".json");
|
||||
try {
|
||||
const response = await fetch("/workspace/get_workflow_file", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
|
||||
body: JSON.stringify({
|
||||
path: relPath,
|
||||
id: id,
|
||||
}),
|
||||
});
|
||||
const result = await response.json();
|
||||
if (result.error) {
|
||||
console.error(
|
||||
`Error getting file <${id}> at "${relPath}"`,
|
||||
result.error,
|
||||
);
|
||||
alert({
|
||||
message: `Error getting file <${id}> at "${relPath}"`,
|
||||
level: "error",
|
||||
});
|
||||
}
|
||||
return result;
|
||||
} catch (error) {
|
||||
console.error(`Erro finding file <${id}> at "${relPath}"`, error);
|
||||
return {};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export type ScanLocalFile = {
|
||||
type: "workflow";
|
||||
name: string;
|
||||
id: string;
|
||||
json: string;
|
||||
createTime: number;
|
||||
updateTime: number;
|
||||
lastOpenedTime?: number;
|
||||
};
|
||||
export type ScanLocalFolder = {
|
||||
id: string;
|
||||
type: "folder";
|
||||
name: string;
|
||||
parentFolderID: string;
|
||||
};
|
||||
export async function scanLocalFiles(
|
||||
path: string,
|
||||
recursive = false,
|
||||
metaInfoOnly = false,
|
||||
): Promise<Array<ScanLocalFile | ScanLocalFolder>> {
|
||||
try {
|
||||
const response = await fetch("/workspace/file/scan_my_workflows_folder", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
path: sanitizeRelPath(path),
|
||||
recursive,
|
||||
metaInfoOnly,
|
||||
}),
|
||||
});
|
||||
const result = await response.json();
|
||||
return result;
|
||||
} catch (error) {
|
||||
console.error("Error scan local new files:", error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
import { Folder } from "../types/dbTypes";
|
||||
import { sanitizeRelPath } from "../utils/OsPathUtils";
|
||||
|
||||
export namespace TwowayFolderSyncAPI {
|
||||
export async function genFolderRelPath({
|
||||
parentFolderID,
|
||||
name,
|
||||
}: {
|
||||
parentFolderID: string | null;
|
||||
name: string;
|
||||
}): Promise<string> {
|
||||
return sanitizeRelPath(`${parentFolderID ?? ""}/${name}`);
|
||||
}
|
||||
|
||||
export async function createFolder(folder: Folder) {
|
||||
await fetch("/workspace/folder/create", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
path: sanitizeRelPath(folder.id),
|
||||
}),
|
||||
});
|
||||
}
|
||||
export async function moveFolder(
|
||||
folderToBeMoved: string,
|
||||
newParentPath: string,
|
||||
) {
|
||||
const resp = await fetch("/workspace/folder/move", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
folder: folderToBeMoved,
|
||||
newParentPath: newParentPath,
|
||||
}),
|
||||
});
|
||||
const result = await resp.json();
|
||||
if (result.error) {
|
||||
alert("Failed to move folder: " + result.error);
|
||||
}
|
||||
return;
|
||||
}
|
||||
export async function deleteFolder(relPath: string) {
|
||||
try {
|
||||
const response = await fetch("/workspace/folder/delete", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
path: relPath,
|
||||
}),
|
||||
});
|
||||
const result = await response.text();
|
||||
return result;
|
||||
} catch (error) {
|
||||
console.error("Error deleting file:", error);
|
||||
}
|
||||
}
|
||||
|
||||
export async function genFilesCountInFolder(
|
||||
folderRelPath: string,
|
||||
): Promise<number | null> {
|
||||
try {
|
||||
const response = await fetch("/workspace/file/count_files", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
path: folderRelPath,
|
||||
}),
|
||||
});
|
||||
const result = await response.json();
|
||||
if (result.error) {
|
||||
console.error("Error counting files:", result.error);
|
||||
}
|
||||
return result.count;
|
||||
} catch (error) {
|
||||
console.error("Error renaming file:", error);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export async function renameFolder(
|
||||
folderRelPath: string,
|
||||
newName: string,
|
||||
): Promise<void> {
|
||||
try {
|
||||
const response = await fetch("/workspace/folder/rename", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
absPath: folderRelPath,
|
||||
newName: newName,
|
||||
}),
|
||||
});
|
||||
const result = await response.json();
|
||||
if (result.error) {
|
||||
alert("Failed to rename folder: " + result.error);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error renaming file:", error);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -12,41 +12,8 @@ export function openCognitoPopup() {
|
||||
const popupWindow = window.open(
|
||||
popupUrl,
|
||||
"ComfyspaceLogin",
|
||||
"width=800,height=600"
|
||||
"width=800,height=600",
|
||||
);
|
||||
|
||||
popupWindow?.focus();
|
||||
}
|
||||
|
||||
export function pullAuthTokenCloseIfExist() {
|
||||
if (window.location.href.includes(COMFYSPACE_AUTH_REDIRECT_URL + "#")) {
|
||||
const paramsString = window.location.href.split("#")[1];
|
||||
let params = new URLSearchParams(paramsString);
|
||||
|
||||
let accessToken = params.get("access_token");
|
||||
window.opener.postMessage(
|
||||
{ comfyspace_authToken: accessToken },
|
||||
"http://127.0.0.1:8188/"
|
||||
);
|
||||
window.close();
|
||||
}
|
||||
}
|
||||
|
||||
export function authTokenListener(event: MessageEvent) {
|
||||
const { comfyspace_authToken } = event.data;
|
||||
if (comfyspace_authToken) {
|
||||
fetch(`${COGNITO_DOMAIN}/oauth2/userInfo`, {
|
||||
method: "GET",
|
||||
headers: {
|
||||
Authorization: `Bearer ${comfyspace_authToken}`,
|
||||
},
|
||||
})
|
||||
.then((response) => response.json())
|
||||
.then((data) => {
|
||||
console.log("User Info:", data);
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error("Error fetching user info:", error);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,10 +21,18 @@ interface DialogButton {
|
||||
label: string;
|
||||
onClick: () => void;
|
||||
colorScheme?: string;
|
||||
icon?: React.ReactElement;
|
||||
}
|
||||
|
||||
interface DialogContextType {
|
||||
showDialog: (message: string, buttons: DialogButton[]) => void;
|
||||
showDialog: (
|
||||
message: string | React.ReactNode,
|
||||
buttons?: (DialogButton | null)[],
|
||||
options?: {
|
||||
hideCloseIcon?: boolean;
|
||||
closeOnOverlayClick?: boolean;
|
||||
},
|
||||
) => void;
|
||||
}
|
||||
|
||||
const DialogContext = createContext<DialogContextType | undefined>(undefined);
|
||||
@@ -41,15 +49,22 @@ export const AlertDialogProvider: React.FC<{ children: ReactNode }> = ({
|
||||
children,
|
||||
}) => {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [message, setMessage] = useState("");
|
||||
const [buttons, setButtons] = useState<DialogButton[]>([]);
|
||||
const [message, setMessage] = useState<string | React.ReactNode>("");
|
||||
const [buttons, setButtons] = useState<(DialogButton | null)[]>([]);
|
||||
const [hideCloseIcon, setHideCloseIcon] = useState(false);
|
||||
const [closeOnOverlayClick, setCloseOnOverlayClick] = useState(true);
|
||||
const cancelRef = React.useRef<HTMLButtonElement>(null);
|
||||
|
||||
const showDialog = useCallback((message: string, buttons: DialogButton[]) => {
|
||||
setMessage(message);
|
||||
setButtons(buttons);
|
||||
setIsOpen(true);
|
||||
}, []);
|
||||
const showDialog: DialogContextType["showDialog"] = useCallback(
|
||||
(message, buttons, options) => {
|
||||
setMessage(message);
|
||||
setButtons(buttons ?? []);
|
||||
setIsOpen(true);
|
||||
setHideCloseIcon(options?.hideCloseIcon ?? false);
|
||||
setCloseOnOverlayClick(options?.closeOnOverlayClick ?? true);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const handleClose = useCallback(() => {
|
||||
setIsOpen(false);
|
||||
@@ -63,7 +78,8 @@ export const AlertDialogProvider: React.FC<{ children: ReactNode }> = ({
|
||||
isOpen={isOpen}
|
||||
leastDestructiveRef={cancelRef}
|
||||
onClose={handleClose}
|
||||
size={"lg"}
|
||||
size={"xl"}
|
||||
closeOnOverlayClick={closeOnOverlayClick}
|
||||
>
|
||||
<DarkMode>
|
||||
<AlertDialogOverlay>
|
||||
@@ -72,24 +88,31 @@ export const AlertDialogProvider: React.FC<{ children: ReactNode }> = ({
|
||||
fontSize="lg"
|
||||
fontWeight="bold"
|
||||
></AlertDialogHeader>
|
||||
<AlertDialogCloseButton />
|
||||
{!hideCloseIcon ? <AlertDialogCloseButton /> : null}
|
||||
|
||||
<AlertDialogBody>{message}</AlertDialogBody>
|
||||
<AlertDialogBody whiteSpace="pre-wrap">
|
||||
{message}
|
||||
</AlertDialogBody>
|
||||
|
||||
<AlertDialogFooter>
|
||||
{buttons.map((button, index) => (
|
||||
<Button
|
||||
colorScheme={button.colorScheme ?? "gray"}
|
||||
onClick={() => {
|
||||
button.onClick();
|
||||
handleClose();
|
||||
}}
|
||||
ml={3}
|
||||
key={index}
|
||||
>
|
||||
{button.label}
|
||||
</Button>
|
||||
))}
|
||||
{buttons.map(
|
||||
(button, index) =>
|
||||
button && (
|
||||
<Button
|
||||
colorScheme={button.colorScheme ?? "gray"}
|
||||
onClick={() => {
|
||||
button.onClick();
|
||||
handleClose();
|
||||
}}
|
||||
iconSpacing={1}
|
||||
leftIcon={button.icon}
|
||||
ml={3}
|
||||
key={index}
|
||||
>
|
||||
{button.label}
|
||||
</Button>
|
||||
),
|
||||
)}
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialogOverlay>
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
import React from "react";
|
||||
import { Flex, IconButton, Image } from "@chakra-ui/react";
|
||||
import { isImageFormat } from "../../utils.tsx";
|
||||
import { IconChevronLeft, IconChevronRight } from "@tabler/icons-react";
|
||||
|
||||
type Media = {
|
||||
id: string;
|
||||
imageUrl: string;
|
||||
};
|
||||
|
||||
interface CarouselProps {
|
||||
media: Media[];
|
||||
currentNum: number;
|
||||
setMediaAct: (media: Media) => void;
|
||||
}
|
||||
|
||||
const Carousel: React.FC<CarouselProps> = ({
|
||||
setMediaAct,
|
||||
media,
|
||||
currentNum,
|
||||
}) => {
|
||||
const paginate = (newDirection: number) => {
|
||||
const cur = (currentNum + newDirection + media.length) % media.length;
|
||||
if (setMediaAct) {
|
||||
setMediaAct(media[cur]);
|
||||
}
|
||||
};
|
||||
|
||||
const imageSize = {
|
||||
width: "100%", // Adjust the width as needed
|
||||
height: "100%", // Adjust the height as needed
|
||||
};
|
||||
|
||||
if (media.length === 0)
|
||||
return <p>No images found, let's start generating 🪄</p>;
|
||||
const item = media.at(currentNum);
|
||||
if (!item) return null;
|
||||
return (
|
||||
<Flex
|
||||
justifyContent="center"
|
||||
alignItems="center"
|
||||
position="relative"
|
||||
width={imageSize.width}
|
||||
height={imageSize.height}
|
||||
overflow="hidden"
|
||||
>
|
||||
<div
|
||||
key={item.id}
|
||||
style={{
|
||||
position: "absolute",
|
||||
cursor: "pointer",
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
}}
|
||||
onClick={() => {
|
||||
window.open(item.imageUrl);
|
||||
}}
|
||||
>
|
||||
{isImageFormat(item.imageUrl) ? (
|
||||
<Image
|
||||
src={item.imageUrl}
|
||||
alt={`image-${item.id}`}
|
||||
width={"100%"}
|
||||
height={"100%"}
|
||||
objectFit="contain"
|
||||
/>
|
||||
) : (
|
||||
<video
|
||||
style={{ objectFit: "contain" }}
|
||||
width={"100%"}
|
||||
height={"100%"}
|
||||
src={item.imageUrl}
|
||||
loop={true}
|
||||
autoPlay={true}
|
||||
muted={true}
|
||||
>
|
||||
<track kind="captions" />
|
||||
</video>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<IconButton
|
||||
size={"sm"}
|
||||
color={"white"}
|
||||
bgColor="whiteAlpha.400"
|
||||
aria-label="Previous image"
|
||||
icon={<IconChevronLeft />}
|
||||
onClick={() => paginate(-1)}
|
||||
position="absolute"
|
||||
left="0"
|
||||
zIndex="2"
|
||||
/>
|
||||
<IconButton
|
||||
size={"sm"}
|
||||
color={"white"}
|
||||
bgColor="whiteAlpha.400"
|
||||
aria-label="Next image"
|
||||
icon={<IconChevronRight />}
|
||||
onClick={() => paginate(1)}
|
||||
position="absolute"
|
||||
right="0"
|
||||
zIndex="2"
|
||||
/>
|
||||
</Flex>
|
||||
);
|
||||
};
|
||||
|
||||
export default Carousel;
|
||||
@@ -0,0 +1,91 @@
|
||||
import {
|
||||
Button,
|
||||
Modal,
|
||||
ModalOverlay,
|
||||
ModalContent,
|
||||
ModalHeader,
|
||||
ModalCloseButton,
|
||||
ModalBody,
|
||||
ModalFooter,
|
||||
FormControl,
|
||||
FormLabel,
|
||||
FormErrorMessage,
|
||||
Input,
|
||||
} from "@chakra-ui/react";
|
||||
import { workflowVersionsTable } from "../db-tables/WorkspaceDB";
|
||||
import { useState, ChangeEvent } from "react";
|
||||
// @ts-ignore
|
||||
import { app } from "/scripts/app.js";
|
||||
|
||||
interface Props {
|
||||
workflowId: string;
|
||||
onClose: () => void;
|
||||
}
|
||||
export default function CreateVersionDialog({ workflowId, onClose }: Props) {
|
||||
const [newVersionName, setNewVersionName] = useState("");
|
||||
const [submitError, setSubmitError] = useState("");
|
||||
|
||||
const handleChange = (event: ChangeEvent<HTMLInputElement>) => {
|
||||
setNewVersionName(event.target.value);
|
||||
submitError && setSubmitError("");
|
||||
};
|
||||
|
||||
const onSubmit = async () => {
|
||||
const trimName = newVersionName.trim();
|
||||
setNewVersionName(trimName);
|
||||
const versionList =
|
||||
(await workflowVersionsTable?.listByWorkflowID(workflowId)) ?? [];
|
||||
if (versionList.some((version) => version.name === trimName)) {
|
||||
setSubmitError(
|
||||
"The name is duplicated, please modify it and submit again.",
|
||||
);
|
||||
} else {
|
||||
const graphJson = JSON.stringify(app.graph.serialize());
|
||||
await workflowVersionsTable?.add({
|
||||
name: newVersionName,
|
||||
workflowID: workflowId,
|
||||
createTime: Date.now(),
|
||||
json: graphJson,
|
||||
});
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal isOpen={true} onClose={onClose}>
|
||||
<ModalOverlay />
|
||||
<ModalContent>
|
||||
<ModalHeader>Create Version</ModalHeader>
|
||||
<ModalCloseButton />
|
||||
<ModalBody>
|
||||
<FormControl isInvalid={!!submitError}>
|
||||
<FormLabel>Name</FormLabel>
|
||||
<Input
|
||||
value={newVersionName}
|
||||
onChange={handleChange}
|
||||
autoFocus
|
||||
onKeyUp={(e) => {
|
||||
e.code === "Enter" &&
|
||||
!submitError &&
|
||||
newVersionName &&
|
||||
onSubmit();
|
||||
}}
|
||||
/>
|
||||
{submitError && <FormErrorMessage>{submitError}</FormErrorMessage>}
|
||||
</FormControl>
|
||||
</ModalBody>
|
||||
<ModalFooter>
|
||||
<Button
|
||||
colorScheme="teal"
|
||||
mr={3}
|
||||
onClick={onSubmit}
|
||||
isDisabled={!newVersionName || !!submitError}
|
||||
>
|
||||
Create
|
||||
</Button>
|
||||
<Button onClick={onClose}>Cancel</Button>
|
||||
</ModalFooter>
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import React, { useRef } from "react";
|
||||
import { Box, useOutsideClick } from "@chakra-ui/react";
|
||||
|
||||
type Props = {
|
||||
menuButton: React.ReactElement;
|
||||
options: React.ReactElement;
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
};
|
||||
export default function CustomMenu({
|
||||
options,
|
||||
menuButton,
|
||||
isOpen,
|
||||
onClose,
|
||||
}: Props) {
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
useOutsideClick({
|
||||
ref: ref,
|
||||
handler: () => onClose(),
|
||||
});
|
||||
|
||||
return (
|
||||
<Box position="relative">
|
||||
<Box>{menuButton}</Box>
|
||||
{isOpen && (
|
||||
<Box
|
||||
ref={ref}
|
||||
mt="8px"
|
||||
shadow="md"
|
||||
p="2"
|
||||
position="absolute"
|
||||
zIndex="dropdown"
|
||||
>
|
||||
{options}
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
import React, { useState, useRef, useEffect } from "react";
|
||||
import { Box, Button, Card } from "@chakra-ui/react";
|
||||
import { IconChevronDown } from "@tabler/icons-react";
|
||||
|
||||
export interface CustomSelectorOption<T> {
|
||||
label: string;
|
||||
value: T;
|
||||
icon?: React.ReactElement;
|
||||
}
|
||||
|
||||
type Props<T> = {
|
||||
options: CustomSelectorOption<T>[];
|
||||
value: T;
|
||||
onChange: (value: T) => void;
|
||||
};
|
||||
export default function CustomSelector<T>({
|
||||
options,
|
||||
value,
|
||||
onChange,
|
||||
}: Props<T>) {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const selectedOption = options.find((option) => option.value === value);
|
||||
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (event: any) => {
|
||||
if (ref.current && !ref.current.contains(event.target)) {
|
||||
setIsOpen(false);
|
||||
}
|
||||
};
|
||||
document.addEventListener("mousedown", handleClickOutside);
|
||||
return () => {
|
||||
document.removeEventListener("mousedown", handleClickOutside);
|
||||
};
|
||||
}, [ref]);
|
||||
|
||||
const toggleDropdown = () => setIsOpen(!isOpen);
|
||||
|
||||
return (
|
||||
<Box position="relative">
|
||||
<Button
|
||||
onClick={toggleDropdown}
|
||||
rightIcon={<IconChevronDown />}
|
||||
leftIcon={selectedOption?.icon}
|
||||
>
|
||||
{selectedOption?.label}
|
||||
</Button>
|
||||
{isOpen && (
|
||||
<Card
|
||||
gap={4}
|
||||
ref={ref}
|
||||
mt="2"
|
||||
shadow="md"
|
||||
borderWidth="1px"
|
||||
p="2"
|
||||
position="absolute"
|
||||
zIndex={100}
|
||||
>
|
||||
{options.map((option) => (
|
||||
<Button
|
||||
key={option.label}
|
||||
onClick={() => {
|
||||
setIsOpen(false);
|
||||
onChange(option.value);
|
||||
}}
|
||||
leftIcon={option.icon}
|
||||
justifyContent="flex-start"
|
||||
variant="ghost"
|
||||
width="full"
|
||||
>
|
||||
{option.label}
|
||||
</Button>
|
||||
))}
|
||||
</Card>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -31,7 +31,7 @@ export default function DeleteConfirm(props: Props) {
|
||||
} = props;
|
||||
|
||||
return (
|
||||
<Popover isLazy={true} placement="auto">
|
||||
<Popover isLazy={true} placement="auto" gutter={0}>
|
||||
{({ onClose }) => (
|
||||
<>
|
||||
<PopoverTrigger>
|
||||
@@ -40,7 +40,7 @@ export default function DeleteConfirm(props: Props) {
|
||||
<IconButton
|
||||
aria-label="Delete confirm"
|
||||
size={"sm"}
|
||||
icon={<IconTrash color="#F56565" />}
|
||||
icon={<IconTrash color="#F56565" size={"20px"} />}
|
||||
isDisabled={isDisabled}
|
||||
variant={variant}
|
||||
/>
|
||||
@@ -48,7 +48,6 @@ export default function DeleteConfirm(props: Props) {
|
||||
</Box>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent>
|
||||
<PopoverArrow />
|
||||
<PopoverCloseButton onClick={onClose} />
|
||||
<PopoverBody>
|
||||
<Text mb={4} pr={4} fontWeight={600}>
|
||||
|
||||
@@ -1,14 +1,35 @@
|
||||
import { useState, useCallback, MouseEvent, useRef } from "react";
|
||||
import {
|
||||
useState,
|
||||
useCallback,
|
||||
MouseEvent,
|
||||
useRef,
|
||||
PropsWithChildren,
|
||||
} from "react";
|
||||
|
||||
const POSITION = { x: 0, y: 0 };
|
||||
|
||||
export default function Draggable({ children, onDragEnd }) {
|
||||
interface Props {
|
||||
onDragEnd: (position: { x: number; y: number }) => void;
|
||||
dragIconId: string;
|
||||
}
|
||||
|
||||
export default function Draggable({
|
||||
children,
|
||||
onDragEnd,
|
||||
dragIconId,
|
||||
}: PropsWithChildren<Props>) {
|
||||
const originRef = useRef(POSITION);
|
||||
const isDraggingRef = useRef(false);
|
||||
const [translation, setTranslation] = useState(POSITION);
|
||||
|
||||
const handleMouseDown = (e: MouseEvent) => {
|
||||
if ([e.target?.id, e.target?.parentNode?.id].includes("dragPanelIcon")) {
|
||||
if (
|
||||
!(e.target instanceof Element) ||
|
||||
!(e.target.parentNode instanceof Element)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
if ([e.target?.id, e.target?.parentNode?.id].includes(dragIconId)) {
|
||||
originRef.current = {
|
||||
x: e.clientX,
|
||||
y: e.clientY,
|
||||
@@ -21,7 +42,7 @@ export default function Draggable({ children, onDragEnd }) {
|
||||
}
|
||||
};
|
||||
|
||||
const handleMouseMove = useCallback((e: MouseEvent) => {
|
||||
const handleMouseMove = useCallback((e: globalThis.MouseEvent) => {
|
||||
const translation = {
|
||||
x: e.clientX - originRef.current.x,
|
||||
y: e.clientY - originRef.current.y,
|
||||
@@ -32,7 +53,9 @@ export default function Draggable({ children, onDragEnd }) {
|
||||
|
||||
const handleMouseUp = useCallback(() => {
|
||||
setTranslation((state) => {
|
||||
onDragEnd(state);
|
||||
setTimeout(() => {
|
||||
onDragEnd(state);
|
||||
}, 0);
|
||||
return POSITION;
|
||||
});
|
||||
isDraggingRef.current = false;
|
||||
|
||||
@@ -1,17 +1,15 @@
|
||||
import {
|
||||
ChangeEvent,
|
||||
lazy,
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import {
|
||||
Menu,
|
||||
MenuButton,
|
||||
MenuList,
|
||||
MenuItem,
|
||||
IconButton,
|
||||
Button,
|
||||
Modal,
|
||||
ModalOverlay,
|
||||
@@ -24,8 +22,10 @@ import {
|
||||
FormLabel,
|
||||
FormErrorMessage,
|
||||
Input,
|
||||
Portal,
|
||||
Tooltip,
|
||||
DarkMode,
|
||||
Tag,
|
||||
HStack,
|
||||
} from "@chakra-ui/react";
|
||||
import {
|
||||
IconArrowBackUpDouble,
|
||||
@@ -33,146 +33,186 @@ import {
|
||||
IconDeviceFloppy,
|
||||
IconDownload,
|
||||
IconHistory,
|
||||
IconShare2,
|
||||
IconVersions,
|
||||
} from "@tabler/icons-react";
|
||||
import {
|
||||
Workflow,
|
||||
getWorkflow,
|
||||
userSettingsTable,
|
||||
workspace,
|
||||
} from "../db-tables/WorkspaceDB";
|
||||
import { workflowsTable, userSettingsTable } from "../db-tables/WorkspaceDB";
|
||||
import { WorkspaceContext } from "../WorkspaceContext";
|
||||
import { Overlay } from "./Overlay";
|
||||
import { VersionHistoryDrawer } from "./VersionHistoryDrawer";
|
||||
import { Workflow } from "../types/dbTypes";
|
||||
import CreateVersionDialog from "./CreateVersionDialog";
|
||||
import HoverMenu from "./HoverMenu";
|
||||
const ShareDialog = lazy(() => import("../share/ShareDialog"));
|
||||
// @ts-ignore
|
||||
import { app } from "/scripts/app.js";
|
||||
import { TOPBAR_BUTTON_HEIGHT } from "../const";
|
||||
import DownloadSpaceJsonDialog from "../spacejson/DownloadSpaceJsonDialog";
|
||||
import { downloadJsonFile } from "../utils/downloadJsonFile";
|
||||
|
||||
export default function DropdownTitle({ onClick }: { onClick?: () => void }) {
|
||||
export default function DropdownTitle() {
|
||||
const {
|
||||
curFlowID,
|
||||
onDuplicateWorkflow,
|
||||
loadWorkflowID,
|
||||
discardUnsavedChanges,
|
||||
saveCurWorkflow,
|
||||
setRoute,
|
||||
route,
|
||||
} = useContext(WorkspaceContext);
|
||||
|
||||
const [isOpenNewName, setIsOpenNewName] = useState(false);
|
||||
const [isVersionHistoryOpen, setIsVersionHistoryOpen] = useState(false);
|
||||
const [isOpenNewVersion, setIsOpenNewVersion] = useState(false);
|
||||
const [newFlowName, setNewFlowName] = useState("");
|
||||
const [submitError, setSubmitError] = useState("");
|
||||
const [workflow, setWorkflow] = useState<Workflow>();
|
||||
const [saveShortcut, setSaveShortcut] = useState({
|
||||
save: "",
|
||||
saveAs: "",
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const workflow = curFlowID ? getWorkflow(curFlowID) : undefined;
|
||||
setNewFlowName(workflow?.name ?? "");
|
||||
setWorkflow(workflow);
|
||||
if (curFlowID) {
|
||||
workflowsTable?.get(curFlowID).then((workflow) => {
|
||||
setNewFlowName(workflow?.name ?? "");
|
||||
setWorkflow(workflow);
|
||||
});
|
||||
}
|
||||
userSettingsTable?.getSetting("shortcuts").then((res) => {
|
||||
setSaveShortcut(res);
|
||||
});
|
||||
}, [curFlowID]);
|
||||
|
||||
const handleChange = (event: ChangeEvent<HTMLInputElement>) => {
|
||||
setNewFlowName(event.target.value);
|
||||
submitError && setSubmitError("");
|
||||
};
|
||||
|
||||
const onSubmit = () => {
|
||||
const onSubmit = async () => {
|
||||
if (!curFlowID) {
|
||||
alert("Flow ID is required");
|
||||
return;
|
||||
}
|
||||
|
||||
onDuplicateWorkflow?.(curFlowID, newFlowName);
|
||||
const graph = JSON.stringify(app.graph.serialize());
|
||||
const flow = await workflowsTable?.createFlow({
|
||||
json: graph,
|
||||
name: newFlowName,
|
||||
parentFolderID: workflow?.parentFolderID,
|
||||
});
|
||||
|
||||
flow && (await loadWorkflowID(flow.id, null));
|
||||
handleOnCloseModal();
|
||||
};
|
||||
|
||||
const handleOnCloseModal = () => {
|
||||
setIsOpenNewName(false);
|
||||
setRoute("root");
|
||||
};
|
||||
|
||||
const handleDownload = useCallback(() => {
|
||||
if (!workspace || !curFlowID) {
|
||||
alert("You don't have any workspaces");
|
||||
const handleDownload = useCallback(async () => {
|
||||
const curWorkflow = workflowsTable?.curWorkflow;
|
||||
if (!curWorkflow) {
|
||||
alert("No current workflow!");
|
||||
return;
|
||||
}
|
||||
const graph = app.graph.serialize();
|
||||
const json = JSON.stringify(graph, null, 2);
|
||||
|
||||
const json_data = workspace[curFlowID];
|
||||
|
||||
if (!json_data) {
|
||||
alert("Workspace does not exist");
|
||||
return;
|
||||
}
|
||||
|
||||
const blob = new Blob([json_data.json], { type: "application/json" });
|
||||
const url = URL.createObjectURL(blob);
|
||||
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = `${json_data.name}.json`;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
downloadJsonFile(json, curWorkflow.name);
|
||||
}, [curFlowID]);
|
||||
const saveShortcut = userSettingsTable?.getSetting("shortcuts")?.save;
|
||||
|
||||
return (
|
||||
<>
|
||||
<Menu isLazy={true}>
|
||||
{({ isOpen }) => (
|
||||
<>
|
||||
<MenuButton onClick={onClick}>
|
||||
<IconButton
|
||||
icon={<IconChevronDown size={20} />}
|
||||
aria-label="menu"
|
||||
size={"xs"}
|
||||
backgroundColor={"#323232"}
|
||||
color={"white"}
|
||||
/>
|
||||
</MenuButton>
|
||||
<Portal>
|
||||
<MenuList minWidth={150} zIndex={1000}>
|
||||
<MenuItem
|
||||
onClick={saveCurWorkflow}
|
||||
icon={<IconDeviceFloppy size={20} />}
|
||||
iconSpacing={1}
|
||||
command={saveShortcut}
|
||||
>
|
||||
Save
|
||||
</MenuItem>
|
||||
<Tooltip label="This will revert current workflow to your last saved version. You will lose all changes made since your last save.">
|
||||
<HoverMenu
|
||||
menuButton={
|
||||
<DarkMode>
|
||||
<Button
|
||||
px={1}
|
||||
height={TOPBAR_BUTTON_HEIGHT + "px"}
|
||||
aria-label="menu"
|
||||
size={"sm"}
|
||||
backgroundColor={"#434554"}
|
||||
>
|
||||
File
|
||||
<IconChevronDown size={20} />
|
||||
</Button>
|
||||
</DarkMode>
|
||||
}
|
||||
menuContent={
|
||||
<Menu isOpen={true}>
|
||||
<MenuList minWidth={150} zIndex={1000}>
|
||||
<MenuItem
|
||||
onClick={() => saveCurWorkflow()}
|
||||
icon={<IconDeviceFloppy size={20} />}
|
||||
iconSpacing={1}
|
||||
command={saveShortcut.save}
|
||||
>
|
||||
Save
|
||||
</MenuItem>
|
||||
{!userSettingsTable?.settings?.autoSave && (
|
||||
<Tooltip label="Revert workflow to your last saved version. You will lose all changes made since your last save.">
|
||||
<MenuItem
|
||||
onClick={discardUnsavedChanges}
|
||||
icon={<IconArrowBackUpDouble size={20} />}
|
||||
iconSpacing={1}
|
||||
isDisabled={workflow?.lastSavedJson == null}
|
||||
>
|
||||
Discard unsaved changes
|
||||
</MenuItem>
|
||||
</Tooltip>
|
||||
<MenuItem
|
||||
onClick={handleDownload}
|
||||
icon={<IconDownload size={20} />}
|
||||
iconSpacing={1}
|
||||
>
|
||||
Download
|
||||
</MenuItem>
|
||||
<MenuItem
|
||||
onClick={() => setIsOpenNewName(true)}
|
||||
icon={<IconDeviceFloppy size={20} />}
|
||||
iconSpacing={1}
|
||||
>
|
||||
Save As
|
||||
</MenuItem>
|
||||
<MenuItem
|
||||
onClick={() => setIsVersionHistoryOpen(true)}
|
||||
icon={<IconHistory size={20} />}
|
||||
iconSpacing={1}
|
||||
>
|
||||
Versions History
|
||||
</MenuItem>
|
||||
</MenuList>
|
||||
{isOpen && <Overlay backgroundColor={null} />}
|
||||
</Portal>
|
||||
</>
|
||||
)}
|
||||
</Menu>
|
||||
{isVersionHistoryOpen && (
|
||||
<VersionHistoryDrawer onClose={() => setIsVersionHistoryOpen(false)} />
|
||||
)}
|
||||
<MenuItem
|
||||
onClick={handleDownload}
|
||||
icon={<IconDownload size={20} />}
|
||||
iconSpacing={1}
|
||||
>
|
||||
Download
|
||||
</MenuItem>
|
||||
<MenuItem
|
||||
onClick={() => setRoute("saveAsModal")}
|
||||
icon={<IconDeviceFloppy size={20} />}
|
||||
iconSpacing={1}
|
||||
command={saveShortcut.saveAs}
|
||||
>
|
||||
Save As
|
||||
</MenuItem>
|
||||
<MenuItem
|
||||
onClick={() => setIsOpenNewVersion(true)}
|
||||
icon={<IconVersions size={20} />}
|
||||
iconSpacing={1}
|
||||
>
|
||||
Create Version
|
||||
</MenuItem>
|
||||
<MenuItem
|
||||
onClick={() => setRoute("versionHistory")}
|
||||
icon={<IconHistory size={20} />}
|
||||
iconSpacing={1}
|
||||
>
|
||||
Versions History
|
||||
</MenuItem>
|
||||
{/* <MenuItem
|
||||
onClick={() => setRoute("downloadSpaceJson")}
|
||||
icon={<IconDownload size={20} />}
|
||||
iconSpacing={1}
|
||||
>
|
||||
Save runnable workflow🧪
|
||||
</MenuItem> */}
|
||||
<MenuItem
|
||||
onClick={() => setRoute("downloadSpaceJson")}
|
||||
icon={<IconShare2 size={20} />}
|
||||
iconSpacing={1}
|
||||
alignItems={"center"}
|
||||
>
|
||||
<HStack>
|
||||
<p>Share</p> <Tag size={"sm"}>🧪beta</Tag>
|
||||
</HStack>
|
||||
</MenuItem>
|
||||
</MenuList>
|
||||
</Menu>
|
||||
}
|
||||
/>
|
||||
{route === "share" && <ShareDialog onClose={() => setRoute("root")} />}
|
||||
{route == "versionHistory" && (
|
||||
<VersionHistoryDrawer onClose={() => setRoute("root")} />
|
||||
)}
|
||||
{isOpenNewName && (
|
||||
{route == "downloadSpaceJson" && <DownloadSpaceJsonDialog />}
|
||||
{route === "saveAsModal" && (
|
||||
<Modal isOpen={true} onClose={handleOnCloseModal}>
|
||||
<ModalOverlay />
|
||||
<ModalContent>
|
||||
@@ -186,7 +226,10 @@ export default function DropdownTitle({ onClick }: { onClick?: () => void }) {
|
||||
onChange={handleChange}
|
||||
autoFocus
|
||||
onKeyUp={(e) => {
|
||||
e.code === "Enter" && !submitError && onSubmit();
|
||||
e.code === "Enter" &&
|
||||
!submitError &&
|
||||
newFlowName &&
|
||||
onSubmit();
|
||||
}}
|
||||
/>
|
||||
{submitError && (
|
||||
@@ -208,6 +251,12 @@ export default function DropdownTitle({ onClick }: { onClick?: () => void }) {
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
)}
|
||||
{isOpenNewVersion && (
|
||||
<CreateVersionDialog
|
||||
workflowId={curFlowID!}
|
||||
onClose={() => setIsOpenNewVersion(false)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -17,20 +17,15 @@ import {
|
||||
Tooltip,
|
||||
} from "@chakra-ui/react";
|
||||
import { useContext, ChangeEvent, useState } from "react";
|
||||
import { listWorkflows, updateFlow } from "../db-tables/WorkspaceDB";
|
||||
import { workflowsTable } from "../db-tables/WorkspaceDB";
|
||||
import { WorkspaceContext } from "../WorkspaceContext";
|
||||
|
||||
type Props = {
|
||||
displayName: string;
|
||||
updateFlowName: (newName: string) => void;
|
||||
isDirty: boolean;
|
||||
isActive: boolean;
|
||||
};
|
||||
|
||||
export default function EditFlowName({
|
||||
displayName,
|
||||
updateFlowName,
|
||||
isDirty,
|
||||
}: Props) {
|
||||
export default function EditFlowName({ displayName, isActive }: Props) {
|
||||
const { isOpen, onOpen, onClose } = useDisclosure();
|
||||
const { curFlowID } = useContext(WorkspaceContext);
|
||||
const [editName, setEditName] = useState(displayName);
|
||||
@@ -46,21 +41,23 @@ export default function EditFlowName({
|
||||
submitError && setSubmitError("");
|
||||
};
|
||||
|
||||
const onSubmit = () => {
|
||||
const onSubmit = async () => {
|
||||
if (curFlowID) {
|
||||
const trimEditName = editName.trim();
|
||||
setEditName(trimEditName);
|
||||
if (displayName === trimEditName) return onCloseModal();
|
||||
const flowNameList = listWorkflows()?.map((flow) => flow.name);
|
||||
if (flowNameList.includes(trimEditName)) {
|
||||
const uniqueName = await workflowsTable?.generateUniqueName(
|
||||
trimEditName,
|
||||
workflowsTable.curWorkflow?.parentFolderID ?? "",
|
||||
);
|
||||
if (uniqueName !== trimEditName) {
|
||||
setSubmitError(
|
||||
"The name is duplicated, please modify it and submit again."
|
||||
"The name is duplicated, please modify it and submit again.",
|
||||
);
|
||||
} else {
|
||||
updateFlow(curFlowID, {
|
||||
await workflowsTable?.updateName(curFlowID, {
|
||||
name: trimEditName,
|
||||
});
|
||||
updateFlowName(trimEditName);
|
||||
onCloseModal();
|
||||
}
|
||||
}
|
||||
@@ -75,15 +72,16 @@ export default function EditFlowName({
|
||||
<HStack>
|
||||
<Tooltip label={displayName} placement="bottom">
|
||||
<Text
|
||||
color="white"
|
||||
onClick={startEdit}
|
||||
maxW={240}
|
||||
fontWeight={500}
|
||||
as="div"
|
||||
color={isActive ? "white" : "#9d9d9d"}
|
||||
onDoubleClick={startEdit}
|
||||
maxW="114px"
|
||||
marginRight="4px"
|
||||
whiteSpace="nowrap"
|
||||
overflow="hidden"
|
||||
textOverflow="ellipsis"
|
||||
userSelect="none"
|
||||
>
|
||||
{isDirty && "* "}
|
||||
{displayName}
|
||||
</Text>
|
||||
</Tooltip>
|
||||
@@ -101,7 +99,7 @@ export default function EditFlowName({
|
||||
onChange={handleChange}
|
||||
autoFocus
|
||||
onKeyUp={(e) => {
|
||||
e.code === "Enter" && !submitError && onSubmit();
|
||||
e.code === "Enter" && !submitError && editName && onSubmit();
|
||||
}}
|
||||
/>
|
||||
{submitError && (
|
||||
|
||||
@@ -32,18 +32,20 @@ export default function EditFolderNameModal({ folder, onclose }: Props) {
|
||||
submitError && setSubmitError("");
|
||||
};
|
||||
|
||||
const onSubmit = () => {
|
||||
const onSubmit = async () => {
|
||||
const trimEditName = editName.trim();
|
||||
setEditName(trimEditName);
|
||||
if (trimEditName === folder.name) return onclose();
|
||||
const folderNameList = foldersTable?.listAll()?.map((f) => f.name) ?? [];
|
||||
if (folderNameList.includes(trimEditName)) {
|
||||
const uniqName = await foldersTable?.generateUniqueName(
|
||||
trimEditName,
|
||||
folder.parentFolderID ?? "",
|
||||
);
|
||||
if (uniqName !== trimEditName) {
|
||||
setSubmitError(
|
||||
"The name is duplicated, please modify it and submit again."
|
||||
"The name is duplicated, please modify it and submit again.",
|
||||
);
|
||||
} else {
|
||||
foldersTable?.update({
|
||||
id: folder.id,
|
||||
await foldersTable?.update(folder.id, {
|
||||
name: trimEditName,
|
||||
});
|
||||
onRefreshFilesList && onRefreshFilesList();
|
||||
@@ -65,7 +67,7 @@ export default function EditFolderNameModal({ folder, onclose }: Props) {
|
||||
onChange={handleChange}
|
||||
autoFocus
|
||||
onKeyUp={(e) => {
|
||||
e.code === "Enter" && !submitError && onSubmit();
|
||||
e.code === "Enter" && !submitError && editName && onSubmit();
|
||||
}}
|
||||
/>
|
||||
{submitError && <FormErrorMessage>{submitError}</FormErrorMessage>}
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
import { useRef, useState } from "react";
|
||||
import CustomMenu from "./CustomMenu";
|
||||
import { Box, Menu, MenuList } from "@chakra-ui/react";
|
||||
type Props = {
|
||||
menuButton: React.ReactElement;
|
||||
menuContent: React.ReactElement;
|
||||
onClose?: () => void;
|
||||
};
|
||||
export default function HoverMenu({ menuButton, menuContent, onClose }: Props) {
|
||||
const closeTimeoutId = useRef<number>();
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const delayedClose = () => {
|
||||
closeTimeoutId.current = setTimeout(() => setIsOpen(false), 400); // delay of 300ms
|
||||
};
|
||||
|
||||
const onOpen = () => {
|
||||
setIsOpen(true);
|
||||
clearTimeout(closeTimeoutId.current);
|
||||
closeTimeoutId.current = undefined;
|
||||
};
|
||||
return (
|
||||
<CustomMenu
|
||||
isOpen={isOpen}
|
||||
onClose={delayedClose}
|
||||
menuButton={
|
||||
<Box
|
||||
aria-label="menu"
|
||||
onClick={onOpen}
|
||||
onMouseEnter={onOpen}
|
||||
onMouseLeave={delayedClose}
|
||||
>
|
||||
{menuButton}
|
||||
</Box>
|
||||
}
|
||||
options={
|
||||
<Menu isOpen={true}>
|
||||
<MenuList
|
||||
minWidth={150}
|
||||
zIndex={1000}
|
||||
onMouseEnter={onOpen}
|
||||
onMouseLeave={delayedClose}
|
||||
>
|
||||
{menuContent}
|
||||
</MenuList>
|
||||
</Menu>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
import { Image } from "@chakra-ui/react";
|
||||
import { isImageFormat } from "../utils";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
type Props = {
|
||||
mediaLocalPath: string;
|
||||
size: number;
|
||||
autoPlay?: boolean;
|
||||
onBrokenLink?: () => void;
|
||||
hideBrokenImage?: boolean;
|
||||
isPreview?: boolean;
|
||||
objectFit?: "cover" | "contain";
|
||||
};
|
||||
export default function MediaPreview({
|
||||
mediaLocalPath,
|
||||
size,
|
||||
isPreview,
|
||||
autoPlay,
|
||||
hideBrokenImage,
|
||||
objectFit,
|
||||
onBrokenLink,
|
||||
}: Props) {
|
||||
const [isVisible, setIsVisible] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
const checkMediaExists = async () => {
|
||||
try {
|
||||
const response = await fetch(
|
||||
isPreview
|
||||
? `/workspace/preview_media?filename=${mediaLocalPath}`
|
||||
: `/workspace/view_media?filename=${mediaLocalPath}`,
|
||||
);
|
||||
if (response.status == 404) {
|
||||
setIsVisible(false);
|
||||
onBrokenLink?.();
|
||||
return;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error checking media exists", error);
|
||||
}
|
||||
};
|
||||
|
||||
hideBrokenImage && checkMediaExists();
|
||||
}, []);
|
||||
|
||||
if (!isVisible) return null;
|
||||
return isImageFormat(mediaLocalPath) ? (
|
||||
<Image
|
||||
borderRadius={3}
|
||||
boxSize={`${size}px`}
|
||||
objectFit={objectFit ?? "cover"}
|
||||
src={`/workspace/view_media?filename=${mediaLocalPath}`}
|
||||
alt="workflow image renamed or moved from output folder"
|
||||
/>
|
||||
) : (
|
||||
<div
|
||||
style={{
|
||||
width: size + "px",
|
||||
height: size + `px`,
|
||||
overflow: "hidden",
|
||||
position: "relative",
|
||||
}}
|
||||
>
|
||||
<video
|
||||
width={size}
|
||||
height={size}
|
||||
src={`/workspace/view_media?filename=${mediaLocalPath}`}
|
||||
loop={true}
|
||||
autoPlay={autoPlay ?? false}
|
||||
muted={true}
|
||||
style={{
|
||||
// for video to crop to center
|
||||
objectFit: "cover",
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
// position: "absolute",
|
||||
// top: "50%", // Align the center of the video with the center of the container vertically
|
||||
// left: "50%", // Align the center of the video with the center of the container horizontally
|
||||
// transform: "translate(-50%, -50%)", // Offset the video to ensure it is centered
|
||||
}}
|
||||
>
|
||||
<track kind="captions" />
|
||||
</video>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -5,6 +5,8 @@ import { CSSProperties } from "react";
|
||||
type Props = {
|
||||
searchValue: string;
|
||||
onUpdateSearchValue: (value: string) => void;
|
||||
placeholder?: string;
|
||||
style?: CSSProperties;
|
||||
};
|
||||
|
||||
const IconSearchStyle: CSSProperties = {
|
||||
@@ -18,11 +20,11 @@ const IconSearchStyle: CSSProperties = {
|
||||
};
|
||||
|
||||
export default function SearchInput(props: Props) {
|
||||
const { searchValue, onUpdateSearchValue } = props;
|
||||
const { searchValue, onUpdateSearchValue, style } = props;
|
||||
const isSearchValueNotEmpty = searchValue !== "";
|
||||
|
||||
return (
|
||||
<Box style={{ position: "relative" }} mb={2}>
|
||||
<Box style={{ position: "relative", ...style }} mb={2}>
|
||||
<IconSearch style={IconSearchStyle} />
|
||||
{isSearchValueNotEmpty && (
|
||||
<IconButton
|
||||
@@ -40,7 +42,7 @@ export default function SearchInput(props: Props) {
|
||||
/>
|
||||
)}
|
||||
<Input
|
||||
placeholder="Search"
|
||||
placeholder={props.placeholder ?? "Search"}
|
||||
size={"sm"}
|
||||
paddingLeft={"35px"}
|
||||
paddingBlock={"5px"}
|
||||
|
||||
@@ -0,0 +1,278 @@
|
||||
import {
|
||||
Modal,
|
||||
ModalContent,
|
||||
Input,
|
||||
InputGroup,
|
||||
InputRightElement,
|
||||
VStack,
|
||||
Button,
|
||||
HStack,
|
||||
Text,
|
||||
Spinner,
|
||||
useColorMode,
|
||||
} from "@chakra-ui/react";
|
||||
import { IconSearch } from "@tabler/icons-react";
|
||||
import { useState, ChangeEvent, useEffect, useContext, useRef } from "react";
|
||||
import { SHORTCUT_TRIGGER_EVENT } from "../const";
|
||||
import {
|
||||
EOtherKeys,
|
||||
EShortcutKeys,
|
||||
RecentlyOpenedFile,
|
||||
Workflow,
|
||||
} from "../types/dbTypes";
|
||||
import { formatTimestamp } from "../utils";
|
||||
import { WorkspaceContext } from "../WorkspaceContext";
|
||||
import { userSettingsTable, workflowsTable } from "../db-tables/WorkspaceDB";
|
||||
import useDebounceFn from "../customHooks/useDebounceFn";
|
||||
import { useStateRef } from "../customHooks/useStateRef";
|
||||
import { ESortTypes } from "../RecentFilesDrawer/types";
|
||||
|
||||
export default function SpotlightSearch() {
|
||||
const { colorMode } = useColorMode();
|
||||
const { loadWorkflowID, setRoute } = useContext(WorkspaceContext);
|
||||
const [allFileList, setAllFileList] = useState<RecentlyOpenedFile[]>([]);
|
||||
const [searchKey, setSearchKey, searchKeyRef] = useStateRef("");
|
||||
const recentlyOpenedFileListRef = useRef<RecentlyOpenedFile[]>([]);
|
||||
const [renderDataSource, setRenderDataSource, renderDataSourceRef] =
|
||||
useStateRef<RecentlyOpenedFile[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [selectIndex, setSelectIndex, selectIndexRef] = useStateRef(0);
|
||||
const keyRef = useRef({
|
||||
lastPressedKey: "",
|
||||
spotlightSearchShortcutFirstKey: "",
|
||||
spotlightSearchShortcutLastKey: "",
|
||||
});
|
||||
|
||||
const onClose = () => {
|
||||
setRoute("root");
|
||||
};
|
||||
|
||||
const onOpen = (fileInfo: RecentlyOpenedFile) => {
|
||||
loadWorkflowID(fileInfo.id);
|
||||
onClose();
|
||||
};
|
||||
|
||||
const handlerSearch = () => {
|
||||
const searchResult = allFileList.filter(
|
||||
(file) =>
|
||||
!!file.name
|
||||
.toLocaleLowerCase()
|
||||
.includes(searchKeyRef.current.toLocaleLowerCase()),
|
||||
);
|
||||
setRenderDataSource(searchResult);
|
||||
setSelectIndex(0);
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
const [debounceSearch, cancelDebounceSearch] = useDebounceFn(
|
||||
handlerSearch,
|
||||
300,
|
||||
);
|
||||
|
||||
const onSearchChange = (event: ChangeEvent<HTMLInputElement>) => {
|
||||
setSearchKey(event.target.value);
|
||||
if (event.target.value) {
|
||||
debounceSearch();
|
||||
!loading && setLoading(true);
|
||||
} else {
|
||||
setLoading(false);
|
||||
cancelDebounceSearch();
|
||||
setRenderDataSource(recentlyOpenedFileListRef.current);
|
||||
}
|
||||
};
|
||||
|
||||
const renderSearchResult = () => {
|
||||
if (loading) return null;
|
||||
|
||||
if (searchKey && renderDataSource.length === 0) {
|
||||
return <Text ml={2}>No matching results</Text>;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{renderDataSource.map((result, index) => (
|
||||
<Button
|
||||
colorScheme="teal"
|
||||
variant="ghost"
|
||||
key={result.id}
|
||||
size="sm"
|
||||
justifyContent="start"
|
||||
px={2}
|
||||
color={
|
||||
colorMode === "light" ? "#1A202C" : "rgba(255, 255, 255, 0.92)"
|
||||
}
|
||||
onClick={() => {
|
||||
onOpen(result);
|
||||
}}
|
||||
minH="26px"
|
||||
isActive={index === selectIndex}
|
||||
>
|
||||
{result.name}
|
||||
{result.updateTime && (
|
||||
<Text
|
||||
color={
|
||||
colorMode === "light" ? "GrayText" : "rgb(255, 255, 255, 0.6)"
|
||||
}
|
||||
ml={2}
|
||||
fontSize={"sm"}
|
||||
>
|
||||
Updated: {formatTimestamp(result.updateTime)}
|
||||
</Text>
|
||||
)}
|
||||
</Button>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const init = async () => {
|
||||
const getData = (list: Workflow[]) =>
|
||||
list.map(
|
||||
(flow) =>
|
||||
({
|
||||
type: "workflow",
|
||||
updateTime: flow?.updateTime ?? Date.now(),
|
||||
id: flow.id,
|
||||
name: flow.name.replace(/\.json$/, ""),
|
||||
}) as RecentlyOpenedFile,
|
||||
) ?? [];
|
||||
|
||||
const allFiles =
|
||||
(await workflowsTable?.listAll(ESortTypes.RECENTLY_OPENED)) ?? [];
|
||||
|
||||
setAllFileList(getData(allFiles));
|
||||
|
||||
const recentlyOpenedFileList = getData(allFiles.slice(0, 20));
|
||||
|
||||
recentlyOpenedFileListRef.current = recentlyOpenedFileList;
|
||||
setRenderDataSource(recentlyOpenedFileList);
|
||||
};
|
||||
|
||||
const changeSelectIndex = (num: number) => {
|
||||
setSelectIndex((preIndex) => {
|
||||
const nextIndex = preIndex + num;
|
||||
if (nextIndex < 0) {
|
||||
return renderDataSourceRef.current.length - 1;
|
||||
} else if (nextIndex > renderDataSourceRef.current.length - 1) {
|
||||
return 0;
|
||||
} else {
|
||||
return nextIndex;
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
init();
|
||||
|
||||
userSettingsTable?.getSetting("shortcuts").then((res) => {
|
||||
const openSpotlightSearch =
|
||||
res.openSpotlightSearch ??
|
||||
userSettingsTable?.defaultSettings.shortcuts.openSpotlightSearch;
|
||||
|
||||
const keys = openSpotlightSearch.split("+");
|
||||
keyRef.current.spotlightSearchShortcutFirstKey =
|
||||
keys[0] === "Command" ? "Meta" : keys[0];
|
||||
keyRef.current.spotlightSearchShortcutLastKey = keys[keys.length - 1];
|
||||
});
|
||||
|
||||
const handleKeyUp = (event: KeyboardEvent) => {
|
||||
/**
|
||||
* Determine whether to open the selected file when the button is lifted. Conditions are met:
|
||||
* 1. The raised button is the first button among the shortcut keys that trigger focus search;
|
||||
* 2. The last pressed key is the last key to trigger the focus search shortcut key;
|
||||
* 3. The selected file exists;
|
||||
*/
|
||||
if (
|
||||
event.key === keyRef.current.spotlightSearchShortcutFirstKey &&
|
||||
keyRef.current.lastPressedKey ===
|
||||
keyRef.current.spotlightSearchShortcutLastKey &&
|
||||
selectIndexRef.current > 0 &&
|
||||
renderDataSourceRef.current[selectIndexRef.current]
|
||||
) {
|
||||
onOpen(renderDataSourceRef.current[selectIndexRef.current]);
|
||||
}
|
||||
};
|
||||
|
||||
const keydownListener = (event: KeyboardEvent) => {
|
||||
const key = event.key.toUpperCase();
|
||||
keyRef.current.lastPressedKey = key;
|
||||
switch (key) {
|
||||
case "ARROWDOWN":
|
||||
changeSelectIndex(1);
|
||||
break;
|
||||
case "ARROWUP":
|
||||
changeSelectIndex(-1);
|
||||
break;
|
||||
case "ENTER":
|
||||
onOpen(renderDataSourceRef.current[selectIndexRef.current]);
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
const shortcutTrigger = (event: CustomEvent) => {
|
||||
switch (event.detail.shortcutType) {
|
||||
case EShortcutKeys.openSpotlightSearch:
|
||||
changeSelectIndex(1);
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener("keyup", handleKeyUp);
|
||||
document.addEventListener("keydown", keydownListener);
|
||||
document.addEventListener(SHORTCUT_TRIGGER_EVENT, shortcutTrigger);
|
||||
|
||||
return () => {
|
||||
document.removeEventListener("keyup", handleKeyUp);
|
||||
document.removeEventListener("keydown", keydownListener);
|
||||
document.removeEventListener(SHORTCUT_TRIGGER_EVENT, shortcutTrigger);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<Modal isOpen={true} onClose={onClose} size="xl">
|
||||
<ModalContent
|
||||
bg={
|
||||
colorMode === "light"
|
||||
? "rgba(255, 255, 255, 0.8)"
|
||||
: "rgb(45, 55, 72, 0.8)"
|
||||
}
|
||||
backdropFilter="auto"
|
||||
backdropBlur="8px"
|
||||
>
|
||||
<HStack
|
||||
spacing={1}
|
||||
px={4}
|
||||
py={2}
|
||||
borderBottom="1px"
|
||||
borderColor="gray.400"
|
||||
>
|
||||
<IconSearch size={20} />
|
||||
<InputGroup>
|
||||
<Input
|
||||
value={searchKey}
|
||||
onChange={onSearchChange}
|
||||
autoFocus
|
||||
variant="unstyled"
|
||||
fontSize={18}
|
||||
/>
|
||||
{loading && (
|
||||
<InputRightElement h="27px">
|
||||
<Spinner size="sm" />
|
||||
</InputRightElement>
|
||||
)}
|
||||
</InputGroup>
|
||||
</HStack>
|
||||
<VStack
|
||||
spacing={1}
|
||||
align="stretch"
|
||||
px={2}
|
||||
py={2}
|
||||
maxHeight="calc(100vh - 200px)"
|
||||
overflowY="auto"
|
||||
>
|
||||
{renderSearchResult()}
|
||||
</VStack>
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,4 @@
|
||||
import {
|
||||
Box,
|
||||
Button,
|
||||
Card,
|
||||
CardBody,
|
||||
@@ -10,49 +9,83 @@ import {
|
||||
Stack,
|
||||
StackDivider,
|
||||
Text,
|
||||
Tabs,
|
||||
TabList,
|
||||
TabPanels,
|
||||
Tab,
|
||||
TabPanel,
|
||||
useToast,
|
||||
HStack,
|
||||
Tag,
|
||||
} from "@chakra-ui/react";
|
||||
import { IconX } from "@tabler/icons-react";
|
||||
import {
|
||||
changelogsTable,
|
||||
getWorkflow,
|
||||
updateFlow,
|
||||
workflowsTable,
|
||||
workflowVersionsTable,
|
||||
} from "../db-tables/WorkspaceDB";
|
||||
import { useContext, useEffect, useState } from "react";
|
||||
import { WorkspaceContext } from "../WorkspaceContext";
|
||||
import { formatTimestamp } from "../utils";
|
||||
// @ts-ignore
|
||||
import { app } from "/scripts/app.js";
|
||||
import { Changelog } from "../types/dbTypes";
|
||||
import { Changelog, WorkflowVersion } from "../types/dbTypes";
|
||||
import DeleteConfirm from "./DeleteConfirm";
|
||||
|
||||
export function VersionHistoryDrawer({ onClose }: { onClose: () => void }) {
|
||||
const { curFlowID, isDirty } = useContext(WorkspaceContext);
|
||||
const toast = useToast();
|
||||
const { curFlowID, isDirty, loadWorkflowID, curVersion } =
|
||||
useContext(WorkspaceContext);
|
||||
const [active, setActive] = useState(0); // 0: version、1: changelog
|
||||
const [selectedVersion, setSelectedVersion] = useState<string | null>(null);
|
||||
const curWorkflow = curFlowID != null ? getWorkflow(curFlowID) : null;
|
||||
const [changelogs, setChangelogs] = useState<Changelog[]>([]);
|
||||
if (curFlowID == null || curWorkflow == null) {
|
||||
alert("No current workflow found!");
|
||||
return null;
|
||||
}
|
||||
const [versions, setVersions] = useState<WorkflowVersion[]>([]);
|
||||
|
||||
const loadChangeLogs = async (flowId: string) => {
|
||||
const workflow = await workflowsTable?.get(flowId);
|
||||
const changelogs = await changelogsTable?.listByWorkflowID(flowId);
|
||||
setChangelogs(changelogs ?? []);
|
||||
const selectedChangelog = changelogs?.filter(
|
||||
(c) => c.json === workflow?.json,
|
||||
);
|
||||
const selectedChangelogID = selectedChangelog?.[0]?.id;
|
||||
selectedChangelogID && setSelectedVersion(selectedChangelogID);
|
||||
};
|
||||
|
||||
const loadVersions = async () => {
|
||||
const vers =
|
||||
(await workflowVersionsTable?.listByWorkflowID(curFlowID!)) ?? [];
|
||||
setVersions(vers);
|
||||
};
|
||||
|
||||
const onDelete = (id: string) => {
|
||||
workflowVersionsTable?.delete(id).then(() => {
|
||||
loadVersions();
|
||||
});
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const loadData = async () => {
|
||||
const changelogs = await changelogsTable?.listByWorkflowID(curFlowID);
|
||||
setChangelogs(changelogs ?? []);
|
||||
const selectedChangelog = changelogs?.filter(
|
||||
(c) => c.json === curWorkflow.lastSavedJson
|
||||
);
|
||||
const selectedChangelogID = selectedChangelog?.[0]?.id;
|
||||
selectedChangelogID && setSelectedVersion(selectedChangelogID);
|
||||
switch (active) {
|
||||
case 0:
|
||||
loadVersions();
|
||||
break;
|
||||
case 1:
|
||||
loadChangeLogs(curFlowID!);
|
||||
break;
|
||||
}
|
||||
}, [active]);
|
||||
|
||||
useEffect(() => {
|
||||
app.canvasEl.addEventListener("click", onClose);
|
||||
return () => {
|
||||
app.canvasEl.removeEventListener("click", onClose);
|
||||
};
|
||||
loadData();
|
||||
}, []);
|
||||
}, [active]);
|
||||
|
||||
return (
|
||||
<Card
|
||||
width={400}
|
||||
width={430}
|
||||
height={"100vh"}
|
||||
pl={4}
|
||||
pr={5}
|
||||
pt={4}
|
||||
gap={0}
|
||||
position={"fixed"}
|
||||
top={0}
|
||||
@@ -72,31 +105,107 @@ export function VersionHistoryDrawer({ onClose }: { onClose: () => void }) {
|
||||
</Flex>
|
||||
</CardHeader>
|
||||
<CardBody overflowY={"auto"} gap={0}>
|
||||
<Text mb={4}>All the saved changes of this workflow</Text>
|
||||
<Stack divider={<StackDivider />} spacing={2}>
|
||||
{changelogs?.map((c) => {
|
||||
return (
|
||||
<Button
|
||||
size={"sm"}
|
||||
variant={"ghost"}
|
||||
onClick={() => {
|
||||
if (isDirty) {
|
||||
alert(
|
||||
"You have unsaved changes, please save or discard your changes to proceed switching version, in case losing your changes."
|
||||
);
|
||||
return;
|
||||
}
|
||||
app.loadGraphData(JSON.parse(c.json));
|
||||
updateFlow(curFlowID, { lastSavedJson: c.json });
|
||||
onClose();
|
||||
}}
|
||||
isActive={c.id === selectedVersion}
|
||||
>
|
||||
Saved at {formatTimestamp(c.createTime, true)}
|
||||
</Button>
|
||||
);
|
||||
})}
|
||||
</Stack>
|
||||
<Tabs
|
||||
isFitted
|
||||
variant="enclosed"
|
||||
onChange={setActive}
|
||||
colorScheme="teal"
|
||||
>
|
||||
<TabList mb="1em">
|
||||
<Tab>Versions</Tab>
|
||||
<Tab>Change History</Tab>
|
||||
</TabList>
|
||||
<TabPanels>
|
||||
<TabPanel>
|
||||
<Stack divider={<StackDivider />} spacing={2}>
|
||||
{versions?.map((version) => {
|
||||
return (
|
||||
<Flex
|
||||
key={version.id}
|
||||
w={"100%"}
|
||||
mb={1}
|
||||
justify={"space-between"}
|
||||
backgroundColor={
|
||||
version.id === curVersion?.id ? "teal.300" : undefined
|
||||
}
|
||||
borderRadius={4}
|
||||
>
|
||||
<Stack textAlign={"left"} gap={0}>
|
||||
<Text fontWeight={"500"}>
|
||||
{version.name ?? "untitled"}
|
||||
</Text>
|
||||
<Text color={"GrayText"} fontSize={"sm"}>
|
||||
Created: {formatTimestamp(version.createTime)}
|
||||
</Text>
|
||||
</Stack>
|
||||
<Flex alignItems="center" gap={2}>
|
||||
<Button
|
||||
size={"sm"}
|
||||
onClick={() => {
|
||||
if (isDirty) {
|
||||
alert(
|
||||
"You have unsaved changes, please save or discard your changes to proceed switching version, in case losing your changes.",
|
||||
);
|
||||
return;
|
||||
}
|
||||
app.loadGraphData(JSON.parse(version.json));
|
||||
loadWorkflowID(curFlowID!, version.id);
|
||||
onClose();
|
||||
}}
|
||||
>
|
||||
Load
|
||||
</Button>
|
||||
<DeleteConfirm
|
||||
promptMessage="Are you sure you want to delete this version?"
|
||||
onDelete={() => {
|
||||
onDelete(version.id);
|
||||
}}
|
||||
/>
|
||||
</Flex>
|
||||
</Flex>
|
||||
);
|
||||
})}
|
||||
</Stack>
|
||||
</TabPanel>
|
||||
<TabPanel>
|
||||
<Text mb={4}>All the saved changes of this workflow</Text>
|
||||
<Stack divider={<StackDivider />} spacing={2}>
|
||||
{changelogs?.map((c) => {
|
||||
return (
|
||||
<HStack key={c.id} justifyContent={"space-between"}>
|
||||
<Button
|
||||
key={c.id}
|
||||
size={"sm"}
|
||||
variant={"ghost"}
|
||||
onClick={() => {
|
||||
if (isDirty) {
|
||||
alert(
|
||||
"You have unsaved changes, please save or discard your changes to proceed switching version, in case losing your changes.",
|
||||
);
|
||||
return;
|
||||
}
|
||||
app.loadGraphData(JSON.parse(c.json));
|
||||
workflowsTable?.updateFlow(curFlowID!, {
|
||||
json: c.json,
|
||||
});
|
||||
onClose();
|
||||
}}
|
||||
isActive={c.id === selectedVersion}
|
||||
>
|
||||
Saved at {new Date(c.createTime).toLocaleString()}
|
||||
</Button>
|
||||
{c.isAutoSave ? (
|
||||
<Tag colorScheme="teal" size={"sm"}>
|
||||
Auto save
|
||||
</Tag>
|
||||
) : null}
|
||||
</HStack>
|
||||
);
|
||||
})}
|
||||
</Stack>
|
||||
</TabPanel>
|
||||
</TabPanels>
|
||||
</Tabs>
|
||||
</CardBody>
|
||||
</Card>
|
||||
);
|
||||
|
||||
@@ -2,3 +2,7 @@ export const LEGACY_COMFYSPACE_TRACKING_FIELD_NAME = "comfyspace_tracking";
|
||||
export const COMFYSPACE_TRACKING_FIELD_NAME = "workspace_info";
|
||||
export const COMFYSPACE_AUTH_ENDPOINT = "comfyspace_auth";
|
||||
export const COMFYSPACE_AUTH_REDIRECT_URL = "http://localhost:8188/";
|
||||
export const DRAWER_Z_INDEX = 9999999;
|
||||
export const UPGRADE_TO_2WAY_SYNC_KEY = "upgrade_to_2way_sync";
|
||||
export const TOPBAR_BUTTON_HEIGHT = 28;
|
||||
export const SHORTCUT_TRIGGER_EVENT = "SHORTCUT_TRIGGER_EVENT";
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
export const useDebounce = <T>(value: T, delay: number): T => {
|
||||
const [debouncedValue, setDebouncedValue] = useState<T>(value);
|
||||
@@ -14,4 +14,4 @@ export const useDebounce = <T>(value: T, delay: number): T => {
|
||||
}, [value, delay]);
|
||||
|
||||
return debouncedValue;
|
||||
};
|
||||
};
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
import { useCallback, useEffect, useRef } from "react";
|
||||
|
||||
export default function useDebounceFn<T extends (...args: any[]) => void>(
|
||||
fn: T,
|
||||
delay: number,
|
||||
): [T, () => void] {
|
||||
const timeoutRef = useRef(0);
|
||||
|
||||
const cancel = useCallback(() => {
|
||||
if (timeoutRef.current !== null) {
|
||||
clearTimeout(timeoutRef.current);
|
||||
timeoutRef.current = 0;
|
||||
}
|
||||
}, []);
|
||||
|
||||
const debouncedFn = useCallback(
|
||||
(...args: Parameters<T>) => {
|
||||
cancel();
|
||||
timeoutRef.current = setTimeout(() => fn(...args), delay);
|
||||
},
|
||||
[fn, delay, cancel],
|
||||
) as T;
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
cancel();
|
||||
};
|
||||
}, []);
|
||||
|
||||
return [debouncedFn, cancel];
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import { useCallback, useRef, useState, SetStateAction, Dispatch } from "react";
|
||||
|
||||
const isFunction = <S>(
|
||||
setStateAction: SetStateAction<S>,
|
||||
): setStateAction is (prevState: S) => S =>
|
||||
typeof setStateAction === "function";
|
||||
|
||||
type ReadOnlyRefObject<T> = {
|
||||
readonly current: T;
|
||||
};
|
||||
|
||||
type UseStateRef = {
|
||||
<S>(
|
||||
initialState: S | (() => S),
|
||||
): [S, Dispatch<SetStateAction<S>>, ReadOnlyRefObject<S>];
|
||||
<S = undefined>(): [
|
||||
S | undefined,
|
||||
Dispatch<SetStateAction<S | undefined>>,
|
||||
ReadOnlyRefObject<S | undefined>,
|
||||
];
|
||||
};
|
||||
|
||||
export const useStateRef: UseStateRef = <S>(initialState?: S | (() => S)) => {
|
||||
const [state, setState] = useState(initialState);
|
||||
const ref = useRef(state);
|
||||
|
||||
const dispatch: typeof setState = useCallback((setStateAction: any) => {
|
||||
ref.current = isFunction(setStateAction)
|
||||
? setStateAction(ref.current)
|
||||
: setStateAction;
|
||||
|
||||
setState(ref.current);
|
||||
}, []);
|
||||
|
||||
return [state, dispatch, ref];
|
||||
};
|
||||
@@ -1,68 +1,64 @@
|
||||
import { getDB, saveDB } from "../Api";
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
import { getWorkspaceIndexDB, updateWorkspaceIndexDB } from "./IndexDBUtils";
|
||||
import { Table } from "./WorkspaceDB";
|
||||
import { nanoid } from "nanoid";
|
||||
import { Table, userSettingsTable } from "./WorkspaceDB";
|
||||
import { Changelog } from "../types/dbTypes";
|
||||
|
||||
type ChangelogRecords = {
|
||||
[id: string]: Changelog;
|
||||
};
|
||||
export class ChangelogsTable {
|
||||
import { TableBase } from "./TableBase";
|
||||
import { indexdb } from "./indexdb";
|
||||
const LIMIT = 80;
|
||||
export class ChangelogsTable extends TableBase<Changelog> {
|
||||
static readonly TABLE_NAME: Table = "changelogs";
|
||||
|
||||
private constructor() {}
|
||||
|
||||
constructor() {
|
||||
super("changelogs");
|
||||
}
|
||||
static async load(): Promise<ChangelogsTable> {
|
||||
const instance = new ChangelogsTable();
|
||||
return instance;
|
||||
}
|
||||
|
||||
public async listByWorkflowID(workflowID: string): Promise<Changelog[]> {
|
||||
const records = await this.getRecords();
|
||||
return Object.values(records)
|
||||
.filter((c) => c.workflowID === workflowID)
|
||||
.sort((a, b) => b.createTime - a.createTime);
|
||||
}
|
||||
public async getRecords(): Promise<ChangelogRecords> {
|
||||
let jsonStr = await getDB(ChangelogsTable.TABLE_NAME);
|
||||
let json = jsonStr != null ? JSON.parse(jsonStr) : null;
|
||||
if (json == null) {
|
||||
const comfyspace = (await getWorkspaceIndexDB()) ?? "{}";
|
||||
const comfyspaceData = JSON.parse(comfyspace);
|
||||
json = comfyspaceData[ChangelogsTable.TABLE_NAME];
|
||||
}
|
||||
return json ?? {};
|
||||
}
|
||||
public async get(id: string): Promise<Changelog | undefined> {
|
||||
const records = await this.getRecords();
|
||||
return records[id];
|
||||
const objects = await indexdb["changelogs"]
|
||||
.where("workflowID")
|
||||
.equals(workflowID)
|
||||
.reverse()
|
||||
.sortBy("createTime");
|
||||
return objects;
|
||||
}
|
||||
public async getLastestByWorkflowID(workflowID: string): Promise<Changelog> {
|
||||
const records = await this.getRecords();
|
||||
const all = Object.values(records)
|
||||
.filter((c) => c.workflowID === workflowID)
|
||||
.sort((a, b) => b.createTime - a.createTime);
|
||||
return all[0];
|
||||
const objects = await this.listByWorkflowID(workflowID);
|
||||
return objects[0];
|
||||
}
|
||||
public async create(input: {
|
||||
json: string;
|
||||
workflowID: string;
|
||||
isAutoSave: boolean;
|
||||
}): Promise<Changelog | null> {
|
||||
const latest = await this.getLastestByWorkflowID(input.workflowID);
|
||||
// only create when there is a change
|
||||
if (latest != null && latest.json === input.json) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const change: Changelog = {
|
||||
id: uuidv4(),
|
||||
id: nanoid(),
|
||||
json: input.json,
|
||||
workflowID: input.workflowID,
|
||||
createTime: Date.now(),
|
||||
isAutoSave: input.isAutoSave,
|
||||
};
|
||||
const records = await this.getRecords();
|
||||
records[change.id] = change;
|
||||
saveDB(ChangelogsTable.TABLE_NAME, JSON.stringify(records));
|
||||
updateWorkspaceIndexDB();
|
||||
await this.deleteLogsExceedLimit(input.workflowID);
|
||||
await indexdb.changelogs.add(change);
|
||||
await this.saveDiskDB();
|
||||
return change;
|
||||
}
|
||||
async deleteLogsExceedLimit(workflowID: string) {
|
||||
const all = await indexdb.changelogs
|
||||
.where("workflowID")
|
||||
.equals(workflowID)
|
||||
.reverse()
|
||||
.sortBy("createTime");
|
||||
const limit = userSettingsTable?.settings?.maximumChangelogNumber ?? LIMIT;
|
||||
if (all.length > limit) {
|
||||
const toDelete = all.slice(limit);
|
||||
await indexdb.changelogs.bulkDelete(toDelete.map((c) => c.id));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,45 +1,48 @@
|
||||
import { deleteFile, saveDB, updateFile } from "../Api";
|
||||
import { Workflow, foldersTable, userSettingsTable } from "./WorkspaceDB";
|
||||
import { deleteFile, updateFile } from "../Api";
|
||||
import { foldersTable, workflowsTable, userSettingsTable } from "./WorkspaceDB";
|
||||
import {
|
||||
COMFYSPACE_TRACKING_FIELD_NAME,
|
||||
LEGACY_COMFYSPACE_TRACKING_FIELD_NAME,
|
||||
} from "../const";
|
||||
|
||||
import { toFileNameFriendly } from "../utils";
|
||||
import { Folder, Workflow } from "../types/dbTypes";
|
||||
import { TwowayFolderSyncAPI } from "../apis/TwowaySyncFolderApi";
|
||||
|
||||
export function saveJsonFileMyWorkflows(
|
||||
workflow: Workflow,
|
||||
onFileSaved?: (fullPath: string) => void
|
||||
) {
|
||||
const file_path = generateFilePath(workflow);
|
||||
export async function saveJsonFileMyWorkflows(workflow: Workflow) {
|
||||
const file_path = await generateFilePath(workflow);
|
||||
if (file_path == null) {
|
||||
return;
|
||||
}
|
||||
const fullPath = generateFilePathAbsolute(workflow);
|
||||
fullPath && onFileSaved && onFileSaved(fullPath);
|
||||
|
||||
const fullPath = await generateFilePathAbsolute(workflow);
|
||||
await workflowsTable?.updateMetaInfo(workflow.id, {
|
||||
filePath: fullPath ?? undefined,
|
||||
});
|
||||
const json = workflow.json;
|
||||
const flow = JSON.parse(json);
|
||||
delete flow.extra[LEGACY_COMFYSPACE_TRACKING_FIELD_NAME];
|
||||
flow.extra[COMFYSPACE_TRACKING_FIELD_NAME] = {
|
||||
id: workflow.id,
|
||||
name: workflow.name,
|
||||
};
|
||||
updateFile(file_path, JSON.stringify(flow));
|
||||
delete flow.extra[LEGACY_COMFYSPACE_TRACKING_FIELD_NAME];
|
||||
|
||||
await updateFile(file_path, JSON.stringify(flow));
|
||||
}
|
||||
|
||||
export function deleteJsonFileMyWorkflows(workflow: Workflow) {
|
||||
export async function deleteJsonFileMyWorkflows(workflow: Workflow) {
|
||||
if (workflow.name == null) {
|
||||
return;
|
||||
}
|
||||
const file_path = generateFilePath(workflow);
|
||||
const file_path = await generateFilePath(workflow);
|
||||
file_path != null && deleteFile(file_path);
|
||||
}
|
||||
|
||||
export function generateFilePath(workflow: Workflow): string | null {
|
||||
export async function generateFilePath(
|
||||
workflow: Workflow,
|
||||
): Promise<string | null> {
|
||||
let filePath = toFileNameFriendly(workflow.name) + ".json";
|
||||
let curFolderID = workflow.parentFolderID;
|
||||
while (curFolderID != null) {
|
||||
const folder = foldersTable?.get(curFolderID);
|
||||
const folder = await foldersTable?.get(curFolderID);
|
||||
if (folder == null) {
|
||||
break;
|
||||
}
|
||||
@@ -51,10 +54,29 @@ export function generateFilePath(workflow: Workflow): string | null {
|
||||
return filePath ?? null;
|
||||
}
|
||||
|
||||
export function generateFilePathAbsolute(workflow: Workflow): string | null {
|
||||
const subPath = generateFilePath(workflow);
|
||||
let myWorkflowsDir = userSettingsTable?.getSetting("myWorkflowsDir");
|
||||
if (myWorkflowsDir == null) {
|
||||
export async function genFolderRelPath(
|
||||
folderId: string | null,
|
||||
): Promise<string> {
|
||||
let filePath = "";
|
||||
let curFolderID = folderId;
|
||||
while (curFolderID != null) {
|
||||
const folder = await foldersTable?.get(curFolderID);
|
||||
if (folder == null) {
|
||||
break;
|
||||
}
|
||||
const folderName = folder.name;
|
||||
filePath = `${folderName}/${filePath}`;
|
||||
curFolderID = folder.parentFolderID ?? null;
|
||||
}
|
||||
|
||||
return filePath;
|
||||
}
|
||||
export async function generateFilePathAbsolute(
|
||||
workflow: Workflow,
|
||||
): Promise<string | null> {
|
||||
const subPath = await generateFilePath(workflow);
|
||||
let myWorkflowsDir = await userSettingsTable?.getSetting("myWorkflowsDir");
|
||||
if (!myWorkflowsDir) {
|
||||
console.error("myWorkflowsDir is not set");
|
||||
return null;
|
||||
}
|
||||
@@ -63,3 +85,62 @@ export function generateFilePathAbsolute(workflow: Workflow): string | null {
|
||||
}
|
||||
return myWorkflowsDir + subPath;
|
||||
}
|
||||
|
||||
export async function generateFolderPath(id: string): Promise<string | null> {
|
||||
const folder = await foldersTable?.get(id);
|
||||
let parentFolderID = folder?.parentFolderID;
|
||||
let folderPath = folder?.name;
|
||||
while (parentFolderID) {
|
||||
const folder = await foldersTable?.get(parentFolderID);
|
||||
if (folder == null) {
|
||||
break;
|
||||
}
|
||||
folderPath = `${folder.name}/${folderPath}`;
|
||||
parentFolderID = folder.parentFolderID ?? undefined;
|
||||
}
|
||||
|
||||
let myWorkflowsDir = await userSettingsTable?.getSetting("myWorkflowsDir");
|
||||
|
||||
if (!myWorkflowsDir) {
|
||||
console.error("myWorkflowsDir is not set");
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!myWorkflowsDir.endsWith("/")) {
|
||||
myWorkflowsDir = myWorkflowsDir + "/";
|
||||
}
|
||||
return myWorkflowsDir + folderPath;
|
||||
}
|
||||
|
||||
export async function getFileCountInFolder(folderId: string): Promise<number> {
|
||||
const twoWaySyncEnabled = await userSettingsTable?.getSetting("twoWaySync");
|
||||
if (twoWaySyncEnabled) {
|
||||
return (await TwowayFolderSyncAPI.genFilesCountInFolder(folderId)) ?? 0;
|
||||
}
|
||||
const allFlows = (await workflowsTable?.listAll()) ?? [];
|
||||
const allFolders = (await foldersTable?.listAll()) ?? [];
|
||||
const nestedFolderIdStack = [folderId];
|
||||
let count = 0;
|
||||
|
||||
while (nestedFolderIdStack.length > 0) {
|
||||
const curFolderId = nestedFolderIdStack.shift();
|
||||
|
||||
if (curFolderId) {
|
||||
for (const flow of allFlows) {
|
||||
if (flow.parentFolderID === curFolderId) {
|
||||
count++;
|
||||
}
|
||||
}
|
||||
|
||||
const curNestedFolderIds = allFolders
|
||||
.filter((f) => f.parentFolderID === curFolderId)
|
||||
.map((f) => f.id);
|
||||
|
||||
if (curNestedFolderIds.length) {
|
||||
nestedFolderIdStack.push(...curNestedFolderIds);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return count;
|
||||
}
|
||||
|
||||
@@ -1,67 +1,74 @@
|
||||
import { getDB, saveDB } from "../Api";
|
||||
import { listWorkflows, updateFlow } from "./WorkspaceDB";
|
||||
import { Folder } from "../types/dbTypes";
|
||||
import { userSettingsTable, workflowsTable } from "./WorkspaceDB";
|
||||
import { EFlowOperationType, Folder } from "../types/dbTypes";
|
||||
import { validateOrSaveAllJsonFileMyWorkflows } from "../utils";
|
||||
import { getWorkspaceIndexDB, updateWorkspaceIndexDB } from "./IndexDBUtils";
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
import { nanoid } from "nanoid";
|
||||
import { TableBase } from "./TableBase";
|
||||
import { indexdb } from "./indexdb";
|
||||
import { TwowayFolderSyncAPI } from "../apis/TwowaySyncFolderApi";
|
||||
import { TwowaySyncAPI, scanLocalFiles } from "../apis/TwowaySyncApi";
|
||||
|
||||
export class FoldersTable {
|
||||
export class FoldersTable extends TableBase<Folder> {
|
||||
static readonly TABLE_NAME = "folders";
|
||||
private records: {
|
||||
[id: string]: Folder;
|
||||
};
|
||||
private constructor() {
|
||||
this.records = {};
|
||||
|
||||
constructor() {
|
||||
super("folders");
|
||||
}
|
||||
|
||||
static async load(): Promise<FoldersTable> {
|
||||
const instance = new FoldersTable();
|
||||
let jsonStr = await getDB(FoldersTable.TABLE_NAME);
|
||||
let json = jsonStr != null ? JSON.parse(jsonStr) : null;
|
||||
if (json == null) {
|
||||
const comfyspace = (await getWorkspaceIndexDB()) ?? "{}";
|
||||
const comfyspaceData = JSON.parse(comfyspace);
|
||||
json = comfyspaceData[FoldersTable.TABLE_NAME];
|
||||
}
|
||||
if (json != null) {
|
||||
instance.records = json;
|
||||
}
|
||||
return instance;
|
||||
}
|
||||
public listAll(): Folder[] {
|
||||
return Object.values(this.records);
|
||||
}
|
||||
public getRecords() {
|
||||
return this.records;
|
||||
}
|
||||
public get(id: string): Folder | undefined {
|
||||
return this.records[id];
|
||||
}
|
||||
public create(input: { name: string; parentFolderID?: string }): Folder {
|
||||
const uniqueName = this.generateUniqueName(input.name);
|
||||
|
||||
public async create(input: {
|
||||
name: string;
|
||||
parentFolderID?: string;
|
||||
}): Promise<Folder> {
|
||||
const uniqueName = await this.generateUniqueName(
|
||||
input.name,
|
||||
input.parentFolderID,
|
||||
);
|
||||
const twoWaySyncEnabled = await userSettingsTable?.getSetting("twoWaySync");
|
||||
const rel = await TwowayFolderSyncAPI.genFolderRelPath({
|
||||
name: uniqueName,
|
||||
parentFolderID: input.parentFolderID ?? "",
|
||||
});
|
||||
const folder: Folder = {
|
||||
id: uuidv4(),
|
||||
id: twoWaySyncEnabled ? rel : nanoid(),
|
||||
name: uniqueName,
|
||||
parentFolderID: input.parentFolderID ?? null,
|
||||
updateTime: Date.now(),
|
||||
createTime: Date.now(),
|
||||
type: "folder",
|
||||
};
|
||||
this.records[folder.id] = folder;
|
||||
saveDB("folders", JSON.stringify(this.records));
|
||||
updateWorkspaceIndexDB();
|
||||
|
||||
if (twoWaySyncEnabled) {
|
||||
await TwowayFolderSyncAPI.createFolder(folder);
|
||||
} else {
|
||||
await indexdb.folders.add(folder);
|
||||
this.saveDiskDB();
|
||||
}
|
||||
return folder;
|
||||
}
|
||||
public update(
|
||||
input: {
|
||||
id: string;
|
||||
} & Partial<Folder>
|
||||
) {
|
||||
const folder = this.records[input.id];
|
||||
if (folder == null) {
|
||||
return;
|
||||
|
||||
public async update(id: string, input: Partial<Folder>) {
|
||||
const twoWaySyncEnabled = await userSettingsTable?.getSetting("twoWaySync");
|
||||
if (twoWaySyncEnabled) {
|
||||
if ("parentFolderID" in input) {
|
||||
await TwowayFolderSyncAPI.moveFolder(id, input["parentFolderID"] ?? "");
|
||||
}
|
||||
if ("name" in input) {
|
||||
await TwowayFolderSyncAPI.renameFolder(id, input["name"] ?? "");
|
||||
}
|
||||
return null;
|
||||
}
|
||||
const folder = await this.get(id);
|
||||
if (folder == null) {
|
||||
return null;
|
||||
}
|
||||
const nameChanged = "name" in input && input.name != folder.name;
|
||||
const parentFolderChanged =
|
||||
"parentFolderID" in input &&
|
||||
input.parentFolderID != folder.parentFolderID;
|
||||
const newRecord = {
|
||||
...folder,
|
||||
...input,
|
||||
@@ -69,29 +76,91 @@ export class FoldersTable {
|
||||
if (input.name != null) {
|
||||
newRecord.updateTime = Date.now();
|
||||
}
|
||||
this.records[input.id] = newRecord;
|
||||
saveDB("folders", JSON.stringify(this.records));
|
||||
updateWorkspaceIndexDB();
|
||||
// move or rename all workflows to the right directory(not required when folded state changes)
|
||||
if (input.name != null || input.parentFolderID != null) {
|
||||
if (parentFolderChanged) {
|
||||
input.name = await this.generateUniqueName(
|
||||
newRecord.name,
|
||||
newRecord.parentFolderID ?? undefined,
|
||||
);
|
||||
}
|
||||
await indexdb.folders.update(id, input);
|
||||
this.saveDiskDB();
|
||||
|
||||
// folder moved or renamed - move all workflows to the right directory(not required when folded state changes)
|
||||
if (nameChanged || parentFolderChanged) {
|
||||
validateOrSaveAllJsonFileMyWorkflows(true);
|
||||
}
|
||||
return newRecord;
|
||||
}
|
||||
public delete(id: string) {
|
||||
delete this.records[id];
|
||||
const childrenFlows = listWorkflows().filter(
|
||||
(flow) => flow.parentFolderID == id
|
||||
);
|
||||
childrenFlows.forEach((flow) =>
|
||||
updateFlow(flow.id, { parentFolderID: undefined })
|
||||
);
|
||||
saveDB("folders", JSON.stringify(this.records));
|
||||
updateWorkspaceIndexDB();
|
||||
validateOrSaveAllJsonFileMyWorkflows();
|
||||
public async deleteFolder(
|
||||
id: string,
|
||||
flowOperationType: EFlowOperationType = EFlowOperationType.DELETE,
|
||||
) {
|
||||
const twoWaySyncEnabled = await userSettingsTable?.getSetting("twoWaySync");
|
||||
if (twoWaySyncEnabled) {
|
||||
await scanLocalFiles(id, true, true).then((files) => {
|
||||
files.forEach((file) => {
|
||||
if (file.type === "workflow") {
|
||||
workflowsTable?.deleteFlow(file.id);
|
||||
}
|
||||
});
|
||||
});
|
||||
await TwowayFolderSyncAPI.deleteFolder(id);
|
||||
return;
|
||||
}
|
||||
/**
|
||||
* When deleting a folder, if there are files in the folder
|
||||
* Breadth traverse all nested folders, find all files, move to root directory or delete as needed.
|
||||
*/
|
||||
const allFlows = (await workflowsTable?.listAll()) ?? [];
|
||||
const allFolders = await this.listAll();
|
||||
const nestedFolderIdStack = [id];
|
||||
|
||||
while (nestedFolderIdStack.length > 0) {
|
||||
const curFolderId = nestedFolderIdStack.shift();
|
||||
|
||||
if (curFolderId) {
|
||||
for (const flow of allFlows) {
|
||||
if (flow.parentFolderID === curFolderId) {
|
||||
switch (flowOperationType) {
|
||||
case EFlowOperationType.DELETE:
|
||||
await workflowsTable?.deleteFlow(flow.id);
|
||||
break;
|
||||
case EFlowOperationType.MOVE_TO_ROOT_FOLDER:
|
||||
await workflowsTable?.updateFolder(flow.id, {
|
||||
parentFolderID: undefined,
|
||||
});
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await indexdb.folders.delete(curFolderId);
|
||||
const curNestedFolderIds = allFolders
|
||||
.filter((f) => f.parentFolderID === curFolderId)
|
||||
.map((f) => f.id);
|
||||
|
||||
if (curNestedFolderIds.length) {
|
||||
nestedFolderIdStack.push(...curNestedFolderIds);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
this.saveDiskDB();
|
||||
}
|
||||
public generateUniqueName(name?: string) {
|
||||
|
||||
public async generateUniqueName(name?: string, parentFolderID?: string) {
|
||||
const twoWaySyncEnabled = await userSettingsTable?.getSetting("twoWaySync");
|
||||
if (twoWaySyncEnabled) {
|
||||
const fileName = await TwowaySyncAPI.genFileUniqueName(
|
||||
name ?? "New folder",
|
||||
parentFolderID ?? "",
|
||||
);
|
||||
return fileName ?? "New folder";
|
||||
}
|
||||
let newFlowName = name ?? "New folder";
|
||||
const folderNameList = this.listAll()?.map((f) => f.name);
|
||||
const folderNameList = await this.listAll().then((list) =>
|
||||
list.filter((f) => f.parentFolderID == parentFolderID).map((f) => f.name),
|
||||
);
|
||||
if (folderNameList.includes(newFlowName)) {
|
||||
let num = 2;
|
||||
let flag = true;
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { curComfyspaceJson } from "./WorkspaceDB";
|
||||
// Legacy indexdb backup, to be deleted after indexdb migration is done
|
||||
|
||||
const WORKSPACE_KEY = "backup";
|
||||
const WORKSPACE_TABLE = "workspace";
|
||||
let indexDB: IDBDatabase | null = null;
|
||||
@@ -44,15 +45,6 @@ async function writeWorkspaceTable(data: string): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
export async function updateWorkspaceIndexDB() {
|
||||
try {
|
||||
const comfyspaceData = await curComfyspaceJson();
|
||||
await writeWorkspaceTable(comfyspaceData);
|
||||
} catch (error) {
|
||||
console.error("Error writing to IndexedDB:", error);
|
||||
}
|
||||
}
|
||||
|
||||
// Function to read data from the database
|
||||
async function readDataFromDatabase(): Promise<string | undefined> {
|
||||
try {
|
||||
@@ -79,7 +71,6 @@ async function readDataFromDatabase(): Promise<string | undefined> {
|
||||
}
|
||||
}
|
||||
|
||||
// Usage example
|
||||
export async function getWorkspaceIndexDB() {
|
||||
try {
|
||||
const comfyspaceData = await readDataFromDatabase();
|
||||
|
||||
@@ -1,10 +1,8 @@
|
||||
import { saveDB } from "../Api";
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
import { Table, getWorkflow, updateFlow } from "./WorkspaceDB";
|
||||
import { updateWorkspaceIndexDB } from "./IndexDBUtils";
|
||||
import { Table, workflowsTable } from "./WorkspaceDB";
|
||||
import { TableBase } from "./TableBase";
|
||||
import { Media } from "../types/dbTypes";
|
||||
import { indexdb } from "./indexdb";
|
||||
import { getMetadataFromUrl } from "../gallery/utils.ts";
|
||||
|
||||
export class MediaTable extends TableBase<Media> {
|
||||
static readonly TABLE_NAME: Table = "media";
|
||||
@@ -12,18 +10,12 @@ export class MediaTable extends TableBase<Media> {
|
||||
super("media");
|
||||
}
|
||||
public async listByWorkflowID(workflowID: string): Promise<Media[]> {
|
||||
const records = await this.getRecords();
|
||||
return Object.values(records)
|
||||
.filter((c) => c.workflowID === workflowID)
|
||||
.sort((a, b) => b.createTime - a.createTime);
|
||||
}
|
||||
|
||||
public async getLastestByWorkflowID(workflowID: string): Promise<Media> {
|
||||
const records = await this.getRecords();
|
||||
const all = Object.values(records)
|
||||
.filter((c) => c.workflowID === workflowID)
|
||||
.sort((a, b) => b.createTime - a.createTime);
|
||||
return all[0];
|
||||
const medias = await indexdb["media"]
|
||||
.where("workflowID")
|
||||
.equals(workflowID)
|
||||
.reverse()
|
||||
.sortBy("createTime");
|
||||
return medias;
|
||||
}
|
||||
|
||||
public async create(input: {
|
||||
@@ -32,28 +24,43 @@ export class MediaTable extends TableBase<Media> {
|
||||
}): Promise<Media | null> {
|
||||
const format = input.localPath.split(".").pop();
|
||||
if (format == null) return null;
|
||||
|
||||
const res = await getMetadataFromUrl(
|
||||
`/workspace/view_media?filename=${input.localPath}`,
|
||||
).catch((e) => {
|
||||
console.error("get meta error:", e);
|
||||
return { prompt: "" };
|
||||
});
|
||||
|
||||
const md: Media = {
|
||||
id: uuidv4(),
|
||||
id: input.localPath,
|
||||
localPath: input.localPath,
|
||||
workflowID: input.workflowID,
|
||||
createTime: Date.now(),
|
||||
workflowJSON: JSON.stringify(res?.prompt),
|
||||
format: format,
|
||||
};
|
||||
//link media to workflow
|
||||
const curMedia = getWorkflow(input.workflowID)?.mediaIDs ?? [];
|
||||
const newMedia = new Set(curMedia).add(md.id);
|
||||
updateFlow(input.workflowID, {
|
||||
mediaIDs: Array.from(newMedia),
|
||||
coverMediaPath: md.localPath,
|
||||
await workflowsTable?.updateMetaInfo(input.workflowID, {
|
||||
latestImage: md.localPath,
|
||||
});
|
||||
// save indexdb
|
||||
// indexdb.media.add(md);
|
||||
const records = await this.getRecords();
|
||||
records[md.id] = md;
|
||||
indexdb.media.put(md);
|
||||
// save disk file db
|
||||
saveDB("media", JSON.stringify(records));
|
||||
// save legacy indexdb backup
|
||||
updateWorkspaceIndexDB();
|
||||
this.saveDiskDB();
|
||||
return md;
|
||||
}
|
||||
|
||||
async delete(id: string): Promise<void> {
|
||||
super.delete(id);
|
||||
if (workflowsTable?.curWorkflow?.coverMediaPath === id) {
|
||||
workflowsTable?.updateMetaInfo(workflowsTable.curWorkflow.id, {
|
||||
coverMediaPath: undefined,
|
||||
});
|
||||
}
|
||||
if (workflowsTable?.curWorkflow?.latestImage === id) {
|
||||
workflowsTable?.updateMetaInfo(workflowsTable.curWorkflow.id, {
|
||||
latestImage: undefined,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
import { Model } from "../types/dbTypes";
|
||||
import { Table } from "./WorkspaceDB";
|
||||
import { TableBase } from "./TableBase";
|
||||
|
||||
export class ModelsTable extends TableBase<Model> {
|
||||
static readonly TABLE_NAME: Table = "models";
|
||||
constructor() {
|
||||
super("models");
|
||||
}
|
||||
static async load(): Promise<ModelsTable> {
|
||||
const instance = new ModelsTable();
|
||||
return instance;
|
||||
}
|
||||
}
|
||||
@@ -1,22 +1,92 @@
|
||||
import { getDB, saveDB } from "../Api";
|
||||
import { getWorkspaceIndexDB, updateWorkspaceIndexDB } from "./IndexDBUtils";
|
||||
import { Table } from "./WorkspaceDB";
|
||||
import { getWorkspaceIndexDB } from "./IndexDBUtils";
|
||||
import { TableBaseModel } from "../types/dbTypes";
|
||||
import { Table, userSettingsTable } from "./WorkspaceDB";
|
||||
import { indexdb } from "./indexdb";
|
||||
import { nanoid } from "nanoid";
|
||||
|
||||
export class TableBase<T> {
|
||||
export class TableBase<T extends TableBaseModel> {
|
||||
public readonly tableName: Table;
|
||||
|
||||
protected constructor(tableName: Table) {
|
||||
this.tableName = tableName;
|
||||
}
|
||||
protected async saveDiskDB() {
|
||||
const objs = (await indexdb[this.tableName].toArray()) as TableBaseModel[];
|
||||
const backup: Record<string, TableBaseModel> = {};
|
||||
objs.forEach((f) => {
|
||||
// only tags table is using name as primary key, we either give up backup that table to diskDB
|
||||
// or add a id as primary key to it
|
||||
if (f.id) {
|
||||
backup[f.id] = f;
|
||||
} else if (f.name) {
|
||||
backup[f.name] = f;
|
||||
} else {
|
||||
console.error("TableBaseModel should have id or name");
|
||||
}
|
||||
});
|
||||
try {
|
||||
saveDB(this.tableName, JSON.stringify(backup));
|
||||
} catch (e) {
|
||||
console.error("error saving disk db", e);
|
||||
}
|
||||
}
|
||||
|
||||
public async add(
|
||||
newItem: Omit<T, "id" | "createTime"> &
|
||||
Partial<Pick<T, "id" | "createTime">>,
|
||||
): Promise<T> {
|
||||
if (!newItem.id) {
|
||||
newItem.id = nanoid();
|
||||
}
|
||||
if (!newItem.createTime) {
|
||||
newItem.createTime = Date.now();
|
||||
}
|
||||
await indexdb[this.tableName].add(newItem as any);
|
||||
await this.saveDiskDB();
|
||||
return newItem as T;
|
||||
}
|
||||
|
||||
public async put(newItem: T): Promise<T> {
|
||||
await indexdb[this.tableName].put(newItem as any);
|
||||
await this.saveDiskDB();
|
||||
return newItem;
|
||||
}
|
||||
public async bulkPut(newItems: T[]): Promise<void> {
|
||||
// @ts-ignore
|
||||
await indexdb[this.tableName].bulkPut(newItems as any);
|
||||
this.saveDiskDB();
|
||||
}
|
||||
public async update(id: string, changes: Partial<T>): Promise<T | null> {
|
||||
await indexdb[this.tableName].update(id, changes);
|
||||
this.saveDiskDB();
|
||||
return (await this.get(id)) ?? null;
|
||||
}
|
||||
|
||||
public async listAll(): Promise<T[]> {
|
||||
const records = await this.getRecords();
|
||||
const workflows = Object.values(records);
|
||||
return workflows;
|
||||
const list = await indexdb[this.tableName].toArray();
|
||||
return list as any[];
|
||||
}
|
||||
|
||||
async filter(filterFn: (obj: T) => boolean): Promise<T[]> {
|
||||
const list = await indexdb[this.tableName]
|
||||
.filter(filterFn as any)
|
||||
.toArray();
|
||||
return list as any[];
|
||||
}
|
||||
|
||||
public async batchQuery(ids: string[]): Promise<T[]> {
|
||||
const list = await indexdb[this.tableName].bulkGet(ids);
|
||||
return list as any[];
|
||||
}
|
||||
|
||||
public async getRecords(): Promise<Record<string, T>> {
|
||||
let jsonStr = await getDB(this.tableName);
|
||||
console.warn(
|
||||
"[DEPRECATED] getRecords() call, should only be used for one-time backfill for indexdb",
|
||||
this.tableName,
|
||||
);
|
||||
|
||||
const jsonStr = await getDB(this.tableName);
|
||||
let json: any;
|
||||
try {
|
||||
json = jsonStr != null ? JSON.parse(jsonStr) : null;
|
||||
@@ -28,15 +98,21 @@ export class TableBase<T> {
|
||||
}
|
||||
return json ?? {};
|
||||
}
|
||||
|
||||
public async get(id: string): Promise<T | undefined> {
|
||||
const records = await this.getRecords();
|
||||
return records[id];
|
||||
const obj = await indexdb[this.tableName].get(id);
|
||||
return obj as any;
|
||||
}
|
||||
|
||||
public async delete(id: string) {
|
||||
const records = await this.getRecords();
|
||||
delete records[id];
|
||||
saveDB(this.tableName, JSON.stringify(records));
|
||||
updateWorkspaceIndexDB();
|
||||
await indexdb[this.tableName].delete(id);
|
||||
this.saveDiskDB();
|
||||
}
|
||||
public async listByIndex(index: keyof T, value: string): Promise<T[]> {
|
||||
const list = await indexdb[this.tableName]
|
||||
.where(index as string)
|
||||
.equals(value)
|
||||
.toArray();
|
||||
return list as any[];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,12 +1,33 @@
|
||||
import { getDB, getSystemDir, saveDB } from "../Api";
|
||||
import { UserSettings } from "../types/dbTypes";
|
||||
import { getWorkspaceIndexDB, updateWorkspaceIndexDB } from "./IndexDBUtils";
|
||||
import type { UserSettings } from "../types/dbTypes";
|
||||
import { TableBase } from "./TableBase";
|
||||
import { MODEL_TYPE_TO_FOLDER_MAPPING } from "../model-manager/install-models/util/modelTypes";
|
||||
import { fetchMyWorkflowsDir } from "../Api";
|
||||
|
||||
export class UserSettingsTable {
|
||||
public records: UserSettings;
|
||||
export class UserSettingsTable extends TableBase<UserSettings> {
|
||||
public defaultSettings: UserSettings;
|
||||
public readonly DEFAULT_USER = "guest";
|
||||
static readonly TABLE_NAME = "userSettings";
|
||||
private constructor() {
|
||||
this.records = {
|
||||
|
||||
/**
|
||||
* Because in App.js we will use window.addEventListener("beforeunload", handleBeforeUnload); to remind the user to save the workflow when the user leaves the page.
|
||||
* The autoSave needs to be obtained in the handleBeforeUnload function. If it is obtained through getSetting, the handleBeforeUnload function will become an async function, which will cause the "beforeunload" event to fail.
|
||||
* So we maintain an autoSave here that is always up to date.
|
||||
*/
|
||||
private _autoSave: boolean = false;
|
||||
private _settings: UserSettings | undefined = undefined;
|
||||
|
||||
get settings() {
|
||||
return this._settings;
|
||||
}
|
||||
|
||||
get autoSave() {
|
||||
return this._autoSave;
|
||||
}
|
||||
|
||||
constructor() {
|
||||
super("userSettings");
|
||||
this.defaultSettings = {
|
||||
id: this.DEFAULT_USER,
|
||||
topBarStyle: {
|
||||
top: 0,
|
||||
left: 0,
|
||||
@@ -14,40 +35,68 @@ export class UserSettingsTable {
|
||||
myWorkflowsDir: "",
|
||||
shortcuts: {
|
||||
save: "Shift+S",
|
||||
saveAs: "Control+Alt+S",
|
||||
openSpotlightSearch: "Control+P",
|
||||
},
|
||||
autoSave: true,
|
||||
defaultFolders: MODEL_TYPE_TO_FOLDER_MAPPING,
|
||||
autoSave: false,
|
||||
autoSaveDuration: 3,
|
||||
twoWaySync: false,
|
||||
foldersOnTop: false,
|
||||
cloudHost: "https://www.nodecafe.org",
|
||||
overwriteCurWorkflowWhenDroppingFileToCanvas: false,
|
||||
maximumChangelogNumber: 80,
|
||||
hideCoverImage: false,
|
||||
disableUnsavedWarning: false,
|
||||
};
|
||||
}
|
||||
public listSettings() {
|
||||
return this.records;
|
||||
|
||||
public async getSetting<K extends keyof UserSettings>(
|
||||
key: K,
|
||||
): Promise<UserSettings[K]> {
|
||||
const currentUserSettings: UserSettings | undefined = await this.get(
|
||||
this.DEFAULT_USER,
|
||||
);
|
||||
if (key === "shortcuts" && currentUserSettings?.shortcuts) {
|
||||
return {
|
||||
...this.defaultSettings.shortcuts,
|
||||
...currentUserSettings.shortcuts,
|
||||
} as UserSettings[K];
|
||||
}
|
||||
return currentUserSettings?.[key] ?? this.defaultSettings[key];
|
||||
}
|
||||
public getSetting<K extends keyof UserSettings>(key: K): UserSettings[K] {
|
||||
return this.records[key];
|
||||
}
|
||||
public upsert(newPairs: Partial<UserSettings>) {
|
||||
this.records = {
|
||||
...this.records,
|
||||
|
||||
public async upsert(newPairs: Partial<UserSettings>) {
|
||||
const oldSettings =
|
||||
(await this.get(this.DEFAULT_USER)) ?? this.defaultSettings;
|
||||
const newSettings = {
|
||||
...oldSettings,
|
||||
...newPairs,
|
||||
};
|
||||
saveDB(UserSettingsTable.TABLE_NAME, JSON.stringify(this.records));
|
||||
updateWorkspaceIndexDB();
|
||||
await this.put(newSettings);
|
||||
this._settings = {
|
||||
...this.defaultSettings,
|
||||
...newSettings,
|
||||
};
|
||||
}
|
||||
|
||||
static async load(): Promise<UserSettingsTable> {
|
||||
const instance = new UserSettingsTable();
|
||||
const jsonStr = await getDB(UserSettingsTable.TABLE_NAME);
|
||||
let json = jsonStr != null ? JSON.parse(jsonStr) : null;
|
||||
if (json == null) {
|
||||
const comfyspace = (await getWorkspaceIndexDB()) ?? "{}";
|
||||
const comfyspaceData = JSON.parse(comfyspace);
|
||||
json = comfyspaceData[UserSettingsTable.TABLE_NAME] || {};
|
||||
}
|
||||
if (!json.myWorkflowsDir) {
|
||||
const getDir = await getSystemDir();
|
||||
json.myWorkflowsDir = `${getDir.dir_path}/my_workflows`;
|
||||
}
|
||||
instance.records = json;
|
||||
const myWorkflowsDir = await fetchMyWorkflowsDir();
|
||||
|
||||
instance.defaultSettings.myWorkflowsDir = myWorkflowsDir.path!;
|
||||
|
||||
await instance.get(instance.DEFAULT_USER).then((res) => {
|
||||
instance._autoSave = res?.autoSave ?? false;
|
||||
instance._settings = {
|
||||
...instance.defaultSettings,
|
||||
...res,
|
||||
};
|
||||
});
|
||||
// overwrite legacy comfyspace.art
|
||||
await instance.upsert({
|
||||
cloudHost: instance.defaultSettings.cloudHost,
|
||||
});
|
||||
return instance;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
import { Table } from "./WorkspaceDB";
|
||||
import { WorkflowVersion } from "../types/dbTypes";
|
||||
import { TableBase } from "./TableBase";
|
||||
import { indexdb } from "./indexdb";
|
||||
|
||||
export class WorkflowVersionsTable extends TableBase<WorkflowVersion> {
|
||||
static readonly TABLE_NAME: Table = "workflowVersions";
|
||||
constructor() {
|
||||
super("workflowVersions");
|
||||
}
|
||||
static async load(): Promise<WorkflowVersionsTable> {
|
||||
const instance = new WorkflowVersionsTable();
|
||||
return instance;
|
||||
}
|
||||
|
||||
public async listByWorkflowID(
|
||||
workflowID: string,
|
||||
): Promise<WorkflowVersion[]> {
|
||||
const objects = (await indexdb[this.tableName]
|
||||
.where("workflowID")
|
||||
.equals(workflowID)
|
||||
.reverse()
|
||||
.sortBy("createTime")) as WorkflowVersion[];
|
||||
return objects ?? [];
|
||||
}
|
||||
|
||||
public async getLatestByWorkflowID(
|
||||
workflowID: string,
|
||||
): Promise<WorkflowVersion | null> {
|
||||
const objects = await this.listByWorkflowID(workflowID);
|
||||
if (objects?.length) return objects[0];
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,429 @@
|
||||
import {
|
||||
changelogsTable,
|
||||
foldersTable,
|
||||
userSettingsTable,
|
||||
} from "./WorkspaceDB";
|
||||
import { Folder, Workflow } from "../types/dbTypes";
|
||||
import { sortFileItem } from "../utils";
|
||||
import { nanoid } from "nanoid";
|
||||
import { TableBase } from "./TableBase";
|
||||
import { indexdb } from "./indexdb";
|
||||
import {
|
||||
deleteJsonFileMyWorkflows,
|
||||
saveJsonFileMyWorkflows,
|
||||
} from "./DiskFileUtils";
|
||||
import { ESortTypes, ImportWorkflow } from "../RecentFilesDrawer/types";
|
||||
import { defaultGraph } from "../defaultGraph";
|
||||
import { scanMyWorkflowsDir } from "../utils/twowaySyncUtils";
|
||||
import {
|
||||
ScanLocalFile,
|
||||
TwowaySyncAPI,
|
||||
scanLocalFiles,
|
||||
} from "../apis/TwowaySyncApi";
|
||||
import { COMFYSPACE_TRACKING_FIELD_NAME } from "../const";
|
||||
import { tabDataManager } from "../topbar/multipleTabs/TabDataManager";
|
||||
|
||||
export class WorkflowsTable extends TableBase<Workflow> {
|
||||
static readonly TABLE_NAME = "workflows";
|
||||
private _curWorkflow: Workflow | null = null;
|
||||
public get curWorkflow() {
|
||||
return this._curWorkflow;
|
||||
}
|
||||
|
||||
constructor() {
|
||||
super("workflows");
|
||||
}
|
||||
|
||||
static async load(): Promise<WorkflowsTable> {
|
||||
const instance = new WorkflowsTable();
|
||||
return instance;
|
||||
}
|
||||
public updateCurWorkflowID(id: string | null) {
|
||||
if (id == null) {
|
||||
this._curWorkflow = null;
|
||||
return;
|
||||
}
|
||||
this.get(id).then((w) => {
|
||||
this._curWorkflow = w ?? null;
|
||||
});
|
||||
}
|
||||
public updateCurWorkflow(workflow: Workflow | null) {
|
||||
this._curWorkflow = workflow;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check whether the currently opened workflow is the latest version and is consistent with the DB.
|
||||
* Through updateTime comparison, if it is inconsistent, it means that a newer version already exists in the DB.
|
||||
* If the automatic save function is turned on at this time, the latest version in the DB will be overwritten.
|
||||
* Therefore, the user needs to confirm whether to continue to enable automatic saving.
|
||||
* @returns boolean
|
||||
*/
|
||||
public async latestVersionCheck() {
|
||||
if (this._curWorkflow) {
|
||||
const curFlowInDB = await indexdb.workflows.get(this._curWorkflow.id);
|
||||
if (curFlowInDB) {
|
||||
return curFlowInDB?.updateTime === this._curWorkflow.updateTime;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
public async listByIndex(
|
||||
index: keyof Workflow,
|
||||
value: string,
|
||||
): Promise<Workflow[]> {
|
||||
const list = await super.listByIndex(index, value);
|
||||
const twoWaySyncEnabled = await userSettingsTable?.getSetting("twoWaySync");
|
||||
if (!twoWaySyncEnabled) {
|
||||
return list;
|
||||
}
|
||||
// for two way sync we need to varify if it really exists in disk
|
||||
const results = await Promise.all(
|
||||
list.map(async (f) => await this.get(f.id)),
|
||||
);
|
||||
return results.filter((f) => f != null) as Workflow[];
|
||||
}
|
||||
|
||||
async generateUniqueName(name?: string, parentFolderID?: string) {
|
||||
const twoWaySyncEnabled = await userSettingsTable?.getSetting("twoWaySync");
|
||||
if (twoWaySyncEnabled) {
|
||||
const fileName = await TwowaySyncAPI.genFileUniqueName(
|
||||
(name ?? "Untitled Flow") + ".json",
|
||||
parentFolderID ?? "",
|
||||
);
|
||||
return fileName?.replace(/\.json$/, "") ?? "Untitled Flow";
|
||||
}
|
||||
/**
|
||||
* Generate a unique name
|
||||
* For imported scenes, the default name is the file name.
|
||||
* For new scenes, the default name is Untitled Flow.
|
||||
* Get the full workflow list. If the default name already exists, search incrementally starting from 2.
|
||||
* Looks for a unique name in the format `${default name} ${number}`.
|
||||
*/
|
||||
let newFlowName = name ?? "Untitled Flow";
|
||||
const flowList = (await this.listAll()) ?? [];
|
||||
const flowNameList = flowList
|
||||
.filter((f) => f.parentFolderID == parentFolderID)
|
||||
.map((flow) => flow.name);
|
||||
if (flowNameList.includes(newFlowName)) {
|
||||
let num = 2;
|
||||
let flag = true;
|
||||
while (flag) {
|
||||
if (flowNameList.includes(`${newFlowName} ${num}`)) {
|
||||
num++;
|
||||
} else {
|
||||
newFlowName = `${newFlowName} ${num}`;
|
||||
flag = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
return newFlowName;
|
||||
}
|
||||
|
||||
public async createFlow(
|
||||
input: Partial<Workflow>,
|
||||
): Promise<Workflow | undefined> {
|
||||
const { name } = input;
|
||||
const newFlowName = await this.generateUniqueName(
|
||||
name,
|
||||
input.parentFolderID ?? undefined,
|
||||
);
|
||||
const time = Date.now();
|
||||
const newWorkflow: Workflow = {
|
||||
...input,
|
||||
id: input.id ?? nanoid(),
|
||||
json: input.json ?? JSON.stringify(defaultGraph),
|
||||
name: newFlowName,
|
||||
updateTime: input.updateTime ?? time,
|
||||
createTime: input.createTime ?? time,
|
||||
};
|
||||
//add to IndexDB
|
||||
await indexdb.workflows.add(newWorkflow);
|
||||
// add to disk file db
|
||||
this.saveDiskDB();
|
||||
// add to my_workflows/
|
||||
const twoWaySyncEnabled = await userSettingsTable?.getSetting("twoWaySync");
|
||||
if (twoWaySyncEnabled) {
|
||||
await TwowaySyncAPI.createWorkflow(newWorkflow);
|
||||
return await this.get(newWorkflow.id);
|
||||
} else {
|
||||
saveJsonFileMyWorkflows(newWorkflow);
|
||||
}
|
||||
return newWorkflow;
|
||||
}
|
||||
|
||||
// disallow TableBase.update()
|
||||
async update(
|
||||
_id: string,
|
||||
_change: Partial<Workflow>,
|
||||
): Promise<Workflow | null> {
|
||||
throw new Error("Method not allowed.");
|
||||
}
|
||||
|
||||
public async updateTopFields(
|
||||
id: string,
|
||||
change: Pick<Partial<Workflow>, "topFieldsConfig">,
|
||||
): Promise<Workflow | null> {
|
||||
return this._update(id, change);
|
||||
}
|
||||
|
||||
public async updateMetaInfo(
|
||||
id: string,
|
||||
change: Omit<Partial<Workflow>, "id" | "name" | "parentFolderID" | "json">,
|
||||
): Promise<Workflow | null> {
|
||||
return this._update(id, change);
|
||||
}
|
||||
private async _update(
|
||||
id: string,
|
||||
change: Partial<Workflow>,
|
||||
): Promise<Workflow | null> {
|
||||
//update indexdb
|
||||
await indexdb.workflows.update(id, change);
|
||||
const newWorkflow = (await indexdb.workflows.get(id)) ?? null;
|
||||
//update curWorkflow RAM
|
||||
if (this._curWorkflow && this._curWorkflow.id === id) {
|
||||
this._curWorkflow = newWorkflow;
|
||||
}
|
||||
this.saveDiskDB();
|
||||
return newWorkflow;
|
||||
}
|
||||
|
||||
async updateFolder(id: string, change: Pick<Workflow, "parentFolderID">) {
|
||||
const twoWaySyncEnabled = await userSettingsTable?.getSetting("twoWaySync");
|
||||
const oldWorkflow = await this.get(id);
|
||||
const newWorkflow = await this.updateMetaInfo(id, change as any);
|
||||
if (!newWorkflow || !oldWorkflow) {
|
||||
return;
|
||||
}
|
||||
if (twoWaySyncEnabled) {
|
||||
await TwowaySyncAPI.moveWorkflow(oldWorkflow, change.parentFolderID!);
|
||||
} else {
|
||||
await saveJsonFileMyWorkflows(newWorkflow);
|
||||
await deleteJsonFileMyWorkflows(oldWorkflow);
|
||||
// update parent folder updateTime
|
||||
if (newWorkflow?.parentFolderID != null) {
|
||||
await indexdb.folders?.update(newWorkflow.parentFolderID, {
|
||||
updateTime: Date.now(),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
public async genLastManualSavedJson(id: string) {
|
||||
const changelog = await changelogsTable?.getLastestByWorkflowID(id);
|
||||
return changelog?.json;
|
||||
}
|
||||
public async updateName(id: string, change: Pick<Workflow, "name">) {
|
||||
const before = await this.get(id);
|
||||
const twoWaySyncEnabled = await userSettingsTable?.getSetting("twoWaySync");
|
||||
if (twoWaySyncEnabled) {
|
||||
before &&
|
||||
(await TwowaySyncAPI.renameWorkflow(before, change.name + ".json"));
|
||||
}
|
||||
tabDataManager.updateTabData(tabDataManager.activeIndex, change);
|
||||
return await this.updateMetaInfo(id, change as any);
|
||||
}
|
||||
public async updateFlow(id: string, input: Pick<Workflow, "json">) {
|
||||
const before = await this.get(id, false);
|
||||
if (before == null) {
|
||||
return;
|
||||
}
|
||||
const beforeStr = JSON.stringify(before.json);
|
||||
const afterStr = JSON.stringify(input.json);
|
||||
if (beforeStr === afterStr) {
|
||||
// no change detected
|
||||
return;
|
||||
}
|
||||
const after = await this._update(id, {
|
||||
updateTime: Date.now(),
|
||||
json: input.json,
|
||||
});
|
||||
// save to my_workflows/
|
||||
const twoWaySyncEnabled = await userSettingsTable?.getSetting("twoWaySync");
|
||||
if (twoWaySyncEnabled) {
|
||||
after && (await TwowaySyncAPI.saveWorkflow(after));
|
||||
} else {
|
||||
after && (await saveJsonFileMyWorkflows(after));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Add flows in batches
|
||||
* @param flowList Need to add a new flow list
|
||||
* @param isOverwriteExistingFile By automatically scanning the newly added flow on the local disk,
|
||||
* when synchronizing the DB, the flow on the local disk needs to be rewritten
|
||||
* because extra.comfyspace_tracking.id needs to be appended to json.
|
||||
* @param parentFolderID If you are adding batches to the specified files, provide the folder id.
|
||||
* @returns
|
||||
*/
|
||||
public async batchCreateFlows(
|
||||
flowList: ImportWorkflow[] = [],
|
||||
isOverwriteExistingFile: boolean = false,
|
||||
parentFolderID?: string,
|
||||
) {
|
||||
const newWorkflows: Workflow[] = [];
|
||||
for (const flow of flowList) {
|
||||
const newFlowName =
|
||||
flow.name && isOverwriteExistingFile
|
||||
? flow.name
|
||||
: await this.generateUniqueName(flow.name, parentFolderID);
|
||||
const uuid = nanoid();
|
||||
const time = Date.now();
|
||||
const newWorkflow: Workflow = {
|
||||
id: uuid,
|
||||
name: newFlowName,
|
||||
json: flow.json,
|
||||
parentFolderID: parentFolderID,
|
||||
updateTime: time,
|
||||
createTime: time,
|
||||
tags: [],
|
||||
};
|
||||
newWorkflows.push(newWorkflow);
|
||||
const twoWaySyncEnabled =
|
||||
await userSettingsTable?.getSetting("twoWaySync");
|
||||
if (twoWaySyncEnabled) {
|
||||
try {
|
||||
const jsonObj = JSON.parse(flow.json);
|
||||
const existingWorkflowID =
|
||||
jsonObj?.extra?.[COMFYSPACE_TRACKING_FIELD_NAME]?.id;
|
||||
if (existingWorkflowID) {
|
||||
const existingWorkflow = await this.get(existingWorkflowID);
|
||||
if (!existingWorkflow) {
|
||||
newWorkflow.id = existingWorkflowID;
|
||||
}
|
||||
}
|
||||
TwowaySyncAPI.createWorkflow(newWorkflow);
|
||||
} catch (e) {
|
||||
console.error("batchCreateFlows error", e);
|
||||
}
|
||||
} else {
|
||||
await saveJsonFileMyWorkflows(newWorkflow);
|
||||
}
|
||||
}
|
||||
try {
|
||||
await indexdb.workflows.bulkAdd(newWorkflows);
|
||||
this.saveDiskDB();
|
||||
} catch (e) {
|
||||
console.error("batchCreateFlows error", e);
|
||||
}
|
||||
}
|
||||
|
||||
public async deleteFlow(id: string) {
|
||||
const workflow = await this.get(id);
|
||||
|
||||
if (workflow) {
|
||||
const twoWaySyncEnabled =
|
||||
await userSettingsTable?.getSetting("twoWaySync");
|
||||
if (twoWaySyncEnabled) {
|
||||
await TwowaySyncAPI.deleteWorkflow(workflow);
|
||||
} else {
|
||||
deleteJsonFileMyWorkflows({ ...workflow });
|
||||
}
|
||||
}
|
||||
|
||||
const tabIndex = tabDataManager.tabs.findIndex((tab) => tab.id === id);
|
||||
if (tabIndex !== -1) {
|
||||
tabDataManager?.deleteTabData(tabIndex);
|
||||
}
|
||||
|
||||
//add to IndexDB
|
||||
await indexdb.workflows.delete(id);
|
||||
this.saveDiskDB();
|
||||
}
|
||||
|
||||
public async batchDeleteFlow(ids: string[]) {
|
||||
const workflowList = (await indexdb.workflows.bulkGet(ids)) as Workflow[];
|
||||
for (const flow of workflowList) {
|
||||
if (!flow) return;
|
||||
const twoWaySyncEnabled =
|
||||
await userSettingsTable?.getSetting("twoWaySync");
|
||||
if (twoWaySyncEnabled) {
|
||||
await TwowaySyncAPI.deleteWorkflow(flow);
|
||||
} else {
|
||||
deleteJsonFileMyWorkflows({ ...flow });
|
||||
}
|
||||
}
|
||||
await indexdb.workflows.bulkDelete(ids);
|
||||
tabDataManager.batchDeleteTabData(ids);
|
||||
this.saveDiskDB();
|
||||
}
|
||||
|
||||
public async listFolderContent(
|
||||
folderID?: string, // undefined if root folder
|
||||
sortBy?: ESortTypes,
|
||||
): Promise<(Workflow | Folder)[]> {
|
||||
const twoWaySyncEnabled = await userSettingsTable?.getSetting("twoWaySync");
|
||||
if (twoWaySyncEnabled) {
|
||||
const items = await scanMyWorkflowsDir(folderID ?? null);
|
||||
return sortFileItem(items, sortBy ?? ESortTypes.RECENTLY_MODIFIED);
|
||||
}
|
||||
const workflows =
|
||||
(await this.listAll().then((list) =>
|
||||
list.filter((w) => w.parentFolderID == folderID),
|
||||
)) ?? [];
|
||||
const folders =
|
||||
(await foldersTable
|
||||
?.listAll()
|
||||
.then((list) => list.filter((f) => f.parentFolderID == folderID))) ??
|
||||
[];
|
||||
|
||||
const all = [...workflows, ...folders];
|
||||
|
||||
return sortFileItem(all, sortBy ?? ESortTypes.RECENTLY_MODIFIED);
|
||||
}
|
||||
|
||||
public async get(
|
||||
id: string,
|
||||
fetchJsonContentOnDisk = true, //if false, only get the meta info from indexdb
|
||||
): Promise<Workflow | undefined> {
|
||||
const twoWaySyncEnabled = await userSettingsTable?.getSetting("twoWaySync");
|
||||
const wf = await indexdb.workflows.get(id);
|
||||
if (!twoWaySyncEnabled || !fetchJsonContentOnDisk) {
|
||||
return wf;
|
||||
}
|
||||
// two way sync mode
|
||||
if (wf == null) {
|
||||
return undefined;
|
||||
}
|
||||
const data = await TwowaySyncAPI.getFile({
|
||||
parentFolderID: wf.parentFolderID ?? null,
|
||||
name: wf.name,
|
||||
id: wf.id,
|
||||
});
|
||||
|
||||
if (!data.json) {
|
||||
return undefined;
|
||||
}
|
||||
return {
|
||||
...wf,
|
||||
json: JSON.stringify(data.json),
|
||||
};
|
||||
}
|
||||
|
||||
public async listAll(sortBy?: ESortTypes): Promise<Workflow[]> {
|
||||
const twoWaySyncEnabled = await userSettingsTable?.getSetting("twoWaySync");
|
||||
let list: Workflow[];
|
||||
if (twoWaySyncEnabled) {
|
||||
const fileList = (await scanLocalFiles("", true)).filter(
|
||||
(f) => f?.type === "workflow",
|
||||
) as ScanLocalFile[];
|
||||
const allFilesPromises = fileList.map(async (file) => {
|
||||
const fileInfoInDB = await indexdb.workflows.get(file.id);
|
||||
file.updateTime = fileInfoInDB?.updateTime ?? Date.now();
|
||||
fileInfoInDB?.lastOpenedTime &&
|
||||
(file.lastOpenedTime = fileInfoInDB?.lastOpenedTime);
|
||||
return file;
|
||||
});
|
||||
list = await Promise.all(allFilesPromises);
|
||||
} else {
|
||||
list = await indexdb.workflows.toArray();
|
||||
}
|
||||
return sortBy ? sortFileItem(list, sortBy) : list;
|
||||
}
|
||||
|
||||
public async updateLastOpenedTime(id: string) {
|
||||
await indexdb.workflows.update(id, {
|
||||
lastOpenedTime: Date.now(),
|
||||
});
|
||||
}
|
||||
}
|
||||
+99
-281
@@ -1,20 +1,12 @@
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
import { getDB, saveDB, updateFile } from "../Api";
|
||||
import { generateUniqueName, sortFileItem, sortFlows } from "../utils";
|
||||
import { ESortTypes, ImportWorkflow } from "../RecentFilesDrawer/types";
|
||||
import { ChangelogsTable } from "./ChangelogsTable";
|
||||
import { getWorkspaceIndexDB, updateWorkspaceIndexDB } from "./IndexDBUtils";
|
||||
import { WorkflowsTable } from "./WorkflowsTable";
|
||||
import { FoldersTable } from "./FoldersTable";
|
||||
import { MediaTable } from "./MediaTable";
|
||||
import { COMFYSPACE_TRACKING_FIELD_NAME } from "../const";
|
||||
import {
|
||||
deleteJsonFileMyWorkflows,
|
||||
generateFilePath,
|
||||
generateFilePathAbsolute,
|
||||
} from "./DiskFileUtils";
|
||||
import { Folder, TagsTable } from "../types/dbTypes";
|
||||
import { loadTagsTable } from "./tagsTable";
|
||||
import { UserSettingsTable } from "./UserSettingsTable";
|
||||
import { TagsTable } from "./tagsTable";
|
||||
import { indexdb } from "./indexdb";
|
||||
import { Folder, Workflow } from "../types/dbTypes";
|
||||
import { WorkflowVersionsTable } from "./WorkflowVersionsTable";
|
||||
|
||||
export type Table =
|
||||
| "workflows"
|
||||
@@ -22,55 +14,28 @@ export type Table =
|
||||
| "userSettings"
|
||||
| "folders"
|
||||
| "changelogs"
|
||||
| "media";
|
||||
| "media"
|
||||
| "models"
|
||||
| "workflowVersions";
|
||||
|
||||
interface SortableItem {
|
||||
name: string;
|
||||
updateTime: number;
|
||||
}
|
||||
|
||||
export interface Workflow extends SortableItem {
|
||||
id: string;
|
||||
json: string;
|
||||
lastSavedJson?: string;
|
||||
name: string;
|
||||
updateTime: number;
|
||||
createTime: number;
|
||||
filePath?: string;
|
||||
tags?: string[];
|
||||
parentFolderID?: string;
|
||||
mediaIDs?: string[];
|
||||
coverMediaPath?: string;
|
||||
}
|
||||
export function isFolder(n: Folder | Workflow): n is Folder {
|
||||
// @ts-ignore
|
||||
return n.type === "folder";
|
||||
return "type" in n && n.type === "folder";
|
||||
}
|
||||
|
||||
export type Workflows = {
|
||||
[id: string]: Workflow;
|
||||
};
|
||||
|
||||
export let workspace: Workflows | undefined = undefined;
|
||||
export let workflowsTable: WorkflowsTable | null = null;
|
||||
export let tagsTable: TagsTable | null = null;
|
||||
export let userSettingsTable: UserSettingsTable | null = null;
|
||||
export let foldersTable: FoldersTable | null = null;
|
||||
export let changelogsTable: ChangelogsTable | null = null;
|
||||
export let mediaTable: MediaTable | null = null;
|
||||
export let workflowVersionsTable: WorkflowVersionsTable | null = null;
|
||||
|
||||
export async function loadDBs() {
|
||||
const loadWorkflows = async () => {
|
||||
const workflowsStr = await getDB("workflows");
|
||||
if (workflowsStr == null) {
|
||||
const comfyspace = (await getWorkspaceIndexDB()) ?? "{}";
|
||||
const comfyspaceData = JSON.parse(comfyspace);
|
||||
workspace = comfyspaceData["workflows"] || {};
|
||||
} else {
|
||||
workspace = JSON.parse(workflowsStr);
|
||||
}
|
||||
workflowsTable = await WorkflowsTable.load();
|
||||
};
|
||||
const loadTags = async () => {
|
||||
tagsTable = await loadTagsTable();
|
||||
tagsTable = await TagsTable.load();
|
||||
};
|
||||
const loadUserSettings = async () => {
|
||||
userSettingsTable = await UserSettingsTable.load();
|
||||
@@ -84,6 +49,9 @@ export async function loadDBs() {
|
||||
const loadMedia = async () => {
|
||||
mediaTable = new MediaTable();
|
||||
};
|
||||
const loadWorkflowVersions = async () => {
|
||||
workflowVersionsTable = await WorkflowVersionsTable.load();
|
||||
};
|
||||
await Promise.all([
|
||||
loadWorkflows(),
|
||||
loadTags(),
|
||||
@@ -91,238 +59,88 @@ export async function loadDBs() {
|
||||
loadFolders(),
|
||||
loadChangelogs(),
|
||||
loadMedia(),
|
||||
loadWorkflowVersions(),
|
||||
]);
|
||||
if (localStorage.getItem("WORKSPACE_INDEXDB_BACKFILL") !== "true") {
|
||||
await backfillIndexdb();
|
||||
localStorage.setItem("WORKSPACE_INDEXDB_BACKFILL", "true");
|
||||
}
|
||||
}
|
||||
|
||||
export async function backfillIndexdb() {
|
||||
const backfillWorkflows = async () => {
|
||||
try {
|
||||
const all = await workflowsTable?.getRecords();
|
||||
all && (await indexdb.workflows.bulkPut(Object.values(all)));
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
}
|
||||
};
|
||||
const backfillFolders = async () => {
|
||||
try {
|
||||
const all = await foldersTable?.getRecords();
|
||||
all && (await indexdb.folders.bulkPut(Object.values(all)));
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
}
|
||||
};
|
||||
const backfillMedia = async () => {
|
||||
try {
|
||||
const all = await mediaTable?.getRecords();
|
||||
all && (await indexdb.media.bulkPut(Object.values(all)));
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
}
|
||||
};
|
||||
const backfillChangelogs = async () => {
|
||||
try {
|
||||
const all = await changelogsTable?.getRecords();
|
||||
all && (await indexdb.changelogs.bulkPut(Object.values(all)));
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
}
|
||||
};
|
||||
const backfillTags = async () => {
|
||||
try {
|
||||
const all = await tagsTable?.getRecords();
|
||||
all && (await indexdb.tags.bulkPut(Object.values(all)));
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
}
|
||||
};
|
||||
const backfillUserSettings = async () => {
|
||||
try {
|
||||
const tableInstance = userSettingsTable!;
|
||||
const backupData = await tableInstance.getRecords().then((data) => {
|
||||
if (!data[tableInstance.DEFAULT_USER]) {
|
||||
// legacy
|
||||
return data;
|
||||
}
|
||||
return data[tableInstance.DEFAULT_USER];
|
||||
});
|
||||
await indexdb.userSettings.put({
|
||||
...tableInstance.defaultSettings,
|
||||
...backupData,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
}
|
||||
};
|
||||
const backfillWorkflowVersions = async () => {
|
||||
try {
|
||||
const all = await workflowVersionsTable?.getRecords();
|
||||
all && (await indexdb.workflowVersions.bulkPut(Object.values(all)));
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
}
|
||||
};
|
||||
await Promise.all([
|
||||
backfillWorkflows(),
|
||||
backfillFolders(),
|
||||
backfillMedia(),
|
||||
backfillChangelogs(),
|
||||
backfillTags(),
|
||||
backfillUserSettings(),
|
||||
backfillWorkflowVersions(),
|
||||
]);
|
||||
}
|
||||
|
||||
export function listFolderContent(
|
||||
folderID?: string, // undefined if root folder
|
||||
sortBy?: ESortTypes
|
||||
): Array<Workflow | Folder> {
|
||||
if (workspace == null) {
|
||||
return [];
|
||||
}
|
||||
const workflows = Object.values(workspace).filter(
|
||||
(w) => w.parentFolderID == folderID
|
||||
);
|
||||
const folders =
|
||||
foldersTable?.listAll()?.filter((f) => f.parentFolderID == folderID) ?? [];
|
||||
|
||||
const all = [...workflows, ...folders];
|
||||
|
||||
return sortFileItem(all, sortBy ?? ESortTypes.RECENTLY_MODIFIED);
|
||||
}
|
||||
|
||||
/** Class Workflow: below will be migrated to a class */
|
||||
export function updateFlow(id: string, input: Partial<Workflow>) {
|
||||
if (workspace == null) {
|
||||
return;
|
||||
}
|
||||
const before = workspace[id];
|
||||
if (before == null) {
|
||||
return;
|
||||
}
|
||||
const after = {
|
||||
...before,
|
||||
...input,
|
||||
id,
|
||||
};
|
||||
const beforeStr = JSON.stringify(before);
|
||||
const afterStr = JSON.stringify(after);
|
||||
if (beforeStr === afterStr) {
|
||||
// no change detected
|
||||
return;
|
||||
}
|
||||
let newWorkflow: Workflow = after;
|
||||
// When modifying the associated tag or modifying the directory, updateTime is not modified.
|
||||
const updateKey = Object.keys(input);
|
||||
const isModifyingTagOrFolder =
|
||||
updateKey.length === 1 && ["tags", "parentFolderID"].includes(updateKey[0]);
|
||||
if (!isModifyingTagOrFolder) {
|
||||
newWorkflow.updateTime = Date.now();
|
||||
}
|
||||
// update memory
|
||||
workspace[id] = newWorkflow;
|
||||
//update indexdb
|
||||
// indexdb.workflows.update(id, newWorkflow);
|
||||
//update legacy indexdb backup
|
||||
updateWorkspaceIndexDB();
|
||||
// update disk file db
|
||||
saveDB("workflows", JSON.stringify(workspace));
|
||||
// save to my_workflows/
|
||||
if (input.name != null || input.parentFolderID != null) {
|
||||
// renamed file or moved file folder
|
||||
deleteJsonFileMyWorkflows(before);
|
||||
saveJsonFileMyWorkflows(after);
|
||||
return;
|
||||
}
|
||||
if (input.json != null) {
|
||||
if (newWorkflow.parentFolderID != null) {
|
||||
// update parent folder updateTime
|
||||
foldersTable?.update({
|
||||
id: newWorkflow.parentFolderID,
|
||||
updateTime: Date.now(),
|
||||
});
|
||||
}
|
||||
saveJsonFileMyWorkflows(after);
|
||||
}
|
||||
}
|
||||
|
||||
export async function saveJsonFileMyWorkflows(workflow: Workflow) {
|
||||
const file_path = generateFilePath(workflow);
|
||||
if (file_path == null) {
|
||||
return;
|
||||
}
|
||||
if (workspace != null) {
|
||||
const fullPath = generateFilePathAbsolute(workflow);
|
||||
workspace[workflow.id].filePath = fullPath ?? undefined;
|
||||
await updateWorkspaceIndexDB();
|
||||
await saveDB("workflows", JSON.stringify(workspace));
|
||||
}
|
||||
const json = workflow.json;
|
||||
const flow = JSON.parse(json);
|
||||
flow.extra[COMFYSPACE_TRACKING_FIELD_NAME] = {
|
||||
id: workflow.id,
|
||||
name: workflow.name,
|
||||
};
|
||||
await updateFile(file_path, JSON.stringify(flow));
|
||||
}
|
||||
|
||||
export function createFlow({
|
||||
json,
|
||||
name,
|
||||
parentFolderID,
|
||||
tags,
|
||||
}: {
|
||||
json: string;
|
||||
name?: string;
|
||||
parentFolderID?: string;
|
||||
tags?: string[];
|
||||
}): Workflow {
|
||||
if (workspace == null) {
|
||||
throw new Error("workspace is not loaded");
|
||||
}
|
||||
|
||||
const newFlowName = generateUniqueName(name);
|
||||
const uuid = uuidv4(); // ⇨ '9b1deb4d-3b7d-4bad-9bdd-2b0d7b3dcb6d'
|
||||
const time = Date.now();
|
||||
const newWorkflow: Workflow = {
|
||||
id: uuid,
|
||||
name: newFlowName,
|
||||
json,
|
||||
parentFolderID: parentFolderID,
|
||||
updateTime: time,
|
||||
createTime: time,
|
||||
tags: tags ?? [],
|
||||
};
|
||||
//add to cache
|
||||
workspace[uuid] = newWorkflow;
|
||||
//add to IndexDB
|
||||
// indexdb.workflows.add(newWorkflow);
|
||||
// add to disk file db
|
||||
saveDB("workflows", JSON.stringify(workspace));
|
||||
// legacy index cache
|
||||
updateWorkspaceIndexDB();
|
||||
// add to my_workflows/
|
||||
saveJsonFileMyWorkflows(workspace[uuid]);
|
||||
return workspace[uuid];
|
||||
}
|
||||
|
||||
/**
|
||||
* Add flows in batches
|
||||
* @param flowList Need to add a new flow list
|
||||
* @param isOverwriteExistingFile By automatically scanning the newly added flow on the local disk,
|
||||
* when synchronizing the DB, the flow on the local disk needs to be rewritten
|
||||
* because extra.comfyspace_tracking.id needs to be appended to json.
|
||||
* @param parentFolderID If you are adding batches to the specified files, provide the folder id.
|
||||
* @returns
|
||||
*/
|
||||
export async function batchCreateFlows(
|
||||
flowList: ImportWorkflow[] = [],
|
||||
isOverwriteExistingFile: boolean = false,
|
||||
parentFolderID?: string
|
||||
): Promise<string | undefined> {
|
||||
flowList.forEach((flow) => {
|
||||
if (workspace == null) {
|
||||
return;
|
||||
}
|
||||
const newFlowName =
|
||||
flow.name && isOverwriteExistingFile
|
||||
? flow.name
|
||||
: generateUniqueName(flow.name);
|
||||
const uuid = uuidv4();
|
||||
const time = Date.now();
|
||||
workspace[uuid] = {
|
||||
id: uuid,
|
||||
name: newFlowName,
|
||||
json: flow.json,
|
||||
parentFolderID: parentFolderID,
|
||||
updateTime: time,
|
||||
createTime: time,
|
||||
tags: [],
|
||||
};
|
||||
saveJsonFileMyWorkflows(workspace[uuid]);
|
||||
});
|
||||
|
||||
const stringifyWorkspace = JSON.stringify(workspace);
|
||||
updateWorkspaceIndexDB();
|
||||
return await saveDB("workflows", stringifyWorkspace);
|
||||
}
|
||||
|
||||
export function listWorkflows(sortBy?: ESortTypes): Workflow[] {
|
||||
if (workspace == null) {
|
||||
throw new Error("workspace is not loaded");
|
||||
}
|
||||
const workflows = Object.values(workspace);
|
||||
return sortFlows(workflows, sortBy);
|
||||
}
|
||||
export function getWorkflow(id: string): Workflow | undefined {
|
||||
if (workspace == null) {
|
||||
console.error("workspace is not loaded");
|
||||
return;
|
||||
}
|
||||
return workspace[id];
|
||||
}
|
||||
|
||||
export function deleteFlow(id: string) {
|
||||
if (workspace == null) {
|
||||
return;
|
||||
}
|
||||
const workflow = workspace[id];
|
||||
if (workflow) {
|
||||
deleteJsonFileMyWorkflows({ ...workflow });
|
||||
}
|
||||
delete workspace[id];
|
||||
updateWorkspaceIndexDB();
|
||||
saveDB("workflows", JSON.stringify(workspace));
|
||||
}
|
||||
|
||||
export function batchDeleteFlow(ids: string[]) {
|
||||
ids.forEach((id) => {
|
||||
if (workspace == null) {
|
||||
return;
|
||||
}
|
||||
const workflow = workspace[id];
|
||||
if (workflow) {
|
||||
deleteJsonFileMyWorkflows({ ...workflow });
|
||||
}
|
||||
workspace && delete workspace[id];
|
||||
});
|
||||
|
||||
const stringifyWorkspace = JSON.stringify(workspace);
|
||||
updateWorkspaceIndexDB();
|
||||
saveDB("workflows", stringifyWorkspace);
|
||||
}
|
||||
/** End of Class Workflow */
|
||||
|
||||
export async function curComfyspaceJson(): Promise<string> {
|
||||
const changeLogs = await changelogsTable?.getRecords();
|
||||
const media = await mediaTable?.getRecords();
|
||||
return JSON.stringify({
|
||||
[UserSettingsTable.TABLE_NAME]: userSettingsTable?.records,
|
||||
["tags"]: tagsTable?.tags,
|
||||
["workflows"]: workspace,
|
||||
[FoldersTable.TABLE_NAME]: foldersTable?.getRecords(),
|
||||
[ChangelogsTable.TABLE_NAME]: changeLogs,
|
||||
[MediaTable.TABLE_NAME]: media,
|
||||
});
|
||||
}
|
||||
|
||||
+43
-12
@@ -1,25 +1,56 @@
|
||||
// db.ts
|
||||
import Dexie, { Table } from "dexie";
|
||||
import { Workflow } from "./WorkspaceDB";
|
||||
import { Changelog, Media } from "../types/dbTypes";
|
||||
import {
|
||||
Workflow,
|
||||
LocalCache,
|
||||
Changelog,
|
||||
Folder,
|
||||
Media,
|
||||
Model,
|
||||
Tag,
|
||||
UserSettings,
|
||||
WORKSPACE_INDEXDB_NAME,
|
||||
WorkflowVersion,
|
||||
} from "../types/dbTypes";
|
||||
|
||||
class WorkspaceDB extends Dexie {
|
||||
class ManagerDB extends Dexie {
|
||||
workflows!: Table<Workflow, string>;
|
||||
changelogs!: Table<Changelog, string>;
|
||||
media!: Table<Media, string>;
|
||||
folders!: Table<Folder, string>;
|
||||
tags!: Table<Tag, string>;
|
||||
userSettings!: Table<UserSettings, string>;
|
||||
models!: Table<Model, string>;
|
||||
cache!: Table<LocalCache, string>;
|
||||
workflowVersions!: Table<WorkflowVersion, string>;
|
||||
|
||||
constructor() {
|
||||
super("WorkspaceDB");
|
||||
super(WORKSPACE_INDEXDB_NAME);
|
||||
this.version(1)
|
||||
.stores({
|
||||
workflows: "++id, name", // Primary key and indexed props
|
||||
changelogs: "++id, workflowID",
|
||||
media: "++id, workflowID",
|
||||
workflows: "&id, name, parentFolderID", // Primary key and indexed props
|
||||
changelogs: "&id, workflowID",
|
||||
media: "&id, workflowID",
|
||||
folders: "&id, name, parentFolderID",
|
||||
tags: "&name",
|
||||
userSettings: "&id",
|
||||
models: "&id, fileName, fileHash",
|
||||
cache: "&id",
|
||||
})
|
||||
.upgrade((trans) => {
|
||||
// Here you can write logic to initialize or migrate data to the new 'media' table, if necessary
|
||||
});
|
||||
.upgrade((trans) => {});
|
||||
this.version(2)
|
||||
.stores({
|
||||
workflows: "&id, name, parentFolderID,cloudID",
|
||||
changelogs: "&id, workflowID",
|
||||
media: "&id, workflowID",
|
||||
folders: "&id, name, parentFolderID",
|
||||
tags: "&name",
|
||||
userSettings: "&id",
|
||||
models: "&id, fileName, fileHash",
|
||||
cache: "&id",
|
||||
workflowVersions: "&id, name, workflowID,cloudID",
|
||||
})
|
||||
.upgrade((trans) => {});
|
||||
}
|
||||
}
|
||||
|
||||
export const indexdb = new WorkspaceDB();
|
||||
export const indexdb = new ManagerDB();
|
||||
|
||||
@@ -1,37 +1,16 @@
|
||||
import { getDB, saveDB } from "../Api";
|
||||
import { Tags, TagsTable } from "../types/dbTypes";
|
||||
import { getWorkspaceIndexDB } from "./IndexDBUtils";
|
||||
import { Tag } from "../types/dbTypes";
|
||||
import { Table } from "./WorkspaceDB";
|
||||
import { TableBase } from "./TableBase";
|
||||
|
||||
export async function loadTagsTable(): Promise<TagsTable> {
|
||||
const tagsStr = await getDB("tags");
|
||||
let tags: Tags;
|
||||
if (tagsStr == null) {
|
||||
const comfyspace = (await getWorkspaceIndexDB()) ?? "{}";
|
||||
const comfyspaceData = JSON.parse(comfyspace);
|
||||
tags = comfyspaceData["tags"] || {};
|
||||
} else {
|
||||
tags = JSON.parse(tagsStr ?? "{}") ?? {};
|
||||
export class TagsTable extends TableBase<Tag> {
|
||||
static readonly TABLE_NAME: Table = "tags";
|
||||
|
||||
constructor() {
|
||||
super("tags");
|
||||
}
|
||||
|
||||
static async load(): Promise<TagsTable> {
|
||||
const instance = new TagsTable();
|
||||
return instance;
|
||||
}
|
||||
return {
|
||||
tags, // Expose the tags array publicly
|
||||
listAll() {
|
||||
return Object.values(tags).sort((a, b) => b.updateTime - a.updateTime);
|
||||
},
|
||||
upsert(name: string) {
|
||||
if (tags[name] == null) {
|
||||
tags[name] = {
|
||||
name,
|
||||
workflowIDs: [],
|
||||
updateTime: Date.now(),
|
||||
};
|
||||
}
|
||||
tags[name].updateTime = Date.now();
|
||||
saveDB("tags", JSON.stringify(tags));
|
||||
return tags[name];
|
||||
},
|
||||
delete(name: string) {
|
||||
delete tags[name];
|
||||
saveDB("tags", JSON.stringify(tags));
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
+133
-117
@@ -1,119 +1,135 @@
|
||||
export const defaultGraph = {
|
||||
last_node_id: 9,
|
||||
last_link_id: 9,
|
||||
nodes: [
|
||||
{
|
||||
id: 7,
|
||||
type: "CLIPTextEncode",
|
||||
pos: [413, 389],
|
||||
size: { 0: 425.27801513671875, 1: 180.6060791015625 },
|
||||
flags: {},
|
||||
order: 3,
|
||||
mode: 0,
|
||||
inputs: [{ name: "clip", type: "CLIP", link: 5 }],
|
||||
outputs: [{ name: "CONDITIONING", type: "CONDITIONING", links: [6], slot_index: 0 }],
|
||||
properties: {},
|
||||
widgets_values: ["text, watermark"],
|
||||
},
|
||||
{
|
||||
id: 6,
|
||||
type: "CLIPTextEncode",
|
||||
pos: [415, 186],
|
||||
size: { 0: 422.84503173828125, 1: 164.31304931640625 },
|
||||
flags: {},
|
||||
order: 2,
|
||||
mode: 0,
|
||||
inputs: [{ name: "clip", type: "CLIP", link: 3 }],
|
||||
outputs: [{ name: "CONDITIONING", type: "CONDITIONING", links: [4], slot_index: 0 }],
|
||||
properties: {},
|
||||
widgets_values: ["beautiful scenery nature glass bottle landscape, , purple galaxy bottle,"],
|
||||
},
|
||||
{
|
||||
id: 5,
|
||||
type: "EmptyLatentImage",
|
||||
pos: [473, 609],
|
||||
size: { 0: 315, 1: 106 },
|
||||
flags: {},
|
||||
order: 1,
|
||||
mode: 0,
|
||||
outputs: [{ name: "LATENT", type: "LATENT", links: [2], slot_index: 0 }],
|
||||
properties: {},
|
||||
widgets_values: [512, 512, 1],
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
type: "KSampler",
|
||||
pos: [863, 186],
|
||||
size: { 0: 315, 1: 262 },
|
||||
flags: {},
|
||||
order: 4,
|
||||
mode: 0,
|
||||
inputs: [
|
||||
{ name: "model", type: "MODEL", link: 1 },
|
||||
{ name: "positive", type: "CONDITIONING", link: 4 },
|
||||
{ name: "negative", type: "CONDITIONING", link: 6 },
|
||||
{ name: "latent_image", type: "LATENT", link: 2 },
|
||||
],
|
||||
outputs: [{ name: "LATENT", type: "LATENT", links: [7], slot_index: 0 }],
|
||||
properties: {},
|
||||
widgets_values: [156680208700286, true, 20, 8, "euler", "normal", 1],
|
||||
},
|
||||
{
|
||||
id: 8,
|
||||
type: "VAEDecode",
|
||||
pos: [1209, 188],
|
||||
size: { 0: 210, 1: 46 },
|
||||
flags: {},
|
||||
order: 5,
|
||||
mode: 0,
|
||||
inputs: [
|
||||
{ name: "samples", type: "LATENT", link: 7 },
|
||||
{ name: "vae", type: "VAE", link: 8 },
|
||||
],
|
||||
outputs: [{ name: "IMAGE", type: "IMAGE", links: [9], slot_index: 0 }],
|
||||
properties: {},
|
||||
},
|
||||
{
|
||||
id: 9,
|
||||
type: "SaveImage",
|
||||
pos: [1451, 189],
|
||||
size: { 0: 210, 1: 26 },
|
||||
flags: {},
|
||||
order: 6,
|
||||
mode: 0,
|
||||
inputs: [{ name: "images", type: "IMAGE", link: 9 }],
|
||||
properties: {},
|
||||
},
|
||||
{
|
||||
id: 4,
|
||||
type: "CheckpointLoaderSimple",
|
||||
pos: [26, 474],
|
||||
size: { 0: 315, 1: 98 },
|
||||
flags: {},
|
||||
order: 0,
|
||||
mode: 0,
|
||||
outputs: [
|
||||
{ name: "MODEL", type: "MODEL", links: [1], slot_index: 0 },
|
||||
{ name: "CLIP", type: "CLIP", links: [3, 5], slot_index: 1 },
|
||||
{ name: "VAE", type: "VAE", links: [8], slot_index: 2 },
|
||||
],
|
||||
properties: {},
|
||||
widgets_values: ["v1-5-pruned-emaonly.ckpt"],
|
||||
},
|
||||
],
|
||||
links: [
|
||||
[1, 4, 0, 3, 0, "MODEL"],
|
||||
[2, 5, 0, 3, 3, "LATENT"],
|
||||
[3, 4, 1, 6, 0, "CLIP"],
|
||||
[4, 6, 0, 3, 1, "CONDITIONING"],
|
||||
[5, 4, 1, 7, 0, "CLIP"],
|
||||
[6, 7, 0, 3, 2, "CONDITIONING"],
|
||||
[7, 3, 0, 8, 0, "LATENT"],
|
||||
[8, 4, 2, 8, 1, "VAE"],
|
||||
[9, 8, 0, 9, 0, "IMAGE"],
|
||||
],
|
||||
groups: [],
|
||||
config: {},
|
||||
extra: {},
|
||||
version: 0.4,
|
||||
last_node_id: 9,
|
||||
last_link_id: 9,
|
||||
nodes: [
|
||||
{
|
||||
id: 7,
|
||||
type: "CLIPTextEncode",
|
||||
pos: [413, 389],
|
||||
size: { 0: 425.27801513671875, 1: 180.6060791015625 },
|
||||
flags: {},
|
||||
order: 3,
|
||||
mode: 0,
|
||||
inputs: [{ name: "clip", type: "CLIP", link: 5 }],
|
||||
outputs: [
|
||||
{
|
||||
name: "CONDITIONING",
|
||||
type: "CONDITIONING",
|
||||
links: [6],
|
||||
slot_index: 0,
|
||||
},
|
||||
],
|
||||
properties: {},
|
||||
widgets_values: ["text, watermark"],
|
||||
},
|
||||
{
|
||||
id: 6,
|
||||
type: "CLIPTextEncode",
|
||||
pos: [415, 186],
|
||||
size: { 0: 422.84503173828125, 1: 164.31304931640625 },
|
||||
flags: {},
|
||||
order: 2,
|
||||
mode: 0,
|
||||
inputs: [{ name: "clip", type: "CLIP", link: 3 }],
|
||||
outputs: [
|
||||
{
|
||||
name: "CONDITIONING",
|
||||
type: "CONDITIONING",
|
||||
links: [4],
|
||||
slot_index: 0,
|
||||
},
|
||||
],
|
||||
properties: {},
|
||||
widgets_values: [
|
||||
"beautiful scenery nature glass bottle landscape, , purple galaxy bottle,",
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 5,
|
||||
type: "EmptyLatentImage",
|
||||
pos: [473, 609],
|
||||
size: { 0: 315, 1: 106 },
|
||||
flags: {},
|
||||
order: 1,
|
||||
mode: 0,
|
||||
outputs: [{ name: "LATENT", type: "LATENT", links: [2], slot_index: 0 }],
|
||||
properties: {},
|
||||
widgets_values: [512, 512, 1],
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
type: "KSampler",
|
||||
pos: [863, 186],
|
||||
size: { 0: 315, 1: 262 },
|
||||
flags: {},
|
||||
order: 4,
|
||||
mode: 0,
|
||||
inputs: [
|
||||
{ name: "model", type: "MODEL", link: 1 },
|
||||
{ name: "positive", type: "CONDITIONING", link: 4 },
|
||||
{ name: "negative", type: "CONDITIONING", link: 6 },
|
||||
{ name: "latent_image", type: "LATENT", link: 2 },
|
||||
],
|
||||
outputs: [{ name: "LATENT", type: "LATENT", links: [7], slot_index: 0 }],
|
||||
properties: {},
|
||||
widgets_values: [156680208700286, true, 20, 8, "euler", "normal", 1],
|
||||
},
|
||||
{
|
||||
id: 8,
|
||||
type: "VAEDecode",
|
||||
pos: [1209, 188],
|
||||
size: { 0: 210, 1: 46 },
|
||||
flags: {},
|
||||
order: 5,
|
||||
mode: 0,
|
||||
inputs: [
|
||||
{ name: "samples", type: "LATENT", link: 7 },
|
||||
{ name: "vae", type: "VAE", link: 8 },
|
||||
],
|
||||
outputs: [{ name: "IMAGE", type: "IMAGE", links: [9], slot_index: 0 }],
|
||||
properties: {},
|
||||
},
|
||||
{
|
||||
id: 9,
|
||||
type: "SaveImage",
|
||||
pos: [1451, 189],
|
||||
size: { 0: 210, 1: 26 },
|
||||
flags: {},
|
||||
order: 6,
|
||||
mode: 0,
|
||||
inputs: [{ name: "images", type: "IMAGE", link: 9 }],
|
||||
properties: {},
|
||||
},
|
||||
{
|
||||
id: 4,
|
||||
type: "CheckpointLoaderSimple",
|
||||
pos: [26, 474],
|
||||
size: { 0: 315, 1: 98 },
|
||||
flags: {},
|
||||
order: 0,
|
||||
mode: 0,
|
||||
outputs: [
|
||||
{ name: "MODEL", type: "MODEL", links: [1], slot_index: 0 },
|
||||
{ name: "CLIP", type: "CLIP", links: [3, 5], slot_index: 1 },
|
||||
{ name: "VAE", type: "VAE", links: [8], slot_index: 2 },
|
||||
],
|
||||
properties: {},
|
||||
widgets_values: ["v1-5-pruned-emaonly.ckpt"],
|
||||
},
|
||||
],
|
||||
links: [
|
||||
[1, 4, 0, 3, 0, "MODEL"],
|
||||
[2, 5, 0, 3, 3, "LATENT"],
|
||||
[3, 4, 1, 6, 0, "CLIP"],
|
||||
[4, 6, 0, 3, 1, "CONDITIONING"],
|
||||
[5, 4, 1, 7, 0, "CLIP"],
|
||||
[6, 7, 0, 3, 2, "CONDITIONING"],
|
||||
[7, 3, 0, 8, 0, "LATENT"],
|
||||
[8, 4, 2, 8, 1, "VAE"],
|
||||
[9, 8, 0, 9, 0, "IMAGE"],
|
||||
],
|
||||
groups: [],
|
||||
config: {},
|
||||
extra: {},
|
||||
version: 0.4,
|
||||
};
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
import { type Dispatch, type SetStateAction, createContext } from "react";
|
||||
import type { Media } from "../types/dbTypes.ts";
|
||||
|
||||
interface GalleryContextProps {
|
||||
showAllImages: boolean;
|
||||
curMedia: Media | null;
|
||||
setCurMedia: (media: Media | null) => void;
|
||||
setMediaList: Dispatch<SetStateAction<Media[]>>;
|
||||
setShowAllImages: (showAllImages: boolean) => void;
|
||||
}
|
||||
|
||||
export const GalleryContext = createContext<GalleryContextProps>({
|
||||
showAllImages: false,
|
||||
curMedia: null,
|
||||
setCurMedia(): void {},
|
||||
setMediaList(): void {},
|
||||
setShowAllImages(): void {},
|
||||
});
|
||||
+96
-224
@@ -1,260 +1,132 @@
|
||||
// @ts-expect-error ComfyUI import
|
||||
import { app } from "/scripts/app.js";
|
||||
import {
|
||||
Button,
|
||||
Checkbox,
|
||||
Flex,
|
||||
Heading,
|
||||
HStack,
|
||||
Text,
|
||||
IconButton,
|
||||
Modal,
|
||||
ModalOverlay,
|
||||
ModalContent,
|
||||
ModalHeader,
|
||||
ModalBody,
|
||||
ModalCloseButton,
|
||||
Image,
|
||||
Link,
|
||||
Stack,
|
||||
Tooltip,
|
||||
IconButton,
|
||||
Heading,
|
||||
Checkbox,
|
||||
ModalContent,
|
||||
ModalHeader,
|
||||
ModalOverlay,
|
||||
Text,
|
||||
} from "@chakra-ui/react";
|
||||
import { useContext, useEffect, useState } from "react";
|
||||
import { getWorkflow, mediaTable, updateFlow } from "../db-tables/WorkspaceDB";
|
||||
import { IconDownload, IconPin, IconPinFilled, IconTrash, IconX } from "@tabler/icons-react";
|
||||
import { mediaTable, workflowsTable } from "../db-tables/WorkspaceDB";
|
||||
import { IconX } from "@tabler/icons-react";
|
||||
import { WorkspaceContext } from "../WorkspaceContext";
|
||||
import { formatTimestamp, isImageFormat } from "../utils";
|
||||
import { useDialog } from "../components/AlertDialogProvider";
|
||||
import { Media } from "../types/dbTypes";
|
||||
const IMAGE_SIZE = 200;
|
||||
import { GalleryCarouselImageViewer } from "./components/GalleryCarouselImageViewer.tsx";
|
||||
import GalleryGridView from "./components/GalleryGridView.tsx";
|
||||
import { useDebounce } from "../customHooks/useDebounce.ts";
|
||||
import SearchInput from "../components/SearchInput.tsx";
|
||||
import { GalleryContext } from "./GalleryContext.ts";
|
||||
|
||||
export default function GalleryModal({ onclose }: { onclose: () => void }) {
|
||||
const { curFlowID, loadNewWorkflow, loadFilePath } =
|
||||
useContext(WorkspaceContext);
|
||||
const workflow = curFlowID != null ? getWorkflow(curFlowID) : null;
|
||||
const { curFlowID } = useContext(WorkspaceContext);
|
||||
const [workflowName, setWorkflowName] = useState("");
|
||||
const [selectedID, setSelectedID] = useState<string[]>([]);
|
||||
const [isSelecting, setIsSelecting] = useState(false);
|
||||
const [coverPath, setCoverPath] = useState(workflow?.coverMediaPath);
|
||||
const [images, setImages] = useState<Media[]>([]);
|
||||
const { showDialog } = useDialog();
|
||||
const [curMedia, setCurMedia] = useState<Media | null>();
|
||||
const [searchValue, setSearchValue] = useState("");
|
||||
const debounceSearchValue = useDebounce(searchValue, 300);
|
||||
const [showAllImages, setShowAllImages] = useState(false);
|
||||
|
||||
const loadData = async () => {
|
||||
if (curFlowID == null) return;
|
||||
|
||||
const media = await mediaTable?.listByWorkflowID(curFlowID);
|
||||
setImages(media ?? []);
|
||||
if (media?.length) {
|
||||
setCurMedia(media[0]);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
loadData();
|
||||
curFlowID &&
|
||||
workflowsTable?.get(curFlowID).then((flow) => {
|
||||
if (flow) {
|
||||
setWorkflowName(flow.name);
|
||||
}
|
||||
});
|
||||
}, []);
|
||||
|
||||
if (curFlowID == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const onRefreshImagesList = () => {
|
||||
loadData();
|
||||
};
|
||||
const onClickMedia = (media: Media) => {
|
||||
if (isSelecting) {
|
||||
if (selectedID.includes(media.id)) {
|
||||
setSelectedID(selectedID.filter((id) => id !== media.id));
|
||||
} else {
|
||||
setSelectedID([...selectedID, media.id]);
|
||||
}
|
||||
return;
|
||||
}
|
||||
window.open(`/workspace/view_media?filename=${media.localPath}`);
|
||||
};
|
||||
const isAllSelected =
|
||||
images.length > 0 && selectedID.length === images.length;
|
||||
|
||||
return (
|
||||
<Modal isOpen={true} onClose={onclose} blockScrollOnMount={true}>
|
||||
<ModalOverlay />
|
||||
<ModalContent width={"90%"} maxWidth={"90vw"} height={"90vh"}>
|
||||
<ModalHeader>
|
||||
<HStack gap={2} mb={2}>
|
||||
<Heading size={"md"} mr={2}>
|
||||
Gallery - {workflow?.name}
|
||||
</Heading>
|
||||
{/* <Button
|
||||
size={"sm"}
|
||||
colorScheme="pink"
|
||||
onClick={() => {
|
||||
setIsSelecting(true);
|
||||
setSelectedID(images.map((i) => i.id));
|
||||
}}
|
||||
>
|
||||
Share{" "}
|
||||
{isSelecting && selectedID.length > 0 ? selectedID.length : ""}
|
||||
</Button> */}
|
||||
</HStack>
|
||||
{isSelecting && (
|
||||
<HStack gap={3}>
|
||||
<Checkbox
|
||||
isChecked={isAllSelected}
|
||||
onChange={() => {
|
||||
if (isAllSelected) {
|
||||
setSelectedID([]);
|
||||
} else {
|
||||
setSelectedID(images.map((i) => i.id));
|
||||
<GalleryContext.Provider
|
||||
value={{
|
||||
curMedia: curMedia ?? null,
|
||||
setCurMedia,
|
||||
setMediaList: setImages,
|
||||
showAllImages,
|
||||
setShowAllImages,
|
||||
}}
|
||||
>
|
||||
<Modal isOpen={true} onClose={onclose} blockScrollOnMount={true}>
|
||||
<ModalOverlay />
|
||||
<ModalContent width={"90%"} maxWidth={"90vw"} height={"90vh"}>
|
||||
<ModalHeader>
|
||||
<HStack gap={2} mb={2}>
|
||||
<Heading size={"md"} mr={2}>
|
||||
Gallery - {workflowName}
|
||||
</Heading>
|
||||
|
||||
<SearchInput
|
||||
searchValue={searchValue}
|
||||
onUpdateSearchValue={(val) => {
|
||||
setSearchValue(val);
|
||||
if (!showAllImages) {
|
||||
setShowAllImages(true);
|
||||
}
|
||||
}}
|
||||
>
|
||||
All
|
||||
</Checkbox>
|
||||
<Text fontSize={16}>{selectedID.length} Selected</Text>
|
||||
<IconButton
|
||||
size={"sm"}
|
||||
icon={<IconX size={19} />}
|
||||
onClick={() => setIsSelecting(false)}
|
||||
aria-label="cancel"
|
||||
placeholder="Search prompt, model name, etc."
|
||||
style={{ width: "300px" }}
|
||||
/>
|
||||
</HStack>
|
||||
)}
|
||||
</ModalHeader>
|
||||
<ModalCloseButton />
|
||||
<ModalBody overflowY={"auto"}>
|
||||
<HStack wrap={"wrap"}>
|
||||
{images.map((media) => {
|
||||
if (media.localPath == null) {
|
||||
return null;
|
||||
}
|
||||
const mediaPreview = isImageFormat(media.localPath) ? (
|
||||
<Link
|
||||
isExternal
|
||||
onClick={() => onClickMedia(media)}
|
||||
position={"relative"}
|
||||
{isSelecting && (
|
||||
<HStack gap={3}>
|
||||
<Checkbox
|
||||
isChecked={isAllSelected}
|
||||
onChange={() => {
|
||||
if (isAllSelected) {
|
||||
setSelectedID([]);
|
||||
} else {
|
||||
setSelectedID(images.map((i) => i.id));
|
||||
}
|
||||
}}
|
||||
>
|
||||
{isSelecting && (
|
||||
<Checkbox
|
||||
isChecked={selectedID.includes(media.id)}
|
||||
position={"absolute"}
|
||||
top={2}
|
||||
left={2}
|
||||
/>
|
||||
)}
|
||||
<Image
|
||||
borderRadius={3}
|
||||
boxSize={IMAGE_SIZE + "px"}
|
||||
objectFit="cover"
|
||||
src={`/workspace/view_media?filename=${media.localPath}`}
|
||||
alt={"workflow image renamed or moved from output folder"}
|
||||
/>
|
||||
</Link>
|
||||
) : (
|
||||
<Link isExternal onClick={() => onClickMedia(media)}>
|
||||
{isSelecting && (
|
||||
<Checkbox isChecked={selectedID.includes(media.id)} />
|
||||
)}
|
||||
<video
|
||||
width={IMAGE_SIZE}
|
||||
height={IMAGE_SIZE}
|
||||
src={`/workspace/view_media?filename=${media.localPath}`}
|
||||
loop={true}
|
||||
autoPlay={true}
|
||||
muted={true}
|
||||
>
|
||||
<track kind="captions" />
|
||||
</video>
|
||||
</Link>
|
||||
);
|
||||
const isCover =
|
||||
coverPath != null && coverPath === media.localPath;
|
||||
|
||||
return (
|
||||
<Stack
|
||||
width={IMAGE_SIZE}
|
||||
justifyContent={"space-between"}
|
||||
mb={2}
|
||||
>
|
||||
<Tooltip label={formatTimestamp(media.createTime, true)}>
|
||||
{mediaPreview}
|
||||
</Tooltip>
|
||||
<Tooltip label={media.localPath}>
|
||||
<Text color={"GrayText"} noOfLines={1}>
|
||||
{media.localPath}
|
||||
</Text>
|
||||
</Tooltip>
|
||||
<HStack justifyContent={"space-between"} hidden={isSelecting}>
|
||||
<Tooltip label="Set as cover">
|
||||
<IconButton
|
||||
size={"sm"}
|
||||
variant={"ghost"}
|
||||
icon={
|
||||
isCover ? (
|
||||
<IconPinFilled size={19} />
|
||||
) : (
|
||||
<IconPin size={19} />
|
||||
)
|
||||
}
|
||||
aria-label="set as cover"
|
||||
isActive={isCover}
|
||||
onClick={() => {
|
||||
updateFlow(curFlowID, {
|
||||
coverMediaPath: media.localPath,
|
||||
});
|
||||
setCoverPath(media.localPath);
|
||||
}}
|
||||
/>
|
||||
</Tooltip>
|
||||
<Button
|
||||
flexGrow={1}
|
||||
onClick={() =>
|
||||
showDialog("How do you want to load this workflow?", [
|
||||
{
|
||||
label: "Load in new workflow",
|
||||
onClick: () => {
|
||||
loadFilePath(media.localPath);
|
||||
},
|
||||
colorScheme: "teal",
|
||||
},
|
||||
{
|
||||
label: "Overwrite current workflow",
|
||||
onClick: () => loadFilePath(media.localPath, true),
|
||||
colorScheme: "red",
|
||||
},
|
||||
])
|
||||
}
|
||||
size={"sm"}
|
||||
>
|
||||
Load
|
||||
</Button>
|
||||
<Tooltip label="Donwload image from gallery">
|
||||
<Link href={`/workspace/view_media?filename=${media.localPath}`} download={media.localPath} >
|
||||
<IconButton
|
||||
size={"sm"}
|
||||
variant={"ghost"}
|
||||
icon={<IconDownload size={19} />}
|
||||
aria-label="donwload image from gallery"
|
||||
/>
|
||||
</Link>
|
||||
</Tooltip>
|
||||
<Tooltip label="Remove image from gallery">
|
||||
<IconButton
|
||||
size={"sm"}
|
||||
variant={"ghost"}
|
||||
icon={<IconTrash size={19} />}
|
||||
aria-label="remove image from gallery"
|
||||
colorScheme="red"
|
||||
onClick={() => {
|
||||
const res = confirm(
|
||||
"Are you sure to remove this image from gallery? (won't delete image on your disk)"
|
||||
);
|
||||
if (!res) return;
|
||||
mediaTable?.delete(media.id);
|
||||
if (isCover) {
|
||||
updateFlow(curFlowID, {
|
||||
coverMediaPath: undefined,
|
||||
});
|
||||
setCoverPath(undefined);
|
||||
}
|
||||
onRefreshImagesList();
|
||||
}}
|
||||
/>
|
||||
</Tooltip>
|
||||
</HStack>
|
||||
</Stack>
|
||||
);
|
||||
})}
|
||||
</HStack>
|
||||
</ModalBody>
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
All
|
||||
</Checkbox>
|
||||
<Text fontSize={16}>{selectedID.length} Selected</Text>
|
||||
<IconButton
|
||||
size={"sm"}
|
||||
icon={<IconX size={19} />}
|
||||
onClick={() => setIsSelecting(false)}
|
||||
aria-label="cancel"
|
||||
/>
|
||||
</HStack>
|
||||
)}
|
||||
</ModalHeader>
|
||||
<ModalCloseButton />
|
||||
<ModalBody overflowY={"auto"}>
|
||||
{showAllImages ? (
|
||||
<GalleryGridView searchQuery={debounceSearchValue} />
|
||||
) : (
|
||||
<GalleryCarouselImageViewer mediaList={images} />
|
||||
)}
|
||||
</ModalBody>
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
</GalleryContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
import { Box, Flex, Text, Stack } from "@chakra-ui/react";
|
||||
import { FormItemComponent } from "../FormItem/FormItemComponent.tsx";
|
||||
import { isInTopField } from "../MetaBox/MetadataForm.tsx";
|
||||
import { useCallback, useContext, useEffect, useState } from "react";
|
||||
import { MetaBoxContext } from "../MetaBox/metaBoxContext.ts";
|
||||
import { PromptNodeInputItem } from "../MetaBox/utils.ts";
|
||||
|
||||
export default function AllPromptForm() {
|
||||
const { topFields, calcInputList, showNodeName } = useContext(MetaBoxContext);
|
||||
|
||||
const groupInputsByNodeType = useCallback(
|
||||
(inputList: PromptNodeInputItem[]) => {
|
||||
const groupedInputs: PromptNodeInputItem[][] = [];
|
||||
inputList.forEach((input) => {
|
||||
const lastGroup = groupedInputs[groupedInputs.length - 1];
|
||||
if (
|
||||
!lastGroup ||
|
||||
lastGroup[0].nodeID !== input.nodeID ||
|
||||
lastGroup[0].classType !== input.classType
|
||||
) {
|
||||
groupedInputs.push([input]);
|
||||
} else {
|
||||
lastGroup.push(input);
|
||||
}
|
||||
});
|
||||
return groupedInputs;
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
useEffect(() => {}, [showNodeName]);
|
||||
|
||||
if (!showNodeName) {
|
||||
return (
|
||||
<Stack>
|
||||
{calcInputList.map((input) => {
|
||||
if (
|
||||
isInTopField(topFields, {
|
||||
name: input.inputName,
|
||||
promptKey: input.nodeID,
|
||||
classType: input.classType,
|
||||
})
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<FormItemComponent
|
||||
key={`form${input.nodeID}${input.inputName}`}
|
||||
inputItem={input}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
const nodes = groupInputsByNodeType(calcInputList);
|
||||
return (
|
||||
<Stack>
|
||||
{nodes.map((nodeInputs) => {
|
||||
if (!nodeInputs[0]) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<CustomAccordionPanel
|
||||
title={nodeInputs[0].classType + " #" + nodeInputs[0].nodeID}
|
||||
key={nodeInputs[0].nodeID}
|
||||
>
|
||||
<Flex px={2} gap={1} direction={"column"}>
|
||||
{nodeInputs.map((input) => {
|
||||
if (
|
||||
isInTopField(topFields, {
|
||||
name: input.inputName,
|
||||
promptKey: input.nodeID,
|
||||
classType: input.classType,
|
||||
})
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<FormItemComponent
|
||||
key={`form${input.nodeID}${input.inputName}`}
|
||||
inputItem={input}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</Flex>
|
||||
</CustomAccordionPanel>
|
||||
);
|
||||
})}
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
function CustomAccordionPanel({
|
||||
title,
|
||||
children,
|
||||
}: {
|
||||
title: string;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<Box border="1px" borderColor="gray.500" borderRadius="md" p={4}>
|
||||
<Text size="sm" mb={1} color={"GrayText"}>
|
||||
{title}
|
||||
</Text>
|
||||
<div>{children}</div>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import { FC } from "react";
|
||||
import { Checkbox, Flex } from "@chakra-ui/react";
|
||||
import { PromptNodeInputItem } from "../MetaBox/utils.ts";
|
||||
|
||||
export const CheckboxBase: FC<
|
||||
{
|
||||
inputItem: PromptNodeInputItem;
|
||||
} & {
|
||||
[key in string]: any;
|
||||
}
|
||||
> = (props) => {
|
||||
const inputItem = props.inputItem;
|
||||
return (
|
||||
<Flex gap={2}>
|
||||
<Flex gap={1} alignItems={"center"} flexBasis={"200px"}>
|
||||
{inputItem.label ?? inputItem.inputName}
|
||||
</Flex>
|
||||
<Checkbox
|
||||
defaultChecked={!!inputItem.inputValue}
|
||||
onChange={(e) => {
|
||||
props?.updateMetaData?.({
|
||||
promptKey: props.promptKey,
|
||||
name: props.name,
|
||||
value: e.target.checked,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</Flex>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,133 @@
|
||||
import { FC, useContext, useState } from "react";
|
||||
import { InputBase } from "./InputBase.tsx";
|
||||
import { InputSlider } from "./InputSlider.tsx";
|
||||
import { SelectBase } from "./SelectBase.tsx";
|
||||
import { TextareaBase } from "./TextareaBase.tsx";
|
||||
import { FormItem, FormItemType } from "./types.ts";
|
||||
import { getNodesInfo } from "../../utils.ts";
|
||||
import { NoSupport } from "./NoSupport.tsx";
|
||||
import { CheckboxBase } from "./CheckboxBase.tsx";
|
||||
import { Flex, Grid, IconButton } from "@chakra-ui/react";
|
||||
import { IconPin, IconPinFilled } from "@tabler/icons-react";
|
||||
import { isInTopField } from "../MetaBox/MetadataForm.tsx";
|
||||
import { PromptNodeInputItem } from "../MetaBox/utils.ts";
|
||||
import { MetaBoxContext } from "../MetaBox/metaBoxContext.ts";
|
||||
|
||||
const INPUT_TYPE_COMPONENT_MAPPING = {
|
||||
[FormItemType.Input]: InputBase,
|
||||
[FormItemType.InputSlider]: InputSlider,
|
||||
[FormItemType.Select]: SelectBase,
|
||||
[FormItemType.Textarea]: TextareaBase,
|
||||
[FormItemType.Checkbox]: CheckboxBase,
|
||||
NoSupport: NoSupport,
|
||||
} as Record<FormItemType, any>;
|
||||
|
||||
// @ts-expect-error
|
||||
const nodesInfo = LiteGraph.registered_node_types;
|
||||
|
||||
function getInputConfigByInfo(
|
||||
classType: string,
|
||||
inputName: string,
|
||||
): Partial<FormItem> {
|
||||
const nodeData = nodesInfo[classType]?.nodeData;
|
||||
const inputsInfo = {
|
||||
...(nodeData?.input?.required ?? {}),
|
||||
...(nodeData?.input?.optional ?? {}),
|
||||
}[inputName];
|
||||
|
||||
if (inputsInfo?.[0] === "STRING") {
|
||||
if (inputsInfo?.[1]?.multiline) {
|
||||
return {
|
||||
type: FormItemType.Textarea,
|
||||
};
|
||||
}
|
||||
return {
|
||||
type: FormItemType.Input,
|
||||
};
|
||||
}
|
||||
if (inputsInfo?.[0] === "BOOLEAN") {
|
||||
return {
|
||||
type: FormItemType.Checkbox,
|
||||
};
|
||||
}
|
||||
if (inputsInfo?.[0] === "FLOAT") {
|
||||
return {
|
||||
...(inputsInfo?.[1] ?? {}),
|
||||
type: FormItemType.InputSlider,
|
||||
};
|
||||
}
|
||||
if (inputsInfo?.[0] === "INT") {
|
||||
return {
|
||||
...(inputsInfo?.[1] ?? {}),
|
||||
type: FormItemType.InputSlider,
|
||||
};
|
||||
}
|
||||
if (
|
||||
Array.isArray(inputsInfo?.[0]) &&
|
||||
inputsInfo?.[0]?.every((v) => typeof v === "string")
|
||||
) {
|
||||
return {
|
||||
options: inputsInfo?.[0],
|
||||
type: FormItemType.Select,
|
||||
};
|
||||
}
|
||||
return { type: FormItemType.NoSupport };
|
||||
}
|
||||
|
||||
export const FormItemComponent: FC<{ inputItem: PromptNodeInputItem }> = ({
|
||||
inputItem,
|
||||
}) => {
|
||||
const configByNodeInfo = getInputConfigByInfo(
|
||||
inputItem.classType,
|
||||
inputItem.inputName,
|
||||
);
|
||||
const [isHovered, setIsHovered] = useState(false); // Track hover state
|
||||
|
||||
const Com = INPUT_TYPE_COMPONENT_MAPPING[configByNodeInfo.type ?? "Input"];
|
||||
const { updateTopField, topFields } = useContext(MetaBoxContext);
|
||||
const childrenOutputLink =
|
||||
inputItem.children[0] === "negative" || inputItem.children[0] === "positive"
|
||||
? `-${inputItem.children[0]}`
|
||||
: "";
|
||||
const inputWithLabel = {
|
||||
...inputItem,
|
||||
label: inputItem.label ?? inputItem.inputName + childrenOutputLink,
|
||||
};
|
||||
|
||||
return (
|
||||
<Flex
|
||||
alignItems={"center"}
|
||||
onMouseEnter={() => setIsHovered(true)}
|
||||
onMouseLeave={() => setIsHovered(false)}
|
||||
>
|
||||
{true && (
|
||||
<IconButton
|
||||
size={"xs"}
|
||||
onClick={() =>
|
||||
updateTopField?.({
|
||||
name: inputItem.inputName,
|
||||
promptKey: inputItem.nodeID,
|
||||
class_type: inputItem.classType,
|
||||
})
|
||||
}
|
||||
variant={"ghost"}
|
||||
icon={
|
||||
isInTopField(topFields, {
|
||||
name: inputItem.inputName,
|
||||
promptKey: inputItem.nodeID,
|
||||
classType: inputItem.classType,
|
||||
}) ? (
|
||||
<IconPinFilled size={14} />
|
||||
) : (
|
||||
<IconPin size={14} />
|
||||
)
|
||||
}
|
||||
aria-label={"pin"}
|
||||
/>
|
||||
)}
|
||||
<div style={{ flex: 1 }}>
|
||||
<Com inputItem={inputWithLabel} {...configByNodeInfo} />
|
||||
</div>
|
||||
</Flex>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,20 @@
|
||||
import { FC } from "react";
|
||||
import { Flex, Input } from "@chakra-ui/react";
|
||||
import { PromptNodeInputItem } from "../MetaBox/utils.ts";
|
||||
|
||||
export const InputBase: FC<
|
||||
{
|
||||
inputItem: PromptNodeInputItem;
|
||||
} & {
|
||||
[key in string]: any;
|
||||
}
|
||||
> = ({ inputItem }) => {
|
||||
return (
|
||||
<Flex gap={2}>
|
||||
<Flex gap={1} alignItems={"center"} flexBasis={"200px"}>
|
||||
{inputItem.label ?? inputItem.inputName}
|
||||
</Flex>
|
||||
<Input value={inputItem.inputValue} onChange={(e) => {}} />
|
||||
</Flex>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,62 @@
|
||||
import { FC } from "react";
|
||||
import {
|
||||
Flex,
|
||||
NumberDecrementStepper,
|
||||
NumberIncrementStepper,
|
||||
NumberInput,
|
||||
NumberInputField,
|
||||
NumberInputStepper,
|
||||
Slider,
|
||||
SliderFilledTrack,
|
||||
SliderThumb,
|
||||
SliderTrack,
|
||||
} from "@chakra-ui/react";
|
||||
import { PromptNodeInputItem } from "../MetaBox/utils.ts";
|
||||
|
||||
export const InputSlider: FC<
|
||||
{
|
||||
inputItem: PromptNodeInputItem;
|
||||
} & {
|
||||
[key in string]: any;
|
||||
}
|
||||
> = (props) => {
|
||||
const inputItem = props.inputItem;
|
||||
return (
|
||||
<Flex gap={1} direction={"column"}>
|
||||
<Flex gap={2}>
|
||||
<Flex gap={1} alignItems={"center"} flexBasis={"200px"}>
|
||||
{inputItem.label ?? inputItem.inputName}
|
||||
</Flex>
|
||||
<NumberInput
|
||||
width={"100%"}
|
||||
step={props?.step}
|
||||
value={inputItem.inputValue}
|
||||
min={props?.min}
|
||||
max={props?.max}
|
||||
onChange={(val) => {}}
|
||||
>
|
||||
<NumberInputField />
|
||||
<NumberInputStepper>
|
||||
<NumberIncrementStepper />
|
||||
<NumberDecrementStepper />
|
||||
</NumberInputStepper>
|
||||
</NumberInput>
|
||||
</Flex>
|
||||
<Flex>
|
||||
<Slider
|
||||
maxWidth={"400px"}
|
||||
step={props?.step}
|
||||
value={Number(inputItem.inputValue)}
|
||||
min={props?.min ?? 0}
|
||||
max={props?.max ?? 100}
|
||||
onChange={(val) => {}}
|
||||
>
|
||||
<SliderTrack>
|
||||
<SliderFilledTrack />
|
||||
</SliderTrack>
|
||||
<SliderThumb />
|
||||
</Slider>
|
||||
</Flex>
|
||||
</Flex>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,14 @@
|
||||
import { FC } from "react";
|
||||
import { Flex } from "@chakra-ui/react";
|
||||
import { PromptNodeInputItem } from "../MetaBox/utils.ts";
|
||||
|
||||
export const NoSupport: FC<{ inputItem: PromptNodeInputItem }> = (props) => {
|
||||
return (
|
||||
<Flex gap={2}>
|
||||
<Flex gap={1} alignItems={"center"} flexBasis={"200px"}>
|
||||
{props.inputItem.label ?? props.inputItem.inputName}
|
||||
</Flex>
|
||||
<Flex>No Support</Flex>
|
||||
</Flex>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,31 @@
|
||||
import { FC } from "react";
|
||||
import { Flex, Select } from "@chakra-ui/react";
|
||||
import { FormItem } from "./types.ts";
|
||||
import { PromptNodeInputItem } from "../MetaBox/utils.ts";
|
||||
|
||||
export const SelectBase: FC<
|
||||
{
|
||||
inputItem: PromptNodeInputItem;
|
||||
} & {
|
||||
[key in string]: any;
|
||||
}
|
||||
> = (props) => {
|
||||
const { inputItem } = props;
|
||||
return (
|
||||
<Flex gap={2}>
|
||||
<Flex gap={1} alignItems={"center"} flexBasis={"200px"}>
|
||||
{inputItem.label ?? inputItem.inputName}
|
||||
</Flex>
|
||||
<Select value={inputItem.inputValue} onChange={(e) => {}}>
|
||||
{props?.options?.map((v: string, i: number) => (
|
||||
<option
|
||||
key={`select${inputItem.nodeID}${inputItem.inputName}${i}`}
|
||||
value={v}
|
||||
>
|
||||
{v}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
</Flex>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,19 @@
|
||||
import { FC } from "react";
|
||||
import { Flex, Textarea } from "@chakra-ui/react";
|
||||
import { PromptNodeInputItem } from "../MetaBox/utils.ts";
|
||||
|
||||
export const TextareaBase: FC<
|
||||
{
|
||||
inputItem: PromptNodeInputItem;
|
||||
} & {
|
||||
[key in string]: any;
|
||||
}
|
||||
> = (props) => {
|
||||
const inputItem = props.inputItem;
|
||||
return (
|
||||
<Flex gap={1} direction={"column"}>
|
||||
<Flex>{inputItem.label ?? inputItem.inputName}</Flex>
|
||||
<Textarea value={inputItem.inputValue} onChange={(e) => {}} rows={5} />
|
||||
</Flex>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,20 @@
|
||||
export enum FormItemType {
|
||||
Input = "Input",
|
||||
InputSlider = "InputSlider",
|
||||
Select = "Select",
|
||||
Textarea = "Textarea",
|
||||
Checkbox = "Checkbox",
|
||||
NoSupport = "NoSupport",
|
||||
}
|
||||
|
||||
export type FormItem = {
|
||||
type?: FormItemType;
|
||||
name: string;
|
||||
label?: string;
|
||||
value: string | number;
|
||||
onChange?: (val: any) => void;
|
||||
classType: string;
|
||||
promptKey: string | number;
|
||||
} & {
|
||||
[key in string]: any;
|
||||
};
|
||||
@@ -0,0 +1,65 @@
|
||||
import { Media } from "../../types/dbTypes.ts";
|
||||
import { useContext } from "react";
|
||||
import { Box, Flex, Grid } from "@chakra-ui/react";
|
||||
import Carousel from "../../components/Carousel/Carousel.tsx";
|
||||
import { GalleryRightCol } from "./GalleryRightCol.tsx";
|
||||
import MediaPreview from "../../components/MediaPreview.tsx";
|
||||
import { mediaTable } from "../../db-tables/WorkspaceDB.ts";
|
||||
import { GalleryContext } from "../GalleryContext.ts";
|
||||
|
||||
interface MetaDataInfoProps {
|
||||
mediaList: Media[];
|
||||
}
|
||||
const GALLERY_IMAGE_SIZE = 120;
|
||||
export function GalleryCarouselImageViewer({ mediaList }: MetaDataInfoProps) {
|
||||
const { curMedia, setCurMedia } = useContext(GalleryContext);
|
||||
|
||||
return (
|
||||
<Flex gap={3} h={"100%"}>
|
||||
<Grid
|
||||
gridTemplateRows={mediaList.length <= 6 ? "1fr 20%" : "1fr"}
|
||||
flex={1}
|
||||
gap={2}
|
||||
>
|
||||
<div style={{ height: "56vh" }}>
|
||||
<Carousel
|
||||
media={mediaList.map((v) => ({
|
||||
id: v.id,
|
||||
imageUrl: `/workspace/view_media?filename=${v.localPath}`,
|
||||
}))}
|
||||
currentNum={mediaList?.findIndex((p) => p.id === curMedia?.id) ?? 0}
|
||||
setMediaAct={(newMedia) =>
|
||||
setCurMedia(mediaList?.find((v) => v.id === newMedia.id) ?? null)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<Flex wrap={"wrap"}>
|
||||
{mediaList?.map((media) => (
|
||||
<Box
|
||||
display={"inline-block"}
|
||||
p={1}
|
||||
borderRadius={"4px"}
|
||||
key={`image-bottom-${media.id}`}
|
||||
width={`${GALLERY_IMAGE_SIZE + 3}px`}
|
||||
height={`${GALLERY_IMAGE_SIZE + 3}px`}
|
||||
cursor={"pointer"}
|
||||
border={curMedia?.id === media.id ? "1px solid gray" : ""}
|
||||
onClick={() => setCurMedia(media)}
|
||||
>
|
||||
<MediaPreview
|
||||
mediaLocalPath={media.localPath}
|
||||
size={GALLERY_IMAGE_SIZE}
|
||||
objectFit="contain"
|
||||
hideBrokenImage
|
||||
onBrokenLink={() => {
|
||||
mediaTable?.delete(media.id);
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
))}
|
||||
</Flex>
|
||||
</Grid>
|
||||
<GalleryRightCol media={curMedia ?? undefined} />
|
||||
</Flex>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import { useContext, useEffect, useState } from "react";
|
||||
import GalleryMediaItem from "./GalleryMediaItem";
|
||||
import { HStack } from "@chakra-ui/react";
|
||||
import type { Media } from "../../types/dbTypes";
|
||||
import { mediaTable } from "../../db-tables/WorkspaceDB";
|
||||
import { GalleryContext } from "../GalleryContext";
|
||||
|
||||
export default function GalleryGridView({
|
||||
searchQuery,
|
||||
}: {
|
||||
searchQuery: string;
|
||||
}) {
|
||||
const [medias, setMedias] = useState<Media[]>([]);
|
||||
const { setCurMedia, setShowAllImages, setMediaList } =
|
||||
useContext(GalleryContext);
|
||||
useEffect(() => {
|
||||
if (searchQuery === "") {
|
||||
mediaTable?.listAll().then((data) => {
|
||||
setMedias(data.slice(0, 50) ?? []);
|
||||
});
|
||||
} else {
|
||||
mediaTable
|
||||
?.filter((v) => v.workflowJSON?.includes(searchQuery) ?? false)
|
||||
.then((data) => {
|
||||
setMedias(data);
|
||||
});
|
||||
}
|
||||
}, [searchQuery]);
|
||||
|
||||
const onClickMedia = (media: Media) => {
|
||||
setMediaList([media].concat(medias.filter((v) => v.id !== media.id)));
|
||||
setCurMedia(media);
|
||||
setShowAllImages(false);
|
||||
};
|
||||
return (
|
||||
<HStack wrap={"wrap"}>
|
||||
{medias.map((media) => {
|
||||
return (
|
||||
<GalleryMediaItem
|
||||
key={media.id}
|
||||
selectedID={[]}
|
||||
media={media}
|
||||
isSelecting={false}
|
||||
onClickMedia={onClickMedia}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</HStack>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
import React, { useContext } from "react";
|
||||
import {
|
||||
Link,
|
||||
Checkbox,
|
||||
Stack,
|
||||
HStack,
|
||||
Tooltip,
|
||||
IconButton,
|
||||
Text,
|
||||
Button,
|
||||
} from "@chakra-ui/react";
|
||||
import { formatTimestamp } from "../../utils";
|
||||
import { Media } from "../../types/dbTypes";
|
||||
import { IconDownload, IconTrash } from "@tabler/icons-react";
|
||||
import { mediaTable } from "../../db-tables/WorkspaceDB";
|
||||
import { WorkspaceContext } from "../../WorkspaceContext";
|
||||
import { useDialog } from "../../components/AlertDialogProvider";
|
||||
import MediaPreview from "../../components/MediaPreview";
|
||||
|
||||
interface GalleryMediaItemProps {
|
||||
media: Media;
|
||||
isSelecting: boolean;
|
||||
selectedID: string[];
|
||||
onClickMedia: (media: Media) => void;
|
||||
}
|
||||
|
||||
const GalleryMediaItem: React.FC<GalleryMediaItemProps> = ({
|
||||
media,
|
||||
isSelecting,
|
||||
selectedID,
|
||||
onClickMedia,
|
||||
}) => {
|
||||
const IMAGE_SIZE = 180; // Define your image size
|
||||
const { curFlowID, loadFilePath } = useContext(WorkspaceContext);
|
||||
const { showDialog } = useDialog();
|
||||
if (media.localPath == null) {
|
||||
return null;
|
||||
}
|
||||
if (curFlowID == null) {
|
||||
return null;
|
||||
}
|
||||
const mediaPreview = (
|
||||
<Link isExternal onClick={() => onClickMedia(media)} position="relative">
|
||||
{isSelecting && (
|
||||
<Checkbox
|
||||
isChecked={selectedID.includes(media.id)}
|
||||
position="absolute"
|
||||
top={2}
|
||||
left={2}
|
||||
/>
|
||||
)}
|
||||
<MediaPreview
|
||||
mediaLocalPath={media.localPath}
|
||||
size={IMAGE_SIZE}
|
||||
onBrokenLink={() => {
|
||||
mediaTable?.delete(media.id);
|
||||
}}
|
||||
hideBrokenImage
|
||||
autoPlay={true}
|
||||
isPreview
|
||||
/>
|
||||
</Link>
|
||||
);
|
||||
|
||||
return (
|
||||
<Stack width={IMAGE_SIZE} mb={2}>
|
||||
<Tooltip label={formatTimestamp(media.createTime, true)}>
|
||||
{mediaPreview}
|
||||
</Tooltip>
|
||||
<Tooltip label={media.localPath}>
|
||||
<Text color={"GrayText"} noOfLines={1}>
|
||||
{media.localPath}
|
||||
</Text>
|
||||
</Tooltip>
|
||||
<HStack hidden={isSelecting} gap={0}>
|
||||
<Button
|
||||
flexGrow={1}
|
||||
onClick={() =>
|
||||
showDialog("How do you want to load this workflow?", [
|
||||
{
|
||||
label: "Load in new workflow",
|
||||
onClick: () => {
|
||||
loadFilePath(media.localPath);
|
||||
},
|
||||
colorScheme: "teal",
|
||||
},
|
||||
{
|
||||
label: "Overwrite current workflow",
|
||||
onClick: () => loadFilePath(media.localPath, true),
|
||||
colorScheme: "red",
|
||||
},
|
||||
])
|
||||
}
|
||||
size={"sm"}
|
||||
>
|
||||
Load
|
||||
</Button>
|
||||
<Tooltip label="Donwload image from gallery">
|
||||
<Link
|
||||
href={`/workspace/view_media?filename=${media.localPath}`}
|
||||
download={media.localPath}
|
||||
>
|
||||
<IconButton
|
||||
size={"sm"}
|
||||
variant={"ghost"}
|
||||
icon={<IconDownload size={19} />}
|
||||
aria-label="donwload image from gallery"
|
||||
/>
|
||||
</Link>
|
||||
</Tooltip>
|
||||
<Tooltip label="Remove image from gallery">
|
||||
<IconButton
|
||||
size={"sm"}
|
||||
variant={"ghost"}
|
||||
icon={<IconTrash size={19} />}
|
||||
aria-label="remove image from gallery"
|
||||
colorScheme="red"
|
||||
onClick={async () => {
|
||||
const res = confirm(
|
||||
"Are you sure to remove this image from gallery? (won't delete image on your disk)",
|
||||
);
|
||||
if (!res) return;
|
||||
await mediaTable?.delete(media.id);
|
||||
}}
|
||||
/>
|
||||
</Tooltip>
|
||||
</HStack>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
export default GalleryMediaItem;
|
||||
@@ -0,0 +1,17 @@
|
||||
import { MetaData } from "../utils.ts";
|
||||
import { Media } from "../../types/dbTypes.ts";
|
||||
import { Flex } from "@chakra-ui/react";
|
||||
import MetadataForm from "./MetaBox/MetadataForm.tsx";
|
||||
import { GalleryRightColHeaderButtons } from "./GalleryRightColHeaderButtons.tsx";
|
||||
|
||||
export type MediaWithMetaData = Media & {
|
||||
metaData?: MetaData;
|
||||
};
|
||||
export const GalleryRightCol = ({ media }: { media?: MediaWithMetaData }) => {
|
||||
return (
|
||||
<Flex overflowY={"auto"} mb={4} direction={"column"} gap={2} flex={1}>
|
||||
<GalleryRightColHeaderButtons media={media} />
|
||||
<MetadataForm media={media ?? null} />
|
||||
</Flex>
|
||||
);
|
||||
};
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user