Merge branch 'develop' of https://github.com/NodeBB/NodeBB into develop

This commit is contained in:
Barış Soner Uşaklı
2023-10-31 11:32:45 -04:00
72 changed files with 872 additions and 222 deletions

View File

@@ -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 <span class=\"timeago\" title=\"%2\"></span>",

View File

@@ -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 се присъедини към стаята <span class=\"timeago\" title=\"%2\"></span>",

View File

@@ -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 <span class=\"timeago\" title=\"%2\"></span>",

View File

@@ -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 <span class=\"timeago\" title=\"%2\"></span>",

View File

@@ -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 <span class=\"timeago\" title=\"%2\"></span>",

View File

@@ -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 <span class=\"timeago\" title=\"%2\"></span>",

View File

@@ -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 <span class=\"timeago\" title=\"%2\"></span>",

View File

@@ -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 <span class=\"timeago\" title=\"%2\"></span>",

View File

@@ -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 <span class=\"timeago\" title=\"%2\"></span>",

View File

@@ -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 <span class=\"timeago\" title=\"%2\"></span>",

View File

@@ -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 <span class=\"timeago\" title=\"%2\"></span>",

View File

@@ -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 <span class=\"timeago\" title=\"%2\"></span>",

View File

@@ -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 <span class=\"timeago\" title=\"%2\"></span>",

View File

@@ -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 <span class=\"timeago\" title=\"%2\"></span>",

View File

@@ -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 <span class=\"timeago\" title=\"%2\"></span>",

View File

@@ -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 הצטרף לחדר <span class=\"timeago\" title=\"%2\"></span>",

View File

@@ -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 <span class=\"timeago\" title=\"%2\"></span>",

View File

@@ -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 <span class=\"timeago\" title=\"%2\"></span>",

View File

@@ -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-ը միացել է սենյակին <span class=\"timeago\" title=\"%2\"></span>",

View File

@@ -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 <span class=\"timeago\" title=\"%2\"></span>",

View File

@@ -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 <span class=\"timeago\" title=\"%2\"></span>",

View File

@@ -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 <span class=\"timeago\" title=\"%2\"></span>",

View File

@@ -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 <span class=\"timeago\" title=\"%2\"></span>",

View File

@@ -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 <span class=\"timeago\" title=\"%2\"></span>",

View File

@@ -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 <span class=\"timeago\" title=\"%2\"></span>",

View File

@@ -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 <span class=\"timeago\" title=\"%2\"></span>",

View File

@@ -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 <span class=\"timeago\" title=\"%2\"></span>",

View File

@@ -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 <span class=\"timeago\" title=\"%2\"></span>",

View File

@@ -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 <span class=\"timeago\" title=\"%2\"></span>",

View File

@@ -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 <span class=\"timeago\" title=\"%2\"></span>",

View File

@@ -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 <span class=\"timeago\" title=\"%2\"></span>",

View File

@@ -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 <span class=\"timeago\" title=\"%2\"></span>",

View File

@@ -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 <span class=\"timeago\" title=\"%2\"></span>",

View File

@@ -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 <span class=\"timeago\" title=\"%2\"></span>",

View File

@@ -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 <span class=\"timeago\" title=\"%2\"></span>",

View File

@@ -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 <span class=\"timeago\" title=\"%2\"></span>",

View File

@@ -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 <span class=\"timeago\" title=\"%2\"></span>",

View File

@@ -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 <span class=\"timeago\" title=\"%2\"></span>",

View File

@@ -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 се придружио соби <span class=\"timeago\" title=\"%2\"></span>",

View File

@@ -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 <span class=\"timeago\" title=\"%2\"></span>",

View File

@@ -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 <span class=\"timeago\" title=\"%2\"></span>",

View File

@@ -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ı <span class=\"timeago\" title=\"%2\"></span>",

View File

@@ -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 <span class=\"timeago\" title=\"%2\"></span>",

View File

@@ -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 <span class=\"timeago\" title=\"%2\"></span>",

View File

@@ -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 <span class=\"timeago\" title=\"%2\"></span>加入了房间",

View File

@@ -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 <span class=\"timeago\" title=\"%2\"></span>",

View File

@@ -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:

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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;

View File

@@ -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]]');
});
}

View File

@@ -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,

View File

@@ -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);

View File

@@ -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 () {

View File

@@ -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');

View File

@@ -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'),

105
src/api/search.js Normal file
View File

@@ -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;
}

View File

@@ -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);

View File

@@ -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');

View File

@@ -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));
};

View File

@@ -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]]'));

View File

@@ -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);

View File

@@ -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')());

View File

@@ -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;
};

View File

@@ -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);

View File

@@ -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;
}
};

View File

@@ -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'));
};