diff --git a/public/language/ar/modules.json b/public/language/ar/modules.json index 68cedc6c3f..ddaace29f8 100644 --- a/public/language/ar/modules.json +++ b/public/language/ar/modules.json @@ -68,6 +68,7 @@ "chat.in-room": "In this room", "chat.kick": "Kick", "chat.show-ip": "Show IP", + "chat.copy-link": "Copy link", "chat.owner": "Room Owner", "chat.grant-rescind-ownership": "Grant/Rescind Ownership", "chat.system.user-join": "%1 has joined the room ", diff --git a/public/language/bg/modules.json b/public/language/bg/modules.json index 715ec35e02..1a753d8b19 100644 --- a/public/language/bg/modules.json +++ b/public/language/bg/modules.json @@ -68,6 +68,7 @@ "chat.in-room": "В тази стая", "chat.kick": "Изгонване", "chat.show-ip": "Показване на IP адреса", + "chat.copy-link": "Copy link", "chat.owner": "Собственик на стаята", "chat.grant-rescind-ownership": "Даване/отнемане на собственост", "chat.system.user-join": "%1 се присъедини към стаята ", diff --git a/public/language/bn/modules.json b/public/language/bn/modules.json index 0acbf5265d..f75974dc76 100644 --- a/public/language/bn/modules.json +++ b/public/language/bn/modules.json @@ -68,6 +68,7 @@ "chat.in-room": "In this room", "chat.kick": "Kick", "chat.show-ip": "Show IP", + "chat.copy-link": "Copy link", "chat.owner": "Room Owner", "chat.grant-rescind-ownership": "Grant/Rescind Ownership", "chat.system.user-join": "%1 has joined the room ", diff --git a/public/language/cs/modules.json b/public/language/cs/modules.json index 08bcc63390..2956e3de31 100644 --- a/public/language/cs/modules.json +++ b/public/language/cs/modules.json @@ -68,6 +68,7 @@ "chat.in-room": "V této místnosti", "chat.kick": "Vykopnout", "chat.show-ip": "Zobrazit IP", + "chat.copy-link": "Copy link", "chat.owner": "Majitel místnosti", "chat.grant-rescind-ownership": "Grant/Rescind Ownership", "chat.system.user-join": "%1 has joined the room ", diff --git a/public/language/da/modules.json b/public/language/da/modules.json index 4639c9f1ab..ccc0e8f08d 100644 --- a/public/language/da/modules.json +++ b/public/language/da/modules.json @@ -68,6 +68,7 @@ "chat.in-room": "In this room", "chat.kick": "Kick", "chat.show-ip": "Show IP", + "chat.copy-link": "Copy link", "chat.owner": "Room Owner", "chat.grant-rescind-ownership": "Grant/Rescind Ownership", "chat.system.user-join": "%1 has joined the room ", diff --git a/public/language/de/modules.json b/public/language/de/modules.json index 2e88897593..207d450380 100644 --- a/public/language/de/modules.json +++ b/public/language/de/modules.json @@ -68,6 +68,7 @@ "chat.in-room": "In diesem Chat-Room", "chat.kick": "Rauswerfen", "chat.show-ip": "IP anzeigen", + "chat.copy-link": "Copy link", "chat.owner": "Raumbesitzer", "chat.grant-rescind-ownership": "Erteilung/Aufhebung des Eigentums", "chat.system.user-join": "%1 hat den Raum betreten ", diff --git a/public/language/el/modules.json b/public/language/el/modules.json index 98a6041312..0bd634c214 100644 --- a/public/language/el/modules.json +++ b/public/language/el/modules.json @@ -68,6 +68,7 @@ "chat.in-room": "In this room", "chat.kick": "Kick", "chat.show-ip": "Show IP", + "chat.copy-link": "Copy link", "chat.owner": "Room Owner", "chat.grant-rescind-ownership": "Grant/Rescind Ownership", "chat.system.user-join": "%1 has joined the room ", diff --git a/public/language/en-US/modules.json b/public/language/en-US/modules.json index 98a6041312..0bd634c214 100644 --- a/public/language/en-US/modules.json +++ b/public/language/en-US/modules.json @@ -68,6 +68,7 @@ "chat.in-room": "In this room", "chat.kick": "Kick", "chat.show-ip": "Show IP", + "chat.copy-link": "Copy link", "chat.owner": "Room Owner", "chat.grant-rescind-ownership": "Grant/Rescind Ownership", "chat.system.user-join": "%1 has joined the room ", diff --git a/public/language/en-x-pirate/modules.json b/public/language/en-x-pirate/modules.json index b2ce6a6339..96bdb6b14e 100644 --- a/public/language/en-x-pirate/modules.json +++ b/public/language/en-x-pirate/modules.json @@ -68,6 +68,7 @@ "chat.in-room": "In this room", "chat.kick": "Kick", "chat.show-ip": "Show IP", + "chat.copy-link": "Copy link", "chat.owner": "Room Owner", "chat.grant-rescind-ownership": "Grant/Rescind Ownership", "chat.system.user-join": "%1 has joined the room ", diff --git a/public/language/es/modules.json b/public/language/es/modules.json index 22ded29cc9..119aab5b91 100644 --- a/public/language/es/modules.json +++ b/public/language/es/modules.json @@ -68,6 +68,7 @@ "chat.in-room": "En esta sala", "chat.kick": "Expulsar", "chat.show-ip": "Mostrar IP", + "chat.copy-link": "Copy link", "chat.owner": "Room Owner", "chat.grant-rescind-ownership": "Grant/Rescind Ownership", "chat.system.user-join": "%1 has joined the room ", diff --git a/public/language/et/modules.json b/public/language/et/modules.json index d50f5ea9c2..f8eed1d1d0 100644 --- a/public/language/et/modules.json +++ b/public/language/et/modules.json @@ -68,6 +68,7 @@ "chat.in-room": "In this room", "chat.kick": "Kick", "chat.show-ip": "Show IP", + "chat.copy-link": "Copy link", "chat.owner": "Room Owner", "chat.grant-rescind-ownership": "Grant/Rescind Ownership", "chat.system.user-join": "%1 has joined the room ", diff --git a/public/language/fa-IR/modules.json b/public/language/fa-IR/modules.json index 32fb993062..ce76938e40 100644 --- a/public/language/fa-IR/modules.json +++ b/public/language/fa-IR/modules.json @@ -68,6 +68,7 @@ "chat.in-room": "در این چت روم", "chat.kick": "اخراج", "chat.show-ip": "نشان دادن IP", + "chat.copy-link": "Copy link", "chat.owner": "مدیر چت روم", "chat.grant-rescind-ownership": "Grant/Rescind Ownership", "chat.system.user-join": "%1 has joined the room ", diff --git a/public/language/fi/modules.json b/public/language/fi/modules.json index 802e6f4036..c6beaaa3cd 100644 --- a/public/language/fi/modules.json +++ b/public/language/fi/modules.json @@ -68,6 +68,7 @@ "chat.in-room": "In this room", "chat.kick": "Kick", "chat.show-ip": "Show IP", + "chat.copy-link": "Copy link", "chat.owner": "Room Owner", "chat.grant-rescind-ownership": "Grant/Rescind Ownership", "chat.system.user-join": "%1 has joined the room ", diff --git a/public/language/fr/modules.json b/public/language/fr/modules.json index e9356d7081..5e7ae37ef4 100644 --- a/public/language/fr/modules.json +++ b/public/language/fr/modules.json @@ -68,6 +68,7 @@ "chat.in-room": "Dans cet espace de discussion", "chat.kick": "Exclure", "chat.show-ip": "Voir IP", + "chat.copy-link": "Copy link", "chat.owner": "Espace Admin", "chat.grant-rescind-ownership": "Promouvoir/rétrograder comme propriétaire", "chat.system.user-join": "%1 a rejoint la discussion ", diff --git a/public/language/gl/modules.json b/public/language/gl/modules.json index dfbbaf172e..478d2e227d 100644 --- a/public/language/gl/modules.json +++ b/public/language/gl/modules.json @@ -68,6 +68,7 @@ "chat.in-room": "In this room", "chat.kick": "Kick", "chat.show-ip": "Show IP", + "chat.copy-link": "Copy link", "chat.owner": "Room Owner", "chat.grant-rescind-ownership": "Grant/Rescind Ownership", "chat.system.user-join": "%1 has joined the room ", diff --git a/public/language/he/modules.json b/public/language/he/modules.json index 06c6e81045..88ce48a87d 100644 --- a/public/language/he/modules.json +++ b/public/language/he/modules.json @@ -68,6 +68,7 @@ "chat.in-room": "בתוך חדר זה", "chat.kick": "הוצא", "chat.show-ip": "הצג IP", + "chat.copy-link": "Copy link", "chat.owner": "מנהלי החדר", "chat.grant-rescind-ownership": "הענק/בטל בעלות", "chat.system.user-join": "%1 הצטרף לחדר ", diff --git a/public/language/hr/modules.json b/public/language/hr/modules.json index 758078f326..535ca6816b 100644 --- a/public/language/hr/modules.json +++ b/public/language/hr/modules.json @@ -68,6 +68,7 @@ "chat.in-room": "In this room", "chat.kick": "Kick", "chat.show-ip": "Show IP", + "chat.copy-link": "Copy link", "chat.owner": "Room Owner", "chat.grant-rescind-ownership": "Grant/Rescind Ownership", "chat.system.user-join": "%1 has joined the room ", diff --git a/public/language/hu/modules.json b/public/language/hu/modules.json index 03811c99c7..6a7692cbc9 100644 --- a/public/language/hu/modules.json +++ b/public/language/hu/modules.json @@ -68,6 +68,7 @@ "chat.in-room": "Ebben a szobában", "chat.kick": "Kirúgás", "chat.show-ip": "IP cím mutatása", + "chat.copy-link": "Copy link", "chat.owner": "Szoba tulajdonos", "chat.grant-rescind-ownership": "Grant/Rescind Ownership", "chat.system.user-join": "%1 has joined the room ", diff --git a/public/language/hy/modules.json b/public/language/hy/modules.json index c4773cf8e6..f9706f24c5 100644 --- a/public/language/hy/modules.json +++ b/public/language/hy/modules.json @@ -68,6 +68,7 @@ "chat.in-room": "Այս սենյակում", "chat.kick": "Kick", "chat.show-ip": "Ցույց տալ IP", + "chat.copy-link": "Copy link", "chat.owner": "Սենյակի սեփականատեր", "chat.grant-rescind-ownership": "Տրամադրել/վերացնել սեփականության իրավունքը", "chat.system.user-join": "%1-ը միացել է սենյակին ", diff --git a/public/language/id/modules.json b/public/language/id/modules.json index b027ef70c1..97849e4b85 100644 --- a/public/language/id/modules.json +++ b/public/language/id/modules.json @@ -68,6 +68,7 @@ "chat.in-room": "In this room", "chat.kick": "Kick", "chat.show-ip": "Show IP", + "chat.copy-link": "Copy link", "chat.owner": "Room Owner", "chat.grant-rescind-ownership": "Grant/Rescind Ownership", "chat.system.user-join": "%1 has joined the room ", diff --git a/public/language/it/modules.json b/public/language/it/modules.json index 53f06ad8c0..0ca700cedc 100644 --- a/public/language/it/modules.json +++ b/public/language/it/modules.json @@ -68,6 +68,7 @@ "chat.in-room": "In questa stanza", "chat.kick": "Butta fuori", "chat.show-ip": "Mostra indirizzo IP", + "chat.copy-link": "Copy link", "chat.owner": "Propietario stanza", "chat.grant-rescind-ownership": "Concedi/Revoca Proprietà", "chat.system.user-join": "%1 si è unito alla stanza ", diff --git a/public/language/ja/modules.json b/public/language/ja/modules.json index 4ae805be4d..b44d722a68 100644 --- a/public/language/ja/modules.json +++ b/public/language/ja/modules.json @@ -68,6 +68,7 @@ "chat.in-room": "この部屋内", "chat.kick": "キック", "chat.show-ip": "IP表示", + "chat.copy-link": "Copy link", "chat.owner": "部屋の管理者", "chat.grant-rescind-ownership": "Grant/Rescind Ownership", "chat.system.user-join": "%1 has joined the room ", diff --git a/public/language/ko/modules.json b/public/language/ko/modules.json index 44e872ddd5..ae568ff649 100644 --- a/public/language/ko/modules.json +++ b/public/language/ko/modules.json @@ -68,6 +68,7 @@ "chat.in-room": "채팅 참여자", "chat.kick": "추방", "chat.show-ip": "IP 보이기", + "chat.copy-link": "Copy link", "chat.owner": "채팅 관리자", "chat.grant-rescind-ownership": "Grant/Rescind Ownership", "chat.system.user-join": "%1 has joined the room ", diff --git a/public/language/lt/modules.json b/public/language/lt/modules.json index 8d3d6d2b15..11180da6f0 100644 --- a/public/language/lt/modules.json +++ b/public/language/lt/modules.json @@ -68,6 +68,7 @@ "chat.in-room": "In this room", "chat.kick": "Kick", "chat.show-ip": "Show IP", + "chat.copy-link": "Copy link", "chat.owner": "Room Owner", "chat.grant-rescind-ownership": "Grant/Rescind Ownership", "chat.system.user-join": "%1 has joined the room ", diff --git a/public/language/lv/modules.json b/public/language/lv/modules.json index afdcec9197..66032799e1 100644 --- a/public/language/lv/modules.json +++ b/public/language/lv/modules.json @@ -68,6 +68,7 @@ "chat.in-room": "Šajā tērzētavā", "chat.kick": "Izslēgt", "chat.show-ip": "Rādīt IP adresi", + "chat.copy-link": "Copy link", "chat.owner": "Tērzētavas īpašnieks", "chat.grant-rescind-ownership": "Grant/Rescind Ownership", "chat.system.user-join": "%1 has joined the room ", diff --git a/public/language/ms/modules.json b/public/language/ms/modules.json index 18c4865e54..bc9e268850 100644 --- a/public/language/ms/modules.json +++ b/public/language/ms/modules.json @@ -68,6 +68,7 @@ "chat.in-room": "In this room", "chat.kick": "Kick", "chat.show-ip": "Show IP", + "chat.copy-link": "Copy link", "chat.owner": "Room Owner", "chat.grant-rescind-ownership": "Grant/Rescind Ownership", "chat.system.user-join": "%1 has joined the room ", diff --git a/public/language/nb/modules.json b/public/language/nb/modules.json index e3a941f366..f565ef1348 100644 --- a/public/language/nb/modules.json +++ b/public/language/nb/modules.json @@ -68,6 +68,7 @@ "chat.in-room": "In this room", "chat.kick": "Kick", "chat.show-ip": "Show IP", + "chat.copy-link": "Copy link", "chat.owner": "Room Owner", "chat.grant-rescind-ownership": "Grant/Rescind Ownership", "chat.system.user-join": "%1 has joined the room ", diff --git a/public/language/nl/modules.json b/public/language/nl/modules.json index 2cf149930a..4254d47faf 100644 --- a/public/language/nl/modules.json +++ b/public/language/nl/modules.json @@ -68,6 +68,7 @@ "chat.in-room": "In deze chat room", "chat.kick": "Schop", "chat.show-ip": "Geef IP weer", + "chat.copy-link": "Copy link", "chat.owner": "Chatroom-eigenaar", "chat.grant-rescind-ownership": "Grant/Rescind Ownership", "chat.system.user-join": "%1 has joined the room ", diff --git a/public/language/pl/modules.json b/public/language/pl/modules.json index 032da38b4e..82429f519b 100644 --- a/public/language/pl/modules.json +++ b/public/language/pl/modules.json @@ -68,6 +68,7 @@ "chat.in-room": "W tym pokoju", "chat.kick": "Wyrzuć", "chat.show-ip": "Pokaż IP", + "chat.copy-link": "Copy link", "chat.owner": "Właściciel pokoju", "chat.grant-rescind-ownership": "Grant/Rescind Ownership", "chat.system.user-join": "%1 has joined the room ", diff --git a/public/language/pt-BR/modules.json b/public/language/pt-BR/modules.json index 3ae0e95eae..5a9547f678 100644 --- a/public/language/pt-BR/modules.json +++ b/public/language/pt-BR/modules.json @@ -68,6 +68,7 @@ "chat.in-room": "Nesta sala", "chat.kick": "Expulsar", "chat.show-ip": "Mostrar IP", + "chat.copy-link": "Copy link", "chat.owner": "Dono da Sala", "chat.grant-rescind-ownership": "Grant/Rescind Ownership", "chat.system.user-join": "%1 has joined the room ", diff --git a/public/language/pt-PT/modules.json b/public/language/pt-PT/modules.json index b97f848659..fd2ed13e64 100644 --- a/public/language/pt-PT/modules.json +++ b/public/language/pt-PT/modules.json @@ -68,6 +68,7 @@ "chat.in-room": "Participantes nesta sala", "chat.kick": "Expulsar", "chat.show-ip": "Mostrar IP", + "chat.copy-link": "Copy link", "chat.owner": "Dono da Sala", "chat.grant-rescind-ownership": "Grant/Rescind Ownership", "chat.system.user-join": "%1 has joined the room ", diff --git a/public/language/ro/modules.json b/public/language/ro/modules.json index ede5dea94f..923a63764c 100644 --- a/public/language/ro/modules.json +++ b/public/language/ro/modules.json @@ -68,6 +68,7 @@ "chat.in-room": "In this room", "chat.kick": "Kick", "chat.show-ip": "Show IP", + "chat.copy-link": "Copy link", "chat.owner": "Room Owner", "chat.grant-rescind-ownership": "Grant/Rescind Ownership", "chat.system.user-join": "%1 has joined the room ", diff --git a/public/language/ru/modules.json b/public/language/ru/modules.json index 69769d997e..c4c30f03fb 100644 --- a/public/language/ru/modules.json +++ b/public/language/ru/modules.json @@ -68,6 +68,7 @@ "chat.in-room": "В этой комнате", "chat.kick": "Исключить", "chat.show-ip": "Показать IP", + "chat.copy-link": "Copy link", "chat.owner": "Владелец комнаты", "chat.grant-rescind-ownership": "Grant/Rescind Ownership", "chat.system.user-join": "%1 has joined the room ", diff --git a/public/language/rw/modules.json b/public/language/rw/modules.json index eb2599e1fd..86a2ee9757 100644 --- a/public/language/rw/modules.json +++ b/public/language/rw/modules.json @@ -68,6 +68,7 @@ "chat.in-room": "In this room", "chat.kick": "Kick", "chat.show-ip": "Show IP", + "chat.copy-link": "Copy link", "chat.owner": "Room Owner", "chat.grant-rescind-ownership": "Grant/Rescind Ownership", "chat.system.user-join": "%1 has joined the room ", diff --git a/public/language/sc/modules.json b/public/language/sc/modules.json index 1675ac1fb6..622f68e00d 100644 --- a/public/language/sc/modules.json +++ b/public/language/sc/modules.json @@ -68,6 +68,7 @@ "chat.in-room": "In this room", "chat.kick": "Kick", "chat.show-ip": "Show IP", + "chat.copy-link": "Copy link", "chat.owner": "Room Owner", "chat.grant-rescind-ownership": "Grant/Rescind Ownership", "chat.system.user-join": "%1 has joined the room ", diff --git a/public/language/sk/modules.json b/public/language/sk/modules.json index 91ecfb427f..525dbf2206 100644 --- a/public/language/sk/modules.json +++ b/public/language/sk/modules.json @@ -68,6 +68,7 @@ "chat.in-room": "V tejto miestnosti", "chat.kick": "Vykopnúť", "chat.show-ip": "Zobraziť IP adresu", + "chat.copy-link": "Copy link", "chat.owner": "Majiteľ miestnosti", "chat.grant-rescind-ownership": "Grant/Rescind Ownership", "chat.system.user-join": "%1 has joined the room ", diff --git a/public/language/sl/modules.json b/public/language/sl/modules.json index 07fd6024d2..33cb814c8b 100644 --- a/public/language/sl/modules.json +++ b/public/language/sl/modules.json @@ -68,6 +68,7 @@ "chat.in-room": "In this room", "chat.kick": "Kick", "chat.show-ip": "Pokaži IP", + "chat.copy-link": "Copy link", "chat.owner": "Room Owner", "chat.grant-rescind-ownership": "Grant/Rescind Ownership", "chat.system.user-join": "%1 has joined the room ", diff --git a/public/language/sq-AL/modules.json b/public/language/sq-AL/modules.json index 390133f050..b8eec66eb8 100644 --- a/public/language/sq-AL/modules.json +++ b/public/language/sq-AL/modules.json @@ -68,6 +68,7 @@ "chat.in-room": "Në këtë dhomë", "chat.kick": "Largo", "chat.show-ip": "Shfaq IP", + "chat.copy-link": "Copy link", "chat.owner": "Administratori i hapësirës", "chat.grant-rescind-ownership": "Grant/Rescind Ownership", "chat.system.user-join": "%1 has joined the room ", diff --git a/public/language/sr/modules.json b/public/language/sr/modules.json index 79ddec2c79..e6de178576 100644 --- a/public/language/sr/modules.json +++ b/public/language/sr/modules.json @@ -68,6 +68,7 @@ "chat.in-room": "У овој соби", "chat.kick": "Избаци", "chat.show-ip": "Прикажи IP", + "chat.copy-link": "Copy link", "chat.owner": "Власник собе", "chat.grant-rescind-ownership": "Додели/поништи власништво", "chat.system.user-join": "%1 се придружио соби ", diff --git a/public/language/sv/modules.json b/public/language/sv/modules.json index 0335e67f7b..3d498c4760 100644 --- a/public/language/sv/modules.json +++ b/public/language/sv/modules.json @@ -68,6 +68,7 @@ "chat.in-room": "I detta rum", "chat.kick": "Sparka ut", "chat.show-ip": "Visa IP", + "chat.copy-link": "Copy link", "chat.owner": "Rummets ägare", "chat.grant-rescind-ownership": "Grant/Rescind Ownership", "chat.system.user-join": "%1 has joined the room ", diff --git a/public/language/th/modules.json b/public/language/th/modules.json index bc46f7729e..633a0efa03 100644 --- a/public/language/th/modules.json +++ b/public/language/th/modules.json @@ -68,6 +68,7 @@ "chat.in-room": "ในห้องนี้", "chat.kick": "Kick", "chat.show-ip": "Show IP", + "chat.copy-link": "Copy link", "chat.owner": "Room Owner", "chat.grant-rescind-ownership": "Grant/Rescind Ownership", "chat.system.user-join": "%1 has joined the room ", diff --git a/public/language/tr/modules.json b/public/language/tr/modules.json index 719464d5f2..7e5a72bf35 100644 --- a/public/language/tr/modules.json +++ b/public/language/tr/modules.json @@ -68,6 +68,7 @@ "chat.in-room": "Bu odada", "chat.kick": "Dışarı At", "chat.show-ip": "IP Göster", + "chat.copy-link": "Copy link", "chat.owner": "Oda Sahibi", "chat.grant-rescind-ownership": "Grant/Rescind Ownership", "chat.system.user-join": "%1 odaya katıldı ", diff --git a/public/language/uk/modules.json b/public/language/uk/modules.json index b7e592222f..87be11241a 100644 --- a/public/language/uk/modules.json +++ b/public/language/uk/modules.json @@ -68,6 +68,7 @@ "chat.in-room": "У цій кімнаті", "chat.kick": "Штурхнути", "chat.show-ip": "Показати IP", + "chat.copy-link": "Copy link", "chat.owner": "Власник кімнати", "chat.grant-rescind-ownership": "Grant/Rescind Ownership", "chat.system.user-join": "%1 has joined the room ", diff --git a/public/language/vi/modules.json b/public/language/vi/modules.json index 823ac679a7..de9ad3c35f 100644 --- a/public/language/vi/modules.json +++ b/public/language/vi/modules.json @@ -68,6 +68,7 @@ "chat.in-room": "Trong phòng này", "chat.kick": "Loại ra", "chat.show-ip": "Hiện IP", + "chat.copy-link": "Copy link", "chat.owner": "Chủ Phòng", "chat.grant-rescind-ownership": "Cấp/Hủy bỏ Quyền sở hữu", "chat.system.user-join": "%1 đã tham gia phòng ", diff --git a/public/language/zh-CN/modules.json b/public/language/zh-CN/modules.json index e071c0bb7b..49fbfbae6e 100644 --- a/public/language/zh-CN/modules.json +++ b/public/language/zh-CN/modules.json @@ -68,6 +68,7 @@ "chat.in-room": "在此房间", "chat.kick": "踢出", "chat.show-ip": "显示 IP", + "chat.copy-link": "Copy link", "chat.owner": "房间所有者", "chat.grant-rescind-ownership": "给予/撤销所有权", "chat.system.user-join": "%1 加入了房间", diff --git a/public/language/zh-TW/modules.json b/public/language/zh-TW/modules.json index e1c5cb7257..486c562316 100644 --- a/public/language/zh-TW/modules.json +++ b/public/language/zh-TW/modules.json @@ -68,6 +68,7 @@ "chat.in-room": "在此房間", "chat.kick": "踢出", "chat.show-ip": "顯示 IP", + "chat.copy-link": "Copy link", "chat.owner": "房間所有者", "chat.grant-rescind-ownership": "Grant/Rescind Ownership", "chat.system.user-join": "%1 has joined the room ", diff --git a/public/openapi/write.yaml b/public/openapi/write.yaml index b1dabb778b..1c4dfd44a4 100644 --- a/public/openapi/write.yaml +++ b/public/openapi/write.yaml @@ -112,6 +112,16 @@ paths: $ref: 'write/categories.yaml' /categories/{cid}: $ref: 'write/categories/cid.yaml' + /categories/{cid}/count: + $ref: 'write/categories/cid/count.yaml' + /categories/{cid}/posts: + $ref: 'write/categories/cid/posts.yaml' + /categories/{cid}/children: + $ref: 'write/categories/cid/children.yaml' + /categories/{cid}/topics: + $ref: 'write/categories/cid/topics.yaml' + /categories/{cid}/watch: + $ref: 'write/categories/cid/watch.yaml' /categories/{cid}/privileges: $ref: 'write/categories/cid/privileges.yaml' /categories/{cid}/privileges/{privilege}: @@ -198,6 +208,8 @@ paths: $ref: 'write/flags/flagId/notes.yaml' /flags/{flagId}/notes/{datetime}: $ref: 'write/flags/flagId/notes/datetime.yaml' + /search/categories: + $ref: 'write/search/categories.yaml' /admin/settings/{setting}: $ref: 'write/admin/settings/setting.yaml' /admin/analytics: diff --git a/public/openapi/write/categories.yaml b/public/openapi/write/categories.yaml index 5c26b53633..9c08994759 100644 --- a/public/openapi/write/categories.yaml +++ b/public/openapi/write/categories.yaml @@ -1,3 +1,25 @@ +get: + tags: + - categories + summary: list categories + description: This operation returns a flat list of categories available to the calling user + responses: + '200': + description: categories successfully listed + content: + application/json: + schema: + type: object + properties: + status: + $ref: ../components/schemas/Status.yaml#/Status + response: + type: object + properties: + categories: + type: array + items: + $ref: ../components/schemas/CategoryObject.yaml#/CategoryObject post: tags: - categories diff --git a/public/openapi/write/categories/cid/children.yaml b/public/openapi/write/categories/cid/children.yaml new file mode 100644 index 0000000000..de65fa1449 --- /dev/null +++ b/public/openapi/write/categories/cid/children.yaml @@ -0,0 +1,36 @@ +get: + tags: + - categories + summary: get subcategories + description: | + This operation returns the requested category's children (aka subcategories). + + It is important to note that the number of subcategories returned is dependent on the configured value for that category. + If a lower number is specified than there are children, then the list will be truncated to that number. + + This is defined by the `subCategoriesPerPage` key in the category's hash. + parameters: + - in: path + name: cid + schema: + type: string + required: true + description: a valid category id, `0` for global privileges, `admin` for admin privileges + example: 1 + responses: + '200': + description: categories count successfully retrieved + content: + application/json: + schema: + type: object + properties: + status: + $ref: ../../../components/schemas/Status.yaml#/Status + response: + type: object + properties: + categories: + type: array + items: + $ref: ../../../components/schemas/CategoryObject.yaml#/CategoryObject \ No newline at end of file diff --git a/public/openapi/write/categories/cid/count.yaml b/public/openapi/write/categories/cid/count.yaml new file mode 100644 index 0000000000..886152add9 --- /dev/null +++ b/public/openapi/write/categories/cid/count.yaml @@ -0,0 +1,28 @@ +get: + tags: + - categories + summary: get topic count + description: This operation returns the count of topics in a given category (excluding its subcategories) + parameters: + - in: path + name: cid + schema: + type: string + required: true + description: a valid category id, `0` for global privileges, `admin` for admin privileges + example: 1 + responses: + '200': + description: categories count successfully retrieved + content: + application/json: + schema: + type: object + properties: + status: + $ref: ../../../components/schemas/Status.yaml#/Status + response: + type: object + properties: + count: + type: number \ No newline at end of file diff --git a/public/openapi/write/categories/cid/posts.yaml b/public/openapi/write/categories/cid/posts.yaml new file mode 100644 index 0000000000..0e46ef67a8 --- /dev/null +++ b/public/openapi/write/categories/cid/posts.yaml @@ -0,0 +1,28 @@ +get: + tags: + - categories + summary: get topic posts + description: This operation returns a list of posts in the category, across all topics in that category (excluding its subcategories) + parameters: + - in: path + name: cid + schema: + type: string + required: true + description: a valid category id, `0` for global privileges, `admin` for admin privileges + example: 1 + responses: + '200': + description: categories posts successfully retrieved + content: + application/json: + schema: + type: object + properties: + status: + $ref: ../../../components/schemas/Status.yaml#/Status + response: + type: object + properties: + posts: + $ref: ../../../components/schemas/PostsObject.yaml#/PostsObject \ No newline at end of file diff --git a/public/openapi/write/categories/cid/topics.yaml b/public/openapi/write/categories/cid/topics.yaml new file mode 100644 index 0000000000..a14664b20c --- /dev/null +++ b/public/openapi/write/categories/cid/topics.yaml @@ -0,0 +1,75 @@ +get: + tags: + - categories + summary: get topics + description: | + This operation returns a set of topics in the requested category. + + The number of topics returned is defined by the "Topics per Page" (`topicsPerPage`) setting under ACP > Settings > Pagination. + parameters: + - in: path + name: cid + schema: + type: string + required: true + description: a valid category id, `0` for global privileges, `admin` for admin privileges + example: 1 + - in: query + name: 'query' + schema: + type: string + required: false + description: Likely unused — a URI-encoded JSON string containing values that are passed to `getCategoryTopics`. + example: '' + - in: query + name: 'after' + schema: + type: string + required: false + description: The index to start at when querying for the next set of topics. This parameter would be more aptly named `start`. + example: '0' + - in: query + name: 'sort' + schema: + type: string + required: false + description: Likely deprecated — the sorting method of topics (use `categoryTopicSort` instead.) + example: '' + - in: query + name: 'categoryTopicSort' + schema: + type: string + required: false + description: The sorting method of topics + example: 'newest_to_oldest' + - in: query + name: 'direction' + schema: + type: string + required: false + description: The sorting of returned results (if you scroll up you want the topics reversed). Set to "-1" for reversed results. + example: '1' + responses: + '200': + description: categories topics successfully retrieved + content: + application/json: + schema: + type: object + properties: + status: + $ref: ../../../components/schemas/Status.yaml#/Status + response: + type: object + properties: + topics: + type: array + items: + $ref: ../../../components/schemas/TopicObject.yaml#/TopicObject + nextStart: + type: number + privileges: + type: object + additionalProperties: + type: boolean + description: A set of privileges with either true or false \ No newline at end of file diff --git a/public/openapi/write/categories/cid/watch.yaml b/public/openapi/write/categories/cid/watch.yaml new file mode 100644 index 0000000000..06fc399dbe --- /dev/null +++ b/public/openapi/write/categories/cid/watch.yaml @@ -0,0 +1,102 @@ +put: + tags: + - categories + summary: update watch state + description: | + This operation changes the watch state for the category. + + Note that a category can be watched, not watched, or ignored: + + * A category that is watched will have topics that show up in both `/unread` and `/recent` + * A category that is *not* watched will have topics that show up in `/recent` but not `/unread` + * A category that is ignored will not have topics that show up in either route. + + This API call does not pertain to notifications for new topics in categories. + That behaviour is handled by a third-party plugin — nodebb-plugin-category-notifications + + N.B. When a category's watch state is updated, all of that category's children also have their watch states updated. + parameters: + - in: path + name: cid + schema: + type: string + required: true + description: a valid category id, `0` for global privileges, `admin` for admin privileges + example: 1 + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + uid: + type: number + description: This value is optional, it allows privileged uids to use this call to affect other user accounts. + example: 1 + state: + type: string + enum: ['watching', 'notwatching', 'ignoring'] + example: 'watching' + responses: + '200': + description: categories watch state successfully updated + content: + application/json: + schema: + type: object + properties: + status: + $ref: ../../../components/schemas/Status.yaml#/Status + response: + type: object + properties: + modified: + type: array + description: A list of cids that have had their watch states modified. + items: + type: number +delete: + tags: + - categories + summary: update watch state + description: | + Like the corresponding `PUT` method, this operation changes the watch state for the category. + However, it does not take a `state` parameter. It is assumed to be whatever the system default is (`categoryWatchState`). + parameters: + - in: path + name: cid + schema: + type: string + required: true + description: a valid category id, `0` for global privileges, `admin` for admin privileges + example: 1 + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + uid: + type: number + description: This value is optional, it allows privileged uids to use this call to affect other user accounts. + example: 1 + responses: + '200': + description: categories watch state successfully updated + content: + application/json: + schema: + type: object + properties: + status: + $ref: ../../../components/schemas/Status.yaml#/Status + response: + type: object + properties: + modified: + type: array + description: A list of cids that have had their watch states modified. + items: + type: number \ No newline at end of file diff --git a/public/openapi/write/search/categories.yaml b/public/openapi/write/search/categories.yaml new file mode 100644 index 0000000000..86d9115235 --- /dev/null +++ b/public/openapi/write/search/categories.yaml @@ -0,0 +1,96 @@ +get: + tags: + - search + summary: find categories by keyword + description: | + This operation returns a set of categories matching the keyword search. + + A number of filtering options are available, and can be passed in via query string. + parameters: + - in: query + name: 'search' + schema: + type: string + required: false + description: The keyword used in the category search + example: 'announcements' + - in: query + name: 'query' + schema: + type: string + required: false + description: Likely unused — a URI-encoded JSON string containing values that are passed to `getRecentTopicReplies`. + example: '' + - in: query + name: 'parentCid' + schema: + type: array + required: false + description: A list of category IDs. The values received are simply reflected back in the results. Matching cids will have "selected" set to true. + example: '0' + - in: query + name: 'selectedCids' + schema: + type: array + required: false + description: Likely deprecated — the sorting method of topics (use `categoryTopicSort` instead.) + example: '' + - in: query + name: 'categoryTopicSort' + schema: + type: string + required: false + description: The sorting method of topics + example: 'newest_to_oldest' + - in: query + name: 'direction' + schema: + type: string + required: false + description: The sorting of returned results (if you scroll up you want the topics reversed). Set to "-1" for reversed results. + example: '1' + responses: + '200': + description: matching categories successfully retrieved + content: + application/json: + schema: + type: object + properties: + status: + $ref: ../../components/schemas/Status.yaml#/Status + response: + type: object + properties: + categories: + type: array + items: + type: object + properties: + cid: + type: number + description: A category identifier assigned upon category creation (this value cannot be changed) + name: + type: string + description: The category's name/title + level: + type: number + icon: + type: string + description: A FontAwesome icon string + example: fa-comments-o + bgColor: + type: string + description: Theme-related, a six-character hexadecimal string representing the background colour of the category + color: + type: string + description: Theme-related, a six-character hexadecimal string representing the foreground/text colour of the category + parentCid: + type: number + description: The category identifier for the category that is the immediate ancestor of the current category + imageClass: + type: string + enum: [auto, cover, contain] + description: The `background-position` of the category background image, if one is set + selected: + type: boolean \ No newline at end of file diff --git a/public/src/admin/manage/category.js b/public/src/admin/manage/category.js index 9bbc9c47df..b6255fa251 100644 --- a/public/src/admin/manage/category.js +++ b/public/src/admin/manage/category.js @@ -109,11 +109,14 @@ define('admin/manage/category', [ callback: function () { modal.find('.modal-footer button').prop('disabled', true); - const intervalId = setInterval(function () { - socket.emit('categories.getTopicCount', ajaxify.data.category.cid, function (err, count) { - if (err) { - return alerts.error(err); - } + const intervalId = setInterval(async () => { + if (!ajaxify.data.category) { + // Already navigated away + return; + } + + try { + const { count } = await api.get(`/categories/${ajaxify.data.category.cid}/count`); let percent = 0; if (ajaxify.data.category.topic_count > 0) { @@ -121,16 +124,21 @@ define('admin/manage/category', [ } modal.find('.progress-bar').css({ width: percent + '%' }); - }); + } catch (err) { + clearInterval(intervalId); + alerts.error(err); + } }, 1000); api.del('/categories/' + ajaxify.data.category.cid).then(() => { - if (intervalId) { - clearInterval(intervalId); - } - modal.modal('hide'); - alerts.success('[[admin/manage/categories:alert.purge-success]]'); - ajaxify.go('admin/manage/categories'); + setTimeout(() => { + if (intervalId) { + clearInterval(intervalId); + } + modal.modal('hide'); + alerts.success('[[admin/manage/categories:alert.purge-success]]'); + ajaxify.go('admin/manage/categories'); + }, 5000); }).catch(alerts.error); return false; diff --git a/public/src/client/account/categories.js b/public/src/client/account/categories.js index 8e162db809..bb6849b166 100644 --- a/public/src/client/account/categories.js +++ b/public/src/client/account/categories.js @@ -1,7 +1,7 @@ 'use strict'; -define('forum/account/categories', ['forum/account/header', 'alerts'], function (header, alerts) { +define('forum/account/categories', ['forum/account/header', 'alerts', 'api'], function (header, alerts, api) { const Categories = {}; Categories.init = function () { @@ -11,36 +11,32 @@ define('forum/account/categories', ['forum/account/header', 'alerts'], function handleIgnoreWatch(category.cid); }); - $('[component="category/watch/all"]').find('[component="category/watching"], [component="category/ignoring"], [component="category/notwatching"]').on('click', function () { + $('[component="category/watch/all"]').find('[component="category/watching"], [component="category/ignoring"], [component="category/notwatching"]').on('click', async (e) => { const cids = []; - const state = $(this).attr('data-state'); + const state = e.currentTarget.getAttribute('data-state'); + const { uid } = ajaxify.data; $('[data-parent-cid="0"]').each(function (index, el) { cids.push($(el).attr('data-cid')); }); - socket.emit('categories.setWatchState', { cid: cids, state: state, uid: ajaxify.data.uid }, function (err, modified_cids) { - if (err) { - return alerts.error(err); - } - updateDropdowns(modified_cids, state); - }); + let modified_cids = await Promise.all(cids.map(async cid => api.put(`/categories/${cid}/watch`, { state, uid }))); + modified_cids = modified_cids + .reduce((memo, cur) => memo.concat(cur.modified), []) + .filter((cid, idx, arr) => arr.indexOf(cid) === idx); + + updateDropdowns(modified_cids, state); }); }; function handleIgnoreWatch(cid) { const category = $('[data-cid="' + cid + '"]'); - category.find('[component="category/watching"], [component="category/ignoring"], [component="category/notwatching"]').on('click', function () { - const $this = $(this); - const state = $this.attr('data-state'); + category.find('[component="category/watching"], [component="category/ignoring"], [component="category/notwatching"]').on('click', async (e) => { + const state = e.currentTarget.getAttribute('data-state'); + const { uid } = ajaxify.data; - socket.emit('categories.setWatchState', { cid: cid, state: state, uid: ajaxify.data.uid }, function (err, modified_cids) { - if (err) { - return alerts.error(err); - } - updateDropdowns(modified_cids, state); - - alerts.success('[[category:' + state + '.message]]'); - }); + const { modified } = await api.put(`/categories/${cid}/watch`, { state, uid }); + updateDropdowns(modified, state); + alerts.success('[[category:' + state + '.message]]'); }); } diff --git a/public/src/client/category.js b/public/src/client/category.js index a69ef64616..e1ab97431f 100644 --- a/public/src/client/category.js +++ b/public/src/client/category.js @@ -9,7 +9,8 @@ define('forum/category', [ 'categorySelector', 'hooks', 'alerts', -], function (infinitescroll, share, navigator, topicList, sort, categorySelector, hooks, alerts) { + 'api', +], function (infinitescroll, share, navigator, topicList, sort, categorySelector, hooks, alerts, api) { const Category = {}; $(window).on('action:ajaxify.start', function (ev, data) { @@ -68,7 +69,7 @@ define('forum/category', [ const $this = $(this); const state = $this.attr('data-state'); - socket.emit('categories.setWatchState', { cid: cid, state: state }, function (err) { + api.put(`/categories/${cid}/watch`, { state }, (err) => { if (err) { return alerts.error(err); } @@ -88,28 +89,22 @@ define('forum/category', [ } function handleLoadMoreSubcategories() { - $('[component="category/load-more-subcategories"]').on('click', function () { + $('[component="category/load-more-subcategories"]').on('click', async function () { const btn = $(this); - socket.emit('categories.loadMoreSubCategories', { - cid: ajaxify.data.cid, - start: ajaxify.data.nextSubCategoryStart, - }, function (err, data) { - if (err) { - return alerts.error(err); - } - btn.toggleClass('hidden', !data.length || data.length < ajaxify.data.subCategoriesPerPage); - if (!data.length) { - return; - } - app.parseAndTranslate('category', 'children', { children: data }, function (html) { - html.find('.timeago').timeago(); - $('[component="category/subcategory/container"]').append(html); - ajaxify.data.nextSubCategoryStart += ajaxify.data.subCategoriesPerPage; - ajaxify.data.subCategoriesLeft -= data.length; - btn.toggleClass('hidden', ajaxify.data.subCategoriesLeft <= 0) - .translateText('[[category:x-more-categories, ' + ajaxify.data.subCategoriesLeft + ']]'); - }); + const { categories: data } = await api.get(`/categories/${ajaxify.data.cid}/children?start=${ajaxify.data.nextSubCategoryStart}`); + btn.toggleClass('hidden', !data.length || data.length < ajaxify.data.subCategoriesPerPage); + if (!data.length) { + return; + } + app.parseAndTranslate('category', 'children', { children: data }, function (html) { + html.find('.timeago').timeago(); + $('[component="category/subcategory/container"]').append(html); + ajaxify.data.nextSubCategoryStart += ajaxify.data.subCategoriesPerPage; + ajaxify.data.subCategoriesLeft -= data.length; + btn.toggleClass('hidden', ajaxify.data.subCategoriesLeft <= 0) + .translateText('[[category:x-more-categories, ' + ajaxify.data.subCategoriesLeft + ']]'); }); + return false; }); } @@ -118,14 +113,9 @@ define('forum/category', [ navigator.scrollTop(0); }; - Category.toBottom = function () { - socket.emit('categories.getTopicCount', ajaxify.data.cid, function (err, count) { - if (err) { - return alerts.error(err); - } - - navigator.scrollBottom(count - 1); - }); + Category.toBottom = async () => { + const { count } = await api.get(`/categories/${ajaxify.data.category.cid}/count`); + navigator.scrollBottom(count - 1); }; function loadTopicsAfter(after, direction, callback) { @@ -133,7 +123,7 @@ define('forum/category', [ hooks.fire('action:topics.loading'); const params = utils.params(); - infinitescroll.loadMore('categories.loadMore', { + infinitescroll.loadMore(`/categories/${ajaxify.data.cid}/topics`, { cid: ajaxify.data.cid, after: after, direction: direction, diff --git a/public/src/client/infinitescroll.js b/public/src/client/infinitescroll.js index bd6f98d178..838f164f32 100644 --- a/public/src/client/infinitescroll.js +++ b/public/src/client/infinitescroll.js @@ -1,7 +1,7 @@ 'use strict'; -define('forum/infinitescroll', ['hooks', 'alerts'], function (hooks, alerts) { +define('forum/infinitescroll', ['hooks', 'alerts', 'api'], function (hooks, alerts, api) { const scroll = {}; let callback; let previousScrollTop = 0; @@ -72,7 +72,9 @@ define('forum/infinitescroll', ['hooks', 'alerts'], function (hooks, alerts) { const hookData = { method: method, data: data }; hooks.fire('action:infinitescroll.loadmore', hookData); - socket.emit(hookData.method, hookData.data, function (err, data) { + const call = hookData.method.startsWith('/') ? api.get : socket.emit; + + call(hookData.method, hookData.data, function (err, data) { if (err) { loadingMore = false; return alerts.error(err); diff --git a/public/src/sockets.js b/public/src/sockets.js index 1a87e57646..e4ef8273e1 100644 --- a/public/src/sockets.js +++ b/public/src/sockets.js @@ -111,8 +111,8 @@ app = window.app || {}; alerts.alert(params); }); }); - socket.on('event:deprecated_call', function (data) { - console.warn('[socket.io] ', data.eventName, 'is now deprecated in favour of', data.replacement); + socket.on('event:deprecated_call', (data) => { + console.warn('[socket.io]', data.eventName, 'is now deprecated', data.replacement ? `in favour of ${data.replacement}` : 'with no alternative planned.'); }); socket.on('event:livereload', function () { diff --git a/src/api/categories.js b/src/api/categories.js index c37e287221..774091fd61 100644 --- a/src/api/categories.js +++ b/src/api/categories.js @@ -1,6 +1,8 @@ 'use strict'; +const meta = require('../meta'); const categories = require('../categories'); +const topics = require('../topics'); const events = require('../events'); const user = require('../user'); const groups = require('../groups'); @@ -15,6 +17,22 @@ const hasAdminPrivilege = async (uid, privilege = 'categories') => { } }; +categoriesAPI.list = async (caller) => { + async function getCategories() { + const cids = await categories.getCidsByPrivilege('categories:cid', caller.uid, 'find'); + return await categories.getCategoriesData(cids); + } + + const [isAdmin, categoriesData] = await Promise.all([ + user.isAdministrator(caller.uid), + getCategories(), + ]); + + return { + categories: categoriesData.filter(category => category && (!category.disabled || isAdmin)), + }; +}; + categoriesAPI.get = async function (caller, data) { const [userPrivileges, category] = await Promise.all([ privileges.categories.get(data.cid, caller.uid), @@ -61,6 +79,100 @@ categoriesAPI.delete = async function (caller, { cid }) { }); }; +categoriesAPI.getTopicCount = async (caller, { cid }) => { + const count = await categories.getCategoryField(cid, 'topic_count'); + return { count }; +}; + +categoriesAPI.getPosts = async (caller, { cid }) => await categories.getRecentReplies(cid, caller.uid, 0, 4); + +categoriesAPI.getChildren = async (caller, { cid, start }) => { + if (!start || start < 0) { + start = 0; + } + start = parseInt(start, 10); + + const allowed = await privileges.categories.can('read', cid, caller.uid); + if (!allowed) { + throw new Error('[[error:no-privileges]]'); + } + + const category = await categories.getCategoryData(cid); + await categories.getChildrenTree(category, caller.uid); + const allCategories = []; + categories.flattenCategories(allCategories, category.children); + await categories.getRecentTopicReplies(allCategories, caller.uid); + + const payload = category.children.slice(start, start + category.subCategoriesPerPage); + return { categories: payload }; +}; + +categoriesAPI.getTopics = async (caller, data) => { + data.query = data.query || {}; + const [userPrivileges, settings, targetUid] = await Promise.all([ + privileges.categories.get(data.cid, caller.uid), + user.getSettings(caller.uid), + user.getUidByUserslug(data.query.author), + ]); + + if (!userPrivileges.read) { + throw new Error('[[error:no-privileges]]'); + } + + const infScrollTopicsPerPage = 20; + const sort = data.sort || data.categoryTopicSort || meta.config.categoryTopicSort || 'newest_to_oldest'; + + let start = Math.max(0, parseInt(data.after || 0, 10)); + + if (data.direction === -1) { + start -= infScrollTopicsPerPage; + } + + let stop = start + infScrollTopicsPerPage - 1; + + start = Math.max(0, start); + stop = Math.max(0, stop); + const result = await categories.getCategoryTopics({ + uid: caller.uid, + cid: data.cid, + start, + stop, + sort, + settings, + query: data.query, + tag: data.query.tag, + targetUid, + }); + categories.modifyTopicsByPrivilege(result.topics, userPrivileges); + + return { ...result, privileges: userPrivileges }; +}; + +categoriesAPI.setWatchState = async (caller, { cid, state, uid }) => { + let targetUid = caller.uid; + const cids = Array.isArray(cid) ? cid.map(cid => parseInt(cid, 10)) : [parseInt(cid, 10)]; + if (uid) { + targetUid = uid; + } + await user.isAdminOrGlobalModOrSelf(caller.uid, targetUid); + const allCids = await categories.getAllCidsFromSet('categories:cid'); + const categoryData = await categories.getCategoriesFields(allCids, ['cid', 'parentCid']); + + // filter to subcategories of cid + let cat; + do { + cat = categoryData.find(c => !cids.includes(c.cid) && cids.includes(c.parentCid)); + if (cat) { + cids.push(cat.cid); + } + } while (cat); + + await user.setCategoryWatchState(targetUid, cids, state); + await topics.pushUnreadCount(targetUid); + + return { cids }; +}; + categoriesAPI.getPrivileges = async (caller, { cid }) => { await hasAdminPrivilege(caller.uid, 'privileges'); diff --git a/src/api/index.js b/src/api/index.js index 9e5446c325..c454de93a5 100644 --- a/src/api/index.js +++ b/src/api/index.js @@ -9,6 +9,7 @@ module.exports = { posts: require('./posts'), chats: require('./chats'), categories: require('./categories'), + search: require('./search'), flags: require('./flags'), files: require('./files'), utils: require('./utils'), diff --git a/src/api/search.js b/src/api/search.js new file mode 100644 index 0000000000..18bd9fa160 --- /dev/null +++ b/src/api/search.js @@ -0,0 +1,105 @@ +'use strict'; + +const _ = require('lodash'); + +const categories = require('../categories'); +const privileges = require('../privileges'); +const meta = require('../meta'); +const plugins = require('../plugins'); + +const controllersHelpers = require('../controllers/helpers'); + +const searchApi = module.exports; + +searchApi.categories = async (caller, data) => { + // used by categorySearch module + + let cids = []; + let matchedCids = []; + const privilege = data.privilege || 'topics:read'; + data.states = (data.states || ['watching', 'notwatching', 'ignoring']).map( + state => categories.watchStates[state] + ); + data.parentCid = parseInt(data.parentCid || 0, 10); + + if (data.search) { + ({ cids, matchedCids } = await findMatchedCids(caller.uid, data)); + } else { + cids = await loadCids(caller.uid, data.parentCid); + } + + const visibleCategories = await controllersHelpers.getVisibleCategories({ + cids, uid: caller.uid, states: data.states, privilege, showLinks: data.showLinks, parentCid: data.parentCid, + }); + + if (Array.isArray(data.selectedCids)) { + data.selectedCids = data.selectedCids.map(cid => parseInt(cid, 10)); + } + + let categoriesData = categories.buildForSelectCategories(visibleCategories, ['disabledClass'], data.parentCid); + categoriesData = categoriesData.slice(0, 200); + + categoriesData.forEach((category) => { + category.selected = data.selectedCids ? data.selectedCids.includes(category.cid) : false; + if (matchedCids.includes(category.cid)) { + category.match = true; + } + }); + const result = await plugins.hooks.fire('filter:categories.categorySearch', { + categories: categoriesData, + ...data, + uid: caller.uid, + }); + + return { categories: result.categories }; +}; + +async function findMatchedCids(uid, data) { + const result = await categories.search({ + uid: uid, + query: data.search, + qs: data.query, + paginate: false, + }); + + let matchedCids = result.categories.map(c => c.cid); + // no need to filter if all 3 states are used + const filterByWatchState = !Object.values(categories.watchStates) + .every(state => data.states.includes(state)); + + if (filterByWatchState) { + const states = await categories.getWatchState(matchedCids, uid); + matchedCids = matchedCids.filter((cid, index) => data.states.includes(states[index])); + } + + const rootCids = _.uniq(_.flatten(await Promise.all(matchedCids.map(categories.getParentCids)))); + const allChildCids = _.uniq(_.flatten(await Promise.all(matchedCids.map(categories.getChildrenCids)))); + + return { + cids: _.uniq(rootCids.concat(allChildCids).concat(matchedCids)), + matchedCids: matchedCids, + }; +} + +async function loadCids(uid, parentCid) { + let resultCids = []; + async function getCidsRecursive(cids) { + const categoryData = await categories.getCategoriesFields(cids, ['subCategoriesPerPage']); + const cidToData = _.zipObject(cids, categoryData); + await Promise.all(cids.map(async (cid) => { + const allChildCids = await categories.getAllCidsFromSet(`cid:${cid}:children`); + if (allChildCids.length) { + const childCids = await privileges.categories.filterCids('find', allChildCids, uid); + resultCids.push(...childCids.slice(0, cidToData[cid].subCategoriesPerPage)); + await getCidsRecursive(childCids); + } + })); + } + + const allRootCids = await categories.getAllCidsFromSet(`cid:${parentCid}:children`); + const rootCids = await privileges.categories.filterCids('find', allRootCids, uid); + const pageCids = rootCids.slice(0, meta.config.categoriesPerPage); + resultCids = pageCids; + await getCidsRecursive(pageCids); + return resultCids; +} diff --git a/src/controllers/write/categories.js b/src/controllers/write/categories.js index d84f2bddfb..80ee961fbf 100644 --- a/src/controllers/write/categories.js +++ b/src/controllers/write/categories.js @@ -1,12 +1,17 @@ 'use strict'; const categories = require('../../categories'); +const meta = require('../../meta'); const api = require('../../api'); const helpers = require('../helpers'); const Categories = module.exports; +Categories.list = async (req, res) => { + helpers.formatApiResponse(200, res, await api.categories.list(req)); +}; + Categories.get = async (req, res) => { helpers.formatApiResponse(200, res, await api.categories.get(req, req.params)); }; @@ -31,6 +36,47 @@ Categories.delete = async (req, res) => { helpers.formatApiResponse(200, res); }; +Categories.getTopicCount = async (req, res) => { + helpers.formatApiResponse(200, res, await api.categories.getTopicCount(req, { ...req.params })); +}; + +Categories.getPosts = async (req, res) => { + const posts = await api.categories.getPosts(req, { ...req.params }); + helpers.formatApiResponse(200, res, { posts }); +}; + +Categories.getChildren = async (req, res) => { + const { cid } = req.params; + const { start } = req.query; + helpers.formatApiResponse(200, res, await api.categories.getChildren(req, { cid, start })); +}; + +Categories.getTopics = async (req, res) => { + const { cid } = req.params; + const result = await api.categories.getTopics(req, { ...req.query, cid }); + + helpers.formatApiResponse(200, res, result); +}; + +Categories.setWatchState = async (req, res) => { + const { cid } = req.params; + let { uid, state } = req.body; + + if (req.method === 'DELETE') { + // DELETE is always setting state to system default in acp + state = categories.watchStates[meta.config.categoryWatchState]; + } else if (Object.keys(categories.watchStates).includes(state)) { + state = categories.watchStates[state]; // convert to integer for backend processing + } else { + console.log('throwing', cid, uid, state); + throw new Error('[[error:invalid-data]]'); + } + + const { cids: modified } = await api.categories.setWatchState(req, { cid, state, uid }); + + helpers.formatApiResponse(200, res, { modified }); +}; + Categories.getPrivileges = async (req, res) => { const privilegeSet = await api.categories.getPrivileges(req, { cid: req.params.cid }); helpers.formatApiResponse(200, res, privilegeSet); diff --git a/src/controllers/write/index.js b/src/controllers/write/index.js index 46a8dd8110..26c74128d8 100644 --- a/src/controllers/write/index.js +++ b/src/controllers/write/index.js @@ -10,6 +10,7 @@ Write.tags = require('./tags'); Write.posts = require('./posts'); Write.chats = require('./chats'); Write.flags = require('./flags'); +Write.search = require('./search'); Write.admin = require('./admin'); Write.files = require('./files'); Write.utilities = require('./utilities'); diff --git a/src/controllers/write/search.js b/src/controllers/write/search.js new file mode 100644 index 0000000000..a6acd0a59a --- /dev/null +++ b/src/controllers/write/search.js @@ -0,0 +1,10 @@ +'use strict'; + +const api = require('../../api'); +const helpers = require('../helpers'); + +const Search = module.exports; + +Search.categories = async (req, res) => { + helpers.formatApiResponse(200, res, await api.search.categories(req, req.query)); +}; diff --git a/src/middleware/assert.js b/src/middleware/assert.js index 553114f870..6c0f5ef72f 100644 --- a/src/middleware/assert.js +++ b/src/middleware/assert.js @@ -11,6 +11,7 @@ const nconf = require('nconf'); const file = require('../file'); const user = require('../user'); const groups = require('../groups'); +const categories = require('../categories'); const topics = require('../topics'); const posts = require('../posts'); const messaging = require('../messaging'); @@ -39,6 +40,14 @@ Assert.group = helpers.try(async (req, res, next) => { next(); }); +Assert.category = helpers.try(async (req, res, next) => { + if (!await categories.exists(req.params.cid)) { + return controllerHelpers.formatApiResponse(404, res, new Error('[[error:no-category]]')); + } + + next(); +}); + Assert.topic = helpers.try(async (req, res, next) => { if (!await topics.exists(req.params.tid)) { return controllerHelpers.formatApiResponse(404, res, new Error('[[error:no-topic]]')); diff --git a/src/routes/write/categories.js b/src/routes/write/categories.js index ed3ffd2dce..ca149a54da 100644 --- a/src/routes/write/categories.js +++ b/src/routes/write/categories.js @@ -10,11 +10,20 @@ const { setupApiRoute } = routeHelpers; module.exports = function () { const middlewares = [middleware.ensureLoggedIn]; + setupApiRoute(router, 'get', '/', [...middlewares], controllers.write.categories.list); setupApiRoute(router, 'post', '/', [...middlewares, middleware.checkRequired.bind(null, ['name'])], controllers.write.categories.create); setupApiRoute(router, 'get', '/:cid', [], controllers.write.categories.get); setupApiRoute(router, 'put', '/:cid', [...middlewares], controllers.write.categories.update); setupApiRoute(router, 'delete', '/:cid', [...middlewares], controllers.write.categories.delete); + setupApiRoute(router, 'get', '/:cid/count', [...middlewares, middleware.assert.category], controllers.write.categories.getTopicCount); + setupApiRoute(router, 'get', '/:cid/posts', [...middlewares, middleware.assert.category], controllers.write.categories.getPosts); + setupApiRoute(router, 'get', '/:cid/children', [...middlewares, middleware.assert.category], controllers.write.categories.getChildren); + setupApiRoute(router, 'get', '/:cid/topics', [...middlewares, middleware.assert.category], controllers.write.categories.getTopics); + + setupApiRoute(router, 'put', '/:cid/watch', [...middlewares, middleware.assert.category], controllers.write.categories.setWatchState); + setupApiRoute(router, 'delete', '/:cid/watch', [...middlewares, middleware.assert.category], controllers.write.categories.setWatchState); + setupApiRoute(router, 'get', '/:cid/privileges', [...middlewares], controllers.write.categories.getPrivileges); setupApiRoute(router, 'put', '/:cid/privileges/:privilege', [...middlewares, middleware.checkRequired.bind(null, ['member'])], controllers.write.categories.setPrivilege); setupApiRoute(router, 'delete', '/:cid/privileges/:privilege', [...middlewares, middleware.checkRequired.bind(null, ['member'])], controllers.write.categories.setPrivilege); diff --git a/src/routes/write/index.js b/src/routes/write/index.js index 8e29c3ddd1..2ebec74ce1 100644 --- a/src/routes/write/index.js +++ b/src/routes/write/index.js @@ -41,6 +41,7 @@ Write.reload = async (params) => { router.use('/api/v3/posts', require('./posts')()); router.use('/api/v3/chats', require('./chats')()); router.use('/api/v3/flags', require('./flags')()); + router.use('/api/v3/search', require('./search')()); router.use('/api/v3/admin', require('./admin')()); router.use('/api/v3/files', require('./files')()); router.use('/api/v3/utilities', require('./utilities')()); diff --git a/src/routes/write/search.js b/src/routes/write/search.js new file mode 100644 index 0000000000..01b98cdeed --- /dev/null +++ b/src/routes/write/search.js @@ -0,0 +1,19 @@ +'use strict'; + +const router = require('express').Router(); +// const middleware = require('../../middleware'); +const controllers = require('../../controllers'); +const routeHelpers = require('../helpers'); + +const { setupApiRoute } = routeHelpers; + +module.exports = function () { + // const middlewares = []; + + // maybe redirect to /search/posts? + // setupApiRoute(router, 'post', '/', [...middlewares], controllers.write.search.TBD); + + setupApiRoute(router, 'get', '/categories', [], controllers.write.search.categories); + + return router; +}; diff --git a/src/socket.io/categories.js b/src/socket.io/categories.js index 169e207b0d..934defaeac 100644 --- a/src/socket.io/categories.js +++ b/src/socket.io/categories.js @@ -1,31 +1,30 @@ 'use strict'; const categories = require('../categories'); -const privileges = require('../privileges'); const user = require('../user'); const topics = require('../topics'); +const api = require('../api'); + +const sockets = require('.'); const SocketCategories = module.exports; require('./categories/search')(SocketCategories); SocketCategories.getRecentReplies = async function (socket, cid) { - return await categories.getRecentReplies(cid, socket.uid, 0, 4); + sockets.warnDeprecated(socket, 'GET /api/v3/categories/:cid/posts'); + return await api.categories.getPosts(socket, { cid }); }; SocketCategories.get = async function (socket) { - async function getCategories() { - const cids = await categories.getCidsByPrivilege('categories:cid', socket.uid, 'find'); - return await categories.getCategoriesData(cids); - } - const [isAdmin, categoriesData] = await Promise.all([ - user.isAdministrator(socket.uid), - getCategories(), - ]); - return categoriesData.filter(category => category && (!category.disabled || isAdmin)); + sockets.warnDeprecated(socket, 'GET /api/v3/categories'); + const { categories } = await api.categories.list(socket); + return categories; }; SocketCategories.getWatchedCategories = async function (socket) { + sockets.warnDeprecated(socket); + const [categoriesData, ignoredCids] = await Promise.all([ categories.getCategoriesByPrivilege('cid:0:children', socket.uid, 'find'), user.getIgnoredCategories(socket.uid), @@ -38,63 +37,40 @@ SocketCategories.loadMore = async function (socket, data) { throw new Error('[[error:invalid-data]]'); } data.query = data.query || {}; - const [userPrivileges, settings, targetUid] = await Promise.all([ - privileges.categories.get(data.cid, socket.uid), - user.getSettings(socket.uid), - user.getUidByUserslug(data.query.author), - ]); - if (!userPrivileges.read) { - throw new Error('[[error:no-privileges]]'); - } + const result = await api.categories.getTopics(socket, data); - const infScrollTopicsPerPage = 20; - const sort = data.sort || data.categoryTopicSort; - - let start = Math.max(0, parseInt(data.after, 10)); - - if (data.direction === -1) { - start -= infScrollTopicsPerPage; - } - - let stop = start + infScrollTopicsPerPage - 1; - - start = Math.max(0, start); - stop = Math.max(0, stop); - const result = await categories.getCategoryTopics({ - uid: socket.uid, - cid: data.cid, - start: start, - stop: stop, - sort: sort, - settings: settings, - query: data.query, - tag: data.query.tag, - targetUid: targetUid, - }); - categories.modifyTopicsByPrivilege(result.topics, userPrivileges); - - result.privileges = userPrivileges; + // Backwards compatibility — unsure of current usage. result.template = { category: true, name: 'category', }; + return result; }; SocketCategories.getTopicCount = async function (socket, cid) { - return await categories.getCategoryField(cid, 'topic_count'); + sockets.warnDeprecated(socket, 'GET /api/v3/categories/:cid'); + + const { count } = await api.categories.getTopicCount(socket, { cid }); + return count; }; SocketCategories.getCategoriesByPrivilege = async function (socket, privilege) { + sockets.warnDeprecated(socket); + return await categories.getCategoriesByPrivilege('categories:cid', socket.uid, privilege); }; SocketCategories.getMoveCategories = async function (socket, data) { + sockets.warnDeprecated(socket); + return await SocketCategories.getSelectCategories(socket, data); }; SocketCategories.getSelectCategories = async function (socket) { + sockets.warnDeprecated(socket); + const [isAdmin, categoriesData] = await Promise.all([ user.isAdministrator(socket.uid), categories.buildForSelect(socket.uid, 'find', ['disabled', 'link']), @@ -103,19 +79,27 @@ SocketCategories.getSelectCategories = async function (socket) { }; SocketCategories.setWatchState = async function (socket, data) { + sockets.warnDeprecated(socket, 'PUT/DELETE /api/v3/categories/:cid/watch'); + if (!data || !data.cid || !data.state) { throw new Error('[[error:invalid-data]]'); } - return await ignoreOrWatch(async (uid, cids) => { - await user.setCategoryWatchState(uid, cids, categories.watchStates[data.state]); - }, socket, data); + + data.state = categories.watchStates[data.state]; + + await api.categories.setWatchState(socket, data); + return data.cid; }; SocketCategories.watch = async function (socket, data) { + sockets.warnDeprecated(socket); + return await ignoreOrWatch(user.watchCategory, socket, data); }; SocketCategories.ignore = async function (socket, data) { + sockets.warnDeprecated(socket); + return await ignoreOrWatch(user.ignoreCategory, socket, data); }; @@ -144,24 +128,20 @@ async function ignoreOrWatch(fn, socket, data) { } SocketCategories.isModerator = async function (socket, cid) { + sockets.warnDeprecated(socket); + return await user.isModerator(socket.uid, cid); }; SocketCategories.loadMoreSubCategories = async function (socket, data) { + sockets.warnDeprecated(socket, `GET /api/v3/categories/:cid/children`); + if (!data || !data.cid || !(parseInt(data.start, 10) >= 0)) { throw new Error('[[error:invalid-data]]'); } - const allowed = await privileges.categories.can('read', data.cid, socket.uid); - if (!allowed) { - throw new Error('[[error:no-privileges]]'); - } - const category = await categories.getCategoryData(data.cid); - await categories.getChildrenTree(category, socket.uid); - const allCategories = []; - categories.flattenCategories(allCategories, category.children); - await categories.getRecentTopicReplies(allCategories, socket.uid); - const start = parseInt(data.start, 10); - return category.children.slice(start, start + category.subCategoriesPerPage); + + const { categories: children } = await api.categories.getChildren(socket, data); + return children; }; require('../promisify')(SocketCategories); diff --git a/src/socket.io/categories/search.js b/src/socket.io/categories/search.js index ad04c20edf..dbb355ce89 100644 --- a/src/socket.io/categories/search.js +++ b/src/socket.io/categories/search.js @@ -1,101 +1,13 @@ 'use strict'; -const _ = require('lodash'); - -const meta = require('../../meta'); -const categories = require('../../categories'); -const privileges = require('../../privileges'); -const controllersHelpers = require('../../controllers/helpers'); -const plugins = require('../../plugins'); +const sockets = require('..'); +const api = require('../../api'); module.exports = function (SocketCategories) { - // used by categorySearch module SocketCategories.categorySearch = async function (socket, data) { - let cids = []; - let matchedCids = []; - const privilege = data.privilege || 'topics:read'; - data.states = (data.states || ['watching', 'notwatching', 'ignoring']).map( - state => categories.watchStates[state] - ); + sockets.warnDeprecated(socket, 'GET /api/v3/search/categories'); - if (data.search) { - ({ cids, matchedCids } = await findMatchedCids(socket.uid, data)); - } else { - cids = await loadCids(socket.uid, data.parentCid); - } - - const visibleCategories = await controllersHelpers.getVisibleCategories({ - cids, uid: socket.uid, states: data.states, privilege, showLinks: data.showLinks, parentCid: data.parentCid, - }); - - if (Array.isArray(data.selectedCids)) { - data.selectedCids = data.selectedCids.map(cid => parseInt(cid, 10)); - } - - let categoriesData = categories.buildForSelectCategories(visibleCategories, ['disabledClass'], data.parentCid); - categoriesData = categoriesData.slice(0, 200); - - categoriesData.forEach((category) => { - category.selected = data.selectedCids ? data.selectedCids.includes(category.cid) : false; - if (matchedCids.includes(category.cid)) { - category.match = true; - } - }); - const result = await plugins.hooks.fire('filter:categories.categorySearch', { - categories: categoriesData, - ...data, - uid: socket.uid, - }); - return result.categories; + const { categories } = await api.search.categories(socket, data); + return categories; }; - - async function findMatchedCids(uid, data) { - const result = await categories.search({ - uid: uid, - query: data.search, - qs: data.query, - paginate: false, - }); - - let matchedCids = result.categories.map(c => c.cid); - // no need to filter if all 3 states are used - const filterByWatchState = !Object.values(categories.watchStates) - .every(state => data.states.includes(state)); - - if (filterByWatchState) { - const states = await categories.getWatchState(matchedCids, uid); - matchedCids = matchedCids.filter((cid, index) => data.states.includes(states[index])); - } - - const rootCids = _.uniq(_.flatten(await Promise.all(matchedCids.map(categories.getParentCids)))); - const allChildCids = _.uniq(_.flatten(await Promise.all(matchedCids.map(categories.getChildrenCids)))); - - return { - cids: _.uniq(rootCids.concat(allChildCids).concat(matchedCids)), - matchedCids: matchedCids, - }; - } - - async function loadCids(uid, parentCid) { - let resultCids = []; - async function getCidsRecursive(cids) { - const categoryData = await categories.getCategoriesFields(cids, ['subCategoriesPerPage']); - const cidToData = _.zipObject(cids, categoryData); - await Promise.all(cids.map(async (cid) => { - const allChildCids = await categories.getAllCidsFromSet(`cid:${cid}:children`); - if (allChildCids.length) { - const childCids = await privileges.categories.filterCids('find', allChildCids, uid); - resultCids.push(...childCids.slice(0, cidToData[cid].subCategoriesPerPage)); - await getCidsRecursive(childCids); - } - })); - } - - const allRootCids = await categories.getAllCidsFromSet(`cid:${parentCid}:children`); - const rootCids = await privileges.categories.filterCids('find', allRootCids, uid); - const pageCids = rootCids.slice(0, meta.config.categoriesPerPage); - resultCids = pageCids; - await getCidsRecursive(pageCids); - return resultCids; - } }; diff --git a/src/socket.io/index.js b/src/socket.io/index.js index 8f03eb2a9d..c10f271585 100644 --- a/src/socket.io/index.js +++ b/src/socket.io/index.js @@ -333,5 +333,9 @@ Sockets.warnDeprecated = (socket, replacement) => { replacement: replacement, }); } - winston.warn(`[deprecated]\n ${new Error('-').stack.split('\n').slice(2, 5).join('\n')}\n use ${replacement}`); + winston.warn([ + '[deprecated]', + `${new Error('-').stack.split('\n').slice(2, 5).join('\n')}`, + ` ${replacement ? `use ${replacement}` : 'there is no replacement for this call.'}`, + ].join('\n')); };