mirror of
https://github.com/BookStackApp/BookStack.git
synced 2026-02-05 08:39:55 +03:00
Compare commits
575 Commits
docker_env
...
v25.12.2
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ed4baed28c | ||
|
|
90d011fc15 | ||
|
|
ff59bbdc07 | ||
|
|
4dc443b7df | ||
|
|
19f02d927e | ||
|
|
da7bedd2e4 | ||
|
|
20db372596 | ||
|
|
43eed1660c | ||
|
|
e6b754fad0 | ||
|
|
018de5def3 | ||
|
|
5c4fc3dc2c | ||
|
|
805fd98c0f | ||
|
|
fcbae16730 | ||
|
|
07ec880e33 | ||
|
|
ab436ed5c3 | ||
|
|
082befb2fc | ||
|
|
b0a8cb0c5d | ||
|
|
b08d1b36de | ||
|
|
88d86df66f | ||
|
|
7c3a4c7e85 | ||
|
|
114fa802c0 | ||
|
|
38d3697246 | ||
|
|
d93354ff0e | ||
|
|
3336e0c6ae | ||
|
|
8fc9a2af4e | ||
|
|
8aec571123 | ||
|
|
382f4db276 | ||
|
|
d504b19143 | ||
|
|
d87e8d05c7 | ||
|
|
0b48361780 | ||
|
|
2de3247ae4 | ||
|
|
48df2be0d8 | ||
|
|
a4c0556551 | ||
|
|
a941d1b403 | ||
|
|
51f9b63db0 | ||
|
|
90fc02c57f | ||
|
|
4aeb571126 | ||
|
|
3d9aba7b1f | ||
|
|
48cdaab690 | ||
|
|
4f760479c3 | ||
|
|
9211062e8e | ||
|
|
221c6c7e9f | ||
|
|
e2f91c2bbb | ||
|
|
147ff00c7a | ||
|
|
1e768ce33f | ||
|
|
8fcd3b24b3 | ||
|
|
ce703403c2 | ||
|
|
313326b32a | ||
|
|
1d87b513be | ||
|
|
9bf9ae9c37 | ||
|
|
50540e23a1 | ||
|
|
3e1b0587ec | ||
|
|
6661ae8178 | ||
|
|
1ee5711435 | ||
|
|
08e7ba7064 | ||
|
|
34e747162f | ||
|
|
10f5ceee35 | ||
|
|
9886bbd3a0 | ||
|
|
92a3c22b4c | ||
|
|
b5246a28f0 | ||
|
|
ab4b1c8efa | ||
|
|
8890746278 | ||
|
|
dfdcfcfdb8 | ||
|
|
ebceba0afe | ||
|
|
16110273ff | ||
|
|
93bcbd168e | ||
|
|
65f7b61c1f | ||
|
|
2fde803c76 | ||
|
|
adfac3e30e | ||
|
|
21730aeb39 | ||
|
|
75231d2d4a | ||
|
|
9d732d8dd8 | ||
|
|
9e8088f186 | ||
|
|
cf847974d2 | ||
|
|
3cd3e73f60 | ||
|
|
bb350639c6 | ||
|
|
46001d61d0 | ||
|
|
8dd238ceae | ||
|
|
bb7fd59de9 | ||
|
|
9de294343d | ||
|
|
98a09bcc37 | ||
|
|
959981a676 | ||
|
|
674bb84fac | ||
|
|
ba675b6349 | ||
|
|
f073994bc3 | ||
|
|
0f40aeb0d3 | ||
|
|
cdd164e3e3 | ||
|
|
c90816987c | ||
|
|
dd393691b1 | ||
|
|
dd5375f480 | ||
|
|
291a807d98 | ||
|
|
e64fc60bdf | ||
|
|
ad582ab9f8 | ||
|
|
870f3c58c0 | ||
|
|
ad8fc95521 | ||
|
|
cca066a258 | ||
|
|
22a7772c3d | ||
|
|
9934f85ba9 | ||
|
|
73c6bf4f8d | ||
|
|
bbda5fd468 | ||
|
|
8429cc93eb | ||
|
|
47f12cc8f6 | ||
|
|
b2f81f5c62 | ||
|
|
1be2969055 | ||
|
|
99a1d82f0a | ||
|
|
f06a6de2e7 | ||
|
|
aaa28186bc | ||
|
|
fef61f054a | ||
|
|
8082c95ec3 | ||
|
|
8ab9252f9b | ||
|
|
befc645705 | ||
|
|
4eb4407ef7 | ||
|
|
fcabf478de | ||
|
|
8de2c28497 | ||
|
|
5bf2d801cf | ||
|
|
1421ba871d | ||
|
|
563828ba52 | ||
|
|
d40a68b411 | ||
|
|
4a57933cd1 | ||
|
|
1df850ea3e | ||
|
|
7881bddce0 | ||
|
|
02d024aa32 | ||
|
|
652124abaf | ||
|
|
751934c84a | ||
|
|
3fd25bd03e | ||
|
|
f0303de2e5 | ||
|
|
0b26573314 | ||
|
|
c21c36e2a6 | ||
|
|
a949900570 | ||
|
|
9c4a9225af | ||
|
|
4627dfd4f7 | ||
|
|
fcacf7cacb | ||
|
|
cbf27d70c8 | ||
|
|
3ad1e31fcc | ||
|
|
082dbc9944 | ||
|
|
abe9c1e5a3 | ||
|
|
ebf82617b8 | ||
|
|
2c81447c9e | ||
|
|
8898647f78 | ||
|
|
ea6344898f | ||
|
|
0bfd79925e | ||
|
|
efff8700d4 | ||
|
|
5754acf2fb | ||
|
|
4c7d6420ee | ||
|
|
0838d5ea16 | ||
|
|
449ac40114 | ||
|
|
3131050acd | ||
|
|
c0d2874892 | ||
|
|
5940a91809 | ||
|
|
9a4651badb | ||
|
|
92d15d9cf2 | ||
|
|
b06147fef7 | ||
|
|
841350a937 | ||
|
|
12183bac07 | ||
|
|
e65b4b63a2 | ||
|
|
7cac3f4780 | ||
|
|
92cd11d105 | ||
|
|
13115ace84 | ||
|
|
73f9834e6f | ||
|
|
3afe855156 | ||
|
|
b6110ed3cd | ||
|
|
bfde896f0b | ||
|
|
1cdc0a7a3d | ||
|
|
d19b86640b | ||
|
|
2936ba609b | ||
|
|
573a2dd22a | ||
|
|
b55cc803d3 | ||
|
|
304ade418e | ||
|
|
997931c42f | ||
|
|
268e353431 | ||
|
|
b491b5fbca | ||
|
|
387c786768 | ||
|
|
2641586a6f | ||
|
|
6d2cd20e80 | ||
|
|
b0c574356a | ||
|
|
07e45a20e5 | ||
|
|
14056c69e6 | ||
|
|
fb9c840c46 | ||
|
|
5fba4a5399 | ||
|
|
c0b377050e | ||
|
|
f3efb6441d | ||
|
|
0cf313a21e | ||
|
|
26aadffb20 | ||
|
|
a5f48e3202 | ||
|
|
b0dda6e6a7 | ||
|
|
d4025d95e7 | ||
|
|
d6021f4d22 | ||
|
|
b9a3290731 | ||
|
|
48f235ea5a | ||
|
|
047771b9f4 | ||
|
|
b5375114d3 | ||
|
|
fc13e56cea | ||
|
|
77fc37ac25 | ||
|
|
3424351e84 | ||
|
|
606f9d92d0 | ||
|
|
a5e25abb9c | ||
|
|
b310e87e4c | ||
|
|
425baf9d6e | ||
|
|
825c369ad9 | ||
|
|
10bab70438 | ||
|
|
350e0b281b | ||
|
|
08805ea3c8 | ||
|
|
9441e32c69 | ||
|
|
530fc37067 | ||
|
|
369e499dce | ||
|
|
655815de6d | ||
|
|
457adc1fee | ||
|
|
e86a90967e | ||
|
|
5d08f7cf14 | ||
|
|
8744eb2d62 | ||
|
|
d8383cfa80 | ||
|
|
4626278447 | ||
|
|
c61af9c22b | ||
|
|
72521d0906 | ||
|
|
7e44b195c5 | ||
|
|
5b45eac5e1 | ||
|
|
c1d30341e7 | ||
|
|
80d2b4913b | ||
|
|
3f473528b1 | ||
|
|
d0dcd4f61b | ||
|
|
bde66a1396 | ||
|
|
4de5a2d9bf | ||
|
|
27bf4299cf | ||
|
|
164f01bb25 | ||
|
|
f563a005f5 | ||
|
|
a14d8e30cc | ||
|
|
a9194ffb63 | ||
|
|
2f9c1b7127 | ||
|
|
bbea76668b | ||
|
|
becc630acf | ||
|
|
4ac8ecad6b | ||
|
|
903e88c700 | ||
|
|
ed96aa820e | ||
|
|
63ec079b7b | ||
|
|
d485fcb3db | ||
|
|
0f895668a4 | ||
|
|
6c577ac3bf | ||
|
|
31cc2423d2 | ||
|
|
c9ed32e518 | ||
|
|
6b4c3a0969 | ||
|
|
2dad92d1bd | ||
|
|
c1fb7ab7dc | ||
|
|
98315f3899 | ||
|
|
8c82aaabd6 | ||
|
|
ce9b536b78 | ||
|
|
d9c50e5bc1 | ||
|
|
bf075f7dd8 | ||
|
|
a4fd673285 | ||
|
|
e794c977bc | ||
|
|
0b088ef1d3 | ||
|
|
bf6a6af683 | ||
|
|
914790fd99 | ||
|
|
edb0c6a9e8 | ||
|
|
84049de696 | ||
|
|
da0531e63b | ||
|
|
421dc75f4e | ||
|
|
8ae91df038 | ||
|
|
64b41dd626 | ||
|
|
ebd6e4d3a2 | ||
|
|
80374aea5c | ||
|
|
2ac9efae7d | ||
|
|
a11d565ba4 | ||
|
|
1fdf854ea7 | ||
|
|
e9c9792cb9 | ||
|
|
5ae524c25a | ||
|
|
0d7287fc8b | ||
|
|
e77c96f6b7 | ||
|
|
9b8a10dd3a | ||
|
|
49200ca5ce | ||
|
|
34aa4dbf10 | ||
|
|
5ee79d16c9 | ||
|
|
a1ea4006e0 | ||
|
|
9078188939 | ||
|
|
ed0aad1a7a | ||
|
|
5c59cfb020 | ||
|
|
3ca15ad68a | ||
|
|
60014989f5 | ||
|
|
57b10f195e | ||
|
|
b1e95eb39f | ||
|
|
b3da77b8f9 | ||
|
|
1a345b74bb | ||
|
|
8ffc3a4abf | ||
|
|
7233c1c7b2 | ||
|
|
1309a01131 | ||
|
|
0333185b6d | ||
|
|
83f89f64e8 | ||
|
|
11a1a6fb16 | ||
|
|
882c609296 | ||
|
|
176a0dcd59 | ||
|
|
94b0f70bfa | ||
|
|
08b2a77d41 | ||
|
|
3e8e9a23cf | ||
|
|
58b83b64c8 | ||
|
|
dfe4cde6ee | ||
|
|
d11144d9e2 | ||
|
|
f96b0ea5f3 | ||
|
|
815f8d79ed | ||
|
|
b62dab32e0 | ||
|
|
262f863981 | ||
|
|
a4c94390a1 | ||
|
|
53f3cca85d | ||
|
|
ed08bbcecc | ||
|
|
de97ebf9b7 | ||
|
|
f492a660a8 | ||
|
|
09436836a5 | ||
|
|
bb455d7788 | ||
|
|
009212ab80 | ||
|
|
ba9cb591c8 | ||
|
|
d00ac2f34e | ||
|
|
bd4dc6d463 | ||
|
|
d91180a909 | ||
|
|
bc2913a5cb | ||
|
|
4802394562 | ||
|
|
1755556468 | ||
|
|
01cdbdb7ae | ||
|
|
fc8bbf3eab | ||
|
|
3cdab19319 | ||
|
|
5661d20e87 | ||
|
|
91f80123e8 | ||
|
|
7a0636d0f8 | ||
|
|
0fe5bdfbac | ||
|
|
f88687e977 | ||
|
|
68d437d05b | ||
|
|
1e56aaea04 | ||
|
|
dab170a6fe | ||
|
|
a8de717d9b | ||
|
|
78fe95b6fc | ||
|
|
e0c24e41aa | ||
|
|
fa8553839b | ||
|
|
b8fcefc794 | ||
|
|
88bcb68fcb | ||
|
|
7c000553ae | ||
|
|
391fa35c80 | ||
|
|
c6773a8c9f | ||
|
|
9b226e7d39 | ||
|
|
9865446267 | ||
|
|
926abbe776 | ||
|
|
4fabef3a57 | ||
|
|
5ef4cd80c3 | ||
|
|
e01f23583f | ||
|
|
7792cb3915 | ||
|
|
be26253a18 | ||
|
|
1bdd1f8189 | ||
|
|
fa62c79b17 | ||
|
|
d7d8fa1e5b | ||
|
|
18562f1e10 | ||
|
|
86090a694f | ||
|
|
1ee8287c73 | ||
|
|
8eb98cd591 | ||
|
|
0f9ba21b05 | ||
|
|
834f8e7046 | ||
|
|
32e3399334 | ||
|
|
2d8698a218 | ||
|
|
454fb883a2 | ||
|
|
6f4a6ab8ea | ||
|
|
9c4b6f36f1 | ||
|
|
78886b1e67 | ||
|
|
d9debaf032 | ||
|
|
d4360d6347 | ||
|
|
175b1785c0 | ||
|
|
c8740c0171 | ||
|
|
91ee895a74 | ||
|
|
a045e46571 | ||
|
|
44eaa65c3b | ||
|
|
0a22af7b14 | ||
|
|
b54702ab08 | ||
|
|
c4fdcfc5d1 | ||
|
|
cb8117e8df | ||
|
|
5a218d5056 | ||
|
|
8dbc5cf9c6 | ||
|
|
71e81615a3 | ||
|
|
611d37da04 | ||
|
|
0e799a3857 | ||
|
|
b91d6e2bfa | ||
|
|
ea16ad7e94 | ||
|
|
ba6eb54552 | ||
|
|
f705e7683b | ||
|
|
dc996adb20 | ||
|
|
a64c638ccc | ||
|
|
359c067279 | ||
|
|
66a746e297 | ||
|
|
a4d43ee24b | ||
|
|
f7793a70a9 | ||
|
|
ceba3d31fb | ||
|
|
eecc08edde | ||
|
|
eb19aadc75 | ||
|
|
06c81e69b9 | ||
|
|
3dc3d4a639 | ||
|
|
94c59c1e3d | ||
|
|
4d2205853a | ||
|
|
751772b87a | ||
|
|
76e30869e1 | ||
|
|
3edc9fe9eb | ||
|
|
616c62703e | ||
|
|
ecd56917e7 | ||
|
|
e22c9cae91 | ||
|
|
29ddb6e1b9 | ||
|
|
2ff90e2ff0 | ||
|
|
04ecc128a2 | ||
|
|
87d1d3423b | ||
|
|
4818192a2a | ||
|
|
965dd97f54 | ||
|
|
195b74926c | ||
|
|
2120db12b2 | ||
|
|
ed563fef28 | ||
|
|
0d31a8e3f1 | ||
|
|
b8354b974b | ||
|
|
034c1e289d | ||
|
|
f31605a3de | ||
|
|
e7cc75c74d | ||
|
|
4b79d5e4e8 | ||
|
|
34854915b3 | ||
|
|
af6f34b529 | ||
|
|
fb82a2b896 | ||
|
|
5b464938b6 | ||
|
|
81f954890d | ||
|
|
0e2bbcec62 | ||
|
|
fdd339f525 | ||
|
|
8cf7d6a83d | ||
|
|
58a5008718 | ||
|
|
c44a8df55d | ||
|
|
ff1494c519 | ||
|
|
b8ce8fd852 | ||
|
|
75e7454a5f | ||
|
|
2558ea8931 | ||
|
|
ac0f47a4b2 | ||
|
|
4f16129869 | ||
|
|
64a8037fdd | ||
|
|
7502ba1bc8 | ||
|
|
33a04697ef | ||
|
|
b70a5c0cdb | ||
|
|
9443ae9f40 | ||
|
|
220c2a4102 | ||
|
|
e9914eb301 | ||
|
|
934512d09c | ||
|
|
9102c90986 | ||
|
|
c3e74219c4 | ||
|
|
13c9d7bc2d | ||
|
|
119b539586 | ||
|
|
29a5c180f0 | ||
|
|
7906602291 | ||
|
|
6dafe773ff | ||
|
|
25bc28a1be | ||
|
|
4c561c7fa0 | ||
|
|
95b3e78573 | ||
|
|
63a345bc93 | ||
|
|
e093a172cb | ||
|
|
4b01f8934b | ||
|
|
bc116b45b5 | ||
|
|
a059960b9e | ||
|
|
7770966fed | ||
|
|
d7adcf6c69 | ||
|
|
04a364dcc3 | ||
|
|
db83ac7eaa | ||
|
|
3ca9dddf61 | ||
|
|
bf74f53ca7 | ||
|
|
9d67efb4a4 | ||
|
|
3a39b9f440 | ||
|
|
27f7aab375 | ||
|
|
337da0c467 | ||
|
|
f56b3560c4 | ||
|
|
02dfe11ce6 | ||
|
|
83d06beb70 | ||
|
|
a8cfc059c8 | ||
|
|
1614b2bab0 | ||
|
|
4bdec0d214 | ||
|
|
6a7d7e7c2b | ||
|
|
30d4674657 | ||
|
|
9f961f95f8 | ||
|
|
bab99a26ec | ||
|
|
9a7fecd269 | ||
|
|
a8dc0d449b | ||
|
|
a0381f76bf | ||
|
|
6102f66daa | ||
|
|
c6134d162d | ||
|
|
2046f9b9de | ||
|
|
ac3ba594a4 | ||
|
|
22df25a480 | ||
|
|
8b30c7f02e | ||
|
|
757cdddc7c | ||
|
|
df95e99680 | ||
|
|
5a6d544db7 | ||
|
|
16117d329c | ||
|
|
e90da18ada | ||
|
|
a08d80e1cc | ||
|
|
6258175922 | ||
|
|
15736777a0 | ||
|
|
75915e8a94 | ||
|
|
9bde0ae4ea | ||
|
|
0c802d1f86 | ||
|
|
b7a96c6466 | ||
|
|
4b645a82c7 | ||
|
|
d599b77b6f | ||
|
|
26e93dc8c1 | ||
|
|
a4c9a8491b | ||
|
|
70ee636d87 | ||
|
|
b35f6dbb03 | ||
|
|
67d9e24d8f | ||
|
|
3903fda6ca | ||
|
|
441e46ebaa | ||
|
|
1f4260f359 | ||
|
|
dc0bf8ad4e | ||
|
|
102e326e6a | ||
|
|
2b25bf6f3b | ||
|
|
f93280696d | ||
|
|
1787391b07 | ||
|
|
a74a8ee483 | ||
|
|
7fa5405cb7 | ||
|
|
6725ddcc41 | ||
|
|
bce941db3f | ||
|
|
6d926048ec | ||
|
|
5335c973b4 | ||
|
|
15c3e5c96e | ||
|
|
a5d5904969 | ||
|
|
598758b991 | ||
|
|
9926e23bc8 | ||
|
|
5d3264bc63 | ||
|
|
d71f819f95 | ||
|
|
ee13509760 | ||
|
|
82d7bb1f32 | ||
|
|
cdfda508d8 | ||
|
|
da941e584f | ||
|
|
65874d7b96 | ||
|
|
ac9b8f405c | ||
|
|
8d1419a12e | ||
|
|
04f7a7d301 | ||
|
|
c10d2a1493 | ||
|
|
97bbf79ffd | ||
|
|
f7b01ae53d | ||
|
|
d704e1dbba | ||
|
|
ef2ff5e093 | ||
|
|
7caed3b0db | ||
|
|
45641d0754 | ||
|
|
4b1d08ba99 | ||
|
|
160fa99ba4 | ||
|
|
d2a5ab49ed | ||
|
|
c6404d8917 | ||
|
|
7113807f12 | ||
|
|
be711215e8 | ||
|
|
7e3b404240 | ||
|
|
e86901ca20 | ||
|
|
bdfa61c8b2 | ||
|
|
2cc36787f5 | ||
|
|
448ac61b48 | ||
|
|
753f6394f7 | ||
|
|
b1faf65934 | ||
|
|
09f478bd74 | ||
|
|
a0497feddd | ||
|
|
789693bde9 | ||
|
|
1fe933e4ea | ||
|
|
724b4b5a70 | ||
|
|
1778a56146 | ||
|
|
744865fcb2 | ||
|
|
7f8c8b448d | ||
|
|
a67c53826d | ||
|
|
14b131e850 | ||
|
|
9b55a52b85 | ||
|
|
db1d10e80f | ||
|
|
1be576966f | ||
|
|
b97e792c5f | ||
|
|
8dec674cc3 | ||
|
|
f784c03746 | ||
|
|
148e172fe8 | ||
|
|
56ae86646f | ||
|
|
1d2b6fdfa2 | ||
|
|
4fc75beed4 | ||
|
|
3b3bc0c4bf | ||
|
|
910faab88e | ||
|
|
f184d763ad | ||
|
|
a91d42634d | ||
|
|
f517ef3616 | ||
|
|
e99507ddcf | ||
|
|
d2cacf1945 | ||
|
|
448ac1405b | ||
|
|
6ad21ce885 |
@@ -26,6 +26,13 @@ DB_DATABASE=database_database
|
||||
DB_USERNAME=database_username
|
||||
DB_PASSWORD=database_user_password
|
||||
|
||||
# Storage system to use
|
||||
# By default files are stored on the local filesystem, with images being placed in
|
||||
# public web space so they can be efficiently served directly by the web-server.
|
||||
# For other options with different security levels & considerations, refer to:
|
||||
# https://www.bookstackapp.com/docs/admin/upload-config/
|
||||
STORAGE_TYPE=local
|
||||
|
||||
# Mail system to use
|
||||
# Can be 'smtp' or 'sendmail'
|
||||
MAIL_DRIVER=smtp
|
||||
|
||||
23
.github/translators.txt
vendored
23
.github/translators.txt
vendored
@@ -177,7 +177,7 @@ Alexander Predl (Harveyhase68) :: German
|
||||
Rem (Rem9000) :: Dutch
|
||||
Michał Stelmach (stelmach-web) :: Polish
|
||||
arniom :: French
|
||||
REMOVED_USER :: French; Dutch; Portuguese, Brazilian; Portuguese; Turkish;
|
||||
REMOVED_USER :: French; German; Dutch; Portuguese, Brazilian; Portuguese; Turkish;
|
||||
林祖年 (contagion) :: Chinese Traditional
|
||||
Siamak Guodarzi (siamakgoudarzi88) :: Persian
|
||||
Lis Maestrelo (lismtrl) :: Portuguese, Brazilian
|
||||
@@ -222,7 +222,7 @@ SmokingCrop :: Dutch
|
||||
Maciej Lebiest (Szwendacz) :: Polish
|
||||
DiscordDigital :: German; German Informal
|
||||
Gábor Marton (dodver) :: Hungarian
|
||||
Jasell :: Swedish
|
||||
Jakob Åsell (Jasell) :: Swedish
|
||||
Ghost_chu (ghostchu) :: Chinese Simplified
|
||||
Ravid Shachar (ravidshachar) :: Hebrew
|
||||
Helga Guchshenskaya (guchshenskaya) :: Russian
|
||||
@@ -509,3 +509,22 @@ iamwhoiamwhoami :: Swedish
|
||||
Grogui :: French
|
||||
MrCharlesIII :: Arabic
|
||||
David Olsen (dawin) :: Danish
|
||||
ltnzr :: French
|
||||
Frank Holler (holler.frank) :: German; German Informal
|
||||
Korab Arifi (korabidev) :: Albanian
|
||||
Petr Husák (petrhusak) :: Czech
|
||||
Bernardo Maia (bernardo.bmaia2) :: Portuguese, Brazilian
|
||||
Amr (amr3k) :: Arabic
|
||||
Tahsin Ahmed (tahsinahmed2012) :: Bengali
|
||||
bojan_che :: Serbian (Cyrillic)
|
||||
setiawan setiawan (culture.setiawan) :: Indonesian
|
||||
Donald Mac Kenzie (kiuman) :: Norwegian Bokmal
|
||||
Gabriel Silver (GabrielBSilver) :: Hebrew
|
||||
Tomas Darius Davainis (Tomasdd) :: Lithuanian
|
||||
CriedHero :: Chinese Simplified
|
||||
Henrik (henrik2105) :: Norwegian Bokmal
|
||||
FoW (fofwisdom) :: Korean
|
||||
serinf-lauza :: French
|
||||
Diyan Nikolaev (nikolaev.diyan) :: Bulgarian
|
||||
Shadluk Avan (quldosh) :: Uzbek
|
||||
Marci (MartonPoto) :: Hungarian
|
||||
|
||||
2
.github/workflows/test-migrations.yml
vendored
2
.github/workflows/test-migrations.yml
vendored
@@ -16,7 +16,7 @@ jobs:
|
||||
runs-on: ubuntu-24.04
|
||||
strategy:
|
||||
matrix:
|
||||
php: ['8.2', '8.3', '8.4']
|
||||
php: ['8.2', '8.3', '8.4', '8.5']
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
|
||||
2
.github/workflows/test-php.yml
vendored
2
.github/workflows/test-php.yml
vendored
@@ -16,7 +16,7 @@ jobs:
|
||||
runs-on: ubuntu-24.04
|
||||
strategy:
|
||||
matrix:
|
||||
php: ['8.2', '8.3', '8.4']
|
||||
php: ['8.2', '8.3', '8.4', '8.5']
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
|
||||
6
.gitignore
vendored
6
.gitignore
vendored
@@ -8,10 +8,10 @@ Homestead.yaml
|
||||
.idea
|
||||
npm-debug.log
|
||||
yarn-error.log
|
||||
/public/dist
|
||||
/public/dist/*.map
|
||||
/public/plugins
|
||||
/public/css
|
||||
/public/js
|
||||
/public/css/*.map
|
||||
/public/js/*.map
|
||||
/public/bower
|
||||
/public/build/
|
||||
/public/favicon.ico
|
||||
|
||||
2
LICENSE
2
LICENSE
@@ -1,6 +1,6 @@
|
||||
The MIT License (MIT)
|
||||
|
||||
Copyright (c) 2015-2025, Dan Brown and the BookStack project contributors.
|
||||
Copyright (c) 2015-2026, Dan Brown and the BookStack project contributors.
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
|
||||
@@ -9,11 +9,9 @@ use Illuminate\Http\Request;
|
||||
|
||||
class OidcController extends Controller
|
||||
{
|
||||
protected OidcService $oidcService;
|
||||
|
||||
public function __construct(OidcService $oidcService)
|
||||
{
|
||||
$this->oidcService = $oidcService;
|
||||
public function __construct(
|
||||
protected OidcService $oidcService
|
||||
) {
|
||||
$this->middleware('guard:oidc');
|
||||
}
|
||||
|
||||
@@ -30,7 +28,7 @@ class OidcController extends Controller
|
||||
return redirect('/login');
|
||||
}
|
||||
|
||||
session()->flash('oidc_state', $loginDetails['state']);
|
||||
session()->put('oidc_state', time() . ':' . $loginDetails['state']);
|
||||
|
||||
return redirect($loginDetails['url']);
|
||||
}
|
||||
@@ -41,10 +39,16 @@ class OidcController extends Controller
|
||||
*/
|
||||
public function callback(Request $request)
|
||||
{
|
||||
$storedState = session()->pull('oidc_state');
|
||||
$responseState = $request->query('state');
|
||||
$splitState = explode(':', session()->pull('oidc_state', ':'), 2);
|
||||
if (count($splitState) !== 2) {
|
||||
$splitState = [null, null];
|
||||
}
|
||||
|
||||
if ($storedState !== $responseState) {
|
||||
[$storedStateTime, $storedState] = $splitState;
|
||||
$threeMinutesAgo = time() - 3 * 60;
|
||||
|
||||
if (!$storedState || $storedState !== $responseState || intval($storedStateTime) < $threeMinutesAgo) {
|
||||
$this->showErrorNotification(trans('errors.oidc_fail_authed', ['system' => config('oidc.name')]));
|
||||
|
||||
return redirect('/login');
|
||||
@@ -62,7 +66,7 @@ class OidcController extends Controller
|
||||
}
|
||||
|
||||
/**
|
||||
* Log the user out then start the OIDC RP-initiated logout process.
|
||||
* Log the user out, then start the OIDC RP-initiated logout process.
|
||||
*/
|
||||
public function logout()
|
||||
{
|
||||
|
||||
@@ -11,7 +11,6 @@ class MfaSession
|
||||
*/
|
||||
public function isRequiredForUser(User $user): bool
|
||||
{
|
||||
// TODO - Test both these cases
|
||||
return $user->mfaValues()->exists() || $this->userRoleEnforcesMfa($user);
|
||||
}
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ namespace BookStack\Access\Mfa;
|
||||
|
||||
use BookStack\Users\Models\User;
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
/**
|
||||
@@ -16,6 +17,8 @@ use Illuminate\Database\Eloquent\Model;
|
||||
*/
|
||||
class MfaValue extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
protected static $unguarded = true;
|
||||
|
||||
const METHOD_TOTP = 'totp';
|
||||
|
||||
@@ -14,10 +14,9 @@ use PragmaRX\Google2FA\Support\Constants;
|
||||
|
||||
class TotpService
|
||||
{
|
||||
protected $google2fa;
|
||||
|
||||
public function __construct(Google2FA $google2fa)
|
||||
{
|
||||
public function __construct(
|
||||
protected Google2FA $google2fa
|
||||
) {
|
||||
$this->google2fa = $google2fa;
|
||||
// Use SHA1 as a default, Personal testing of other options in 2021 found
|
||||
// many apps lack support for other algorithms yet still will scan
|
||||
@@ -35,7 +34,7 @@ class TotpService
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a TOTP URL from secret key.
|
||||
* Generate a TOTP URL from a secret key.
|
||||
*/
|
||||
public function generateUrl(string $secret, User $user): string
|
||||
{
|
||||
|
||||
@@ -5,18 +5,23 @@ namespace BookStack\Access;
|
||||
use BookStack\Activity\Models\Loggable;
|
||||
use BookStack\App\Model;
|
||||
use BookStack\Users\Models\User;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
/**
|
||||
* Class SocialAccount.
|
||||
*
|
||||
* @property string $driver
|
||||
* @property User $user
|
||||
*/
|
||||
class SocialAccount extends Model implements Loggable
|
||||
{
|
||||
protected $fillable = ['user_id', 'driver', 'driver_id', 'timestamps'];
|
||||
use HasFactory;
|
||||
|
||||
public function user()
|
||||
protected $fillable = ['user_id', 'driver', 'driver_id'];
|
||||
|
||||
/**
|
||||
* @return BelongsTo<User, $this>
|
||||
*/
|
||||
public function user(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class);
|
||||
}
|
||||
|
||||
@@ -4,10 +4,11 @@ namespace BookStack\Activity;
|
||||
|
||||
use BookStack\Activity\Models\Comment;
|
||||
use BookStack\Entities\Models\Entity;
|
||||
use BookStack\Entities\Models\Page;
|
||||
use BookStack\Exceptions\NotifyException;
|
||||
use BookStack\Exceptions\PrettyException;
|
||||
use BookStack\Facades\Activity as ActivityService;
|
||||
use BookStack\Util\HtmlDescriptionFilter;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
|
||||
class CommentRepo
|
||||
{
|
||||
@@ -19,11 +20,46 @@ class CommentRepo
|
||||
return Comment::query()->findOrFail($id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a comment by ID, ensuring it is visible to the user based upon access to the page
|
||||
* which the comment is attached to.
|
||||
*/
|
||||
public function getVisibleById(int $id): Comment
|
||||
{
|
||||
return $this->getQueryForVisible()->findOrFail($id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Start a query for comments visible to the user.
|
||||
* @return Builder<Comment>
|
||||
*/
|
||||
public function getQueryForVisible(): Builder
|
||||
{
|
||||
return Comment::query()->scopes('visible');
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new comment on an entity.
|
||||
*/
|
||||
public function create(Entity $entity, string $html, ?int $parentId, string $contentRef): Comment
|
||||
{
|
||||
// Prevent comments being added to draft pages
|
||||
if ($entity instanceof Page && $entity->draft) {
|
||||
throw new \Exception(trans('errors.cannot_add_comment_to_draft'));
|
||||
}
|
||||
|
||||
// Validate parent ID
|
||||
if ($parentId !== null) {
|
||||
$parentCommentExists = Comment::query()
|
||||
->where('commentable_id', '=', $entity->id)
|
||||
->where('commentable_type', '=', $entity->getMorphClass())
|
||||
->where('local_id', '=', $parentId)
|
||||
->exists();
|
||||
if (!$parentCommentExists) {
|
||||
$parentId = null;
|
||||
}
|
||||
}
|
||||
|
||||
$userId = user()->id;
|
||||
$comment = new Comment();
|
||||
|
||||
@@ -38,6 +74,7 @@ class CommentRepo
|
||||
ActivityService::add(ActivityType::COMMENT_CREATE, $comment);
|
||||
ActivityService::add(ActivityType::COMMENTED_ON, $entity);
|
||||
|
||||
$comment->refresh()->unsetRelations();
|
||||
return $comment;
|
||||
}
|
||||
|
||||
@@ -59,7 +96,7 @@ class CommentRepo
|
||||
/**
|
||||
* Archive an existing comment.
|
||||
*/
|
||||
public function archive(Comment $comment): Comment
|
||||
public function archive(Comment $comment, bool $log = true): Comment
|
||||
{
|
||||
if ($comment->parent_id) {
|
||||
throw new NotifyException('Only top-level comments can be archived.', '/', 400);
|
||||
@@ -68,7 +105,9 @@ class CommentRepo
|
||||
$comment->archived = true;
|
||||
$comment->save();
|
||||
|
||||
ActivityService::add(ActivityType::COMMENT_UPDATE, $comment);
|
||||
if ($log) {
|
||||
ActivityService::add(ActivityType::COMMENT_UPDATE, $comment);
|
||||
}
|
||||
|
||||
return $comment;
|
||||
}
|
||||
@@ -76,7 +115,7 @@ class CommentRepo
|
||||
/**
|
||||
* Un-archive an existing comment.
|
||||
*/
|
||||
public function unarchive(Comment $comment): Comment
|
||||
public function unarchive(Comment $comment, bool $log = true): Comment
|
||||
{
|
||||
if ($comment->parent_id) {
|
||||
throw new NotifyException('Only top-level comments can be un-archived.', '/', 400);
|
||||
@@ -85,7 +124,9 @@ class CommentRepo
|
||||
$comment->archived = false;
|
||||
$comment->save();
|
||||
|
||||
ActivityService::add(ActivityType::COMMENT_UPDATE, $comment);
|
||||
if ($log) {
|
||||
ActivityService::add(ActivityType::COMMENT_UPDATE, $comment);
|
||||
}
|
||||
|
||||
return $comment;
|
||||
}
|
||||
|
||||
148
app/Activity/Controllers/CommentApiController.php
Normal file
148
app/Activity/Controllers/CommentApiController.php
Normal file
@@ -0,0 +1,148 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace BookStack\Activity\Controllers;
|
||||
|
||||
use BookStack\Activity\CommentRepo;
|
||||
use BookStack\Activity\Models\Comment;
|
||||
use BookStack\Entities\Queries\PageQueries;
|
||||
use BookStack\Http\ApiController;
|
||||
use BookStack\Permissions\Permission;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Response;
|
||||
|
||||
/**
|
||||
* The comment data model has a 'local_id' property, which is a unique integer ID
|
||||
* scoped to the page which the comment is on. The 'parent_id' is used for replies
|
||||
* and refers to the 'local_id' of the parent comment on the same page, not the main
|
||||
* globally unique 'id'.
|
||||
*
|
||||
* If you want to get all comments for a page in a tree-like structure, as reflected in
|
||||
* the UI, then that is provided on pages-read API responses.
|
||||
*/
|
||||
class CommentApiController extends ApiController
|
||||
{
|
||||
protected array $rules = [
|
||||
'create' => [
|
||||
'page_id' => ['required', 'integer'],
|
||||
'reply_to' => ['nullable', 'integer'],
|
||||
'html' => ['required', 'string'],
|
||||
'content_ref' => ['string'],
|
||||
],
|
||||
'update' => [
|
||||
'html' => ['string'],
|
||||
'archived' => ['boolean'],
|
||||
]
|
||||
];
|
||||
|
||||
public function __construct(
|
||||
protected CommentRepo $commentRepo,
|
||||
protected PageQueries $pageQueries,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a listing of comments visible to the user.
|
||||
*/
|
||||
public function list(): JsonResponse
|
||||
{
|
||||
$query = $this->commentRepo->getQueryForVisible();
|
||||
|
||||
return $this->apiListingResponse($query, [
|
||||
'id', 'commentable_id', 'commentable_type', 'parent_id', 'local_id', 'content_ref', 'created_by', 'updated_by', 'created_at', 'updated_at'
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new comment on a page.
|
||||
* If commenting as a reply to an existing comment, the 'reply_to' parameter
|
||||
* should be provided, set to the 'local_id' of the comment being replied to.
|
||||
*/
|
||||
public function create(Request $request): JsonResponse
|
||||
{
|
||||
$this->checkPermission(Permission::CommentCreateAll);
|
||||
|
||||
$input = $this->validate($request, $this->rules()['create']);
|
||||
$page = $this->pageQueries->findVisibleByIdOrFail($input['page_id']);
|
||||
|
||||
$comment = $this->commentRepo->create(
|
||||
$page,
|
||||
$input['html'],
|
||||
$input['reply_to'] ?? null,
|
||||
$input['content_ref'] ?? '',
|
||||
);
|
||||
|
||||
return response()->json($comment);
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the details of a single comment, along with its direct replies.
|
||||
*/
|
||||
public function read(string $id): JsonResponse
|
||||
{
|
||||
$comment = $this->commentRepo->getVisibleById(intval($id));
|
||||
$comment->load('createdBy', 'updatedBy');
|
||||
|
||||
$replies = $this->commentRepo->getQueryForVisible()
|
||||
->where('parent_id', '=', $comment->local_id)
|
||||
->where('commentable_id', '=', $comment->commentable_id)
|
||||
->where('commentable_type', '=', $comment->commentable_type)
|
||||
->get();
|
||||
|
||||
/** @var Comment[] $toProcess */
|
||||
$toProcess = [$comment, ...$replies];
|
||||
foreach ($toProcess as $commentToProcess) {
|
||||
$commentToProcess->setAttribute('html', $commentToProcess->safeHtml());
|
||||
$commentToProcess->makeVisible('html');
|
||||
}
|
||||
|
||||
$comment->setRelation('replies', $replies);
|
||||
|
||||
return response()->json($comment);
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Update the content or archived status of an existing comment.
|
||||
*
|
||||
* Only provide a new archived status if needing to actively change the archive state.
|
||||
* Only top-level comments (non-replies) can be archived or unarchived.
|
||||
*/
|
||||
public function update(Request $request, string $id): JsonResponse
|
||||
{
|
||||
$comment = $this->commentRepo->getVisibleById(intval($id));
|
||||
$this->checkOwnablePermission(Permission::CommentUpdate, $comment);
|
||||
|
||||
$input = $this->validate($request, $this->rules()['update']);
|
||||
$hasHtml = isset($input['html']);
|
||||
|
||||
if (isset($input['archived'])) {
|
||||
if ($input['archived']) {
|
||||
$this->commentRepo->archive($comment, !$hasHtml);
|
||||
} else {
|
||||
$this->commentRepo->unarchive($comment, !$hasHtml);
|
||||
}
|
||||
}
|
||||
|
||||
if ($hasHtml) {
|
||||
$comment = $this->commentRepo->update($comment, $input['html']);
|
||||
}
|
||||
|
||||
return response()->json($comment);
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a single comment from the system.
|
||||
*/
|
||||
public function delete(string $id): Response
|
||||
{
|
||||
$comment = $this->commentRepo->getVisibleById(intval($id));
|
||||
$this->checkOwnablePermission(Permission::CommentDelete, $comment);
|
||||
|
||||
$this->commentRepo->delete($comment);
|
||||
|
||||
return response('', 204);
|
||||
}
|
||||
}
|
||||
@@ -22,7 +22,7 @@ class CommentController extends Controller
|
||||
/**
|
||||
* Save a new comment for a Page.
|
||||
*
|
||||
* @throws ValidationException
|
||||
* @throws ValidationException|\Exception
|
||||
*/
|
||||
public function savePageComment(Request $request, int $pageId)
|
||||
{
|
||||
@@ -37,11 +37,6 @@ class CommentController extends Controller
|
||||
return response('Not found', 404);
|
||||
}
|
||||
|
||||
// Prevent adding comments to draft pages
|
||||
if ($page->draft) {
|
||||
return $this->jsonError(trans('errors.cannot_add_comment_to_draft'), 400);
|
||||
}
|
||||
|
||||
// Create a new comment.
|
||||
$this->checkPermission(Permission::CommentCreateAll);
|
||||
$contentRef = $input['content_ref'] ?? '';
|
||||
|
||||
@@ -6,6 +6,7 @@ use BookStack\App\Model;
|
||||
use BookStack\Entities\Models\Entity;
|
||||
use BookStack\Permissions\Models\JointPermission;
|
||||
use BookStack\Users\Models\User;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
use Illuminate\Database\Eloquent\Relations\MorphTo;
|
||||
@@ -24,6 +25,8 @@ use Illuminate\Support\Str;
|
||||
*/
|
||||
class Activity extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
/**
|
||||
* Get the loggable model related to this activity.
|
||||
* Currently only used for entities (previously entity_[id/type] columns).
|
||||
|
||||
@@ -3,22 +3,24 @@
|
||||
namespace BookStack\Activity\Models;
|
||||
|
||||
use BookStack\App\Model;
|
||||
use BookStack\Permissions\Models\JointPermission;
|
||||
use BookStack\Permissions\PermissionApplicator;
|
||||
use BookStack\Users\Models\HasCreatorAndUpdater;
|
||||
use BookStack\Users\Models\OwnableInterface;
|
||||
use BookStack\Users\Models\User;
|
||||
use BookStack\Util\HtmlContentFilter;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
use Illuminate\Database\Eloquent\Relations\MorphTo;
|
||||
|
||||
/**
|
||||
* @property int $id
|
||||
* @property string $text - Deprecated & now unused (#4821)
|
||||
* @property string $html
|
||||
* @property int|null $parent_id - Relates to local_id, not id
|
||||
* @property int $local_id
|
||||
* @property string $entity_type
|
||||
* @property int $entity_id
|
||||
* @property string $commentable_type
|
||||
* @property int $commentable_id
|
||||
* @property string $content_ref
|
||||
* @property bool $archived
|
||||
*/
|
||||
@@ -28,13 +30,30 @@ class Comment extends Model implements Loggable, OwnableInterface
|
||||
use HasCreatorAndUpdater;
|
||||
|
||||
protected $fillable = ['parent_id'];
|
||||
protected $hidden = ['html'];
|
||||
|
||||
protected $casts = [
|
||||
'archived' => 'boolean',
|
||||
];
|
||||
|
||||
/**
|
||||
* Get the entity that this comment belongs to.
|
||||
*/
|
||||
public function entity(): MorphTo
|
||||
{
|
||||
return $this->morphTo('entity');
|
||||
// We specifically define null here to avoid the different name (commentable)
|
||||
// being used by Laravel eager loading instead of the method name, which it was doing
|
||||
// in some scenarios like when deserialized when going through the queue system.
|
||||
// So we instead specify the type and id column names to use.
|
||||
// Related to:
|
||||
// https://github.com/laravel/framework/pull/24815
|
||||
// https://github.com/laravel/framework/issues/27342
|
||||
// https://github.com/laravel/framework/issues/47953
|
||||
// (and probably more)
|
||||
|
||||
// Ultimately, we could just align the method name to 'commentable' but that would be a potential
|
||||
// breaking change and not really worthwhile in a patch due to the risk of creating extra problems.
|
||||
return $this->morphTo(null, 'commentable_type', 'commentable_id');
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -44,8 +63,8 @@ class Comment extends Model implements Loggable, OwnableInterface
|
||||
public function parent(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Comment::class, 'parent_id', 'local_id', 'parent')
|
||||
->where('entity_type', '=', $this->entity_type)
|
||||
->where('entity_id', '=', $this->entity_id);
|
||||
->where('commentable_type', '=', $this->commentable_type)
|
||||
->where('commentable_id', '=', $this->commentable_id);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -58,11 +77,27 @@ class Comment extends Model implements Loggable, OwnableInterface
|
||||
|
||||
public function logDescriptor(): string
|
||||
{
|
||||
return "Comment #{$this->local_id} (ID: {$this->id}) for {$this->entity_type} (ID: {$this->entity_id})";
|
||||
return "Comment #{$this->local_id} (ID: {$this->id}) for {$this->commentable_type} (ID: {$this->commentable_id})";
|
||||
}
|
||||
|
||||
public function safeHtml(): string
|
||||
{
|
||||
return HtmlContentFilter::removeScriptsFromHtmlString($this->html ?? '');
|
||||
}
|
||||
|
||||
public function jointPermissions(): HasMany
|
||||
{
|
||||
return $this->hasMany(JointPermission::class, 'entity_id', 'commentable_id')
|
||||
->whereColumn('joint_permissions.entity_type', '=', 'comments.commentable_type');
|
||||
}
|
||||
|
||||
/**
|
||||
* Scope the query to just the comments visible to the user based upon the
|
||||
* user visibility of what has been commented on.
|
||||
*/
|
||||
public function scopeVisible(Builder $query): Builder
|
||||
{
|
||||
return app()->make(PermissionApplicator::class)
|
||||
->restrictEntityRelationQuery($query, 'comments', 'commentable_id', 'commentable_type');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,11 +4,14 @@ namespace BookStack\Activity\Models;
|
||||
|
||||
use BookStack\App\Model;
|
||||
use BookStack\Permissions\Models\JointPermission;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
use Illuminate\Database\Eloquent\Relations\MorphTo;
|
||||
|
||||
class Favourite extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
protected $fillable = ['user_id'];
|
||||
|
||||
/**
|
||||
|
||||
20
app/Activity/Models/MentionHistory.php
Normal file
20
app/Activity/Models/MentionHistory.php
Normal file
@@ -0,0 +1,20 @@
|
||||
<?php
|
||||
|
||||
namespace BookStack\Activity\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Support\Carbon;
|
||||
|
||||
/**
|
||||
* @property int $id
|
||||
* @property string $mentionable_type
|
||||
* @property int $mentionable_id
|
||||
* @property int $from_user_id
|
||||
* @property int $to_user_id
|
||||
* @property Carbon $created_at
|
||||
* @property Carbon $updated_at
|
||||
*/
|
||||
class MentionHistory extends Model
|
||||
{
|
||||
protected $table = 'mention_history';
|
||||
}
|
||||
@@ -5,6 +5,7 @@ namespace BookStack\Activity\Models;
|
||||
use BookStack\Activity\WatchLevels;
|
||||
use BookStack\Permissions\Models\JointPermission;
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
use Illuminate\Database\Eloquent\Relations\MorphTo;
|
||||
@@ -20,6 +21,8 @@ use Illuminate\Database\Eloquent\Relations\MorphTo;
|
||||
*/
|
||||
class Watch extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
protected $guarded = [];
|
||||
|
||||
public function watchable(): MorphTo
|
||||
|
||||
@@ -20,6 +20,7 @@ abstract class BaseNotificationHandler implements NotificationHandler
|
||||
{
|
||||
$users = User::query()->whereIn('id', array_unique($userIds))->get();
|
||||
|
||||
/** @var User $user */
|
||||
foreach ($users as $user) {
|
||||
// Prevent sending to the user that initiated the activity
|
||||
if ($user->id === $initiator->id) {
|
||||
|
||||
@@ -27,7 +27,7 @@ class CommentCreationNotificationHandler extends BaseNotificationHandler
|
||||
$watcherIds = $watchers->getWatcherUserIds();
|
||||
|
||||
// Page owner if user preferences allow
|
||||
if (!$watchers->isUserIgnoring($page->owned_by) && $page->ownedBy) {
|
||||
if ($page->owned_by && !$watchers->isUserIgnoring($page->owned_by) && $page->ownedBy) {
|
||||
$userNotificationPrefs = new UserNotificationPreferences($page->ownedBy);
|
||||
if ($userNotificationPrefs->notifyOnOwnPageComments()) {
|
||||
$watcherIds[] = $page->owned_by;
|
||||
@@ -36,7 +36,7 @@ class CommentCreationNotificationHandler extends BaseNotificationHandler
|
||||
|
||||
// Parent comment creator if preferences allow
|
||||
$parentComment = $detail->parent()->first();
|
||||
if ($parentComment && !$watchers->isUserIgnoring($parentComment->created_by) && $parentComment->createdBy) {
|
||||
if ($parentComment && $parentComment->created_by && !$watchers->isUserIgnoring($parentComment->created_by) && $parentComment->createdBy) {
|
||||
$parentCommenterNotificationsPrefs = new UserNotificationPreferences($parentComment->createdBy);
|
||||
if ($parentCommenterNotificationsPrefs->notifyOnCommentReplies()) {
|
||||
$watcherIds[] = $parentComment->created_by;
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
<?php
|
||||
|
||||
namespace BookStack\Activity\Notifications\Handlers;
|
||||
|
||||
use BookStack\Activity\ActivityType;
|
||||
use BookStack\Activity\Models\Activity;
|
||||
use BookStack\Activity\Models\Comment;
|
||||
use BookStack\Activity\Models\Loggable;
|
||||
use BookStack\Activity\Models\MentionHistory;
|
||||
use BookStack\Activity\Notifications\Messages\CommentMentionNotification;
|
||||
use BookStack\Activity\Tools\MentionParser;
|
||||
use BookStack\Entities\Models\Page;
|
||||
use BookStack\Settings\UserNotificationPreferences;
|
||||
use BookStack\Users\Models\User;
|
||||
use Illuminate\Database\Eloquent\Collection;
|
||||
use Illuminate\Support\Carbon;
|
||||
|
||||
class CommentMentionNotificationHandler extends BaseNotificationHandler
|
||||
{
|
||||
public function handle(Activity $activity, Loggable|string $detail, User $user): void
|
||||
{
|
||||
if (!($detail instanceof Comment) || !($detail->entity instanceof Page)) {
|
||||
throw new \InvalidArgumentException("Detail for comment mention notifications must be a comment on a page");
|
||||
}
|
||||
|
||||
/** @var Page $page */
|
||||
$page = $detail->entity;
|
||||
|
||||
$parser = new MentionParser();
|
||||
$mentionedUserIds = $parser->parseUserIdsFromHtml($detail->html);
|
||||
$realMentionedUsers = User::whereIn('id', $mentionedUserIds)->get();
|
||||
|
||||
$receivingNotifications = $realMentionedUsers->filter(function (User $user) {
|
||||
$prefs = new UserNotificationPreferences($user);
|
||||
return $prefs->notifyOnCommentMentions();
|
||||
});
|
||||
$receivingNotificationsUserIds = $receivingNotifications->pluck('id')->toArray();
|
||||
|
||||
$userMentionsToLog = $realMentionedUsers;
|
||||
|
||||
// When an edit, we check our history to see if we've already notified the user about this comment before
|
||||
// so that we can filter them out to avoid double notifications.
|
||||
if ($activity->type === ActivityType::COMMENT_UPDATE) {
|
||||
$previouslyNotifiedUserIds = $this->getPreviouslyNotifiedUserIds($detail);
|
||||
$receivingNotificationsUserIds = array_values(array_diff($receivingNotificationsUserIds, $previouslyNotifiedUserIds));
|
||||
$userMentionsToLog = $userMentionsToLog->filter(function (User $user) use ($previouslyNotifiedUserIds) {
|
||||
return !in_array($user->id, $previouslyNotifiedUserIds);
|
||||
});
|
||||
}
|
||||
|
||||
$this->logMentions($userMentionsToLog, $detail, $user);
|
||||
$this->sendNotificationToUserIds(CommentMentionNotification::class, $receivingNotificationsUserIds, $user, $detail, $page);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param Collection<User> $mentionedUsers
|
||||
*/
|
||||
protected function logMentions(Collection $mentionedUsers, Comment $comment, User $fromUser): void
|
||||
{
|
||||
$mentions = [];
|
||||
$now = Carbon::now();
|
||||
|
||||
foreach ($mentionedUsers as $mentionedUser) {
|
||||
$mentions[] = [
|
||||
'mentionable_type' => $comment->getMorphClass(),
|
||||
'mentionable_id' => $comment->id,
|
||||
'from_user_id' => $fromUser->id,
|
||||
'to_user_id' => $mentionedUser->id,
|
||||
'created_at' => $now,
|
||||
'updated_at' => $now,
|
||||
];
|
||||
}
|
||||
|
||||
MentionHistory::query()->insert($mentions);
|
||||
}
|
||||
|
||||
protected function getPreviouslyNotifiedUserIds(Comment $comment): array
|
||||
{
|
||||
return MentionHistory::query()
|
||||
->where('mentionable_id', $comment->id)
|
||||
->where('mentionable_type', $comment->getMorphClass())
|
||||
->pluck('to_user_id')
|
||||
->toArray();
|
||||
}
|
||||
}
|
||||
@@ -39,8 +39,8 @@ class PageUpdateNotificationHandler extends BaseNotificationHandler
|
||||
$watchers = new EntityWatchers($detail, WatchLevels::UPDATES);
|
||||
$watcherIds = $watchers->getWatcherUserIds();
|
||||
|
||||
// Add page owner if preferences allow
|
||||
if (!$watchers->isUserIgnoring($detail->owned_by) && $detail->ownedBy) {
|
||||
// Add the page owner if preferences allow
|
||||
if ($detail->owned_by && !$watchers->isUserIgnoring($detail->owned_by) && $detail->ownedBy) {
|
||||
$userNotificationPrefs = new UserNotificationPreferences($detail->ownedBy);
|
||||
if ($userNotificationPrefs->notifyOnOwnPageChanges()) {
|
||||
$watcherIds[] = $detail->owned_by;
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
<?php
|
||||
|
||||
namespace BookStack\Activity\Notifications\Messages;
|
||||
|
||||
use BookStack\Activity\Models\Comment;
|
||||
use BookStack\Activity\Notifications\MessageParts\EntityLinkMessageLine;
|
||||
use BookStack\Activity\Notifications\MessageParts\ListMessageLine;
|
||||
use BookStack\Entities\Models\Page;
|
||||
use BookStack\Users\Models\User;
|
||||
use Illuminate\Notifications\Messages\MailMessage;
|
||||
|
||||
class CommentMentionNotification extends BaseActivityNotification
|
||||
{
|
||||
public function toMail(User $notifiable): MailMessage
|
||||
{
|
||||
/** @var Comment $comment */
|
||||
$comment = $this->detail;
|
||||
/** @var Page $page */
|
||||
$page = $comment->entity;
|
||||
|
||||
$locale = $notifiable->getLocale();
|
||||
|
||||
$listLines = array_filter([
|
||||
$locale->trans('notifications.detail_page_name') => new EntityLinkMessageLine($page),
|
||||
$locale->trans('notifications.detail_page_path') => $this->buildPagePathLine($page, $notifiable),
|
||||
$locale->trans('notifications.detail_commenter') => $this->user->name,
|
||||
$locale->trans('notifications.detail_comment') => strip_tags($comment->html),
|
||||
]);
|
||||
|
||||
return $this->newMailMessage($locale)
|
||||
->subject($locale->trans('notifications.comment_mention_subject', ['pageName' => $page->getShortName()]))
|
||||
->line($locale->trans('notifications.comment_mention_intro', ['appName' => setting('app-name')]))
|
||||
->line(new ListMessageLine($listLines))
|
||||
->action($locale->trans('notifications.action_view_comment'), $page->getUrl('#comment' . $comment->local_id))
|
||||
->line($this->buildReasonFooterLine($locale));
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,7 @@ use BookStack\Activity\ActivityType;
|
||||
use BookStack\Activity\Models\Activity;
|
||||
use BookStack\Activity\Models\Loggable;
|
||||
use BookStack\Activity\Notifications\Handlers\CommentCreationNotificationHandler;
|
||||
use BookStack\Activity\Notifications\Handlers\CommentMentionNotificationHandler;
|
||||
use BookStack\Activity\Notifications\Handlers\NotificationHandler;
|
||||
use BookStack\Activity\Notifications\Handlers\PageCreationNotificationHandler;
|
||||
use BookStack\Activity\Notifications\Handlers\PageUpdateNotificationHandler;
|
||||
@@ -48,5 +49,7 @@ class NotificationManager
|
||||
$this->registerHandler(ActivityType::PAGE_CREATE, PageCreationNotificationHandler::class);
|
||||
$this->registerHandler(ActivityType::PAGE_UPDATE, PageUpdateNotificationHandler::class);
|
||||
$this->registerHandler(ActivityType::COMMENT_CREATE, CommentCreationNotificationHandler::class);
|
||||
$this->registerHandler(ActivityType::COMMENT_CREATE, CommentMentionNotificationHandler::class);
|
||||
$this->registerHandler(ActivityType::COMMENT_UPDATE, CommentMentionNotificationHandler::class);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,6 +13,11 @@ class CommentTree
|
||||
* @var CommentTreeNode[]
|
||||
*/
|
||||
protected array $tree;
|
||||
|
||||
/**
|
||||
* A linear array of loaded comments.
|
||||
* @var Comment[]
|
||||
*/
|
||||
protected array $comments;
|
||||
|
||||
public function __construct(
|
||||
@@ -39,7 +44,7 @@ class CommentTree
|
||||
|
||||
public function getActive(): array
|
||||
{
|
||||
return array_filter($this->tree, fn (CommentTreeNode $node) => !$node->comment->archived);
|
||||
return array_values(array_filter($this->tree, fn (CommentTreeNode $node) => !$node->comment->archived));
|
||||
}
|
||||
|
||||
public function activeThreadCount(): int
|
||||
@@ -49,7 +54,7 @@ class CommentTree
|
||||
|
||||
public function getArchived(): array
|
||||
{
|
||||
return array_filter($this->tree, fn (CommentTreeNode $node) => $node->comment->archived);
|
||||
return array_values(array_filter($this->tree, fn (CommentTreeNode $node) => $node->comment->archived));
|
||||
}
|
||||
|
||||
public function archivedThreadCount(): int
|
||||
@@ -79,6 +84,14 @@ class CommentTree
|
||||
return false;
|
||||
}
|
||||
|
||||
public function loadVisibleHtml(): void
|
||||
{
|
||||
foreach ($this->comments as $comment) {
|
||||
$comment->setAttribute('html', $comment->safeHtml());
|
||||
$comment->makeVisible('html');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param Comment[] $comments
|
||||
* @return CommentTreeNode[]
|
||||
@@ -123,6 +136,9 @@ class CommentTree
|
||||
return new CommentTreeNode($byId[$id], $depth, $children);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return Comment[]
|
||||
*/
|
||||
protected function loadComments(): array
|
||||
{
|
||||
if (!$this->enabled()) {
|
||||
|
||||
28
app/Activity/Tools/MentionParser.php
Normal file
28
app/Activity/Tools/MentionParser.php
Normal file
@@ -0,0 +1,28 @@
|
||||
<?php
|
||||
|
||||
namespace BookStack\Activity\Tools;
|
||||
|
||||
use BookStack\Util\HtmlDocument;
|
||||
use DOMElement;
|
||||
|
||||
class MentionParser
|
||||
{
|
||||
public function parseUserIdsFromHtml(string $html): array
|
||||
{
|
||||
$doc = new HtmlDocument($html);
|
||||
|
||||
$ids = [];
|
||||
$mentionLinks = $doc->queryXPath('//a[@data-mention-user-id]');
|
||||
|
||||
foreach ($mentionLinks as $link) {
|
||||
if ($link instanceof DOMElement) {
|
||||
$id = intval($link->getAttribute('data-mention-user-id'));
|
||||
if ($id > 0) {
|
||||
$ids[] = $id;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return array_values(array_unique($ids));
|
||||
}
|
||||
}
|
||||
@@ -83,11 +83,19 @@ class ApiDocsGenerator
|
||||
protected function loadDetailsFromControllers(Collection $routes): Collection
|
||||
{
|
||||
return $routes->map(function (array $route) {
|
||||
$class = $this->getReflectionClass($route['controller']);
|
||||
$method = $this->getReflectionMethod($route['controller'], $route['controller_method']);
|
||||
$comment = $method->getDocComment();
|
||||
$route['description'] = $comment ? $this->parseDescriptionFromMethodComment($comment) : null;
|
||||
$route['description'] = $comment ? $this->parseDescriptionFromDocBlockComment($comment) : null;
|
||||
$route['body_params'] = $this->getBodyParamsFromClass($route['controller'], $route['controller_method']);
|
||||
|
||||
// Load class description for the model
|
||||
// Not ideal to have it here on each route, but adding it in a more structured manner would break
|
||||
// docs resulting JSON format and therefore be an API break.
|
||||
// Save refactoring for a more significant set of changes.
|
||||
$classComment = $class->getDocComment();
|
||||
$route['model_description'] = $classComment ? $this->parseDescriptionFromDocBlockComment($classComment) : null;
|
||||
|
||||
return $route;
|
||||
});
|
||||
}
|
||||
@@ -140,7 +148,7 @@ class ApiDocsGenerator
|
||||
/**
|
||||
* Parse out the description text from a class method comment.
|
||||
*/
|
||||
protected function parseDescriptionFromMethodComment(string $comment): string
|
||||
protected function parseDescriptionFromDocBlockComment(string $comment): string
|
||||
{
|
||||
$matches = [];
|
||||
preg_match_all('/^\s*?\*\s?($|((?![\/@\s]).*?))$/m', $comment, $matches);
|
||||
@@ -155,6 +163,16 @@ class ApiDocsGenerator
|
||||
* @throws ReflectionException
|
||||
*/
|
||||
protected function getReflectionMethod(string $className, string $methodName): ReflectionMethod
|
||||
{
|
||||
return $this->getReflectionClass($className)->getMethod($methodName);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a reflection class from the given class name.
|
||||
*
|
||||
* @throws ReflectionException
|
||||
*/
|
||||
protected function getReflectionClass(string $className): ReflectionClass
|
||||
{
|
||||
$class = $this->reflectionClasses[$className] ?? null;
|
||||
if ($class === null) {
|
||||
@@ -162,7 +180,7 @@ class ApiDocsGenerator
|
||||
$this->reflectionClasses[$className] = $class;
|
||||
}
|
||||
|
||||
return $class->getMethod($methodName);
|
||||
return $class;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -83,7 +83,7 @@ class HomeController extends Controller
|
||||
if ($homepageOption === 'bookshelves') {
|
||||
$shelves = $this->queries->shelves->visibleForListWithCover()
|
||||
->orderBy($commonData['listOptions']->getSort(), $commonData['listOptions']->getOrder())
|
||||
->paginate(18);
|
||||
->paginate(setting()->getInteger('lists-page-count-shelves', 18, 1, 1000));
|
||||
$data = array_merge($commonData, ['shelves' => $shelves]);
|
||||
|
||||
return view('home.shelves', $data);
|
||||
@@ -92,7 +92,7 @@ class HomeController extends Controller
|
||||
if ($homepageOption === 'books') {
|
||||
$books = $this->queries->books->visibleForListWithCover()
|
||||
->orderBy($commonData['listOptions']->getSort(), $commonData['listOptions']->getOrder())
|
||||
->paginate(18);
|
||||
->paginate(setting()->getInteger('lists-page-count-books', 18, 1, 1000));
|
||||
$data = array_merge($commonData, ['books' => $books]);
|
||||
|
||||
return view('home.books', $data);
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
namespace BookStack\App\Providers;
|
||||
|
||||
use BookStack\Access\SocialDriverManager;
|
||||
use BookStack\Activity\Models\Comment;
|
||||
use BookStack\Activity\Tools\ActivityLogger;
|
||||
use BookStack\Entities\Models\Book;
|
||||
use BookStack\Entities\Models\Bookshelf;
|
||||
@@ -73,6 +74,7 @@ class AppServiceProvider extends ServiceProvider
|
||||
'book' => Book::class,
|
||||
'chapter' => Chapter::class,
|
||||
'page' => Page::class,
|
||||
'comment' => Comment::class,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,11 +5,9 @@ namespace BookStack\App;
|
||||
/**
|
||||
* Assigned to models that can have slugs.
|
||||
* Must have the below properties.
|
||||
*
|
||||
* @property string $slug
|
||||
*/
|
||||
interface SluggableInterface
|
||||
{
|
||||
/**
|
||||
* Regenerate the slug for this model.
|
||||
*/
|
||||
public function refreshSlug(): string;
|
||||
}
|
||||
|
||||
@@ -81,7 +81,8 @@ return [
|
||||
'strict' => false,
|
||||
'engine' => null,
|
||||
'options' => extension_loaded('pdo_mysql') ? array_filter([
|
||||
PDO::MYSQL_ATTR_SSL_CA => env('MYSQL_ATTR_SSL_CA'),
|
||||
// @phpstan-ignore class.notFound
|
||||
(PHP_VERSION_ID >= 80500 ? \Pdo\Mysql::ATTR_SSL_CA : \PDO::MYSQL_ATTR_SSL_CA) => env('MYSQL_ATTR_SSL_CA'),
|
||||
]) : [],
|
||||
],
|
||||
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
return [
|
||||
|
||||
// Default Filesystem Disk
|
||||
// Options: local, local_secure, s3
|
||||
// Options: local, local_secure, local_secure_restricted, s3
|
||||
'default' => env('STORAGE_TYPE', 'local'),
|
||||
|
||||
// Filesystem to use specifically for image uploads.
|
||||
|
||||
@@ -41,6 +41,7 @@ return [
|
||||
'bookshelves_view_type' => env('APP_VIEWS_BOOKSHELVES', 'grid'),
|
||||
'bookshelf_view_type' => env('APP_VIEWS_BOOKSHELF', 'grid'),
|
||||
'books_view_type' => env('APP_VIEWS_BOOKS', 'grid'),
|
||||
'notifications#comment-mentions' => true,
|
||||
],
|
||||
|
||||
];
|
||||
|
||||
@@ -45,10 +45,8 @@ class UpdateUrlCommand extends Command
|
||||
|
||||
$columnsToUpdateByTable = [
|
||||
'attachments' => ['path'],
|
||||
'pages' => ['html', 'text', 'markdown'],
|
||||
'chapters' => ['description_html'],
|
||||
'books' => ['description_html'],
|
||||
'bookshelves' => ['description_html'],
|
||||
'entity_page_data' => ['html', 'text', 'markdown'],
|
||||
'entity_container_data' => ['description_html'],
|
||||
'page_revisions' => ['html', 'text', 'markdown'],
|
||||
'images' => ['url'],
|
||||
'settings' => ['value'],
|
||||
|
||||
@@ -58,7 +58,7 @@ class BookApiController extends ApiController
|
||||
|
||||
/**
|
||||
* View the details of a single book.
|
||||
* The response data will contain 'content' property listing the chapter and pages directly within, in
|
||||
* The response data will contain a 'content' property listing the chapter and pages directly within, in
|
||||
* the same structure as you'd see within the BookStack interface when viewing a book. Top-level
|
||||
* contents will have a 'type' property to distinguish between pages & chapters.
|
||||
*/
|
||||
@@ -122,9 +122,10 @@ class BookApiController extends ApiController
|
||||
$book = clone $book;
|
||||
$book->unsetRelations()->refresh();
|
||||
|
||||
$book->load(['tags', 'cover']);
|
||||
$book->makeVisible('description_html')
|
||||
->setAttribute('description_html', $book->descriptionHtml());
|
||||
$book->load(['tags']);
|
||||
$book->makeVisible(['cover', 'description_html'])
|
||||
->setAttribute('description_html', $book->descriptionInfo()->getHtml())
|
||||
->setAttribute('cover', $book->coverInfo()->getImage());
|
||||
|
||||
return $book;
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ use BookStack\Activity\Models\View;
|
||||
use BookStack\Activity\Tools\UserEntityWatchOptions;
|
||||
use BookStack\Entities\Queries\BookQueries;
|
||||
use BookStack\Entities\Queries\BookshelfQueries;
|
||||
use BookStack\Entities\Queries\EntityQueries;
|
||||
use BookStack\Entities\Repos\BookRepo;
|
||||
use BookStack\Entities\Tools\BookContents;
|
||||
use BookStack\Entities\Tools\Cloner;
|
||||
@@ -31,6 +32,7 @@ class BookController extends Controller
|
||||
protected ShelfContext $shelfContext,
|
||||
protected BookRepo $bookRepo,
|
||||
protected BookQueries $queries,
|
||||
protected EntityQueries $entityQueries,
|
||||
protected BookshelfQueries $shelfQueries,
|
||||
protected ReferenceFetcher $referenceFetcher,
|
||||
) {
|
||||
@@ -50,7 +52,7 @@ class BookController extends Controller
|
||||
|
||||
$books = $this->queries->visibleForListWithCover()
|
||||
->orderBy($listOptions->getSort(), $listOptions->getOrder())
|
||||
->paginate(18);
|
||||
->paginate(setting()->getInteger('lists-page-count-books', 18, 1, 1000));
|
||||
$recents = $this->isSignedIn() ? $this->queries->recentlyViewedForCurrentUser()->take(4)->get() : false;
|
||||
$popular = $this->queries->popularForList()->take(4)->get();
|
||||
$new = $this->queries->visibleForList()->orderBy('created_at', 'desc')->take(4)->get();
|
||||
@@ -127,7 +129,16 @@ class BookController extends Controller
|
||||
*/
|
||||
public function show(Request $request, ActivityQueries $activities, string $slug)
|
||||
{
|
||||
$book = $this->queries->findVisibleBySlugOrFail($slug);
|
||||
try {
|
||||
$book = $this->queries->findVisibleBySlugOrFail($slug);
|
||||
} catch (NotFoundException $exception) {
|
||||
$book = $this->entityQueries->findVisibleByOldSlugs('book', $slug);
|
||||
if (is_null($book)) {
|
||||
throw $exception;
|
||||
}
|
||||
return redirect($book->getUrl());
|
||||
}
|
||||
|
||||
$bookChildren = (new BookContents($book))->getTree(true);
|
||||
$bookParentShelves = $book->shelves()->scopes('visible')->get();
|
||||
|
||||
|
||||
@@ -116,9 +116,10 @@ class BookshelfApiController extends ApiController
|
||||
$shelf = clone $shelf;
|
||||
$shelf->unsetRelations()->refresh();
|
||||
|
||||
$shelf->load(['tags', 'cover']);
|
||||
$shelf->makeVisible('description_html')
|
||||
->setAttribute('description_html', $shelf->descriptionHtml());
|
||||
$shelf->load(['tags']);
|
||||
$shelf->makeVisible(['cover', 'description_html'])
|
||||
->setAttribute('description_html', $shelf->descriptionInfo()->getHtml())
|
||||
->setAttribute('cover', $shelf->coverInfo()->getImage());
|
||||
|
||||
return $shelf;
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ use BookStack\Activity\ActivityQueries;
|
||||
use BookStack\Activity\Models\View;
|
||||
use BookStack\Entities\Queries\BookQueries;
|
||||
use BookStack\Entities\Queries\BookshelfQueries;
|
||||
use BookStack\Entities\Queries\EntityQueries;
|
||||
use BookStack\Entities\Repos\BookshelfRepo;
|
||||
use BookStack\Entities\Tools\ShelfContext;
|
||||
use BookStack\Exceptions\ImageUploadException;
|
||||
@@ -23,6 +24,7 @@ class BookshelfController extends Controller
|
||||
public function __construct(
|
||||
protected BookshelfRepo $shelfRepo,
|
||||
protected BookshelfQueries $queries,
|
||||
protected EntityQueries $entityQueries,
|
||||
protected BookQueries $bookQueries,
|
||||
protected ShelfContext $shelfContext,
|
||||
protected ReferenceFetcher $referenceFetcher,
|
||||
@@ -43,7 +45,7 @@ class BookshelfController extends Controller
|
||||
|
||||
$shelves = $this->queries->visibleForListWithCover()
|
||||
->orderBy($listOptions->getSort(), $listOptions->getOrder())
|
||||
->paginate(18);
|
||||
->paginate(setting()->getInteger('lists-page-count-shelves', 18, 1, 1000));
|
||||
$recents = $this->isSignedIn() ? $this->queries->recentlyViewedForCurrentUser()->get() : false;
|
||||
$popular = $this->queries->popularForList()->get();
|
||||
$new = $this->queries->visibleForList()
|
||||
@@ -105,7 +107,16 @@ class BookshelfController extends Controller
|
||||
*/
|
||||
public function show(Request $request, ActivityQueries $activities, string $slug)
|
||||
{
|
||||
$shelf = $this->queries->findVisibleBySlugOrFail($slug);
|
||||
try {
|
||||
$shelf = $this->queries->findVisibleBySlugOrFail($slug);
|
||||
} catch (NotFoundException $exception) {
|
||||
$shelf = $this->entityQueries->findVisibleByOldSlugs('bookshelf', $slug);
|
||||
if (is_null($shelf)) {
|
||||
throw $exception;
|
||||
}
|
||||
return redirect($shelf->getUrl());
|
||||
}
|
||||
|
||||
$this->checkOwnablePermission(Permission::BookshelfView, $shelf);
|
||||
|
||||
$listOptions = SimpleListOptions::fromRequest($request, 'shelf_books')->withSortOptions([
|
||||
@@ -116,6 +127,7 @@ class BookshelfController extends Controller
|
||||
]);
|
||||
|
||||
$sort = $listOptions->getSort();
|
||||
|
||||
$sortedVisibleShelfBooks = $shelf->visibleBooks()
|
||||
->reorder($sort === 'default' ? 'order' : $sort, $listOptions->getOrder())
|
||||
->get()
|
||||
|
||||
@@ -104,7 +104,7 @@ class ChapterApiController extends ApiController
|
||||
$chapter = $this->queries->findVisibleByIdOrFail(intval($id));
|
||||
$this->checkOwnablePermission(Permission::ChapterUpdate, $chapter);
|
||||
|
||||
if ($request->has('book_id') && $chapter->book_id !== intval($requestData['book_id'])) {
|
||||
if ($request->has('book_id') && $chapter->book_id !== (intval($requestData['book_id']) ?: null)) {
|
||||
$this->checkOwnablePermission(Permission::ChapterDelete, $chapter);
|
||||
|
||||
try {
|
||||
@@ -144,7 +144,7 @@ class ChapterApiController extends ApiController
|
||||
|
||||
$chapter->load(['tags']);
|
||||
$chapter->makeVisible('description_html');
|
||||
$chapter->setAttribute('description_html', $chapter->descriptionHtml());
|
||||
$chapter->setAttribute('description_html', $chapter->descriptionInfo()->getHtml());
|
||||
|
||||
/** @var Book $book */
|
||||
$book = $chapter->book()->first();
|
||||
|
||||
@@ -77,7 +77,15 @@ class ChapterController extends Controller
|
||||
*/
|
||||
public function show(string $bookSlug, string $chapterSlug)
|
||||
{
|
||||
$chapter = $this->queries->findVisibleBySlugsOrFail($bookSlug, $chapterSlug);
|
||||
try {
|
||||
$chapter = $this->queries->findVisibleBySlugsOrFail($bookSlug, $chapterSlug);
|
||||
} catch (NotFoundException $exception) {
|
||||
$chapter = $this->entityQueries->findVisibleByOldSlugs('chapter', $chapterSlug, $bookSlug);
|
||||
if (is_null($chapter)) {
|
||||
throw $exception;
|
||||
}
|
||||
return redirect($chapter->getUrl());
|
||||
}
|
||||
|
||||
$sidebarTree = (new BookContents($chapter->book))->getTree();
|
||||
$pages = $this->entityQueries->pages->visibleForChapterList($chapter->id)->get();
|
||||
@@ -130,7 +138,7 @@ class ChapterController extends Controller
|
||||
$chapter = $this->queries->findVisibleBySlugsOrFail($bookSlug, $chapterSlug);
|
||||
$this->checkOwnablePermission(Permission::ChapterUpdate, $chapter);
|
||||
|
||||
$this->chapterRepo->update($chapter, $validated);
|
||||
$chapter = $this->chapterRepo->update($chapter, $validated);
|
||||
|
||||
return redirect($chapter->getUrl());
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
namespace BookStack\Entities\Controllers;
|
||||
|
||||
use BookStack\Activity\Tools\CommentTree;
|
||||
use BookStack\Entities\Queries\EntityQueries;
|
||||
use BookStack\Entities\Queries\PageQueries;
|
||||
use BookStack\Entities\Repos\PageRepo;
|
||||
@@ -88,21 +89,32 @@ class PageApiController extends ApiController
|
||||
/**
|
||||
* View the details of a single page.
|
||||
* Pages will always have HTML content. They may have markdown content
|
||||
* if the markdown editor was used to last update the page.
|
||||
* if the Markdown editor was used to last update the page.
|
||||
*
|
||||
* The 'html' property is the fully rendered & escaped HTML content that BookStack
|
||||
* The 'html' property is the fully rendered and escaped HTML content that BookStack
|
||||
* would show on page view, with page includes handled.
|
||||
* The 'raw_html' property is the direct database stored HTML content, which would be
|
||||
* what BookStack shows on page edit.
|
||||
*
|
||||
* See the "Content Security" section of these docs for security considerations when using
|
||||
* the page content returned from this endpoint.
|
||||
*
|
||||
* Comments for the page are provided in a tree-structure representing the hierarchy of top-level
|
||||
* comments and replies, for both archived and active comments.
|
||||
*/
|
||||
public function read(string $id)
|
||||
{
|
||||
$page = $this->queries->findVisibleByIdOrFail($id);
|
||||
|
||||
return response()->json($page->forJsonDisplay());
|
||||
$page = $page->forJsonDisplay();
|
||||
$commentTree = (new CommentTree($page));
|
||||
$commentTree->loadVisibleHtml();
|
||||
$page->setAttribute('comments', [
|
||||
'active' => $commentTree->getActive(),
|
||||
'archived' => $commentTree->getArchived(),
|
||||
]);
|
||||
|
||||
return response()->json($page);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -17,7 +17,6 @@ use BookStack\Entities\Tools\PageContent;
|
||||
use BookStack\Entities\Tools\PageEditActivity;
|
||||
use BookStack\Entities\Tools\PageEditorData;
|
||||
use BookStack\Exceptions\NotFoundException;
|
||||
use BookStack\Exceptions\NotifyException;
|
||||
use BookStack\Exceptions\PermissionsException;
|
||||
use BookStack\Http\Controller;
|
||||
use BookStack\Permissions\Permission;
|
||||
@@ -120,6 +119,7 @@ class PageController extends Controller
|
||||
$this->validate($request, [
|
||||
'name' => ['required', 'string', 'max:255'],
|
||||
]);
|
||||
|
||||
$draftPage = $this->queries->findVisibleByIdOrFail($pageId);
|
||||
$this->checkOwnablePermission(Permission::PageCreate, $draftPage->getParent());
|
||||
|
||||
@@ -139,9 +139,7 @@ class PageController extends Controller
|
||||
try {
|
||||
$page = $this->queries->findVisibleBySlugsOrFail($bookSlug, $pageSlug);
|
||||
} catch (NotFoundException $e) {
|
||||
$revision = $this->entityQueries->revisions->findLatestVersionBySlugs($bookSlug, $pageSlug);
|
||||
$page = $revision->page ?? null;
|
||||
|
||||
$page = $this->entityQueries->findVisibleByOldSlugs('page', $pageSlug, $bookSlug);
|
||||
if (is_null($page)) {
|
||||
throw $e;
|
||||
}
|
||||
|
||||
20
app/Entities/EntityExistsRule.php
Normal file
20
app/Entities/EntityExistsRule.php
Normal file
@@ -0,0 +1,20 @@
|
||||
<?php
|
||||
|
||||
namespace BookStack\Entities;
|
||||
|
||||
use Illuminate\Validation\Rules\Exists;
|
||||
|
||||
class EntityExistsRule implements \Stringable
|
||||
{
|
||||
public function __construct(
|
||||
protected string $type,
|
||||
) {
|
||||
}
|
||||
|
||||
public function __toString()
|
||||
{
|
||||
$existsRule = (new Exists('entities', 'id'))
|
||||
->where('type', $this->type);
|
||||
return $existsRule->__toString();
|
||||
}
|
||||
}
|
||||
@@ -2,9 +2,10 @@
|
||||
|
||||
namespace BookStack\Entities\Models;
|
||||
|
||||
use BookStack\Entities\Tools\EntityCover;
|
||||
use BookStack\Entities\Tools\EntityDefaultTemplate;
|
||||
use BookStack\Sorting\SortRule;
|
||||
use BookStack\Uploads\Image;
|
||||
use Exception;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
|
||||
@@ -15,26 +16,25 @@ use Illuminate\Support\Collection;
|
||||
* Class Book.
|
||||
*
|
||||
* @property string $description
|
||||
* @property string $description_html
|
||||
* @property int $image_id
|
||||
* @property ?int $default_template_id
|
||||
* @property ?int $sort_rule_id
|
||||
* @property Image|null $cover
|
||||
* @property \Illuminate\Database\Eloquent\Collection $chapters
|
||||
* @property \Illuminate\Database\Eloquent\Collection $pages
|
||||
* @property \Illuminate\Database\Eloquent\Collection $directPages
|
||||
* @property \Illuminate\Database\Eloquent\Collection $shelves
|
||||
* @property ?Page $defaultTemplate
|
||||
* @property ?SortRule $sortRule
|
||||
* @property ?SortRule $sortRule
|
||||
*/
|
||||
class Book extends Entity implements CoverImageInterface, HtmlDescriptionInterface
|
||||
class Book extends Entity implements HasDescriptionInterface, HasCoverInterface, HasDefaultTemplateInterface
|
||||
{
|
||||
use HasFactory;
|
||||
use HtmlDescriptionTrait;
|
||||
use ContainerTrait;
|
||||
|
||||
public float $searchFactor = 1.2;
|
||||
|
||||
protected $hidden = ['pivot', 'deleted_at', 'description_html', 'entity_id', 'entity_type', 'chapter_id', 'book_id', 'priority'];
|
||||
protected $fillable = ['name'];
|
||||
protected $hidden = ['pivot', 'image_id', 'deleted_at', 'description_html'];
|
||||
|
||||
/**
|
||||
* Get the url for this book.
|
||||
@@ -44,55 +44,6 @@ class Book extends Entity implements CoverImageInterface, HtmlDescriptionInterfa
|
||||
return url('/books/' . implode('/', [urlencode($this->slug), trim($path, '/')]));
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns book cover image, if book cover not exists return default cover image.
|
||||
*/
|
||||
public function getBookCover(int $width = 440, int $height = 250): string
|
||||
{
|
||||
$default = 'data:image/gif;base64,R0lGODlhAQABAAAAACH5BAEKAAEALAAAAAABAAEAAAICTAEAOw==';
|
||||
if (!$this->image_id || !$this->cover) {
|
||||
return $default;
|
||||
}
|
||||
|
||||
try {
|
||||
return $this->cover->getThumb($width, $height, false) ?? $default;
|
||||
} catch (Exception $err) {
|
||||
return $default;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the cover image of the book.
|
||||
*/
|
||||
public function cover(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Image::class, 'image_id');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the type of the image model that is used when storing a cover image.
|
||||
*/
|
||||
public function coverImageTypeKey(): string
|
||||
{
|
||||
return 'cover_book';
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the Page that is used as default template for newly created pages within this Book.
|
||||
*/
|
||||
public function defaultTemplate(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Page::class, 'default_template_id');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the sort set assigned to this book, if existing.
|
||||
*/
|
||||
public function sortRule(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(SortRule::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all pages within this book.
|
||||
* @return HasMany<Page, $this>
|
||||
@@ -107,7 +58,7 @@ class Book extends Entity implements CoverImageInterface, HtmlDescriptionInterfa
|
||||
*/
|
||||
public function directPages(): HasMany
|
||||
{
|
||||
return $this->pages()->where('chapter_id', '=', '0');
|
||||
return $this->pages()->whereNull('chapter_id');
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -137,4 +88,27 @@ class Book extends Entity implements CoverImageInterface, HtmlDescriptionInterfa
|
||||
|
||||
return $pages->concat($chapters)->sortBy('priority')->sortByDesc('draft');
|
||||
}
|
||||
|
||||
public function defaultTemplate(): EntityDefaultTemplate
|
||||
{
|
||||
return new EntityDefaultTemplate($this);
|
||||
}
|
||||
|
||||
public function cover(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Image::class, 'image_id');
|
||||
}
|
||||
|
||||
public function coverInfo(): EntityCover
|
||||
{
|
||||
return new EntityCover($this);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the sort rule assigned to this container, if existing.
|
||||
*/
|
||||
public function sortRule(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(SortRule::class);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,8 +2,6 @@
|
||||
|
||||
namespace BookStack\Entities\Models;
|
||||
|
||||
use BookStack\References\ReferenceUpdater;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
/**
|
||||
@@ -18,34 +16,10 @@ abstract class BookChild extends Entity
|
||||
{
|
||||
/**
|
||||
* Get the book this page sits in.
|
||||
* @return BelongsTo<Book, $this>
|
||||
*/
|
||||
public function book(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Book::class)->withTrashed();
|
||||
}
|
||||
|
||||
/**
|
||||
* Change the book that this entity belongs to.
|
||||
*/
|
||||
public function changeBook(int $newBookId): Entity
|
||||
{
|
||||
$oldUrl = $this->getUrl();
|
||||
$this->book_id = $newBookId;
|
||||
$this->refreshSlug();
|
||||
$this->save();
|
||||
$this->refresh();
|
||||
|
||||
if ($oldUrl !== $this->getUrl()) {
|
||||
app()->make(ReferenceUpdater::class)->updateEntityReferences($this, $oldUrl);
|
||||
}
|
||||
|
||||
// Update all child pages if a chapter
|
||||
if ($this instanceof Chapter) {
|
||||
foreach ($this->pages()->withTrashed()->get() as $page) {
|
||||
$page->changeBook($newBookId);
|
||||
}
|
||||
}
|
||||
|
||||
return $this;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,34 +2,34 @@
|
||||
|
||||
namespace BookStack\Entities\Models;
|
||||
|
||||
use BookStack\Entities\Tools\EntityCover;
|
||||
use BookStack\Uploads\Image;
|
||||
use Exception;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
|
||||
|
||||
class Bookshelf extends Entity implements CoverImageInterface, HtmlDescriptionInterface
|
||||
/**
|
||||
* @property string $description
|
||||
* @property string $description_html
|
||||
*/
|
||||
class Bookshelf extends Entity implements HasDescriptionInterface, HasCoverInterface
|
||||
{
|
||||
use HasFactory;
|
||||
use HtmlDescriptionTrait;
|
||||
|
||||
protected $table = 'bookshelves';
|
||||
use ContainerTrait;
|
||||
|
||||
public float $searchFactor = 1.2;
|
||||
|
||||
protected $fillable = ['name', 'description', 'image_id'];
|
||||
|
||||
protected $hidden = ['image_id', 'deleted_at', 'description_html'];
|
||||
protected $hidden = ['image_id', 'deleted_at', 'description_html', 'priority', 'default_template_id', 'sort_rule_id', 'entity_id', 'entity_type', 'chapter_id', 'book_id'];
|
||||
protected $fillable = ['name'];
|
||||
|
||||
/**
|
||||
* Get the books in this shelf.
|
||||
* Should not be used directly since does not take into account permissions.
|
||||
*
|
||||
* @return \Illuminate\Database\Eloquent\Relations\BelongsToMany
|
||||
* Should not be used directly since it does not take into account permissions.
|
||||
*/
|
||||
public function books()
|
||||
public function books(): BelongsToMany
|
||||
{
|
||||
return $this->belongsToMany(Book::class, 'bookshelves_books', 'bookshelf_id', 'book_id')
|
||||
->select(['entities.*', 'entity_container_data.*'])
|
||||
->withPivot('order')
|
||||
->orderBy('order', 'asc');
|
||||
}
|
||||
@@ -50,41 +50,6 @@ class Bookshelf extends Entity implements CoverImageInterface, HtmlDescriptionIn
|
||||
return url('/shelves/' . implode('/', [urlencode($this->slug), trim($path, '/')]));
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns shelf cover image, if cover not exists return default cover image.
|
||||
*/
|
||||
public function getBookCover(int $width = 440, int $height = 250): string
|
||||
{
|
||||
// TODO - Make generic, focused on books right now, Perhaps set-up a better image
|
||||
$default = 'data:image/gif;base64,R0lGODlhAQABAAAAACH5BAEKAAEALAAAAAABAAEAAAICTAEAOw==';
|
||||
if (!$this->image_id || !$this->cover) {
|
||||
return $default;
|
||||
}
|
||||
|
||||
try {
|
||||
return $this->cover->getThumb($width, $height, false) ?? $default;
|
||||
} catch (Exception $err) {
|
||||
return $default;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the cover image of the shelf.
|
||||
* @return BelongsTo<Image, $this>
|
||||
*/
|
||||
public function cover(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Image::class, 'image_id');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the type of the image model that is used when storing a cover image.
|
||||
*/
|
||||
public function coverImageTypeKey(): string
|
||||
{
|
||||
return 'cover_bookshelf';
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if this shelf contains the given book.
|
||||
*/
|
||||
@@ -96,7 +61,7 @@ class Bookshelf extends Entity implements CoverImageInterface, HtmlDescriptionIn
|
||||
/**
|
||||
* Add a book to the end of this shelf.
|
||||
*/
|
||||
public function appendBook(Book $book)
|
||||
public function appendBook(Book $book): void
|
||||
{
|
||||
if ($this->contains($book)) {
|
||||
return;
|
||||
@@ -106,12 +71,13 @@ class Bookshelf extends Entity implements CoverImageInterface, HtmlDescriptionIn
|
||||
$this->books()->attach($book->id, ['order' => $maxOrder + 1]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a visible shelf by its slug.
|
||||
* @throws \Illuminate\Database\Eloquent\ModelNotFoundException
|
||||
*/
|
||||
public static function getBySlug(string $slug): self
|
||||
public function coverInfo(): EntityCover
|
||||
{
|
||||
return static::visible()->where('slug', '=', $slug)->firstOrFail();
|
||||
return new EntityCover($this);
|
||||
}
|
||||
|
||||
public function cover(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Image::class, 'image_id');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,27 +2,25 @@
|
||||
|
||||
namespace BookStack\Entities\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use BookStack\Entities\Tools\EntityDefaultTemplate;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
use Illuminate\Support\Collection;
|
||||
|
||||
/**
|
||||
* Class Chapter.
|
||||
*
|
||||
* @property Collection<Page> $pages
|
||||
* @property ?int $default_template_id
|
||||
* @property ?Page $defaultTemplate
|
||||
* @property string $description
|
||||
* @property string $description_html
|
||||
*/
|
||||
class Chapter extends BookChild implements HtmlDescriptionInterface
|
||||
class Chapter extends BookChild implements HasDescriptionInterface, HasDefaultTemplateInterface
|
||||
{
|
||||
use HasFactory;
|
||||
use HtmlDescriptionTrait;
|
||||
use ContainerTrait;
|
||||
|
||||
public float $searchFactor = 1.2;
|
||||
|
||||
protected $fillable = ['name', 'description', 'priority'];
|
||||
protected $hidden = ['pivot', 'deleted_at', 'description_html'];
|
||||
protected $hidden = ['pivot', 'deleted_at', 'description_html', 'sort_rule_id', 'image_id', 'entity_id', 'entity_type', 'chapter_id'];
|
||||
protected $fillable = ['name', 'priority'];
|
||||
|
||||
/**
|
||||
* Get the pages that this chapter contains.
|
||||
@@ -50,14 +48,6 @@ class Chapter extends BookChild implements HtmlDescriptionInterface
|
||||
return url('/' . implode('/', $parts));
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the Page that is used as default template for newly created pages within this Chapter.
|
||||
*/
|
||||
public function defaultTemplate(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Page::class, 'default_template_id');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the visible pages in this chapter.
|
||||
* @return Collection<Page>
|
||||
@@ -70,4 +60,9 @@ class Chapter extends BookChild implements HtmlDescriptionInterface
|
||||
->orderBy('priority', 'asc')
|
||||
->get();
|
||||
}
|
||||
|
||||
public function defaultTemplate(): EntityDefaultTemplate
|
||||
{
|
||||
return new EntityDefaultTemplate($this);
|
||||
}
|
||||
}
|
||||
|
||||
26
app/Entities/Models/ContainerTrait.php
Normal file
26
app/Entities/Models/ContainerTrait.php
Normal file
@@ -0,0 +1,26 @@
|
||||
<?php
|
||||
|
||||
namespace BookStack\Entities\Models;
|
||||
|
||||
use BookStack\Entities\Tools\EntityHtmlDescription;
|
||||
use Illuminate\Database\Eloquent\Relations\HasOne;
|
||||
|
||||
/**
|
||||
* @mixin Entity
|
||||
*/
|
||||
trait ContainerTrait
|
||||
{
|
||||
public function descriptionInfo(): EntityHtmlDescription
|
||||
{
|
||||
return new EntityHtmlDescription($this);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return HasOne<EntityContainerData, $this>
|
||||
*/
|
||||
public function relatedData(): HasOne
|
||||
{
|
||||
return $this->hasOne(EntityContainerData::class, 'entity_id', 'id')
|
||||
->where('entity_type', '=', $this->getMorphClass());
|
||||
}
|
||||
}
|
||||
@@ -1,18 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace BookStack\Entities\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
interface CoverImageInterface
|
||||
{
|
||||
/**
|
||||
* Get the cover image for this item.
|
||||
*/
|
||||
public function cover(): BelongsTo;
|
||||
|
||||
/**
|
||||
* Get the type of the image model that is used when storing a cover image.
|
||||
*/
|
||||
public function coverImageTypeKey(): string;
|
||||
}
|
||||
@@ -4,6 +4,7 @@ namespace BookStack\Entities\Models;
|
||||
|
||||
use BookStack\Activity\Models\Loggable;
|
||||
use BookStack\Users\Models\User;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\MorphTo;
|
||||
@@ -17,6 +18,8 @@ use Illuminate\Database\Eloquent\Relations\MorphTo;
|
||||
*/
|
||||
class Deletion extends Model implements Loggable
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
protected $hidden = [];
|
||||
|
||||
/**
|
||||
|
||||
@@ -13,7 +13,6 @@ use BookStack\Activity\Models\Viewable;
|
||||
use BookStack\Activity\Models\Watch;
|
||||
use BookStack\App\Model;
|
||||
use BookStack\App\SluggableInterface;
|
||||
use BookStack\Entities\Tools\SlugGenerator;
|
||||
use BookStack\Permissions\JointPermissionBuilder;
|
||||
use BookStack\Permissions\Models\EntityPermission;
|
||||
use BookStack\Permissions\Models\JointPermission;
|
||||
@@ -28,23 +27,25 @@ use Carbon\Carbon;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Database\Eloquent\Collection;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\HasOne;
|
||||
use Illuminate\Database\Eloquent\Relations\MorphMany;
|
||||
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||
|
||||
/**
|
||||
* Class Entity
|
||||
* The base class for book-like items such as pages, chapters & books.
|
||||
* The base class for book-like items such as pages, chapters and books.
|
||||
* This is not a database model in itself but extended.
|
||||
*
|
||||
* @property int $id
|
||||
* @property string $type
|
||||
* @property string $name
|
||||
* @property string $slug
|
||||
* @property Carbon $created_at
|
||||
* @property Carbon $updated_at
|
||||
* @property Carbon $deleted_at
|
||||
* @property int $created_by
|
||||
* @property int $updated_by
|
||||
* @property int $owned_by
|
||||
* @property int|null $created_by
|
||||
* @property int|null $updated_by
|
||||
* @property int|null $owned_by
|
||||
* @property Collection $tags
|
||||
*
|
||||
* @method static Entity|Builder visible()
|
||||
@@ -77,6 +78,72 @@ abstract class Entity extends Model implements
|
||||
*/
|
||||
public float $searchFactor = 1.0;
|
||||
|
||||
/**
|
||||
* Set the table to be that used by all entities.
|
||||
*/
|
||||
protected $table = 'entities';
|
||||
|
||||
/**
|
||||
* Set a custom query builder for entities.
|
||||
*/
|
||||
protected static string $builder = EntityQueryBuilder::class;
|
||||
|
||||
public static array $commonFields = [
|
||||
'id',
|
||||
'type',
|
||||
'name',
|
||||
'slug',
|
||||
'book_id',
|
||||
'chapter_id',
|
||||
'priority',
|
||||
'created_at',
|
||||
'updated_at',
|
||||
'deleted_at',
|
||||
'created_by',
|
||||
'updated_by',
|
||||
'owned_by',
|
||||
];
|
||||
|
||||
/**
|
||||
* Override the save method to also save the contents for convenience.
|
||||
*/
|
||||
public function save(array $options = []): bool
|
||||
{
|
||||
/** @var EntityPageData|EntityContainerData $contents */
|
||||
$contents = $this->relatedData()->firstOrNew();
|
||||
$contentFields = $this->getContentsAttributes();
|
||||
|
||||
foreach ($contentFields as $key => $value) {
|
||||
$contents->setAttribute($key, $value);
|
||||
unset($this->attributes[$key]);
|
||||
}
|
||||
|
||||
$this->setAttribute('type', $this->getMorphClass());
|
||||
$result = parent::save($options);
|
||||
$contentsResult = true;
|
||||
|
||||
if ($result && $contents->isDirty()) {
|
||||
$contentsFillData = $contents instanceof EntityPageData ? ['page_id' => $this->id] : ['entity_id' => $this->id, 'entity_type' => $this->getMorphClass()];
|
||||
$contents->forceFill($contentsFillData);
|
||||
$contentsResult = $contents->save();
|
||||
$this->touch();
|
||||
}
|
||||
|
||||
$this->forceFill($contentFields);
|
||||
|
||||
return $result && $contentsResult;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if this item is a container item.
|
||||
*/
|
||||
public function isContainer(): bool
|
||||
{
|
||||
return $this instanceof Bookshelf ||
|
||||
$this instanceof Book ||
|
||||
$this instanceof Chapter;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the entities that are visible to the current user.
|
||||
*/
|
||||
@@ -91,8 +158,8 @@ abstract class Entity extends Model implements
|
||||
public function scopeWithLastView(Builder $query)
|
||||
{
|
||||
$viewedAtQuery = View::query()->select('updated_at')
|
||||
->whereColumn('viewable_id', '=', $this->getTable() . '.id')
|
||||
->where('viewable_type', '=', $this->getMorphClass())
|
||||
->whereColumn('viewable_id', '=', 'entities.id')
|
||||
->whereColumn('viewable_type', '=', 'entities.type')
|
||||
->where('user_id', '=', user()->id)
|
||||
->take(1);
|
||||
|
||||
@@ -102,11 +169,12 @@ abstract class Entity extends Model implements
|
||||
/**
|
||||
* Query scope to get the total view count of the entities.
|
||||
*/
|
||||
public function scopeWithViewCount(Builder $query)
|
||||
public function scopeWithViewCount(Builder $query): void
|
||||
{
|
||||
$viewCountQuery = View::query()->selectRaw('SUM(views) as view_count')
|
||||
->whereColumn('viewable_id', '=', $this->getTable() . '.id')
|
||||
->where('viewable_type', '=', $this->getMorphClass())->take(1);
|
||||
->whereColumn('viewable_id', '=', 'entities.id')
|
||||
->whereColumn('viewable_type', '=', 'entities.type')
|
||||
->take(1);
|
||||
|
||||
$query->addSelect(['view_count' => $viewCountQuery]);
|
||||
}
|
||||
@@ -162,15 +230,17 @@ abstract class Entity extends Model implements
|
||||
*/
|
||||
public function tags(): MorphMany
|
||||
{
|
||||
return $this->morphMany(Tag::class, 'entity')->orderBy('order', 'asc');
|
||||
return $this->morphMany(Tag::class, 'entity')
|
||||
->orderBy('order', 'asc');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the comments for an entity.
|
||||
* @return MorphMany<Comment, $this>
|
||||
*/
|
||||
public function comments(bool $orderByCreated = true): MorphMany
|
||||
{
|
||||
$query = $this->morphMany(Comment::class, 'entity');
|
||||
$query = $this->morphMany(Comment::class, 'commentable');
|
||||
|
||||
return $orderByCreated ? $query->orderBy('created_at', 'asc') : $query;
|
||||
}
|
||||
@@ -184,7 +254,7 @@ abstract class Entity extends Model implements
|
||||
}
|
||||
|
||||
/**
|
||||
* Get this entities restrictions.
|
||||
* Get this entities assigned permissions.
|
||||
*/
|
||||
public function permissions(): MorphMany
|
||||
{
|
||||
@@ -267,7 +337,7 @@ abstract class Entity extends Model implements
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets a limited-length version of the entities name.
|
||||
* Gets a limited-length version of the entity name.
|
||||
*/
|
||||
public function getShortName(int $length = 25): string
|
||||
{
|
||||
@@ -334,16 +404,6 @@ abstract class Entity extends Model implements
|
||||
app()->make(SearchIndex::class)->indexEntity(clone $this);
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
public function refreshSlug(): string
|
||||
{
|
||||
$this->slug = app()->make(SlugGenerator::class)->generate($this, $this->name);
|
||||
|
||||
return $this->slug;
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
@@ -370,6 +430,14 @@ abstract class Entity extends Model implements
|
||||
return $this->morphMany(Watch::class, 'watchable');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the related slug history for this entity.
|
||||
*/
|
||||
public function slugHistory(): MorphMany
|
||||
{
|
||||
return $this->morphMany(SlugHistory::class, 'sluggable');
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
@@ -377,4 +445,40 @@ abstract class Entity extends Model implements
|
||||
{
|
||||
return "({$this->id}) {$this->name}";
|
||||
}
|
||||
|
||||
/**
|
||||
* @return HasOne<covariant (EntityContainerData|EntityPageData), $this>
|
||||
*/
|
||||
abstract public function relatedData(): HasOne;
|
||||
|
||||
/**
|
||||
* Get the attributes that are intended for the related contents model.
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
protected function getContentsAttributes(): array
|
||||
{
|
||||
$contentFields = [];
|
||||
$contentModel = $this instanceof Page ? EntityPageData::class : EntityContainerData::class;
|
||||
|
||||
foreach ($this->attributes as $key => $value) {
|
||||
if (in_array($key, $contentModel::$fields)) {
|
||||
$contentFields[$key] = $value;
|
||||
}
|
||||
}
|
||||
|
||||
return $contentFields;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new instance for the given entity type.
|
||||
*/
|
||||
public static function instanceFromType(string $type): self
|
||||
{
|
||||
return match ($type) {
|
||||
'page' => new Page(),
|
||||
'chapter' => new Chapter(),
|
||||
'book' => new Book(),
|
||||
'bookshelf' => new Bookshelf(),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
52
app/Entities/Models/EntityContainerData.php
Normal file
52
app/Entities/Models/EntityContainerData.php
Normal file
@@ -0,0 +1,52 @@
|
||||
<?php
|
||||
|
||||
namespace BookStack\Entities\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
/**
|
||||
* @property int $entity_id
|
||||
* @property string $entity_type
|
||||
* @property string $description
|
||||
* @property string $description_html
|
||||
* @property ?int $default_template_id
|
||||
* @property ?int $image_id
|
||||
* @property ?int $sort_rule_id
|
||||
*/
|
||||
class EntityContainerData extends Model
|
||||
{
|
||||
public $timestamps = false;
|
||||
protected $primaryKey = 'entity_id';
|
||||
public $incrementing = false;
|
||||
|
||||
public static array $fields = [
|
||||
'description',
|
||||
'description_html',
|
||||
'default_template_id',
|
||||
'image_id',
|
||||
'sort_rule_id',
|
||||
];
|
||||
|
||||
/**
|
||||
* Override the default set keys for save query method to make it work with composite keys.
|
||||
*/
|
||||
public function setKeysForSaveQuery($query): Builder
|
||||
{
|
||||
$query->where($this->getKeyName(), '=', $this->getKeyForSaveQuery())
|
||||
->where('entity_type', '=', $this->entity_type);
|
||||
|
||||
return $query;
|
||||
}
|
||||
|
||||
/**
|
||||
* Override the default set keys for a select query method to make it work with composite keys.
|
||||
*/
|
||||
protected function setKeysForSelectQuery($query): Builder
|
||||
{
|
||||
$query->where($this->getKeyName(), '=', $this->getKeyForSelectQuery())
|
||||
->where('entity_type', '=', $this->entity_type);
|
||||
|
||||
return $query;
|
||||
}
|
||||
}
|
||||
25
app/Entities/Models/EntityPageData.php
Normal file
25
app/Entities/Models/EntityPageData.php
Normal file
@@ -0,0 +1,25 @@
|
||||
<?php
|
||||
|
||||
namespace BookStack\Entities\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
/**
|
||||
* @property int $page_id
|
||||
*/
|
||||
class EntityPageData extends Model
|
||||
{
|
||||
public $timestamps = false;
|
||||
protected $primaryKey = 'page_id';
|
||||
public $incrementing = false;
|
||||
|
||||
public static array $fields = [
|
||||
'draft',
|
||||
'template',
|
||||
'revision_count',
|
||||
'editor',
|
||||
'html',
|
||||
'text',
|
||||
'markdown',
|
||||
];
|
||||
}
|
||||
38
app/Entities/Models/EntityQueryBuilder.php
Normal file
38
app/Entities/Models/EntityQueryBuilder.php
Normal file
@@ -0,0 +1,38 @@
|
||||
<?php
|
||||
|
||||
namespace BookStack\Entities\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Database\Query\Builder as QueryBuilder;
|
||||
|
||||
class EntityQueryBuilder extends Builder
|
||||
{
|
||||
/**
|
||||
* Create a new Eloquent query builder instance.
|
||||
*/
|
||||
public function __construct(QueryBuilder $query)
|
||||
{
|
||||
parent::__construct($query);
|
||||
|
||||
$this->withGlobalScope('entity', new EntityScope());
|
||||
}
|
||||
|
||||
public function withoutGlobalScope($scope): static
|
||||
{
|
||||
// Prevent removal of the entity scope
|
||||
if ($scope === 'entity') {
|
||||
return $this;
|
||||
}
|
||||
|
||||
return parent::withoutGlobalScope($scope);
|
||||
}
|
||||
|
||||
/**
|
||||
* Override the default forceDelete method to add type filter onto the query
|
||||
* since it specifically ignores scopes by default.
|
||||
*/
|
||||
public function forceDelete()
|
||||
{
|
||||
return $this->query->where('type', '=', $this->model->getMorphClass())->delete();
|
||||
}
|
||||
}
|
||||
28
app/Entities/Models/EntityScope.php
Normal file
28
app/Entities/Models/EntityScope.php
Normal file
@@ -0,0 +1,28 @@
|
||||
<?php
|
||||
|
||||
namespace BookStack\Entities\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Scope;
|
||||
use Illuminate\Database\Query\JoinClause;
|
||||
|
||||
class EntityScope implements Scope
|
||||
{
|
||||
/**
|
||||
* Apply the scope to a given Eloquent query builder.
|
||||
*/
|
||||
public function apply(Builder $builder, Model $model): void
|
||||
{
|
||||
$builder = $builder->where('type', '=', $model->getMorphClass());
|
||||
$table = $model->getTable();
|
||||
if ($model instanceof Page) {
|
||||
$builder->leftJoin('entity_page_data', 'entity_page_data.page_id', '=', "{$table}.id");
|
||||
} else {
|
||||
$builder->leftJoin('entity_container_data', function (JoinClause $join) use ($model, $table) {
|
||||
$join->on('entity_container_data.entity_id', '=', "{$table}.id")
|
||||
->where('entity_container_data.entity_type', '=', $model->getMorphClass());
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
69
app/Entities/Models/EntityTable.php
Normal file
69
app/Entities/Models/EntityTable.php
Normal file
@@ -0,0 +1,69 @@
|
||||
<?php
|
||||
|
||||
namespace BookStack\Entities\Models;
|
||||
|
||||
use BookStack\Activity\Models\Tag;
|
||||
use BookStack\Activity\Models\View;
|
||||
use BookStack\App\Model;
|
||||
use BookStack\Permissions\Models\EntityPermission;
|
||||
use BookStack\Permissions\Models\JointPermission;
|
||||
use BookStack\Permissions\PermissionApplicator;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
use Illuminate\Database\Eloquent\Relations\MorphMany;
|
||||
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||
|
||||
/**
|
||||
* This is a simplistic model interpretation of a generic Entity used to query and represent
|
||||
* that database abstractly. Generally, this should rarely be used outside queries.
|
||||
*/
|
||||
class EntityTable extends Model
|
||||
{
|
||||
use SoftDeletes;
|
||||
|
||||
protected $table = 'entities';
|
||||
|
||||
/**
|
||||
* Get the entities that are visible to the current user.
|
||||
*/
|
||||
public function scopeVisible(Builder $query): Builder
|
||||
{
|
||||
return app()->make(PermissionApplicator::class)->restrictEntityQuery($query);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the entity jointPermissions this is connected to.
|
||||
*/
|
||||
public function jointPermissions(): HasMany
|
||||
{
|
||||
return $this->hasMany(JointPermission::class, 'entity_id')
|
||||
->whereColumn('entity_type', '=', 'entities.type');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the Tags that have been assigned to entities.
|
||||
*/
|
||||
public function tags(): HasMany
|
||||
{
|
||||
return $this->hasMany(Tag::class, 'entity_id')
|
||||
->whereColumn('entity_type', '=', 'entities.type');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the assigned permissions.
|
||||
*/
|
||||
public function permissions(): HasMany
|
||||
{
|
||||
return $this->hasMany(EntityPermission::class, 'entity_id')
|
||||
->whereColumn('entity_type', '=', 'entities.type');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get View objects for this entity.
|
||||
*/
|
||||
public function views(): HasMany
|
||||
{
|
||||
return $this->hasMany(View::class, 'viewable_id')
|
||||
->whereColumn('viewable_type', '=', 'entities.type');
|
||||
}
|
||||
}
|
||||
18
app/Entities/Models/HasCoverInterface.php
Normal file
18
app/Entities/Models/HasCoverInterface.php
Normal file
@@ -0,0 +1,18 @@
|
||||
<?php
|
||||
|
||||
namespace BookStack\Entities\Models;
|
||||
|
||||
use BookStack\Entities\Tools\EntityCover;
|
||||
use BookStack\Uploads\Image;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
interface HasCoverInterface
|
||||
{
|
||||
public function coverInfo(): EntityCover;
|
||||
|
||||
/**
|
||||
* The cover image of this entity.
|
||||
* @return BelongsTo<Image, covariant Entity>
|
||||
*/
|
||||
public function cover(): BelongsTo;
|
||||
}
|
||||
10
app/Entities/Models/HasDefaultTemplateInterface.php
Normal file
10
app/Entities/Models/HasDefaultTemplateInterface.php
Normal file
@@ -0,0 +1,10 @@
|
||||
<?php
|
||||
|
||||
namespace BookStack\Entities\Models;
|
||||
|
||||
use BookStack\Entities\Tools\EntityDefaultTemplate;
|
||||
|
||||
interface HasDefaultTemplateInterface
|
||||
{
|
||||
public function defaultTemplate(): EntityDefaultTemplate;
|
||||
}
|
||||
10
app/Entities/Models/HasDescriptionInterface.php
Normal file
10
app/Entities/Models/HasDescriptionInterface.php
Normal file
@@ -0,0 +1,10 @@
|
||||
<?php
|
||||
|
||||
namespace BookStack\Entities\Models;
|
||||
|
||||
use BookStack\Entities\Tools\EntityHtmlDescription;
|
||||
|
||||
interface HasDescriptionInterface
|
||||
{
|
||||
public function descriptionInfo(): EntityHtmlDescription;
|
||||
}
|
||||
@@ -1,17 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace BookStack\Entities\Models;
|
||||
|
||||
interface HtmlDescriptionInterface
|
||||
{
|
||||
/**
|
||||
* Get the HTML-based description for this item.
|
||||
* By default, the content should be sanitised unless raw is set to true.
|
||||
*/
|
||||
public function descriptionHtml(bool $raw = false): string;
|
||||
|
||||
/**
|
||||
* Set the HTML-based description for this item.
|
||||
*/
|
||||
public function setDescriptionHtml(string $html, string|null $plaintext = null): void;
|
||||
}
|
||||
@@ -1,35 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace BookStack\Entities\Models;
|
||||
|
||||
use BookStack\Util\HtmlContentFilter;
|
||||
|
||||
/**
|
||||
* @property string $description
|
||||
* @property string $description_html
|
||||
*/
|
||||
trait HtmlDescriptionTrait
|
||||
{
|
||||
public function descriptionHtml(bool $raw = false): string
|
||||
{
|
||||
$html = $this->description_html ?: '<p>' . nl2br(e($this->description)) . '</p>';
|
||||
if ($raw) {
|
||||
return $html;
|
||||
}
|
||||
|
||||
return HtmlContentFilter::removeScriptsFromHtmlString($html);
|
||||
}
|
||||
|
||||
public function setDescriptionHtml(string $html, string|null $plaintext = null): void
|
||||
{
|
||||
$this->description_html = $html;
|
||||
|
||||
if ($plaintext !== null) {
|
||||
$this->description = $plaintext;
|
||||
}
|
||||
|
||||
if (empty($html) && !empty($plaintext)) {
|
||||
$this->description_html = $this->descriptionHtml();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3,7 +3,6 @@
|
||||
namespace BookStack\Entities\Models;
|
||||
|
||||
use BookStack\Entities\Tools\PageContent;
|
||||
use BookStack\Entities\Tools\PageEditorType;
|
||||
use BookStack\Permissions\PermissionApplicator;
|
||||
use BookStack\Uploads\Attachment;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
@@ -15,7 +14,7 @@ use Illuminate\Database\Eloquent\Relations\HasOne;
|
||||
|
||||
/**
|
||||
* Class Page.
|
||||
*
|
||||
* @property EntityPageData $pageData
|
||||
* @property int $chapter_id
|
||||
* @property string $html
|
||||
* @property string $markdown
|
||||
@@ -33,12 +32,10 @@ class Page extends BookChild
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
protected $fillable = ['name', 'priority'];
|
||||
|
||||
public string $textField = 'text';
|
||||
public string $htmlField = 'html';
|
||||
|
||||
protected $hidden = ['html', 'markdown', 'text', 'pivot', 'deleted_at'];
|
||||
protected $hidden = ['html', 'markdown', 'text', 'pivot', 'deleted_at', 'entity_id', 'entity_type'];
|
||||
protected $fillable = ['name', 'priority'];
|
||||
|
||||
protected $casts = [
|
||||
'draft' => 'boolean',
|
||||
@@ -57,10 +54,8 @@ class Page extends BookChild
|
||||
|
||||
/**
|
||||
* Get the chapter that this page is in, If applicable.
|
||||
*
|
||||
* @return BelongsTo
|
||||
*/
|
||||
public function chapter()
|
||||
public function chapter(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Chapter::class);
|
||||
}
|
||||
@@ -107,10 +102,8 @@ class Page extends BookChild
|
||||
|
||||
/**
|
||||
* Get the attachments assigned to this page.
|
||||
*
|
||||
* @return HasMany
|
||||
*/
|
||||
public function attachments()
|
||||
public function attachments(): HasMany
|
||||
{
|
||||
return $this->hasMany(Attachment::class, 'uploaded_to')->orderBy('order', 'asc');
|
||||
}
|
||||
@@ -131,6 +124,14 @@ class Page extends BookChild
|
||||
return url('/' . implode('/', $parts));
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the ID-based permalink for this page.
|
||||
*/
|
||||
public function getPermalink(): string
|
||||
{
|
||||
return url("/link/{$this->id}");
|
||||
}
|
||||
|
||||
/**
|
||||
* Get this page for JSON display.
|
||||
*/
|
||||
@@ -139,8 +140,16 @@ class Page extends BookChild
|
||||
$refreshed = $this->refresh()->unsetRelations()->load(['tags', 'createdBy', 'updatedBy', 'ownedBy']);
|
||||
$refreshed->setHidden(array_diff($refreshed->getHidden(), ['html', 'markdown']));
|
||||
$refreshed->setAttribute('raw_html', $refreshed->html);
|
||||
$refreshed->html = (new PageContent($refreshed))->render();
|
||||
$refreshed->setAttribute('html', (new PageContent($refreshed))->render());
|
||||
|
||||
return $refreshed;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return HasOne<EntityPageData, $this>
|
||||
*/
|
||||
public function relatedData(): HasOne
|
||||
{
|
||||
return $this->hasOne(EntityPageData::class, 'page_id', 'id');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ use BookStack\Activity\Models\Loggable;
|
||||
use BookStack\App\Model;
|
||||
use BookStack\Users\Models\User;
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
/**
|
||||
@@ -30,6 +31,8 @@ use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
*/
|
||||
class PageRevision extends Model implements Loggable
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
protected $fillable = ['name', 'text', 'summary'];
|
||||
protected $hidden = ['html', 'markdown', 'text'];
|
||||
|
||||
|
||||
28
app/Entities/Models/SlugHistory.php
Normal file
28
app/Entities/Models/SlugHistory.php
Normal file
@@ -0,0 +1,28 @@
|
||||
<?php
|
||||
|
||||
namespace BookStack\Entities\Models;
|
||||
|
||||
use BookStack\App\Model;
|
||||
use BookStack\Permissions\Models\JointPermission;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
|
||||
/**
|
||||
* @property int $id
|
||||
* @property int $sluggable_id
|
||||
* @property string $sluggable_type
|
||||
* @property string $slug
|
||||
* @property ?string $parent_slug
|
||||
*/
|
||||
class SlugHistory extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
protected $table = 'slug_history';
|
||||
|
||||
public function jointPermissions(): HasMany
|
||||
{
|
||||
return $this->hasMany(JointPermission::class, 'entity_id', 'sluggable_id')
|
||||
->whereColumn('joint_permissions.entity_type', '=', 'slug_history.sluggable_type');
|
||||
}
|
||||
}
|
||||
@@ -55,6 +55,11 @@ class BookQueries implements ProvidesEntityQueries
|
||||
->select(static::$listAttributes);
|
||||
}
|
||||
|
||||
public function visibleForContent(): Builder
|
||||
{
|
||||
return $this->start()->scopes('visible');
|
||||
}
|
||||
|
||||
public function visibleForListWithCover(): Builder
|
||||
{
|
||||
return $this->visibleForList()->with('cover');
|
||||
|
||||
@@ -60,6 +60,11 @@ class BookshelfQueries implements ProvidesEntityQueries
|
||||
return $this->start()->scopes('visible')->select(static::$listAttributes);
|
||||
}
|
||||
|
||||
public function visibleForContent(): Builder
|
||||
{
|
||||
return $this->start()->scopes('visible');
|
||||
}
|
||||
|
||||
public function visibleForListWithCover(): Builder
|
||||
{
|
||||
return $this->visibleForList()->with('cover');
|
||||
|
||||
@@ -65,8 +65,14 @@ class ChapterQueries implements ProvidesEntityQueries
|
||||
->scopes('visible')
|
||||
->select(array_merge(static::$listAttributes, ['book_slug' => function ($builder) {
|
||||
$builder->select('slug')
|
||||
->from('books')
|
||||
->whereColumn('books.id', '=', 'chapters.book_id');
|
||||
->from('entities as books')
|
||||
->where('type', '=', 'book')
|
||||
->whereColumn('books.id', '=', 'entities.book_id');
|
||||
}]));
|
||||
}
|
||||
|
||||
public function visibleForContent(): Builder
|
||||
{
|
||||
return $this->start()->scopes('visible');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,7 +3,12 @@
|
||||
namespace BookStack\Entities\Queries;
|
||||
|
||||
use BookStack\Entities\Models\Entity;
|
||||
use BookStack\Entities\Models\EntityTable;
|
||||
use BookStack\Entities\Tools\SlugHistory;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Database\Query\Builder as QueryBuilder;
|
||||
use Illuminate\Database\Query\JoinClause;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use InvalidArgumentException;
|
||||
|
||||
class EntityQueries
|
||||
@@ -14,6 +19,7 @@ class EntityQueries
|
||||
public ChapterQueries $chapters,
|
||||
public PageQueries $pages,
|
||||
public PageRevisionQueries $revisions,
|
||||
protected SlugHistory $slugHistory,
|
||||
) {
|
||||
}
|
||||
|
||||
@@ -27,9 +33,55 @@ class EntityQueries
|
||||
$explodedId = explode(':', $identifier);
|
||||
$entityType = $explodedId[0];
|
||||
$entityId = intval($explodedId[1]);
|
||||
$queries = $this->getQueriesForType($entityType);
|
||||
|
||||
return $queries->findVisibleById($entityId);
|
||||
return $this->findVisibleById($entityType, $entityId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Find an entity by its ID.
|
||||
*/
|
||||
public function findVisibleById(string $type, int $id): ?Entity
|
||||
{
|
||||
$queries = $this->getQueriesForType($type);
|
||||
return $queries->findVisibleById($id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Find an entity by looking up old slugs in the slug history.
|
||||
*/
|
||||
public function findVisibleByOldSlugs(string $type, string $slug, string $parentSlug = ''): ?Entity
|
||||
{
|
||||
$id = $this->slugHistory->lookupEntityIdUsingSlugs($type, $slug, $parentSlug);
|
||||
if ($id === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $this->findVisibleById($type, $id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Start a query across all entity types.
|
||||
* Combines the description/text fields into a single 'description' field.
|
||||
* @return Builder<EntityTable>
|
||||
*/
|
||||
public function visibleForList(): Builder
|
||||
{
|
||||
$rawDescriptionField = DB::raw('COALESCE(description, text) as description');
|
||||
$bookSlugSelect = function (QueryBuilder $query) {
|
||||
return $query->select('slug')->from('entities as books')
|
||||
->whereColumn('books.id', '=', 'entities.book_id')
|
||||
->where('type', '=', 'book');
|
||||
};
|
||||
|
||||
return EntityTable::query()->scopes('visible')
|
||||
->select(['id', 'type', 'name', 'slug', 'book_id', 'chapter_id', 'created_at', 'updated_at', 'draft', 'book_slug' => $bookSlugSelect, $rawDescriptionField])
|
||||
->leftJoin('entity_container_data', function (JoinClause $join) {
|
||||
$join->on('entity_container_data.entity_id', '=', 'entities.id')
|
||||
->on('entity_container_data.entity_type', '=', 'entities.type');
|
||||
})->leftJoin('entity_page_data', function (JoinClause $join) {
|
||||
$join->on('entity_page_data.page_id', '=', 'entities.id')
|
||||
->where('entities.type', '=', 'page');
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -37,12 +89,23 @@ class EntityQueries
|
||||
* suitable for listing display.
|
||||
* @return Builder<Entity>
|
||||
*/
|
||||
public function visibleForList(string $entityType): Builder
|
||||
public function visibleForListForType(string $entityType): Builder
|
||||
{
|
||||
$queries = $this->getQueriesForType($entityType);
|
||||
return $queries->visibleForList();
|
||||
}
|
||||
|
||||
/**
|
||||
* Start a query of visible entities of the given type,
|
||||
* suitable for using the contents of the items.
|
||||
* @return Builder<Entity>
|
||||
*/
|
||||
public function visibleForContentForType(string $entityType): Builder
|
||||
{
|
||||
$queries = $this->getQueriesForType($entityType);
|
||||
return $queries->visibleForContent();
|
||||
}
|
||||
|
||||
protected function getQueriesForType(string $type): ProvidesEntityQueries
|
||||
{
|
||||
$queries = match ($type) {
|
||||
|
||||
@@ -13,7 +13,7 @@ class PageQueries implements ProvidesEntityQueries
|
||||
{
|
||||
protected static array $contentAttributes = [
|
||||
'name', 'id', 'slug', 'book_id', 'chapter_id', 'draft',
|
||||
'template', 'html', 'text', 'created_at', 'updated_at', 'priority',
|
||||
'template', 'html', 'markdown', 'text', 'created_at', 'updated_at', 'priority',
|
||||
'created_by', 'updated_by', 'owned_by',
|
||||
];
|
||||
protected static array $listAttributes = [
|
||||
@@ -82,6 +82,14 @@ class PageQueries implements ProvidesEntityQueries
|
||||
->select($this->mergeBookSlugForSelect(static::$listAttributes));
|
||||
}
|
||||
|
||||
/**
|
||||
* @return Builder<Page>
|
||||
*/
|
||||
public function visibleForContent(): Builder
|
||||
{
|
||||
return $this->start()->scopes('visible');
|
||||
}
|
||||
|
||||
public function visibleForChapterList(int $chapterId): Builder
|
||||
{
|
||||
return $this->visibleForList()
|
||||
@@ -104,18 +112,19 @@ class PageQueries implements ProvidesEntityQueries
|
||||
->where('created_by', '=', user()->id);
|
||||
}
|
||||
|
||||
public function visibleTemplates(): Builder
|
||||
public function visibleTemplates(bool $includeContents = false): Builder
|
||||
{
|
||||
return $this->visibleForList()
|
||||
->where('template', '=', true);
|
||||
$base = $includeContents ? $this->visibleWithContents() : $this->visibleForList();
|
||||
return $base->where('template', '=', true);
|
||||
}
|
||||
|
||||
protected function mergeBookSlugForSelect(array $columns): array
|
||||
{
|
||||
return array_merge($columns, ['book_slug' => function ($builder) {
|
||||
$builder->select('slug')
|
||||
->from('books')
|
||||
->whereColumn('books.id', '=', 'pages.book_id');
|
||||
->from('entities as books')
|
||||
->where('type', '=', 'book')
|
||||
->whereColumn('books.id', '=', 'entities.book_id');
|
||||
}]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -35,4 +35,11 @@ interface ProvidesEntityQueries
|
||||
* @return Builder<TModel>
|
||||
*/
|
||||
public function visibleForList(): Builder;
|
||||
|
||||
/**
|
||||
* Start a query for items that are visible, with selection
|
||||
* configured for using the content of the items found.
|
||||
* @return Builder<TModel>
|
||||
*/
|
||||
public function visibleForContent(): Builder;
|
||||
}
|
||||
|
||||
@@ -3,14 +3,13 @@
|
||||
namespace BookStack\Entities\Repos;
|
||||
|
||||
use BookStack\Activity\TagRepo;
|
||||
use BookStack\Entities\Models\Book;
|
||||
use BookStack\Entities\Models\BookChild;
|
||||
use BookStack\Entities\Models\Chapter;
|
||||
use BookStack\Entities\Models\HasCoverInterface;
|
||||
use BookStack\Entities\Models\HasDescriptionInterface;
|
||||
use BookStack\Entities\Models\Entity;
|
||||
use BookStack\Entities\Models\CoverImageInterface;
|
||||
use BookStack\Entities\Models\HtmlDescriptionInterface;
|
||||
use BookStack\Entities\Models\HtmlDescriptionTrait;
|
||||
use BookStack\Entities\Queries\PageQueries;
|
||||
use BookStack\Entities\Tools\SlugGenerator;
|
||||
use BookStack\Entities\Tools\SlugHistory;
|
||||
use BookStack\Exceptions\ImageUploadException;
|
||||
use BookStack\References\ReferenceStore;
|
||||
use BookStack\References\ReferenceUpdater;
|
||||
@@ -28,22 +27,32 @@ class BaseRepo
|
||||
protected ReferenceStore $referenceStore,
|
||||
protected PageQueries $pageQueries,
|
||||
protected BookSorter $bookSorter,
|
||||
protected SlugGenerator $slugGenerator,
|
||||
protected SlugHistory $slugHistory,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new entity in the system.
|
||||
* @template T of Entity
|
||||
* @param T $entity
|
||||
* @return T
|
||||
*/
|
||||
public function create(Entity $entity, array $input)
|
||||
public function create(Entity $entity, array $input): Entity
|
||||
{
|
||||
$entity = (clone $entity)->refresh();
|
||||
$entity->fill($input);
|
||||
$this->updateDescription($entity, $input);
|
||||
$entity->forceFill([
|
||||
'created_by' => user()->id,
|
||||
'updated_by' => user()->id,
|
||||
'owned_by' => user()->id,
|
||||
]);
|
||||
$entity->refreshSlug();
|
||||
$this->refreshSlug($entity);
|
||||
|
||||
if ($entity instanceof HasDescriptionInterface) {
|
||||
$this->updateDescription($entity, $input);
|
||||
}
|
||||
|
||||
$entity->save();
|
||||
|
||||
if (isset($input['tags'])) {
|
||||
@@ -53,22 +62,31 @@ class BaseRepo
|
||||
$entity->refresh();
|
||||
$entity->rebuildPermissions();
|
||||
$entity->indexForSearch();
|
||||
|
||||
$this->referenceStore->updateForEntity($entity);
|
||||
|
||||
return $entity;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the given entity.
|
||||
* @template T of Entity
|
||||
* @param T $entity
|
||||
* @return T
|
||||
*/
|
||||
public function update(Entity $entity, array $input)
|
||||
public function update(Entity $entity, array $input): Entity
|
||||
{
|
||||
$oldUrl = $entity->getUrl();
|
||||
|
||||
$entity->fill($input);
|
||||
$this->updateDescription($entity, $input);
|
||||
$entity->updated_by = user()->id;
|
||||
|
||||
if ($entity->isDirty('name') || empty($entity->slug)) {
|
||||
$entity->refreshSlug();
|
||||
$this->refreshSlug($entity);
|
||||
}
|
||||
|
||||
if ($entity instanceof HasDescriptionInterface) {
|
||||
$this->updateDescription($entity, $input);
|
||||
}
|
||||
|
||||
$entity->save();
|
||||
@@ -84,59 +102,35 @@ class BaseRepo
|
||||
if ($oldUrl !== $entity->getUrl()) {
|
||||
$this->referenceUpdater->updateEntityReferences($entity, $oldUrl);
|
||||
}
|
||||
|
||||
return $entity;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the given items' cover image, or clear it.
|
||||
* Update the given items' cover image or clear it.
|
||||
*
|
||||
* @throws ImageUploadException
|
||||
* @throws \Exception
|
||||
*/
|
||||
public function updateCoverImage(Entity&CoverImageInterface $entity, ?UploadedFile $coverImage, bool $removeImage = false)
|
||||
public function updateCoverImage(Entity&HasCoverInterface $entity, ?UploadedFile $coverImage, bool $removeImage = false): void
|
||||
{
|
||||
if ($coverImage) {
|
||||
$imageType = $entity->coverImageTypeKey();
|
||||
$this->imageRepo->destroyImage($entity->cover()->first());
|
||||
$imageType = 'cover_' . $entity->type;
|
||||
$this->imageRepo->destroyImage($entity->coverInfo()->getImage());
|
||||
$image = $this->imageRepo->saveNew($coverImage, $imageType, $entity->id, 512, 512, true);
|
||||
$entity->cover()->associate($image);
|
||||
$entity->coverInfo()->setImage($image);
|
||||
$entity->save();
|
||||
}
|
||||
|
||||
if ($removeImage) {
|
||||
$this->imageRepo->destroyImage($entity->cover()->first());
|
||||
$entity->cover()->dissociate();
|
||||
$this->imageRepo->destroyImage($entity->coverInfo()->getImage());
|
||||
$entity->coverInfo()->setImage(null);
|
||||
$entity->save();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the default page template used for this item.
|
||||
* Checks that, if changing, the provided value is a valid template and the user
|
||||
* has visibility of the provided page template id.
|
||||
*/
|
||||
public function updateDefaultTemplate(Book|Chapter $entity, int $templateId): void
|
||||
{
|
||||
$changing = $templateId !== intval($entity->default_template_id);
|
||||
if (!$changing) {
|
||||
return;
|
||||
}
|
||||
|
||||
if ($templateId === 0) {
|
||||
$entity->default_template_id = null;
|
||||
$entity->save();
|
||||
return;
|
||||
}
|
||||
|
||||
$templateExists = $this->pageQueries->visibleTemplates()
|
||||
->where('id', '=', $templateId)
|
||||
->exists();
|
||||
|
||||
$entity->default_template_id = $templateExists ? $templateId : null;
|
||||
$entity->save();
|
||||
}
|
||||
|
||||
/**
|
||||
* Sort the parent of the given entity, if any auto sort actions are set for it.
|
||||
* Sort the parent of the given entity if any auto sort actions are set for it.
|
||||
* Typically ran during create/update/insert events.
|
||||
*/
|
||||
public function sortParent(Entity $entity): void
|
||||
@@ -147,19 +141,31 @@ class BaseRepo
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the description of the given entity from input data.
|
||||
*/
|
||||
protected function updateDescription(Entity $entity, array $input): void
|
||||
{
|
||||
if (!($entity instanceof HtmlDescriptionInterface)) {
|
||||
if (!$entity instanceof HasDescriptionInterface) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (isset($input['description_html'])) {
|
||||
$entity->setDescriptionHtml(
|
||||
$entity->descriptionInfo()->set(
|
||||
HtmlDescriptionFilter::filterFromString($input['description_html']),
|
||||
html_entity_decode(strip_tags($input['description_html']))
|
||||
);
|
||||
} else if (isset($input['description'])) {
|
||||
$entity->setDescriptionHtml('', $input['description']);
|
||||
$entity->descriptionInfo()->set('', $input['description']);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Refresh the slug for the given entity.
|
||||
*/
|
||||
public function refreshSlug(Entity $entity): void
|
||||
{
|
||||
$this->slugHistory->recordForEntity($entity);
|
||||
$this->slugGenerator->regenerateForEntity($entity);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -30,19 +30,18 @@ class BookRepo
|
||||
public function create(array $input): Book
|
||||
{
|
||||
return (new DatabaseTransaction(function () use ($input) {
|
||||
$book = new Book();
|
||||
|
||||
$this->baseRepo->create($book, $input);
|
||||
$book = $this->baseRepo->create(new Book(), $input);
|
||||
$this->baseRepo->updateCoverImage($book, $input['image'] ?? null);
|
||||
$this->baseRepo->updateDefaultTemplate($book, intval($input['default_template_id'] ?? null));
|
||||
$book->defaultTemplate()->setFromId(intval($input['default_template_id'] ?? null));
|
||||
Activity::add(ActivityType::BOOK_CREATE, $book);
|
||||
|
||||
$defaultBookSortSetting = intval(setting('sorting-book-default', '0'));
|
||||
if ($defaultBookSortSetting && SortRule::query()->find($defaultBookSortSetting)) {
|
||||
$book->sort_rule_id = $defaultBookSortSetting;
|
||||
$book->save();
|
||||
}
|
||||
|
||||
$book->save();
|
||||
|
||||
return $book;
|
||||
}))->run();
|
||||
}
|
||||
@@ -52,28 +51,29 @@ class BookRepo
|
||||
*/
|
||||
public function update(Book $book, array $input): Book
|
||||
{
|
||||
$this->baseRepo->update($book, $input);
|
||||
$book = $this->baseRepo->update($book, $input);
|
||||
|
||||
if (array_key_exists('default_template_id', $input)) {
|
||||
$this->baseRepo->updateDefaultTemplate($book, intval($input['default_template_id']));
|
||||
$book->defaultTemplate()->setFromId(intval($input['default_template_id']));
|
||||
}
|
||||
|
||||
if (array_key_exists('image', $input)) {
|
||||
$this->baseRepo->updateCoverImage($book, $input['image'], $input['image'] === null);
|
||||
}
|
||||
|
||||
$book->save();
|
||||
Activity::add(ActivityType::BOOK_UPDATE, $book);
|
||||
|
||||
return $book;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the given book's cover image, or clear it.
|
||||
* Update the given book's cover image or clear it.
|
||||
*
|
||||
* @throws ImageUploadException
|
||||
* @throws Exception
|
||||
*/
|
||||
public function updateCoverImage(Book $book, ?UploadedFile $coverImage, bool $removeImage = false)
|
||||
public function updateCoverImage(Book $book, ?UploadedFile $coverImage, bool $removeImage = false): void
|
||||
{
|
||||
$this->baseRepo->updateCoverImage($book, $coverImage, $removeImage);
|
||||
}
|
||||
@@ -83,7 +83,7 @@ class BookRepo
|
||||
*
|
||||
* @throws Exception
|
||||
*/
|
||||
public function destroy(Book $book)
|
||||
public function destroy(Book $book): void
|
||||
{
|
||||
$this->trashCan->softDestroyBook($book);
|
||||
Activity::add(ActivityType::BOOK_DELETE, $book);
|
||||
|
||||
@@ -25,8 +25,7 @@ class BookshelfRepo
|
||||
public function create(array $input, array $bookIds): Bookshelf
|
||||
{
|
||||
return (new DatabaseTransaction(function () use ($input, $bookIds) {
|
||||
$shelf = new Bookshelf();
|
||||
$this->baseRepo->create($shelf, $input);
|
||||
$shelf = $this->baseRepo->create(new Bookshelf(), $input);
|
||||
$this->baseRepo->updateCoverImage($shelf, $input['image'] ?? null);
|
||||
$this->updateBooks($shelf, $bookIds);
|
||||
Activity::add(ActivityType::BOOKSHELF_CREATE, $shelf);
|
||||
@@ -39,7 +38,7 @@ class BookshelfRepo
|
||||
*/
|
||||
public function update(Bookshelf $shelf, array $input, ?array $bookIds): Bookshelf
|
||||
{
|
||||
$this->baseRepo->update($shelf, $input);
|
||||
$shelf = $this->baseRepo->update($shelf, $input);
|
||||
|
||||
if (!is_null($bookIds)) {
|
||||
$this->updateBooks($shelf, $bookIds);
|
||||
@@ -96,7 +95,7 @@ class BookshelfRepo
|
||||
*
|
||||
* @throws Exception
|
||||
*/
|
||||
public function destroy(Bookshelf $shelf)
|
||||
public function destroy(Bookshelf $shelf): void
|
||||
{
|
||||
$this->trashCan->softDestroyShelf($shelf);
|
||||
Activity::add(ActivityType::BOOKSHELF_DELETE, $shelf);
|
||||
|
||||
@@ -7,6 +7,7 @@ use BookStack\Entities\Models\Book;
|
||||
use BookStack\Entities\Models\Chapter;
|
||||
use BookStack\Entities\Queries\EntityQueries;
|
||||
use BookStack\Entities\Tools\BookContents;
|
||||
use BookStack\Entities\Tools\ParentChanger;
|
||||
use BookStack\Entities\Tools\TrashCan;
|
||||
use BookStack\Exceptions\MoveOperationException;
|
||||
use BookStack\Exceptions\PermissionsException;
|
||||
@@ -21,6 +22,7 @@ class ChapterRepo
|
||||
protected BaseRepo $baseRepo,
|
||||
protected EntityQueries $entityQueries,
|
||||
protected TrashCan $trashCan,
|
||||
protected ParentChanger $parentChanger,
|
||||
) {
|
||||
}
|
||||
|
||||
@@ -33,8 +35,11 @@ class ChapterRepo
|
||||
$chapter = new Chapter();
|
||||
$chapter->book_id = $parentBook->id;
|
||||
$chapter->priority = (new BookContents($parentBook))->getLastPriority() + 1;
|
||||
$this->baseRepo->create($chapter, $input);
|
||||
$this->baseRepo->updateDefaultTemplate($chapter, intval($input['default_template_id'] ?? null));
|
||||
|
||||
$chapter = $this->baseRepo->create($chapter, $input);
|
||||
$chapter->defaultTemplate()->setFromId(intval($input['default_template_id'] ?? null));
|
||||
|
||||
$chapter->save();
|
||||
Activity::add(ActivityType::CHAPTER_CREATE, $chapter);
|
||||
|
||||
$this->baseRepo->sortParent($chapter);
|
||||
@@ -48,12 +53,13 @@ class ChapterRepo
|
||||
*/
|
||||
public function update(Chapter $chapter, array $input): Chapter
|
||||
{
|
||||
$this->baseRepo->update($chapter, $input);
|
||||
$chapter = $this->baseRepo->update($chapter, $input);
|
||||
|
||||
if (array_key_exists('default_template_id', $input)) {
|
||||
$this->baseRepo->updateDefaultTemplate($chapter, intval($input['default_template_id']));
|
||||
$chapter->defaultTemplate()->setFromId(intval($input['default_template_id']));
|
||||
}
|
||||
|
||||
$chapter->save();
|
||||
Activity::add(ActivityType::CHAPTER_UPDATE, $chapter);
|
||||
|
||||
$this->baseRepo->sortParent($chapter);
|
||||
@@ -66,7 +72,7 @@ class ChapterRepo
|
||||
*
|
||||
* @throws Exception
|
||||
*/
|
||||
public function destroy(Chapter $chapter)
|
||||
public function destroy(Chapter $chapter): void
|
||||
{
|
||||
$this->trashCan->softDestroyChapter($chapter);
|
||||
Activity::add(ActivityType::CHAPTER_DELETE, $chapter);
|
||||
@@ -93,7 +99,7 @@ class ChapterRepo
|
||||
}
|
||||
|
||||
return (new DatabaseTransaction(function () use ($chapter, $parent) {
|
||||
$chapter->changeBook($parent->id);
|
||||
$this->parentChanger->changeBook($chapter, $parent->id);
|
||||
$chapter->rebuildPermissions();
|
||||
Activity::add(ActivityType::CHAPTER_MOVE, $chapter);
|
||||
|
||||
|
||||
@@ -9,11 +9,9 @@ use BookStack\Facades\Activity;
|
||||
|
||||
class DeletionRepo
|
||||
{
|
||||
private TrashCan $trashCan;
|
||||
|
||||
public function __construct(TrashCan $trashCan)
|
||||
{
|
||||
$this->trashCan = $trashCan;
|
||||
public function __construct(
|
||||
protected TrashCan $trashCan
|
||||
) {
|
||||
}
|
||||
|
||||
public function restore(int $id): int
|
||||
|
||||
@@ -12,6 +12,7 @@ use BookStack\Entities\Queries\EntityQueries;
|
||||
use BookStack\Entities\Tools\BookContents;
|
||||
use BookStack\Entities\Tools\PageContent;
|
||||
use BookStack\Entities\Tools\PageEditorType;
|
||||
use BookStack\Entities\Tools\ParentChanger;
|
||||
use BookStack\Entities\Tools\TrashCan;
|
||||
use BookStack\Exceptions\MoveOperationException;
|
||||
use BookStack\Exceptions\PermissionsException;
|
||||
@@ -31,13 +32,14 @@ class PageRepo
|
||||
protected ReferenceStore $referenceStore,
|
||||
protected ReferenceUpdater $referenceUpdater,
|
||||
protected TrashCan $trashCan,
|
||||
protected ParentChanger $parentChanger,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a new draft page belonging to the given parent entity.
|
||||
*/
|
||||
public function getNewDraftPage(Entity $parent)
|
||||
public function getNewDraftPage(Entity $parent): Page
|
||||
{
|
||||
$page = (new Page())->forceFill([
|
||||
'name' => trans('entities.pages_initial_name'),
|
||||
@@ -46,6 +48,9 @@ class PageRepo
|
||||
'updated_by' => user()->id,
|
||||
'draft' => true,
|
||||
'editor' => PageEditorType::getSystemDefault()->value,
|
||||
'html' => '',
|
||||
'markdown' => '',
|
||||
'text' => '',
|
||||
]);
|
||||
|
||||
if ($parent instanceof Chapter) {
|
||||
@@ -55,17 +60,18 @@ class PageRepo
|
||||
$page->book_id = $parent->id;
|
||||
}
|
||||
|
||||
$defaultTemplate = $page->chapter->defaultTemplate ?? $page->book->defaultTemplate;
|
||||
if ($defaultTemplate && userCan(Permission::PageView, $defaultTemplate)) {
|
||||
$defaultTemplate = $page->chapter?->defaultTemplate()->get() ?? $page->book?->defaultTemplate()->get();
|
||||
if ($defaultTemplate) {
|
||||
$page->forceFill([
|
||||
'html' => $defaultTemplate->html,
|
||||
'markdown' => $defaultTemplate->markdown,
|
||||
]);
|
||||
$page->text = (new PageContent($page))->toPlainText();
|
||||
}
|
||||
|
||||
(new DatabaseTransaction(function () use ($page) {
|
||||
$page->save();
|
||||
$page->refresh()->rebuildPermissions();
|
||||
$page->rebuildPermissions();
|
||||
}))->run();
|
||||
|
||||
return $page;
|
||||
@@ -81,7 +87,8 @@ class PageRepo
|
||||
$draft->revision_count = 1;
|
||||
$draft->priority = $this->getNewPriority($draft);
|
||||
$this->updateTemplateStatusAndContentFromInput($draft, $input);
|
||||
$this->baseRepo->update($draft, $input);
|
||||
|
||||
$draft = $this->baseRepo->update($draft, $input);
|
||||
$draft->rebuildPermissions();
|
||||
|
||||
$summary = trim($input['summary'] ?? '') ?: trans('entities.pages_initial_revision');
|
||||
@@ -112,12 +119,12 @@ class PageRepo
|
||||
public function update(Page $page, array $input): Page
|
||||
{
|
||||
// Hold the old details to compare later
|
||||
$oldHtml = $page->html;
|
||||
$oldName = $page->name;
|
||||
$oldHtml = $page->html;
|
||||
$oldMarkdown = $page->markdown;
|
||||
|
||||
$this->updateTemplateStatusAndContentFromInput($page, $input);
|
||||
$this->baseRepo->update($page, $input);
|
||||
$page = $this->baseRepo->update($page, $input);
|
||||
|
||||
// Update with new details
|
||||
$page->revision_count++;
|
||||
@@ -176,12 +183,12 @@ class PageRepo
|
||||
/**
|
||||
* Save a page update draft.
|
||||
*/
|
||||
public function updatePageDraft(Page $page, array $input)
|
||||
public function updatePageDraft(Page $page, array $input): Page|PageRevision
|
||||
{
|
||||
// If the page itself is a draft simply update that
|
||||
// If the page itself is a draft, simply update that
|
||||
if ($page->draft) {
|
||||
$this->updateTemplateStatusAndContentFromInput($page, $input);
|
||||
$page->fill($input);
|
||||
$page->forceFill(array_intersect_key($input, array_flip(['name'])))->save();
|
||||
$page->save();
|
||||
|
||||
return $page;
|
||||
@@ -209,7 +216,7 @@ class PageRepo
|
||||
*
|
||||
* @throws Exception
|
||||
*/
|
||||
public function destroy(Page $page)
|
||||
public function destroy(Page $page): void
|
||||
{
|
||||
$this->trashCan->softDestroyPage($page);
|
||||
Activity::add(ActivityType::PAGE_DELETE, $page);
|
||||
@@ -237,7 +244,7 @@ class PageRepo
|
||||
}
|
||||
|
||||
$page->updated_by = user()->id;
|
||||
$page->refreshSlug();
|
||||
$this->baseRepo->refreshSlug($page);
|
||||
$page->save();
|
||||
$page->indexForSearch();
|
||||
$this->referenceStore->updateForEntity($page);
|
||||
@@ -279,7 +286,7 @@ class PageRepo
|
||||
return (new DatabaseTransaction(function () use ($page, $parent) {
|
||||
$page->chapter_id = ($parent instanceof Chapter) ? $parent->id : null;
|
||||
$newBookId = ($parent instanceof Chapter) ? $parent->book->id : $parent->id;
|
||||
$page->changeBook($newBookId);
|
||||
$this->parentChanger->changeBook($page, $newBookId);
|
||||
$page->rebuildPermissions();
|
||||
|
||||
Activity::add(ActivityType::PAGE_MOVE, $page);
|
||||
|
||||
@@ -23,7 +23,7 @@ class RevisionRepo
|
||||
|
||||
/**
|
||||
* Get a user update_draft page revision to update for the given page.
|
||||
* Checks for an existing revisions before providing a fresh one.
|
||||
* Checks for an existing revision before providing a fresh one.
|
||||
*/
|
||||
public function getNewDraftForCurrentUser(Page $page): PageRevision
|
||||
{
|
||||
@@ -72,7 +72,7 @@ class RevisionRepo
|
||||
/**
|
||||
* Delete old revisions, for the given page, from the system.
|
||||
*/
|
||||
protected function deleteOldRevisions(Page $page)
|
||||
protected function deleteOldRevisions(Page $page): void
|
||||
{
|
||||
$revisionLimit = config('app.revision_limit');
|
||||
if ($revisionLimit === false) {
|
||||
|
||||
@@ -3,13 +3,10 @@
|
||||
namespace BookStack\Entities\Tools;
|
||||
|
||||
use BookStack\Entities\Models\Book;
|
||||
use BookStack\Entities\Models\BookChild;
|
||||
use BookStack\Entities\Models\Chapter;
|
||||
use BookStack\Entities\Models\Entity;
|
||||
use BookStack\Entities\Models\Page;
|
||||
use BookStack\Entities\Queries\EntityQueries;
|
||||
use BookStack\Sorting\BookSortMap;
|
||||
use BookStack\Sorting\BookSortMapItem;
|
||||
use Illuminate\Support\Collection;
|
||||
|
||||
class BookContents
|
||||
@@ -29,7 +26,7 @@ class BookContents
|
||||
{
|
||||
$maxPage = $this->book->pages()
|
||||
->where('draft', '=', false)
|
||||
->where('chapter_id', '=', 0)
|
||||
->whereDoesntHave('chapter')
|
||||
->max('priority');
|
||||
|
||||
$maxChapter = $this->book->chapters()
|
||||
@@ -80,11 +77,11 @@ class BookContents
|
||||
protected function bookChildSortFunc(): callable
|
||||
{
|
||||
return function (Entity $entity) {
|
||||
if (isset($entity['draft']) && $entity['draft']) {
|
||||
if ($entity->getAttribute('draft') ?? false) {
|
||||
return -100;
|
||||
}
|
||||
|
||||
return $entity['priority'] ?? 0;
|
||||
return $entity->getAttribute('priority') ?? 0;
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -6,37 +6,54 @@ use BookStack\Activity\Models\Tag;
|
||||
use BookStack\Entities\Models\Book;
|
||||
use BookStack\Entities\Models\Bookshelf;
|
||||
use BookStack\Entities\Models\Chapter;
|
||||
use BookStack\Entities\Models\HasCoverInterface;
|
||||
use BookStack\Entities\Models\Entity;
|
||||
use BookStack\Entities\Models\CoverImageInterface;
|
||||
use BookStack\Entities\Models\Page;
|
||||
use BookStack\Entities\Repos\BookRepo;
|
||||
use BookStack\Entities\Repos\ChapterRepo;
|
||||
use BookStack\Entities\Repos\PageRepo;
|
||||
use BookStack\Permissions\Permission;
|
||||
use BookStack\References\ReferenceChangeContext;
|
||||
use BookStack\References\ReferenceUpdater;
|
||||
use BookStack\Uploads\Image;
|
||||
use BookStack\Uploads\ImageService;
|
||||
use Illuminate\Http\UploadedFile;
|
||||
|
||||
class Cloner
|
||||
{
|
||||
protected ReferenceChangeContext $referenceChangeContext;
|
||||
|
||||
public function __construct(
|
||||
protected PageRepo $pageRepo,
|
||||
protected ChapterRepo $chapterRepo,
|
||||
protected BookRepo $bookRepo,
|
||||
protected ImageService $imageService,
|
||||
protected ReferenceUpdater $referenceUpdater,
|
||||
) {
|
||||
$this->referenceChangeContext = new ReferenceChangeContext();
|
||||
}
|
||||
|
||||
/**
|
||||
* Clone the given page into the given parent using the provided name.
|
||||
*/
|
||||
public function clonePage(Page $original, Entity $parent, string $newName): Page
|
||||
{
|
||||
$context = $this->newReferenceChangeContext();
|
||||
$page = $this->createPageClone($original, $parent, $newName);
|
||||
$this->referenceUpdater->changeReferencesUsingContext($context);
|
||||
return $page;
|
||||
}
|
||||
|
||||
protected function createPageClone(Page $original, Entity $parent, string $newName): Page
|
||||
{
|
||||
$copyPage = $this->pageRepo->getNewDraftPage($parent);
|
||||
$pageData = $this->entityToInputData($original);
|
||||
$pageData['name'] = $newName;
|
||||
|
||||
return $this->pageRepo->publishDraft($copyPage, $pageData);
|
||||
$newPage = $this->pageRepo->publishDraft($copyPage, $pageData);
|
||||
$this->referenceChangeContext->add($original, $newPage);
|
||||
|
||||
return $newPage;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -44,6 +61,14 @@ class Cloner
|
||||
* Clones all child pages.
|
||||
*/
|
||||
public function cloneChapter(Chapter $original, Book $parent, string $newName): Chapter
|
||||
{
|
||||
$context = $this->newReferenceChangeContext();
|
||||
$chapter = $this->createChapterClone($original, $parent, $newName);
|
||||
$this->referenceUpdater->changeReferencesUsingContext($context);
|
||||
return $chapter;
|
||||
}
|
||||
|
||||
protected function createChapterClone(Chapter $original, Book $parent, string $newName): Chapter
|
||||
{
|
||||
$chapterDetails = $this->entityToInputData($original);
|
||||
$chapterDetails['name'] = $newName;
|
||||
@@ -53,10 +78,12 @@ class Cloner
|
||||
if (userCan(Permission::PageCreate, $copyChapter)) {
|
||||
/** @var Page $page */
|
||||
foreach ($original->getVisiblePages() as $page) {
|
||||
$this->clonePage($page, $copyChapter, $page->name);
|
||||
$this->createPageClone($page, $copyChapter, $page->name);
|
||||
}
|
||||
}
|
||||
|
||||
$this->referenceChangeContext->add($original, $copyChapter);
|
||||
|
||||
return $copyChapter;
|
||||
}
|
||||
|
||||
@@ -65,6 +92,14 @@ class Cloner
|
||||
* Clones all child chapters and pages.
|
||||
*/
|
||||
public function cloneBook(Book $original, string $newName): Book
|
||||
{
|
||||
$context = $this->newReferenceChangeContext();
|
||||
$book = $this->createBookClone($original, $newName);
|
||||
$this->referenceUpdater->changeReferencesUsingContext($context);
|
||||
return $book;
|
||||
}
|
||||
|
||||
protected function createBookClone(Book $original, string $newName): Book
|
||||
{
|
||||
$bookDetails = $this->entityToInputData($original);
|
||||
$bookDetails['name'] = $newName;
|
||||
@@ -76,11 +111,11 @@ class Cloner
|
||||
$directChildren = $original->getDirectVisibleChildren();
|
||||
foreach ($directChildren as $child) {
|
||||
if ($child instanceof Chapter && userCan(Permission::ChapterCreate, $copyBook)) {
|
||||
$this->cloneChapter($child, $copyBook, $child->name);
|
||||
$this->createChapterClone($child, $copyBook, $child->name);
|
||||
}
|
||||
|
||||
if ($child instanceof Page && !$child->draft && userCan(Permission::PageCreate, $copyBook)) {
|
||||
$this->clonePage($child, $copyBook, $child->name);
|
||||
$this->createPageClone($child, $copyBook, $child->name);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -92,6 +127,8 @@ class Cloner
|
||||
}
|
||||
}
|
||||
|
||||
$this->referenceChangeContext->add($original, $copyBook);
|
||||
|
||||
return $copyBook;
|
||||
}
|
||||
|
||||
@@ -106,8 +143,8 @@ class Cloner
|
||||
$inputData['tags'] = $this->entityTagsToInputArray($entity);
|
||||
|
||||
// Add a cover to the data if existing on the original entity
|
||||
if ($entity instanceof CoverImageInterface) {
|
||||
$cover = $entity->cover()->first();
|
||||
if ($entity instanceof HasCoverInterface) {
|
||||
$cover = $entity->coverInfo()->getImage();
|
||||
if ($cover) {
|
||||
$inputData['image'] = $this->imageToUploadedFile($cover);
|
||||
}
|
||||
@@ -155,4 +192,10 @@ class Cloner
|
||||
|
||||
return $tags;
|
||||
}
|
||||
|
||||
protected function newReferenceChangeContext(): ReferenceChangeContext
|
||||
{
|
||||
$this->referenceChangeContext = new ReferenceChangeContext();
|
||||
return $this->referenceChangeContext;
|
||||
}
|
||||
}
|
||||
|
||||
75
app/Entities/Tools/EntityCover.php
Normal file
75
app/Entities/Tools/EntityCover.php
Normal file
@@ -0,0 +1,75 @@
|
||||
<?php
|
||||
|
||||
namespace BookStack\Entities\Tools;
|
||||
|
||||
use BookStack\Entities\Models\Book;
|
||||
use BookStack\Entities\Models\Bookshelf;
|
||||
use BookStack\Uploads\Image;
|
||||
use Exception;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
|
||||
class EntityCover
|
||||
{
|
||||
public function __construct(
|
||||
protected Book|Bookshelf $entity,
|
||||
) {
|
||||
}
|
||||
|
||||
protected function imageQuery(): Builder
|
||||
{
|
||||
return Image::query()->where('id', '=', $this->entity->image_id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a cover image exists for this entity.
|
||||
*/
|
||||
public function exists(): bool
|
||||
{
|
||||
return $this->entity->image_id !== null && $this->imageQuery()->exists();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the assigned cover image model.
|
||||
*/
|
||||
public function getImage(): Image|null
|
||||
{
|
||||
if ($this->entity->image_id === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$cover = $this->imageQuery()->first();
|
||||
if ($cover instanceof Image) {
|
||||
return $cover;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a cover image URL, or the given default if none assigned/existing.
|
||||
*/
|
||||
public function getUrl(int $width = 440, int $height = 250, string|null $default = 'data:image/gif;base64,R0lGODlhAQABAAAAACH5BAEKAAEALAAAAAABAAEAAAICTAEAOw=='): string|null
|
||||
{
|
||||
if (!$this->entity->image_id) {
|
||||
return $default;
|
||||
}
|
||||
|
||||
try {
|
||||
return $this->getImage()?->getThumb($width, $height, false) ?? $default;
|
||||
} catch (Exception $err) {
|
||||
return $default;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the image to use as the cover for this entity.
|
||||
*/
|
||||
public function setImage(Image|null $image): void
|
||||
{
|
||||
if ($image === null) {
|
||||
$this->entity->image_id = null;
|
||||
} else {
|
||||
$this->entity->image_id = $image->id;
|
||||
}
|
||||
}
|
||||
}
|
||||
60
app/Entities/Tools/EntityDefaultTemplate.php
Normal file
60
app/Entities/Tools/EntityDefaultTemplate.php
Normal file
@@ -0,0 +1,60 @@
|
||||
<?php
|
||||
|
||||
namespace BookStack\Entities\Tools;
|
||||
|
||||
use BookStack\Entities\Models\Book;
|
||||
use BookStack\Entities\Models\Chapter;
|
||||
use BookStack\Entities\Models\Page;
|
||||
use BookStack\Entities\Queries\PageQueries;
|
||||
|
||||
class EntityDefaultTemplate
|
||||
{
|
||||
public function __construct(
|
||||
protected Book|Chapter $entity,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the default template ID for this entity.
|
||||
*/
|
||||
public function setFromId(int $templateId): void
|
||||
{
|
||||
$changing = $templateId !== intval($this->entity->default_template_id);
|
||||
if (!$changing) {
|
||||
return;
|
||||
}
|
||||
|
||||
if ($templateId === 0) {
|
||||
$this->entity->default_template_id = null;
|
||||
return;
|
||||
}
|
||||
|
||||
$pageQueries = app()->make(PageQueries::class);
|
||||
$templateExists = $pageQueries->visibleTemplates()
|
||||
->where('id', '=', $templateId)
|
||||
->exists();
|
||||
|
||||
$this->entity->default_template_id = $templateExists ? $templateId : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the default template for this entity (if visible).
|
||||
*/
|
||||
public function get(): Page|null
|
||||
{
|
||||
if (!$this->entity->default_template_id) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$pageQueries = app()->make(PageQueries::class);
|
||||
$page = $pageQueries->visibleTemplates(true)
|
||||
->where('id', '=', $this->entity->default_template_id)
|
||||
->first();
|
||||
|
||||
if ($page instanceof Page) {
|
||||
return $page;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
60
app/Entities/Tools/EntityHtmlDescription.php
Normal file
60
app/Entities/Tools/EntityHtmlDescription.php
Normal file
@@ -0,0 +1,60 @@
|
||||
<?php
|
||||
|
||||
namespace BookStack\Entities\Tools;
|
||||
|
||||
use BookStack\Entities\Models\Book;
|
||||
use BookStack\Entities\Models\Bookshelf;
|
||||
use BookStack\Entities\Models\Chapter;
|
||||
use BookStack\Util\HtmlContentFilter;
|
||||
|
||||
class EntityHtmlDescription
|
||||
{
|
||||
protected string $html = '';
|
||||
protected string $plain = '';
|
||||
|
||||
public function __construct(
|
||||
protected Book|Chapter|Bookshelf $entity,
|
||||
) {
|
||||
$this->html = $this->entity->description_html ?? '';
|
||||
$this->plain = $this->entity->description ?? '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the description from HTML code.
|
||||
* Optionally takes plaintext to use for the model also.
|
||||
*/
|
||||
public function set(string $html, string|null $plaintext = null): void
|
||||
{
|
||||
$this->html = $html;
|
||||
$this->entity->description_html = $this->html;
|
||||
|
||||
if ($plaintext !== null) {
|
||||
$this->plain = $plaintext;
|
||||
$this->entity->description = $this->plain;
|
||||
}
|
||||
|
||||
if (empty($html) && !empty($plaintext)) {
|
||||
$this->html = $this->getHtml();
|
||||
$this->entity->description_html = $this->html;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the description as HTML.
|
||||
* Optionally returns the raw HTML if requested.
|
||||
*/
|
||||
public function getHtml(bool $raw = false): string
|
||||
{
|
||||
$html = $this->html ?: '<p>' . nl2br(e($this->plain)) . '</p>';
|
||||
if ($raw) {
|
||||
return $html;
|
||||
}
|
||||
|
||||
return HtmlContentFilter::removeScriptsFromHtmlString($html);
|
||||
}
|
||||
|
||||
public function getPlain(): string
|
||||
{
|
||||
return $this->plain;
|
||||
}
|
||||
}
|
||||
140
app/Entities/Tools/EntityHydrator.php
Normal file
140
app/Entities/Tools/EntityHydrator.php
Normal file
@@ -0,0 +1,140 @@
|
||||
<?php
|
||||
|
||||
namespace BookStack\Entities\Tools;
|
||||
|
||||
use BookStack\Activity\Models\Tag;
|
||||
use BookStack\Entities\Models\Chapter;
|
||||
use BookStack\Entities\Models\Entity;
|
||||
use BookStack\Entities\Models\EntityTable;
|
||||
use BookStack\Entities\Models\Page;
|
||||
use BookStack\Entities\Queries\EntityQueries;
|
||||
use Illuminate\Database\Eloquent\Collection;
|
||||
|
||||
class EntityHydrator
|
||||
{
|
||||
public function __construct(
|
||||
protected EntityQueries $entityQueries,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Hydrate the entities of this hydrator to return a list of entities represented
|
||||
* in their original intended models.
|
||||
* @param EntityTable[] $entities
|
||||
* @return Entity[]
|
||||
*/
|
||||
public function hydrate(array $entities, bool $loadTags = false, bool $loadParents = false): array
|
||||
{
|
||||
$hydrated = [];
|
||||
|
||||
foreach ($entities as $entity) {
|
||||
$data = $entity->getRawOriginal();
|
||||
$instance = Entity::instanceFromType($entity->type);
|
||||
|
||||
if ($instance instanceof Page) {
|
||||
$data['text'] = $data['description'];
|
||||
unset($data['description']);
|
||||
}
|
||||
|
||||
$instance = $instance->setRawAttributes($data, true);
|
||||
$hydrated[] = $instance;
|
||||
}
|
||||
|
||||
if ($loadTags) {
|
||||
$this->loadTagsIntoModels($hydrated);
|
||||
}
|
||||
|
||||
if ($loadParents) {
|
||||
$this->loadParentsIntoModels($hydrated);
|
||||
}
|
||||
|
||||
return $hydrated;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param Entity[] $entities
|
||||
*/
|
||||
protected function loadTagsIntoModels(array $entities): void
|
||||
{
|
||||
$idsByType = [];
|
||||
$entityMap = [];
|
||||
foreach ($entities as $entity) {
|
||||
if (!isset($idsByType[$entity->type])) {
|
||||
$idsByType[$entity->type] = [];
|
||||
}
|
||||
$idsByType[$entity->type][] = $entity->id;
|
||||
$entityMap[$entity->type . ':' . $entity->id] = $entity;
|
||||
}
|
||||
|
||||
$query = Tag::query();
|
||||
foreach ($idsByType as $type => $ids) {
|
||||
$query->orWhere(function ($query) use ($type, $ids) {
|
||||
$query->where('entity_type', '=', $type)
|
||||
->whereIn('entity_id', $ids);
|
||||
});
|
||||
}
|
||||
|
||||
$tags = empty($idsByType) ? [] : $query->get()->all();
|
||||
$tagMap = [];
|
||||
foreach ($tags as $tag) {
|
||||
$key = $tag->entity_type . ':' . $tag->entity_id;
|
||||
if (!isset($tagMap[$key])) {
|
||||
$tagMap[$key] = [];
|
||||
}
|
||||
$tagMap[$key][] = $tag;
|
||||
}
|
||||
|
||||
foreach ($entityMap as $key => $entity) {
|
||||
$entityTags = new Collection($tagMap[$key] ?? []);
|
||||
$entity->setRelation('tags', $entityTags);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param Entity[] $entities
|
||||
*/
|
||||
protected function loadParentsIntoModels(array $entities): void
|
||||
{
|
||||
$parentsByType = ['book' => [], 'chapter' => []];
|
||||
|
||||
foreach ($entities as $entity) {
|
||||
if ($entity->getAttribute('book_id') !== null) {
|
||||
$parentsByType['book'][] = $entity->getAttribute('book_id');
|
||||
}
|
||||
if ($entity->getAttribute('chapter_id') !== null) {
|
||||
$parentsByType['chapter'][] = $entity->getAttribute('chapter_id');
|
||||
}
|
||||
}
|
||||
|
||||
$parentQuery = $this->entityQueries->visibleForList();
|
||||
$filtered = count($parentsByType['book']) > 0 || count($parentsByType['chapter']) > 0;
|
||||
$parentQuery = $parentQuery->where(function ($query) use ($parentsByType) {
|
||||
foreach ($parentsByType as $type => $ids) {
|
||||
if (count($ids) > 0) {
|
||||
$query = $query->orWhere(function ($query) use ($type, $ids) {
|
||||
$query->where('type', '=', $type)
|
||||
->whereIn('id', $ids);
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
$parentModels = $filtered ? $parentQuery->get()->all() : [];
|
||||
$parents = $this->hydrate($parentModels);
|
||||
$parentMap = [];
|
||||
foreach ($parents as $parent) {
|
||||
$parentMap[$parent->type . ':' . $parent->id] = $parent;
|
||||
}
|
||||
|
||||
foreach ($entities as $entity) {
|
||||
if ($entity instanceof Page || $entity instanceof Chapter) {
|
||||
$key = 'book:' . $entity->getRawAttribute('book_id');
|
||||
$entity->setRelation('book', $parentMap[$key] ?? null);
|
||||
}
|
||||
if ($entity instanceof Page) {
|
||||
$key = 'chapter:' . $entity->getRawAttribute('chapter_id');
|
||||
$entity->setRelation('chapter', $parentMap[$key] ?? null);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -17,7 +17,8 @@ class HierarchyTransformer
|
||||
protected BookRepo $bookRepo,
|
||||
protected BookshelfRepo $shelfRepo,
|
||||
protected Cloner $cloner,
|
||||
protected TrashCan $trashCan
|
||||
protected TrashCan $trashCan,
|
||||
protected ParentChanger $parentChanger,
|
||||
) {
|
||||
}
|
||||
|
||||
@@ -34,7 +35,8 @@ class HierarchyTransformer
|
||||
/** @var Page $page */
|
||||
foreach ($chapter->pages as $page) {
|
||||
$page->chapter_id = 0;
|
||||
$page->changeBook($book->id);
|
||||
$page->save();
|
||||
$this->parentChanger->changeBook($page, $book->id);
|
||||
}
|
||||
|
||||
$this->trashCan->destroyEntity($chapter);
|
||||
|
||||
@@ -19,7 +19,7 @@ class MixedEntityListLoader
|
||||
* This will look for a model id and type via 'name_id' and 'name_type'.
|
||||
* @param Model[] $relations
|
||||
*/
|
||||
public function loadIntoRelations(array $relations, string $relationName, bool $loadParents): void
|
||||
public function loadIntoRelations(array $relations, string $relationName, bool $loadParents, bool $withContents = false): void
|
||||
{
|
||||
$idsByType = [];
|
||||
foreach ($relations as $relation) {
|
||||
@@ -33,7 +33,7 @@ class MixedEntityListLoader
|
||||
$idsByType[$type][] = $id;
|
||||
}
|
||||
|
||||
$modelMap = $this->idsByTypeToModelMap($idsByType, $loadParents);
|
||||
$modelMap = $this->idsByTypeToModelMap($idsByType, $loadParents, $withContents);
|
||||
|
||||
foreach ($relations as $relation) {
|
||||
$type = $relation->getAttribute($relationName . '_type');
|
||||
@@ -49,13 +49,13 @@ class MixedEntityListLoader
|
||||
* @param array<string, int[]> $idsByType
|
||||
* @return array<string, array<int, Model>>
|
||||
*/
|
||||
protected function idsByTypeToModelMap(array $idsByType, bool $eagerLoadParents): array
|
||||
protected function idsByTypeToModelMap(array $idsByType, bool $eagerLoadParents, bool $withContents): array
|
||||
{
|
||||
$modelMap = [];
|
||||
|
||||
foreach ($idsByType as $type => $ids) {
|
||||
$models = $this->queries->visibleForList($type)
|
||||
->whereIn('id', $ids)
|
||||
$base = $withContents ? $this->queries->visibleForContentForType($type) : $this->queries->visibleForListForType($type);
|
||||
$models = $base->whereIn('id', $ids)
|
||||
->with($eagerLoadParents ? $this->getRelationsToEagerLoad($type) : [])
|
||||
->get();
|
||||
|
||||
|
||||
@@ -284,7 +284,7 @@ class PageContent
|
||||
/**
|
||||
* Get a plain-text visualisation of this page.
|
||||
*/
|
||||
protected function toPlainText(): string
|
||||
public function toPlainText(): string
|
||||
{
|
||||
$html = $this->render(true);
|
||||
|
||||
|
||||
40
app/Entities/Tools/ParentChanger.php
Normal file
40
app/Entities/Tools/ParentChanger.php
Normal file
@@ -0,0 +1,40 @@
|
||||
<?php
|
||||
|
||||
namespace BookStack\Entities\Tools;
|
||||
|
||||
use BookStack\Entities\Models\BookChild;
|
||||
use BookStack\Entities\Models\Chapter;
|
||||
use BookStack\References\ReferenceUpdater;
|
||||
|
||||
class ParentChanger
|
||||
{
|
||||
public function __construct(
|
||||
protected SlugGenerator $slugGenerator,
|
||||
protected ReferenceUpdater $referenceUpdater
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Change the parent book of a chapter or page.
|
||||
*/
|
||||
public function changeBook(BookChild $child, int $newBookId): void
|
||||
{
|
||||
$oldUrl = $child->getUrl();
|
||||
|
||||
$child->book_id = $newBookId;
|
||||
$child->unsetRelation('book');
|
||||
$this->slugGenerator->regenerateForEntity($child);
|
||||
$child->save();
|
||||
|
||||
if ($oldUrl !== $child->getUrl()) {
|
||||
$this->referenceUpdater->updateEntityReferences($child, $oldUrl);
|
||||
}
|
||||
|
||||
// Update all child pages if a chapter
|
||||
if ($child instanceof Chapter) {
|
||||
foreach ($child->pages()->withTrashed()->get() as $page) {
|
||||
$this->changeBook($page, $newBookId);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -5,12 +5,14 @@ namespace BookStack\Entities\Tools;
|
||||
use BookStack\App\Model;
|
||||
use BookStack\App\SluggableInterface;
|
||||
use BookStack\Entities\Models\BookChild;
|
||||
use BookStack\Entities\Models\Entity;
|
||||
use BookStack\Users\Models\User;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
class SlugGenerator
|
||||
{
|
||||
/**
|
||||
* Generate a fresh slug for the given entity.
|
||||
* Generate a fresh slug for the given item.
|
||||
* The slug will be generated so that it doesn't conflict within the same parent item.
|
||||
*/
|
||||
public function generate(SluggableInterface&Model $model, string $slugSource): string
|
||||
@@ -23,6 +25,26 @@ class SlugGenerator
|
||||
return $slug;
|
||||
}
|
||||
|
||||
/**
|
||||
* Regenerate the slug for the given entity.
|
||||
*/
|
||||
public function regenerateForEntity(Entity $entity): string
|
||||
{
|
||||
$entity->slug = $this->generate($entity, $entity->name);
|
||||
|
||||
return $entity->slug;
|
||||
}
|
||||
|
||||
/**
|
||||
* Regenerate the slug for a user.
|
||||
*/
|
||||
public function regenerateForUser(User $user): string
|
||||
{
|
||||
$user->slug = $this->generate($user, $user->name);
|
||||
|
||||
return $user->slug;
|
||||
}
|
||||
|
||||
/**
|
||||
* Format a name as a URL slug.
|
||||
*/
|
||||
|
||||
97
app/Entities/Tools/SlugHistory.php
Normal file
97
app/Entities/Tools/SlugHistory.php
Normal file
@@ -0,0 +1,97 @@
|
||||
<?php
|
||||
|
||||
namespace BookStack\Entities\Tools;
|
||||
|
||||
use BookStack\Entities\Models\Book;
|
||||
use BookStack\Entities\Models\BookChild;
|
||||
use BookStack\Entities\Models\Entity;
|
||||
use BookStack\Entities\Models\EntityTable;
|
||||
use BookStack\Entities\Models\SlugHistory as SlugHistoryModel;
|
||||
use BookStack\Permissions\PermissionApplicator;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class SlugHistory
|
||||
{
|
||||
public function __construct(
|
||||
protected PermissionApplicator $permissions,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Record the current slugs for the given entity.
|
||||
*/
|
||||
public function recordForEntity(Entity $entity): void
|
||||
{
|
||||
if (!$entity->id || !$entity->slug) {
|
||||
return;
|
||||
}
|
||||
|
||||
$parentSlug = null;
|
||||
if ($entity instanceof BookChild) {
|
||||
$parentSlug = $entity->book()->first()?->slug;
|
||||
}
|
||||
|
||||
$latest = $this->getLatestEntryForEntity($entity);
|
||||
if ($latest && $latest->slug === $entity->slug && $latest->parent_slug === $parentSlug) {
|
||||
return;
|
||||
}
|
||||
|
||||
$info = [
|
||||
'sluggable_type' => $entity->getMorphClass(),
|
||||
'sluggable_id' => $entity->id,
|
||||
'slug' => $entity->slug,
|
||||
'parent_slug' => $parentSlug,
|
||||
];
|
||||
|
||||
$entry = new SlugHistoryModel();
|
||||
$entry->forceFill($info);
|
||||
$entry->save();
|
||||
|
||||
if ($entity instanceof Book) {
|
||||
$this->recordForBookChildren($entity);
|
||||
}
|
||||
}
|
||||
|
||||
protected function recordForBookChildren(Book $book): void
|
||||
{
|
||||
$query = EntityTable::query()
|
||||
->select(['type', 'id', 'slug', DB::raw("'{$book->slug}' as parent_slug"), DB::raw('now() as created_at'), DB::raw('now() as updated_at')])
|
||||
->where('book_id', '=', $book->id)
|
||||
->whereNotNull('book_id');
|
||||
|
||||
SlugHistoryModel::query()->insertUsing(
|
||||
['sluggable_type', 'sluggable_id', 'slug', 'parent_slug', 'created_at', 'updated_at'],
|
||||
$query
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the latest visible entry for an entity which uses the given slug(s) in the history.
|
||||
*/
|
||||
public function lookupEntityIdUsingSlugs(string $type, string $slug, string $parentSlug = ''): ?int
|
||||
{
|
||||
$query = SlugHistoryModel::query()
|
||||
->where('sluggable_type', '=', $type)
|
||||
->where('slug', '=', $slug);
|
||||
|
||||
if ($parentSlug) {
|
||||
$query->where('parent_slug', '=', $parentSlug);
|
||||
}
|
||||
|
||||
$query = $this->permissions->restrictEntityRelationQuery($query, 'slug_history', 'sluggable_id', 'sluggable_type');
|
||||
|
||||
/** @var SlugHistoryModel|null $result */
|
||||
$result = $query->orderBy('created_at', 'desc')->first();
|
||||
|
||||
return $result?->sluggable_id;
|
||||
}
|
||||
|
||||
protected function getLatestEntryForEntity(Entity $entity): SlugHistoryModel|null
|
||||
{
|
||||
return SlugHistoryModel::query()
|
||||
->where('sluggable_type', '=', $entity->getMorphClass())
|
||||
->where('sluggable_id', '=', $entity->id)
|
||||
->orderBy('created_at', 'desc')
|
||||
->first();
|
||||
}
|
||||
}
|
||||
@@ -6,14 +6,16 @@ use BookStack\Entities\EntityProvider;
|
||||
use BookStack\Entities\Models\Book;
|
||||
use BookStack\Entities\Models\Bookshelf;
|
||||
use BookStack\Entities\Models\Chapter;
|
||||
use BookStack\Entities\Models\EntityContainerData;
|
||||
use BookStack\Entities\Models\HasCoverInterface;
|
||||
use BookStack\Entities\Models\Deletion;
|
||||
use BookStack\Entities\Models\Entity;
|
||||
use BookStack\Entities\Models\CoverImageInterface;
|
||||
use BookStack\Entities\Models\Page;
|
||||
use BookStack\Entities\Queries\EntityQueries;
|
||||
use BookStack\Exceptions\NotifyException;
|
||||
use BookStack\Facades\Activity;
|
||||
use BookStack\Uploads\AttachmentService;
|
||||
use BookStack\Uploads\Image;
|
||||
use BookStack\Uploads\ImageService;
|
||||
use BookStack\Util\DatabaseTransaction;
|
||||
use Exception;
|
||||
@@ -140,6 +142,7 @@ class TrashCan
|
||||
protected function destroyShelf(Bookshelf $shelf): int
|
||||
{
|
||||
$this->destroyCommonRelations($shelf);
|
||||
$shelf->books()->detach();
|
||||
$shelf->forceDelete();
|
||||
|
||||
return 1;
|
||||
@@ -167,6 +170,7 @@ class TrashCan
|
||||
}
|
||||
|
||||
$this->destroyCommonRelations($book);
|
||||
$book->shelves()->detach();
|
||||
$book->forceDelete();
|
||||
|
||||
return $count + 1;
|
||||
@@ -209,15 +213,16 @@ class TrashCan
|
||||
$attachmentService->deleteFile($attachment);
|
||||
}
|
||||
|
||||
// Remove book template usages
|
||||
$this->queries->books->start()
|
||||
// Remove use as a template
|
||||
EntityContainerData::query()
|
||||
->where('default_template_id', '=', $page->id)
|
||||
->update(['default_template_id' => null]);
|
||||
|
||||
// Remove chapter template usages
|
||||
$this->queries->chapters->start()
|
||||
->where('default_template_id', '=', $page->id)
|
||||
->update(['default_template_id' => null]);
|
||||
// Nullify uploaded image relations
|
||||
Image::query()
|
||||
->whereIn('type', ['gallery', 'drawio'])
|
||||
->where('uploaded_to', '=', $page->id)
|
||||
->update(['uploaded_to' => null]);
|
||||
|
||||
$page->forceDelete();
|
||||
|
||||
@@ -268,8 +273,8 @@ class TrashCan
|
||||
// exists in the event it has already been destroyed during this request.
|
||||
$entity = $deletion->deletable()->first();
|
||||
$count = 0;
|
||||
if ($entity) {
|
||||
$count = $this->destroyEntity($deletion->deletable);
|
||||
if ($entity instanceof Entity) {
|
||||
$count = $this->destroyEntity($entity);
|
||||
}
|
||||
$deletion->delete();
|
||||
|
||||
@@ -383,7 +388,7 @@ class TrashCan
|
||||
/**
|
||||
* Update entity relations to remove or update outstanding connections.
|
||||
*/
|
||||
protected function destroyCommonRelations(Entity $entity)
|
||||
protected function destroyCommonRelations(Entity $entity): void
|
||||
{
|
||||
Activity::removeEntity($entity);
|
||||
$entity->views()->delete();
|
||||
@@ -397,10 +402,13 @@ class TrashCan
|
||||
$entity->watches()->delete();
|
||||
$entity->referencesTo()->delete();
|
||||
$entity->referencesFrom()->delete();
|
||||
$entity->slugHistory()->delete();
|
||||
|
||||
if ($entity instanceof CoverImageInterface && $entity->cover()->exists()) {
|
||||
if ($entity instanceof HasCoverInterface && $entity->coverInfo()->exists()) {
|
||||
$imageService = app()->make(ImageService::class);
|
||||
$imageService->destroy($entity->cover()->first());
|
||||
$imageService->destroy($entity->coverInfo()->getImage());
|
||||
}
|
||||
|
||||
$entity->relatedData()->delete();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -284,7 +284,7 @@ class ExportFormatter
|
||||
public function bookToPlainText(Book $book): string
|
||||
{
|
||||
$bookTree = (new BookContents($book))->getTree(false, true);
|
||||
$text = $book->name . "\n" . $book->description;
|
||||
$text = $book->name . "\n" . $book->descriptionInfo()->getPlain();
|
||||
$text = rtrim($text) . "\n\n";
|
||||
|
||||
$parts = [];
|
||||
@@ -318,7 +318,7 @@ class ExportFormatter
|
||||
{
|
||||
$text = '# ' . $chapter->name . "\n\n";
|
||||
|
||||
$description = (new HtmlToMarkdown($chapter->descriptionHtml()))->convert();
|
||||
$description = (new HtmlToMarkdown($chapter->descriptionInfo()->getHtml()))->convert();
|
||||
if ($description) {
|
||||
$text .= $description . "\n\n";
|
||||
}
|
||||
@@ -338,7 +338,7 @@ class ExportFormatter
|
||||
$bookTree = (new BookContents($book))->getTree(false, true);
|
||||
$text = '# ' . $book->name . "\n\n";
|
||||
|
||||
$description = (new HtmlToMarkdown($book->descriptionHtml()))->convert();
|
||||
$description = (new HtmlToMarkdown($book->descriptionInfo()->getHtml()))->convert();
|
||||
if ($description) {
|
||||
$text .= $description . "\n\n";
|
||||
}
|
||||
|
||||
@@ -55,10 +55,10 @@ final class ZipExportBook extends ZipExportModel
|
||||
$instance = new self();
|
||||
$instance->id = $model->id;
|
||||
$instance->name = $model->name;
|
||||
$instance->description_html = $model->descriptionHtml();
|
||||
$instance->description_html = $model->descriptionInfo()->getHtml();
|
||||
|
||||
if ($model->cover) {
|
||||
$instance->cover = $files->referenceForImage($model->cover);
|
||||
if ($model->coverInfo()->exists()) {
|
||||
$instance->cover = $files->referenceForImage($model->coverInfo()->getImage());
|
||||
}
|
||||
|
||||
$instance->tags = ZipExportTag::fromModelArray($model->tags()->get()->all());
|
||||
|
||||
@@ -40,7 +40,7 @@ final class ZipExportChapter extends ZipExportModel
|
||||
$instance = new self();
|
||||
$instance->id = $model->id;
|
||||
$instance->name = $model->name;
|
||||
$instance->description_html = $model->descriptionHtml();
|
||||
$instance->description_html = $model->descriptionInfo()->getHtml();
|
||||
$instance->priority = $model->priority;
|
||||
$instance->tags = ZipExportTag::fromModelArray($model->tags()->get()->all());
|
||||
|
||||
|
||||
@@ -58,6 +58,16 @@ class ZipExportReader
|
||||
{
|
||||
$this->open();
|
||||
|
||||
$info = $this->zip->statName('data.json');
|
||||
if ($info === false) {
|
||||
throw new ZipExportException(trans('errors.import_zip_cant_decode_data'));
|
||||
}
|
||||
|
||||
$maxSize = max(intval(config()->get('app.upload_limit')), 1) * 1000000;
|
||||
if ($info['size'] > $maxSize) {
|
||||
throw new ZipExportException(trans('errors.import_zip_data_too_large'));
|
||||
}
|
||||
|
||||
// Validate json data exists, including metadata
|
||||
$jsonData = $this->zip->getFromName('data.json') ?: '';
|
||||
$importData = json_decode($jsonData, true);
|
||||
@@ -73,6 +83,17 @@ class ZipExportReader
|
||||
return $this->zip->statName("files/{$fileName}") !== false;
|
||||
}
|
||||
|
||||
public function fileWithinSizeLimit(string $fileName): bool
|
||||
{
|
||||
$fileInfo = $this->zip->statName("files/{$fileName}");
|
||||
if ($fileInfo === false) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$maxSize = max(intval(config()->get('app.upload_limit')), 1) * 1000000;
|
||||
return $fileInfo['size'] <= $maxSize;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return false|resource
|
||||
*/
|
||||
|
||||
@@ -15,6 +15,7 @@ use BookStack\Exports\ZipExports\Models\ZipExportPage;
|
||||
use BookStack\Permissions\Permission;
|
||||
use BookStack\Uploads\Attachment;
|
||||
use BookStack\Uploads\Image;
|
||||
use BookStack\Uploads\ImageService;
|
||||
|
||||
class ZipExportReferences
|
||||
{
|
||||
@@ -33,6 +34,7 @@ class ZipExportReferences
|
||||
|
||||
public function __construct(
|
||||
protected ZipReferenceParser $parser,
|
||||
protected ImageService $imageService,
|
||||
) {
|
||||
}
|
||||
|
||||
@@ -133,10 +135,17 @@ class ZipExportReferences
|
||||
return "[[bsexport:image:{$model->id}]]";
|
||||
}
|
||||
|
||||
// Find and include images if in visibility
|
||||
// Get the page which we'll reference this image upon
|
||||
$page = $model->getPage();
|
||||
$pageExportModel = $this->pages[$page->id] ?? ($exportModel instanceof ZipExportPage ? $exportModel : null);
|
||||
if (isset($this->images[$model->id]) || ($page && $pageExportModel && userCan(Permission::PageView, $page))) {
|
||||
$pageExportModel = null;
|
||||
if ($page && isset($this->pages[$page->id])) {
|
||||
$pageExportModel = $this->pages[$page->id];
|
||||
} elseif ($exportModel instanceof ZipExportPage) {
|
||||
$pageExportModel = $exportModel;
|
||||
}
|
||||
|
||||
// Add the image to the export if it's accessible or just return the existing reference if already added
|
||||
if (isset($this->images[$model->id]) || ($pageExportModel && $this->imageService->imageAccessible($model))) {
|
||||
if (!isset($this->images[$model->id])) {
|
||||
$exportImage = ZipExportImage::fromModel($model, $files);
|
||||
$this->images[$model->id] = $exportImage;
|
||||
@@ -144,6 +153,7 @@ class ZipExportReferences
|
||||
}
|
||||
return "[[bsexport:image:{$model->id}]]";
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
@@ -13,7 +13,6 @@ class ZipFileReferenceRule implements ValidationRule
|
||||
) {
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* @inheritDoc
|
||||
*/
|
||||
@@ -23,6 +22,13 @@ class ZipFileReferenceRule implements ValidationRule
|
||||
$fail('validation.zip_file')->translate();
|
||||
}
|
||||
|
||||
if (!$this->context->zipReader->fileWithinSizeLimit($value)) {
|
||||
$fail('validation.zip_file_size')->translate([
|
||||
'attribute' => $value,
|
||||
'size' => config('app.upload_limit'),
|
||||
]);
|
||||
}
|
||||
|
||||
if (!empty($this->acceptedMimes)) {
|
||||
$fileMime = $this->context->zipReader->sniffFileMime($value);
|
||||
if (!in_array($fileMime, $this->acceptedMimes)) {
|
||||
|
||||
@@ -135,8 +135,8 @@ class ZipImportRunner
|
||||
'tags' => $this->exportTagsToInputArray($exportBook->tags ?? []),
|
||||
]);
|
||||
|
||||
if ($book->cover) {
|
||||
$this->references->addImage($book->cover, null);
|
||||
if ($book->coverInfo()->getImage()) {
|
||||
$this->references->addImage($book->coverInfo()->getImage(), null);
|
||||
}
|
||||
|
||||
$children = [
|
||||
@@ -197,8 +197,8 @@ class ZipImportRunner
|
||||
|
||||
$this->pageRepo->publishDraft($page, [
|
||||
'name' => $exportPage->name,
|
||||
'markdown' => $exportPage->markdown,
|
||||
'html' => $exportPage->html,
|
||||
'markdown' => $exportPage->markdown ?? '',
|
||||
'html' => $exportPage->html ?? '',
|
||||
'tags' => $this->exportTagsToInputArray($exportPage->tags ?? []),
|
||||
]);
|
||||
|
||||
@@ -265,6 +265,12 @@ class ZipImportRunner
|
||||
|
||||
protected function zipFileToUploadedFile(string $fileName, ZipExportReader $reader): UploadedFile
|
||||
{
|
||||
if (!$reader->fileWithinSizeLimit($fileName)) {
|
||||
throw new ZipImportException([
|
||||
"File $fileName exceeds app upload limit."
|
||||
]);
|
||||
}
|
||||
|
||||
$tempPath = tempnam(sys_get_temp_dir(), 'bszipextract');
|
||||
$fileStream = $reader->streamFile($fileName);
|
||||
$tempStream = fopen($tempPath, 'wb');
|
||||
|
||||
@@ -8,6 +8,12 @@ use Illuminate\Http\JsonResponse;
|
||||
|
||||
abstract class ApiController extends Controller
|
||||
{
|
||||
/**
|
||||
* The validation rules for this controller.
|
||||
* Can alternative be defined in a rules() method is they need to be dynamic.
|
||||
*
|
||||
* @var array<string, array<string, string[]>>
|
||||
*/
|
||||
protected array $rules = [];
|
||||
|
||||
/**
|
||||
|
||||
@@ -14,7 +14,10 @@ use Illuminate\Session\Middleware\StartSession as Middleware;
|
||||
class StartSessionExtended extends Middleware
|
||||
{
|
||||
protected static array $pathPrefixesExcludedFromHistory = [
|
||||
'uploads/images/'
|
||||
'uploads/images/',
|
||||
'dist/',
|
||||
'manifest.json',
|
||||
'opensearch.xml',
|
||||
];
|
||||
|
||||
/**
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user