mirror of
https://github.com/NodeBB/NodeBB.git
synced 2025-12-25 09:50:35 +01:00
Compare commits
2 Commits
v4.3.0-alp
...
v4.2.1
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
59bc2b0d4b | ||
|
|
33d50637a3 |
21
.eslintignore
Normal file
21
.eslintignore
Normal file
@@ -0,0 +1,21 @@
|
||||
node_modules/
|
||||
*.sublime-project
|
||||
*.sublime-workspace
|
||||
.project
|
||||
.vagrant
|
||||
.DS_Store
|
||||
logs/
|
||||
/public/templates
|
||||
/public/uploads
|
||||
/public/vendor
|
||||
/public/src/modules/string.js
|
||||
.idea/
|
||||
.vscode/
|
||||
*.ipr
|
||||
*.iws
|
||||
/coverage
|
||||
/build
|
||||
.eslintrc
|
||||
test/files
|
||||
*.min.js
|
||||
install/docker/
|
||||
@@ -1,62 +0,0 @@
|
||||
'use strict';
|
||||
const serverConfig = require('eslint-config-nodebb');
|
||||
const publicConfig = require('eslint-config-nodebb/public');
|
||||
|
||||
const { configs } = require('@eslint/js');
|
||||
const globals = require('globals');
|
||||
|
||||
module.exports = [
|
||||
{
|
||||
ignores: [
|
||||
'node_modules/',
|
||||
'.project',
|
||||
'.vagrant',
|
||||
'.DS_Store',
|
||||
'.tx',
|
||||
'logs/',
|
||||
'public/uploads/',
|
||||
'public/vendor/',
|
||||
'.idea/',
|
||||
'.vscode/',
|
||||
'*.ipr',
|
||||
'*.iws',
|
||||
'coverage/',
|
||||
'build/',
|
||||
'test/files/',
|
||||
'*.min.js',
|
||||
'install/docker/',
|
||||
],
|
||||
},
|
||||
configs.recommended,
|
||||
{
|
||||
rules: {
|
||||
'no-bitwise': 'warn',
|
||||
'no-await-in-loop': 'warn',
|
||||
}
|
||||
},
|
||||
// tests
|
||||
{
|
||||
files: ['test/**/*.js'],
|
||||
languageOptions: {
|
||||
ecmaVersion: 2020,
|
||||
sourceType: 'commonjs',
|
||||
globals: {
|
||||
...globals.node,
|
||||
...globals.browser,
|
||||
it: 'readonly',
|
||||
describe: 'readonly',
|
||||
before: 'readonly',
|
||||
beforeEach: 'readonly',
|
||||
after: 'readonly',
|
||||
afterEach: 'readonly',
|
||||
},
|
||||
},
|
||||
rules: {
|
||||
'no-unused-vars': 'off',
|
||||
'no-prototype-builtins': 'off',
|
||||
}
|
||||
},
|
||||
...publicConfig,
|
||||
...serverConfig
|
||||
];
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"name": "nodebb",
|
||||
"license": "GPL-3.0",
|
||||
"description": "NodeBB Forum",
|
||||
"version": "4.3.0-alpha.2",
|
||||
"version": "4.2.1",
|
||||
"homepage": "https://www.nodebb.org",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
@@ -39,13 +39,13 @@
|
||||
"@textcomplete/contenteditable": "0.1.13",
|
||||
"@textcomplete/core": "0.1.13",
|
||||
"@textcomplete/textarea": "0.1.13",
|
||||
"ace-builds": "1.39.1",
|
||||
"ace-builds": "1.39.0",
|
||||
"archiver": "7.0.1",
|
||||
"async": "3.2.6",
|
||||
"autoprefixer": "10.4.21",
|
||||
"bcryptjs": "3.0.2",
|
||||
"benchpressjs": "2.5.3",
|
||||
"body-parser": "2.2.0",
|
||||
"body-parser": "1.20.3",
|
||||
"bootbox": "6.0.0",
|
||||
"bootstrap": "5.3.3",
|
||||
"bootswatch": "5.3.3",
|
||||
@@ -53,7 +53,8 @@
|
||||
"chart.js": "4.4.8",
|
||||
"cli-graph": "3.2.2",
|
||||
"clipboard": "2.0.11",
|
||||
"commander": "13.1.0",
|
||||
"colors": "1.4.0",
|
||||
"commander": "12.1.0",
|
||||
"compare-versions": "6.1.1",
|
||||
"compression": "1.8.0",
|
||||
"connect-flash": "0.1.1",
|
||||
@@ -62,9 +63,9 @@
|
||||
"connect-pg-simple": "10.0.0",
|
||||
"connect-redis": "8.0.2",
|
||||
"cookie-parser": "1.4.7",
|
||||
"cron": "4.1.2",
|
||||
"cron": "4.1.0",
|
||||
"cropperjs": "1.6.2",
|
||||
"csrf-sync": "4.1.0",
|
||||
"csrf-sync": "4.0.3",
|
||||
"daemon": "1.1.0",
|
||||
"diff": "7.0.0",
|
||||
"esbuild": "0.25.1",
|
||||
@@ -98,12 +99,12 @@
|
||||
"multiparty": "4.2.3",
|
||||
"nconf": "0.12.1",
|
||||
"nodebb-plugin-2factor": "7.5.9",
|
||||
"nodebb-plugin-composer-default": "10.2.49",
|
||||
"nodebb-plugin-composer-default": "10.2.47",
|
||||
"nodebb-plugin-dbsearch": "6.2.13",
|
||||
"nodebb-plugin-emoji": "6.0.2",
|
||||
"nodebb-plugin-emoji-android": "4.1.1",
|
||||
"nodebb-plugin-markdown": "13.1.1",
|
||||
"nodebb-plugin-mentions": "4.7.2",
|
||||
"nodebb-plugin-mentions": "4.7.1",
|
||||
"nodebb-plugin-spam-be-gone": "2.3.1",
|
||||
"nodebb-plugin-web-push": "0.7.3",
|
||||
"nodebb-rewards-essentials": "1.0.1",
|
||||
@@ -127,9 +128,9 @@
|
||||
"rimraf": "5.0.10",
|
||||
"rss": "1.2.2",
|
||||
"rtlcss": "4.3.0",
|
||||
"sanitize-html": "2.15.0",
|
||||
"sanitize-html": "2.14.0",
|
||||
"sass": "1.86.0",
|
||||
"satori": "0.12.2",
|
||||
"satori": "0.12.1",
|
||||
"semver": "7.7.1",
|
||||
"serve-favicon": "2.5.0",
|
||||
"sharp": "0.32.6",
|
||||
@@ -146,7 +147,7 @@
|
||||
"tinycon": "0.6.8",
|
||||
"toobusy-js": "0.5.1",
|
||||
"tough-cookie": "5.1.2",
|
||||
"validator": "13.15.0",
|
||||
"validator": "13.12.0",
|
||||
"webpack": "5.98.0",
|
||||
"webpack-merge": "6.0.1",
|
||||
"winston": "3.17.0",
|
||||
@@ -161,8 +162,8 @@
|
||||
"@commitlint/cli": "19.8.0",
|
||||
"@commitlint/config-angular": "19.8.0",
|
||||
"coveralls": "3.1.1",
|
||||
"@eslint/js": "9.23.0",
|
||||
"eslint-config-nodebb": "1.0.7",
|
||||
"eslint": "8.57.1",
|
||||
"eslint-config-nodebb": "0.2.1",
|
||||
"eslint-plugin-import": "2.31.0",
|
||||
"grunt": "1.6.1",
|
||||
"grunt-contrib-watch": "1.1.0",
|
||||
@@ -199,4 +200,4 @@
|
||||
"url": "https://github.com/barisusakli"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
3
public/.eslintrc
Normal file
3
public/.eslintrc
Normal file
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"extends": "nodebb/public"
|
||||
}
|
||||
@@ -22,7 +22,7 @@
|
||||
"invalid-user-data": "Yanlış istifadəçi məlumatı",
|
||||
"invalid-password": "Yanlış şifrə",
|
||||
"invalid-login-credentials": "Yanlış giriş məlumatları",
|
||||
"invalid-username-or-password": "Zəhmət olmasa istifadəçi adı və şifrənizi daxil edin",
|
||||
"invalid-username-or-password": "Zəhmət olmasa həm istifadəçi adı, həm də şifrənizi göstərin",
|
||||
"invalid-search-term": "Yanlış axtarış termini",
|
||||
"invalid-url": "Yanlış URL",
|
||||
"invalid-event": "Yanlış hadisə: %1",
|
||||
|
||||
@@ -66,8 +66,8 @@
|
||||
"alert.unfollow": "Siz artıq %1-i izləmirsiniz!",
|
||||
"alert.follow": "İndi %1-i izləyirsiniz!",
|
||||
"users": "İstifadəçilər",
|
||||
"topics": "Mövzu",
|
||||
"posts": "Yazı",
|
||||
"topics": "Mövzular",
|
||||
"posts": "Yazılar",
|
||||
"x-posts": "<span class=\"formatted-number\">%1</span> yazı",
|
||||
"x-topics": "<span class=\"formatted-number\">%1</span> mövzu",
|
||||
"x-reputation": "<span class=\"formatted-number\">%1</span> reputasiya",
|
||||
@@ -80,7 +80,7 @@
|
||||
"upvoted": "Müsbət səs verildir",
|
||||
"downvoters": "Mənfi səs verənlər",
|
||||
"downvoted": "Mənfi səs verildi",
|
||||
"views": "Baxış",
|
||||
"views": "Baxışlar",
|
||||
"posters": "Yazarlar",
|
||||
"reputation": "Reputasiya",
|
||||
"lastpost": "Son yazı",
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
"chat.user-has-messaged-you": "%1 sizə mesaj göndərib.",
|
||||
"chat.replying-to": "%1-ə cavab verilir",
|
||||
"chat.see-all": "Bütün söhbətlər",
|
||||
"chat.mark-all-read": "Oxunmuş et",
|
||||
"chat.mark-all-read": "Hamısını oxumuş et",
|
||||
"chat.no-messages": "Söhbət mesajı tarixçəsinə baxmaq üçün alıcı tərəfi seçin",
|
||||
"chat.no-users-in-room": "Bu otaqda heç bir istifadəçi yoxdur",
|
||||
"chat.recent-chats": "Son söhbətlər",
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"title": "Bildirişlər",
|
||||
"no-notifs": "Yeni bildirişləriniz yoxdur",
|
||||
"see-all": "Bütün bildirişlər",
|
||||
"mark-all-read": "Oxunmuş et",
|
||||
"mark-all-read": "Hamısını oxumuş et",
|
||||
"back-to-home": "%1-ə qayıt",
|
||||
"outgoing-link": "Çıxış linki",
|
||||
"outgoing-link-message": "İndi %1-i tərk edirsiniz",
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
{
|
||||
"theme-name": "Harmony Theme",
|
||||
"skins": "Örtüklər",
|
||||
"collapse": "Yığmaq",
|
||||
"expand": "Açmaq",
|
||||
"collapse": "Hamısını yığ",
|
||||
"expand": "Hamısını aç",
|
||||
"sidebar-toggle": "Yan panel aç/bağla",
|
||||
"login-register-to-search": "Axtarış etmək üçün daxil olun və ya qeydiyyatdan keçin.",
|
||||
"settings.title": "Mövzu ayarları",
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
"last-reply-time": "Son cavab",
|
||||
"reply-options": "Cavab variantları",
|
||||
"reply-as-topic": "Mövzu olaraq cavablandır",
|
||||
"guest-login-reply": "🔑 Daxil ol",
|
||||
"guest-login-reply": "Cavab yazmaq üçün daxil ol",
|
||||
"login-to-view": "🔒 Görmək üçün daxil ol",
|
||||
"edit": "Redaktə et",
|
||||
"delete": "Sil",
|
||||
@@ -51,8 +51,8 @@
|
||||
"user-locked-topic-on": "%1 bu mövzunu %2-də kilidlədi",
|
||||
"user-unlocked-topic-ago": "%1 bu mövzunu açdı %2",
|
||||
"user-unlocked-topic-on": "%1 bu mövzunu %2-də açdı",
|
||||
"user-pinned-topic-ago": "%1 bu mövzunu sabitlədi %2",
|
||||
"user-pinned-topic-on": "%1 bu mövzunu %2-də sabitlədi",
|
||||
"user-pinned-topic-ago": "% 1 bu mövzunu sabitlədi % 2",
|
||||
"user-pinned-topic-on": "% 1 bu mövzunu % 2-də sabitlədi",
|
||||
"user-unpinned-topic-ago": "%1 bu mövzunu sabitdən qaldırdı %2",
|
||||
"user-unpinned-topic-on": "%1 bu mövzunu %2-də sabitdən qaldırdı",
|
||||
"user-deleted-topic-ago": "%1 bu mövzunu sildi %2",
|
||||
|
||||
@@ -83,7 +83,6 @@
|
||||
"email-confirmed": "Email Confirmed",
|
||||
"email-confirmed-message": "Thank you for validating your email. Your account is now fully activated.",
|
||||
"email-confirm-error-message": "There was a problem validating your email address. Perhaps the code was invalid or has expired.",
|
||||
"email-confirm-error-message-already-validated": "Your email address was already validated.",
|
||||
"email-confirm-sent": "Confirmation email sent.",
|
||||
|
||||
"none": "None",
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
{
|
||||
"intro-lead": "¿Qué es Federación?",
|
||||
"intro-body": "NodeBB puede comunicarse con otras instancias de NodeBB que lo soportan. Esto es conseguido a través de un prótocolo llamado <a href=\"https://activitypub.rocks/\">ActivityPub</a>. Si se habilita, NodeBB también será capaz de comunicarse con otras aplicaciones y sitios web que usan ActivityPub (ej. Mastodon, Peertube, etc.)",
|
||||
"intro-lead": "What is Federation?",
|
||||
"intro-body": "NodeBB is able to communicate with other NodeBB instances that support it. This is achieved through a protocol called <a href=\"https://activitypub.rocks/\">ActivityPub</a>. If enabled, NodeBB will also be able to communicate with other apps and websites that use ActivityPub (e.g. Mastodon, Peertube, etc.)",
|
||||
"general": "General",
|
||||
"pruning": "Purga de contenido",
|
||||
"content-pruning": "Días para mantener contenido remoto",
|
||||
"content-pruning-help": "Nota, remover contenido que ha tenido actividad (respuesta, un voto) será preservado. (0 para deshabilitar)",
|
||||
"user-pruning": "Días para mantener en caché cuentas de usuarios remotos",
|
||||
"user-pruning-help": "Cuentas de usuarios remotos solo serán purgadas si no tienen publicaciones. De lo contrario serán re-obtenidas. (0 para deshabilitar)",
|
||||
"enabled": "Habilitar Federación",
|
||||
"enabled-help": "Si se activa, esto habilitará a NodeBB comunicarse con todos los clientes que tengan habilitado ActivityPub en el fediverso.",
|
||||
"pruning": "Content Pruning",
|
||||
"content-pruning": "Days to keep remote content",
|
||||
"content-pruning-help": "Note that remote content that has received engagement (a reply or a upvote/downvote) will be preserved. (0 for disabled)",
|
||||
"user-pruning": "Days to cache remote user accounts",
|
||||
"user-pruning-help": "Remote user accounts will only be pruned if they have no posts. Otherwise they will be re-retrieved. (0 for disabled)",
|
||||
"enabled": "Enable Federation",
|
||||
"enabled-help": "If enabled, will allow this NodeBB will be able to communicate with all Activitypub-enabled clients on the wider fediverse.",
|
||||
"allowLoopback": "Allow loopback processing",
|
||||
"allowLoopback-help": "Useful for debugging purposes only. You should probably leave this disabled.",
|
||||
|
||||
|
||||
@@ -12,16 +12,16 @@
|
||||
"email": "Correo electrónico",
|
||||
"confirm-email": "Confirmar correo electrónico",
|
||||
"account-info": "Información de cuenta",
|
||||
"admin-actions-label": "Acciones administrativas",
|
||||
"admin-actions-label": "Administrative Actions",
|
||||
"ban-account": "Banear cuenta",
|
||||
"ban-account-confirm": "Quieres confirmar el baneo de este usuario?",
|
||||
"unban-account": "Desbanear cuenta",
|
||||
"mute-account": "Mutear cuenta",
|
||||
"unmute-account": "Desmutear cuenta",
|
||||
"mute-account": "Mute Account",
|
||||
"unmute-account": "Unmute Account",
|
||||
"delete-account": "Eliminar cuenta",
|
||||
"delete-account-as-admin": "Eliminar<strong>cuenta</strong>",
|
||||
"delete-content": "Eliminar<strong>contenido de la cuenta</strong>",
|
||||
"delete-all": "Eliminar<strong>cuenta</strong> y <strong>contenido</strong>",
|
||||
"delete-account-as-admin": "Delete <strong>Account</strong>",
|
||||
"delete-content": "Delete Account <strong>Content</strong>",
|
||||
"delete-all": "Delete <strong>Account</strong> and <strong>Content</strong>",
|
||||
"delete-account-confirm": "¿Estás seguro de que quieres anonimizar tus publicaciones y eliminar tu cuenta?<br /><strong>Esta acción es irreversible y no podrás recuperar ninguno de tus datos.</strong><br /><br />Ingresa tu contraseña para confirmar que deseas eliminar esta cuenta.",
|
||||
"delete-this-account-confirm": "Are you sure you want to delete this account while leaving its contents behind?<br /><strong>This action is irreversible, posts will be anonymized, and you will not be able to restore post associations with the deleted account</strong><br /><br />",
|
||||
"delete-account-content-confirm": "Are you sure you want to delete this account's content (posts/topics/uploads)? <br /><strong>This action is irreversible and you will not be able to recover any data</strong><br /><br />",
|
||||
|
||||
@@ -1,26 +1,26 @@
|
||||
{
|
||||
"intro-lead": "Что такое Федерация?",
|
||||
"intro-body": "NodeBB может взаимодействовать с другими экземплярами NodeBB, которые его поддерживают. Это достигается с помощью протокола <a href=\"https://activitypub.rocks/\">ActivityPub</a>. Если он включен, NodeBB также сможет взаимодействовать с другими приложениями и веб-сайтами, которые используют ActivityPub (например, Mastodon, Peertube и т. д.)",
|
||||
"general": "Общие",
|
||||
"pruning": "Обрезка контента",
|
||||
"content-pruning": "Дней для хранения удаленного контента",
|
||||
"content-pruning-help": "Обратите внимание, что удаленный контент, который привлек внимание (ответ или голосование за/против), будет сохранен. (значение 0 отключено)",
|
||||
"user-pruning": "Дней для кэширования учетных записей удаленных пользователей",
|
||||
"user-pruning-help": "Учетные записи удаленных пользователей будут удалены только в том случае, если в них нет записей. В противном случае они будут восстановлены повторно. (значение 0 отключено)",
|
||||
"enabled": "Включить федерацию",
|
||||
"enabled-help": "Если этот параметр включен, NodeBB сможет взаимодействовать со всеми клиентами, поддерживающими Activitypub, в более широком федеративном пространстве.",
|
||||
"allowLoopback": "Разрешить обработку обратной связи",
|
||||
"allowLoopback-help": "Полезно только для отладки. Вероятно, вам следует оставить это отключенным.",
|
||||
"intro-lead": "What is Federation?",
|
||||
"intro-body": "NodeBB is able to communicate with other NodeBB instances that support it. This is achieved through a protocol called <a href=\"https://activitypub.rocks/\">ActivityPub</a>. If enabled, NodeBB will also be able to communicate with other apps and websites that use ActivityPub (e.g. Mastodon, Peertube, etc.)",
|
||||
"general": "General",
|
||||
"pruning": "Content Pruning",
|
||||
"content-pruning": "Days to keep remote content",
|
||||
"content-pruning-help": "Note that remote content that has received engagement (a reply or a upvote/downvote) will be preserved. (0 for disabled)",
|
||||
"user-pruning": "Days to cache remote user accounts",
|
||||
"user-pruning-help": "Remote user accounts will only be pruned if they have no posts. Otherwise they will be re-retrieved. (0 for disabled)",
|
||||
"enabled": "Enable Federation",
|
||||
"enabled-help": "If enabled, will allow this NodeBB will be able to communicate with all Activitypub-enabled clients on the wider fediverse.",
|
||||
"allowLoopback": "Allow loopback processing",
|
||||
"allowLoopback-help": "Useful for debugging purposes only. You should probably leave this disabled.",
|
||||
|
||||
"probe": "Открыть в приложении",
|
||||
"probe-enabled": "Попробуйте открыть ресурсы с поддержкой ActivityPub в NodeBB",
|
||||
"probe-enabled-help": "Если включено, NodeBB будет проверять каждую внешнюю ссылку на наличие эквивалента ActivityPub и загружать его в NodeBB.",
|
||||
"probe-timeout": "Время ожидания поиска (миллисекунды)",
|
||||
"probe-timeout-help": "(По умолчанию: 2000) Если поисковый запрос не получит ответа в установленные сроки, пользователь будет перенаправлен непосредственно по ссылке. Увеличьте это число, если сайты отвечают медленно и вы хотите предоставить дополнительное время.",
|
||||
"probe": "Open in App",
|
||||
"probe-enabled": "Try to open ActivityPub-enabled resources in NodeBB",
|
||||
"probe-enabled-help": "If enabled, NodeBB will check every external link for an ActivityPub equivalent, and load it in NodeBB instead.",
|
||||
"probe-timeout": "Lookup Timeout (milliseconds)",
|
||||
"probe-timeout-help": "(Default: 2000) If the lookup query does not receive a response within the set timeframe, will send the user to the link directly instead. Adjust this number higher if sites are responding slowly and you wish to give extra time.",
|
||||
|
||||
"server-filtering": "Фильтрация",
|
||||
"count": "В настоящее время NodeBB знает о <strong>%1</strong> сервере(ах)",
|
||||
"server.filter-help": "Укажите серверы, для которых вы хотели бы запретить объединение с вашим NodeBB. В качестве альтернативы вы можете выборочно <em>разрешить</em> объединение с определенными серверами. Поддерживаются оба варианта, хотя они и являются взаимоисключающими.",
|
||||
"server.filter-help-hostname": "Введите ниже только имя хоста экземпляра (например, <code>example.org</code>), разделенное переносами строк.",
|
||||
"server.filter-allow-list": "Вместо этого используйте это как список разрешений."
|
||||
"server-filtering": "Filtering",
|
||||
"count": "This NodeBB is currently aware of <strong>%1</strong> server(s)",
|
||||
"server.filter-help": "Specify servers you would like to bar from federating with your NodeBB. Alternatively, you may opt to selectively <em>allow</em> federation with specific servers, instead. Both options are supported, although they are mutually exclusive.",
|
||||
"server.filter-help-hostname": "Enter just the instance hostname below (e.g. <code>example.org</code>), separated by line breaks.",
|
||||
"server.filter-allow-list": "Use this as an Allow List instead"
|
||||
}
|
||||
@@ -1,9 +1,9 @@
|
||||
{
|
||||
"category": "Категория",
|
||||
"subcategories": "Подкатегории",
|
||||
"uncategorized": "Без рубрики",
|
||||
"uncategorized.description": "Темы, которые не вписываются строго ни в одну из существующих категорий",
|
||||
"handle.description": "За этой категорией можно следить из открытой социальной сети, используя идентификатор %1",
|
||||
"uncategorized": "Uncategorized",
|
||||
"uncategorized.description": "Topics that do not strictly fit in with any existing categories",
|
||||
"handle.description": "This category can be followed from the open social web via the handle %1",
|
||||
"new-topic-button": "Создать тему",
|
||||
"guest-login-post": "Авторизуйтесь, чтобы написать сообщение",
|
||||
"no-topics": "<strong>В этой категории еще нет тем.</strong><br />Почему бы вам не создать первую?",
|
||||
@@ -13,15 +13,15 @@
|
||||
"watch": "Отслеживать",
|
||||
"ignore": "Игнорировать",
|
||||
"watching": "Отслеживается",
|
||||
"tracking": "Отслеживание",
|
||||
"tracking": "Tracking",
|
||||
"not-watching": "Не отслеживается",
|
||||
"ignoring": "Игнорируется",
|
||||
"watching.description": "Уведомляйте меня о новых темах.<br/>Показывать темы в непрочитанных и недавних",
|
||||
"tracking.description": "Показывает темы в непрочитанных и недавних",
|
||||
"watching.description": "Notify me of new topics.<br/>Show topics in unread & recent",
|
||||
"tracking.description": "Shows topics in unread & recent",
|
||||
"not-watching.description": "Не показывать темы из этой категории в непрочитанных, но оставить в списке недавних",
|
||||
"ignoring.description": "Не показывать темы в непрочитанных и недавних",
|
||||
"ignoring.description": "Do not show topics in unread & recent",
|
||||
"watching.message": "Вы отслеживаете обновления этой категории, включая все подкатегории",
|
||||
"tracking.message": "Теперь вы отслеживаете обновления в этой категории и всех подкатегориях",
|
||||
"tracking.message": "You are now tracking updates from this category and all subcategories",
|
||||
"notwatching.message": "Вы более не отслеживаете обновления этой категории, включая все подкатегории",
|
||||
"ignoring.message": "Вы игнорируете обновления этой категории, включая все подкатегории",
|
||||
"watched-categories": "Отслеживаемые категории",
|
||||
|
||||
@@ -1,21 +1,21 @@
|
||||
{
|
||||
"chat.room-id": "Room %1",
|
||||
"chat.chatting-with": "Чат с",
|
||||
"chat.placeholder": "Введите сообщение чата здесь, перетащите изображения",
|
||||
"chat.placeholder.mobile": "Введите сообщение чата",
|
||||
"chat.placeholder.message-room": "Сообщение #%1",
|
||||
"chat.placeholder": "Type chat message here, drag & drop images",
|
||||
"chat.placeholder.mobile": "Type chat message",
|
||||
"chat.placeholder.message-room": "Message #%1",
|
||||
"chat.scroll-up-alert": "Go to most recent message",
|
||||
"chat.usernames-and-x-others": "%1 пользователей и %2 других",
|
||||
"chat.chat-with-usernames": "Чат с %1",
|
||||
"chat.chat-with-usernames-and-x-others": "Чат с %1 и %2 других",
|
||||
"chat.send": "Отправить",
|
||||
"chat.no-active": "У вас нет активных чатов.",
|
||||
"chat.user-typing-1": "<strong>% 1</strong> набирает текст ...",
|
||||
"chat.user-typing-2": "<strong>%1</strong> и <strong>%2</strong> печатают ...",
|
||||
"chat.user-typing-3": "<strong>%1</strong>, <strong>%2</strong> и <strong>%3</strong> печатают ...",
|
||||
"chat.user-typing-n": "<strong>%1</strong>, <strong>%2</strong> и <strong>%3</strong> другие печатают ...",
|
||||
"chat.user-typing-1": "<strong>%1</strong> is typing ...",
|
||||
"chat.user-typing-2": "<strong>%1</strong> and <strong>%2</strong> are typing ...",
|
||||
"chat.user-typing-3": "<strong>%1</strong>, <strong>%2</strong> and <strong>%3</strong> are typing ...",
|
||||
"chat.user-typing-n": "<strong>%1</strong>, <strong>%2</strong> and <strong>%3</strong> others are typing ...",
|
||||
"chat.user-has-messaged-you": "Пользователь %1 отправил вам сообщение.",
|
||||
"chat.replying-to": "Отвечаю %1",
|
||||
"chat.replying-to": "Replying to %1",
|
||||
"chat.see-all": "Все чаты",
|
||||
"chat.mark-all-read": "Пометить как прочитанное",
|
||||
"chat.no-messages": "Пожалуйста, выберите собеседника для просмотра истории сообщений",
|
||||
@@ -33,49 +33,49 @@
|
||||
"chat.three-months": "3 месяца",
|
||||
"chat.delete-message-confirm": "Вы уверены, что хотите удалить это сообщение?",
|
||||
"chat.retrieving-users": "Получение списка пользователей...",
|
||||
"chat.view-users-list": "Просмотр списка пользователей",
|
||||
"chat.pinned-messages": "Закрепленные сообщения",
|
||||
"chat.no-pinned-messages": "Закрепленных сообщений нет",
|
||||
"chat.pin-message": "Закрепить сообщение",
|
||||
"chat.unpin-message": "Открепить сообщение",
|
||||
"chat.public-rooms": "Открытые комнаты (%1)",
|
||||
"chat.private-rooms": "Приватные комнаты (%1)",
|
||||
"chat.create-room": "Создать чат комнату",
|
||||
"chat.private.option": "Приватный (виден только пользователям, добавленным в комнату)",
|
||||
"chat.public.option": "Публичный (виден каждому пользователю в выбранных группах)",
|
||||
"chat.public.groups-help": "Чтобы создать чат-комнату, видимую всем пользователям, выберите зарегистрированных пользователей из списка групп.",
|
||||
"chat.view-users-list": "View users list",
|
||||
"chat.pinned-messages": "Pinned Messages",
|
||||
"chat.no-pinned-messages": "There are no pinned messages",
|
||||
"chat.pin-message": "Pin Message",
|
||||
"chat.unpin-message": "Unpin Message",
|
||||
"chat.public-rooms": "Public Rooms (%1)",
|
||||
"chat.private-rooms": "Private Rooms (%1)",
|
||||
"chat.create-room": "Create Chat Room",
|
||||
"chat.private.option": "Private (Only visible to users added to room)",
|
||||
"chat.public.option": "Public (Visible to every user in selected groups)",
|
||||
"chat.public.groups-help": "To create a chat room that is visible to all users select registered-users from the group list.",
|
||||
"chat.manage-room": "Управлять комнатой чата",
|
||||
"chat.add-user": "Добавить пользователя",
|
||||
"chat.notification-settings": "Настройки уведомлений",
|
||||
"chat.default-notification-setting": "Настройка уведомлений по умолчанию",
|
||||
"chat.notification-setting-room-default": "Комната по умолчанию",
|
||||
"chat.notification-setting-none": "Нет уведомлений",
|
||||
"chat.notification-setting-at-mention-only": "только @упоминание",
|
||||
"chat.notification-setting-all-messages": "Все сообщения",
|
||||
"chat.select-groups": "Выбрать группы",
|
||||
"chat.add-user": "Add User",
|
||||
"chat.notification-settings": "Notification Settings",
|
||||
"chat.default-notification-setting": "Default Notification Setting",
|
||||
"chat.notification-setting-room-default": "Room Default",
|
||||
"chat.notification-setting-none": "No notifications",
|
||||
"chat.notification-setting-at-mention-only": "@mention only",
|
||||
"chat.notification-setting-all-messages": "All messages",
|
||||
"chat.select-groups": "Select Groups",
|
||||
"chat.add-user-help": "Поиск пользователей здесь. Когда выбрали пользователя, он будет добавлен в чат. Новый пользователь не сможет видеть сообщения чата, написанные до его добавления в беседу. Только владельцы комнат (<i class=\"fa fa-star text-warning\"></i>) могут удалить пользователей из чатов.",
|
||||
"chat.confirm-chat-with-dnd-user": "Этот пользователь установил статус \"Не беспокоить\". Вы всё еще хотите написать ему?",
|
||||
"chat.room-name-optional": "Название комнаты (необязательно)",
|
||||
"chat.room-name-optional": "Room Name (Optional)",
|
||||
"chat.rename-room": "Переименовать комнату",
|
||||
"chat.rename-placeholder": "Введите название комнаты здесь",
|
||||
"chat.rename-help": "Название комнаты, установленное здесь, будет доступно для просмотра всеми участниками комнаты.",
|
||||
"chat.leave": "Покинуть",
|
||||
"chat.leave-room": "Покинуть комнату",
|
||||
"chat.leave": "Leave",
|
||||
"chat.leave-room": "Leave Room",
|
||||
"chat.leave-prompt": "Вы действительно хотите покинуть чат?",
|
||||
"chat.leave-help": "Оставив этот чат, вы удалите себя из будущей переписки в этом чате. Если вы будете повторно добавлены в будущем, вы не увидите истории чата до вашего повторного присоединения.",
|
||||
"chat.delete": "Удалить",
|
||||
"chat.delete-room": "Удалить комнату",
|
||||
"chat.delete-prompt": "Вы уверены, что хотите удалить этот чат?",
|
||||
"chat.delete": "Delete",
|
||||
"chat.delete-room": "Delete Room",
|
||||
"chat.delete-prompt": "Are you sure you wish to delete this chat room?",
|
||||
"chat.in-room": "В этой комнате",
|
||||
"chat.kick": "Исключить",
|
||||
"chat.show-ip": "Показать IP",
|
||||
"chat.copy-text": "Копировать текст",
|
||||
"chat.copy-link": "Копировать ссылку",
|
||||
"chat.copy-text": "Copy Text",
|
||||
"chat.copy-link": "Copy Link",
|
||||
"chat.owner": "Владелец комнаты",
|
||||
"chat.grant-rescind-ownership": "Предоставление/отмена права собственности",
|
||||
"chat.system.user-join": "%1 присоединился к комнате <span class=\"timeago\" title=\"%2\"></span>",
|
||||
"chat.system.user-leave": "% 1 покинул комнату <span class=\"timeago\" title=\"%2\"></span>",
|
||||
"chat.system.room-rename": "%2 переименовал эту комнату в \"%1\" <span class=\"timeago\" title=\"%3\"></span>",
|
||||
"chat.grant-rescind-ownership": "Grant/Rescind Ownership",
|
||||
"chat.system.user-join": "%1 has joined the room <span class=\"timeago\" title=\"%2\"></span>",
|
||||
"chat.system.user-leave": "%1 has left the room <span class=\"timeago\" title=\"%2\"></span>",
|
||||
"chat.system.room-rename": "%2 has renamed this room to \"%1\" <span class=\"timeago\" title=\"%3\"></span>",
|
||||
"composer.compose": "Редактор сообщений",
|
||||
"composer.show-preview": "Показать предпросмотр сообщения",
|
||||
"composer.hide-preview": "Скрыть предпросмотр",
|
||||
@@ -88,13 +88,13 @@
|
||||
"composer.uploading": "Загрузка %1",
|
||||
"composer.formatting.bold": "Жирный",
|
||||
"composer.formatting.italic": "Курсив",
|
||||
"composer.formatting.heading": "Заголовок",
|
||||
"composer.formatting.heading1": "Заголовок 1",
|
||||
"composer.formatting.heading2": "Заголовок 2",
|
||||
"composer.formatting.heading3": "Заголовок 3",
|
||||
"composer.formatting.heading4": "Заголовок 4",
|
||||
"composer.formatting.heading5": "Заголовок 5",
|
||||
"composer.formatting.heading6": "Заголовок 6",
|
||||
"composer.formatting.heading": "Heading",
|
||||
"composer.formatting.heading1": "Heading 1",
|
||||
"composer.formatting.heading2": "Heading 2",
|
||||
"composer.formatting.heading3": "Heading 3",
|
||||
"composer.formatting.heading4": "Heading 4",
|
||||
"composer.formatting.heading5": "Heading 5",
|
||||
"composer.formatting.heading6": "Heading 6",
|
||||
"composer.formatting.list": "Список",
|
||||
"composer.formatting.strikethrough": "Зачеркнуть",
|
||||
"composer.formatting.code": "Код",
|
||||
@@ -105,18 +105,18 @@
|
||||
"composer.zen-mode": "Полноэкранный режим",
|
||||
"composer.select-category": "Выберите категорию",
|
||||
"composer.textarea.placeholder": "Введите содержание вашего сообщения здесь, перетащите изображения",
|
||||
"composer.post-queue-alert": "Здравствуйте! <br/> На этом форуме используется система очереди сообщений, поскольку вы новый пользователь, ваше сообщение будет скрыто до тех пор, пока оно не будет одобрено нашей командой модераторов.",
|
||||
"composer.post-queue-alert": "Hello👋!<br/>This forum uses a post queue system, since you are a new user your post will be hidden until it is approved by our moderation team.",
|
||||
"composer.schedule-for": "Установить дату публикации",
|
||||
"composer.schedule-date": "Дата",
|
||||
"composer.schedule-time": "Время",
|
||||
"composer.cancel-scheduling": "Отменить отложенную публикацию",
|
||||
"composer.change-schedule-date": "Изменить дату",
|
||||
"composer.change-schedule-date": "Change Date",
|
||||
"composer.set-schedule-date": "Установить дату",
|
||||
"composer.discard-all-drafts": "Удалить все черновики",
|
||||
"composer.no-drafts": "У вас нет черновиков",
|
||||
"composer.discard-draft-confirm": "Удалить все черновики?",
|
||||
"composer.remote-pid-editing": "Редактирование удаленного поста",
|
||||
"composer.remote-pid-content-immutable": "Содержание удаленных сообщений не может быть отредактировано. Однако вы можете изменить название темы и теги.",
|
||||
"composer.remote-pid-editing": "Editing a remote post",
|
||||
"composer.remote-pid-content-immutable": "The content of remote posts cannot be edited. However, you are able change the topic title and tags.",
|
||||
"bootbox.ok": "ОК",
|
||||
"bootbox.cancel": "Отмена",
|
||||
"bootbox.confirm": "Подтвердить",
|
||||
|
||||
@@ -8,6 +8,6 @@
|
||||
"no-recent-topics": "Нет свежих тем.",
|
||||
"no-popular-topics": "Популярные темы отсутствуют.",
|
||||
"load-new-posts": "Загрузить новые сообщения",
|
||||
"uncategorized.title": "Все известные темы",
|
||||
"uncategorized.intro": "На этой странице представлен хронологический список всех тем, обсуждавшихся на этом форуме.<br /> Мнения и мнения, высказанные в приведенных ниже темах, не модерируются и могут не отражать взгляды и мнения данного веб-сайта."
|
||||
"uncategorized.title": "All known topics",
|
||||
"uncategorized.intro": "This page shows a chronological listing of every topic that this forum has received.<br />The views and opinions expressed in the topics below are not moderated and may not represent the views and opinions of this website."
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"register": "Регистрация",
|
||||
"already-have-account": "У вас уже есть учетная запись?",
|
||||
"already-have-account": "Already have an account?",
|
||||
"cancel-registration": "Отменить регистрацию",
|
||||
"help.email": "Ваш адрес электронной почты будет скрыт от других пользователей.",
|
||||
"help.username-restrictions": "Другие пользователи смогут упоминать вас в своих сообщениях таким образом: @<span id='yourUsername'>никнейм</span>. Длина имени пользователя: %1-%2 символов.",
|
||||
@@ -19,15 +19,15 @@
|
||||
"agree-to-terms-of-use": "Я соглашаюсь с условиями",
|
||||
"terms-of-use-error": "Для регистрации на нашем сайте необходимо согласиться с условиями",
|
||||
"registration-added-to-queue": "Ваша регистрация была добавлена в очередь на утверждение. Вы получите уведомление по электронной почте, когда она будет одобрена администратором.",
|
||||
"registration-queue-average-time": "Среднее время одобрения членства составляет %1 часа %2 минут.",
|
||||
"registration-queue-auto-approve-time": "Ваше членство на этом форуме будет полностью активировано в течение %1 часов.",
|
||||
"interstitial.intro": "Нам нужна дополнительная информация, чтобы обновить вашу учетную запись;",
|
||||
"interstitial.intro-new": "Нам нужна дополнительная информация, прежде чем мы сможем создать вашу учётную запись…",
|
||||
"interstitial.errors-found": "Пожалуйста, ознакомьтесь с введенной информацией:",
|
||||
"registration-queue-average-time": "Our average time for approving memberships is %1 hours %2 minutes.",
|
||||
"registration-queue-auto-approve-time": "Your membership to this forum will be fully activated in up to %1 hours.",
|
||||
"interstitial.intro": "We'd like some additional information in order to update your account…",
|
||||
"interstitial.intro-new": "We'd like some additional information before we can create your account…",
|
||||
"interstitial.errors-found": "Please review the entered information:",
|
||||
"gdpr-agree-data": "Я соглашаюсь на сбор и обработку моей личной информации на этом веб-сайте.",
|
||||
"gdpr-agree-email": "Я соглашаюсь получать дайджесты и уведомления с этого сайта на свой адрес электронной почты.",
|
||||
"gdpr-consent-denied": "Вы должны дать согласие на сбор, обработку вашей информации и отправку вам сообщений по электронной почте.",
|
||||
"invite.error-admin-only": "Прямая регистрация пользователей была отключена. Пожалуйста, свяжитесь с администратором для получения более подробной информации.",
|
||||
"invite.error-invite-only": "Прямая регистрация пользователей отключена. Для доступа к этому форуму вы должны быть приглашены существующим пользователем.",
|
||||
"invite.error-invalid-data": "Полученные регистрационные данные не соответствуют нашим записям. Пожалуйста, свяжитесь с администратором для получения более подробной информации."
|
||||
"invite.error-admin-only": "Direct user registration has been disabled. Please contact an administrator for more details.",
|
||||
"invite.error-invite-only": "Direct user registration has been disabled. You must be invited by an existing user in order to access this forum.",
|
||||
"invite.error-invalid-data": "The registration data received does not correspond to our records. Please contact an administrator for more details"
|
||||
}
|
||||
@@ -1,9 +1,9 @@
|
||||
{
|
||||
"user-menu": "Меню пользователя",
|
||||
"user-menu": "User menu",
|
||||
"banned": "Заблокирован",
|
||||
"unbanned": "Разбанен",
|
||||
"muted": "Заглушенный",
|
||||
"unmuted": "Без звука",
|
||||
"unbanned": "Unbanned",
|
||||
"muted": "Muted",
|
||||
"unmuted": "Unmuted",
|
||||
"offline": "Не в сети",
|
||||
"deleted": "Удалён",
|
||||
"username": "Имя пользователя",
|
||||
@@ -16,8 +16,8 @@
|
||||
"ban-account": "Заблокировать учётную запись",
|
||||
"ban-account-confirm": "Вы действительно хотите заблокировать этого пользователя?",
|
||||
"unban-account": "Разблокировать учётную запись",
|
||||
"mute-account": "Отключение учетной записи",
|
||||
"unmute-account": "Включить учетную запись",
|
||||
"mute-account": "Mute Account",
|
||||
"unmute-account": "Unmute Account",
|
||||
"delete-account": "Удалить учётную запись",
|
||||
"delete-account-as-admin": "Удалить <strong>учётную запись</strong>",
|
||||
"delete-content": "Удалить <strong>контент</strong> учетной записи",
|
||||
@@ -39,15 +39,15 @@
|
||||
"reputation": "Репутация",
|
||||
"bookmarks": "Закладки",
|
||||
"watched-categories": "Отслеживаемые категории",
|
||||
"watched-tags": "Просматриваемые теги",
|
||||
"watched-tags": "Watched tags",
|
||||
"change-all": "Изменить для всех",
|
||||
"watched": "Отслеживаемые темы",
|
||||
"ignored": "Игнорируемые темы",
|
||||
"read": "Прочитать",
|
||||
"read": "Read",
|
||||
"default-category-watch-state": "Стандартная настройка отслеживания категорий",
|
||||
"followers": "Подписчики",
|
||||
"following": "Подписки",
|
||||
"shares": "Поделиться",
|
||||
"shares": "Shares",
|
||||
"blocks": "Чёрный список",
|
||||
"blocked-users": "Заблокированные пользователи",
|
||||
"block-toggle": "Блок./Разблок",
|
||||
@@ -59,18 +59,18 @@
|
||||
"chat": "Чат",
|
||||
"chat-with": "Продолжить чат с %1",
|
||||
"new-chat-with": "Начать новый чат с %1",
|
||||
"view-remote": "Посмотреть оригинал",
|
||||
"view-remote": "View Original",
|
||||
"flag-profile": "Пожаловаться на профиль",
|
||||
"profile-flagged": "Уже отмечено",
|
||||
"profile-flagged": "Already flagged",
|
||||
"follow": "Подписаться",
|
||||
"unfollow": "Отписаться",
|
||||
"cancel-follow": "Отменить запрос на подписку",
|
||||
"cancel-follow": "Cancel follow request",
|
||||
"more": "Больше",
|
||||
"profile-update-success": "Профиль обновлён!",
|
||||
"change-picture": "Изменить аватар",
|
||||
"change-username": "Изменить имя пользователя",
|
||||
"change-email": "Изменить электронную почту",
|
||||
"email-updated": "Электронная почта обновлена",
|
||||
"email-updated": "Email Updated",
|
||||
"email-same-as-password": "Пожалуйста, введите пароль, чтобы продолжить – вы снова указали свой новый адрес электронной почты",
|
||||
"edit": "Редактировать",
|
||||
"edit-profile": "Редактировать профиль",
|
||||
@@ -79,11 +79,11 @@
|
||||
"upload-new-picture": "Загрузить новый аватар",
|
||||
"upload-new-picture-from-url": "Загрузить изображение по ссылке",
|
||||
"current-password": "Текущий пароль",
|
||||
"new-password": "Новый пароль",
|
||||
"new-password": "New Password",
|
||||
"change-password": "Изменить пароль",
|
||||
"change-password-error": "Неправильный пароль!",
|
||||
"change-password-error-wrong-current": "Текущий пароль указан неверно!",
|
||||
"change-password-error-same-password": "Ваш новый пароль совпадает с вашим текущим паролем, пожалуйста, используйте новый пароль.",
|
||||
"change-password-error-same-password": "Your new password matches your current password, please use a new password.",
|
||||
"change-password-error-match": "Пароли должны совпадать!",
|
||||
"change-password-error-privileges": "Вы не можете изменить пароль.",
|
||||
"change-password-success": "Ваш пароль изменён!",
|
||||
@@ -110,28 +110,28 @@
|
||||
"digest-off": "Отключена",
|
||||
"digest-daily": "Ежедневная",
|
||||
"digest-weekly": "Еженедельная",
|
||||
"digest-biweekly": "Два раза в неделю",
|
||||
"digest-biweekly": "Bi-Weekly",
|
||||
"digest-monthly": "Ежемесячная",
|
||||
"has-no-follower": "На этого пользователя никто не подписан :(",
|
||||
"follows-no-one": "Этот пользователь ни на кого не подписан :(",
|
||||
"has-no-posts": "Этот пользователь ещё ничего не написал.",
|
||||
"has-no-best-posts": "У этого пользователя пока нет ни одной публикации, за которую бы он проголосовал.",
|
||||
"has-no-best-posts": "This user does not have any upvoted posts yet.",
|
||||
"has-no-topics": "Этот пользователь ещё не создал ни одной темы.",
|
||||
"has-no-watched-topics": "Этот пользователь не отслеживает ни одной темы.",
|
||||
"has-no-ignored-topics": "Этот пользователь не игнорирует ни одну тему.",
|
||||
"has-no-read-topics": "Этот пользователь еще не прочитал ни одной темы.",
|
||||
"has-no-read-topics": "This user hasn't read any topics yet.",
|
||||
"has-no-upvoted-posts": "Этот пользователь ещё ни одному сообщению не поднимал рейтинг.",
|
||||
"has-no-downvoted-posts": "Этот пользователь ещё ни одному сообщению не понижал рейтинг.",
|
||||
"has-no-controversial-posts": "У этого пользователя пока нет сообщений с отрицательными оценками.",
|
||||
"has-no-controversial-posts": "This user does not have any downvoted posts yet.",
|
||||
"has-no-blocks": "Вы никого не заблокировали.",
|
||||
"has-no-shares": "Этот пользователь не поделился ни одной темой.",
|
||||
"has-no-shares": "This user has not shared any topics.",
|
||||
"email-hidden": "Электронная почта скрыта",
|
||||
"hidden": "скрыто",
|
||||
"paginate-description": "Разбивать темы и сообщения на страницы, а не выводить бесконечным списком",
|
||||
"topics-per-page": "Тем на странице",
|
||||
"posts-per-page": "Сообщений на странице",
|
||||
"category-topic-sort": "Сортировка по категориям и темам",
|
||||
"topic-post-sort": "Сортировка сообщений по темам",
|
||||
"category-topic-sort": "Category topic sort",
|
||||
"topic-post-sort": "Topic post sort",
|
||||
"max-items-per-page": "максимум %1",
|
||||
"acp-language": "Язык панели администратора",
|
||||
"notifications": "Уведомления",
|
||||
@@ -152,19 +152,19 @@
|
||||
"follow-topics-you-create": "Включать отслеживание всех тем, которые вы создаёте",
|
||||
"grouptitle": "Значки групп",
|
||||
"group-order-help": "Выберите группу и укажите порядок значков с помощью стрелок",
|
||||
"show-group-title": "Показать название группы",
|
||||
"hide-group-title": "Скрыть название группы",
|
||||
"order-group-up": "Группа заказов вверх",
|
||||
"order-group-down": "Группа заказов вниз",
|
||||
"show-group-title": "Show group title",
|
||||
"hide-group-title": "Hide group title",
|
||||
"order-group-up": "Order group up",
|
||||
"order-group-down": "Order group down",
|
||||
"no-group-title": "Не показывать значок группы",
|
||||
"select-skin": "Стиль",
|
||||
"default": "По умолчанию (%1)",
|
||||
"no-skin": "Нет скина",
|
||||
"default": "Default (%1)",
|
||||
"no-skin": "No Skin",
|
||||
"select-homepage": "Настройка главной страницы",
|
||||
"homepage": "Главная страница",
|
||||
"homepage-description": "Выберите, на какую страницу вы будете попадать после авторизации и использовать как главную, или оставьте стандартную настройку.",
|
||||
"custom-route": "Ваш маршрут для главной страницы",
|
||||
"custom-route-help": "Введите здесь имя маршрута без предшествующей косой черты (например, «recent» или «category/2/general-discussion»).",
|
||||
"custom-route-help": "Enter a route name here, without any preceding slash (e.g. \"recent\" or \"category/2/general-discussion\")",
|
||||
"sso.title": "Сервисы единого входа",
|
||||
"sso.associated": "Связан с",
|
||||
"sso.not-associated": "Нажмите здесь, чтобы связать учётную запись с",
|
||||
@@ -172,24 +172,24 @@
|
||||
"sso.dissociate-confirm-title": "Подтверждение открепления",
|
||||
"sso.dissociate-confirm": "Вы уверены, что хотите открепить свою учётную запись от %1?",
|
||||
"info.latest-flags": "Последние жалобы",
|
||||
"info.profile": "Профиль",
|
||||
"info.post": "Пост",
|
||||
"info.view-flag": "Посмотреть флаг",
|
||||
"info.reported-by": "Сообщил:",
|
||||
"info.profile": "Profile",
|
||||
"info.post": "Post",
|
||||
"info.view-flag": "View flag",
|
||||
"info.reported-by": "Reported by:",
|
||||
"info.no-flags": "Жалоб не найдено",
|
||||
"info.ban-history": "История блокировок",
|
||||
"info.no-ban-history": "Этот пользователь никогда не был заблокирован",
|
||||
"info.banned-until": "Заблокирован до %1",
|
||||
"info.banned-expiry": "Истечение",
|
||||
"info.ban-expired": "Срок действия бана истек",
|
||||
"info.ban-expired": "Ban expired",
|
||||
"info.banned-permanently": "Заблокирован навсегда",
|
||||
"info.banned-reason-label": "Причина",
|
||||
"info.banned-no-reason": "Без объяснения причин.",
|
||||
"info.mute-history": "Недавняя история отключения звука",
|
||||
"info.no-mute-history": "Этот пользователь никогда не был отключен",
|
||||
"info.muted-until": "Отключено до %1",
|
||||
"info.muted-expiry": "Истечение срока действия",
|
||||
"info.muted-no-reason": "Причина не указана.",
|
||||
"info.mute-history": "Recent Mute History",
|
||||
"info.no-mute-history": "This user has never been muted",
|
||||
"info.muted-until": "Muted until %1",
|
||||
"info.muted-expiry": "Expiry",
|
||||
"info.muted-no-reason": "No reason given.",
|
||||
"info.username-history": "История изменения имён",
|
||||
"info.email-history": "История изменения электронной почты",
|
||||
"info.moderation-note": "Примечание модератора",
|
||||
|
||||
@@ -19,7 +19,7 @@
|
||||
"manage/groups": "Nhóm",
|
||||
"manage/ip-blacklist": "Danh sách đen IP",
|
||||
"manage/uploads": "Tải lên",
|
||||
"manage/digest": "Bản tóm tắt",
|
||||
"manage/digest": "Bản Tóm Tắt",
|
||||
|
||||
"section-settings": "Cài đặt",
|
||||
"settings/general": "Chung",
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
"subcategories": "Danh mục phụ",
|
||||
"uncategorized": "Chưa có danh mục",
|
||||
"uncategorized.description": "Các chủ đề không phù hợp với bất kỳ danh mục hiện có nào",
|
||||
"handle.description": "Có thể theo dõi danh mục này từ mạng xã hội mở thông qua xử lý %1",
|
||||
"handle.description": "Danh mục này có thể được theo sau từ web xã hội mở thông qua xử lý %1",
|
||||
"new-topic-button": "Chủ Đề Mới",
|
||||
"guest-login-post": "Đăng nhập để đăng bài",
|
||||
"no-topics": "<strong>Không có chủ đề nào trong danh mục này.</strong><br />Sao bạn không thử đăng?",
|
||||
@@ -16,14 +16,14 @@
|
||||
"tracking": "Theo dõi",
|
||||
"not-watching": "Chưa Xem",
|
||||
"ignoring": "Bỏ qua",
|
||||
"watching.description": "Thông báo tôi chủ đề mới.<br/>Hiển thị chủ đề trong chưa đọc và gần đây",
|
||||
"tracking.description": "Hiển thị các chủ đề trong chưa đọc & gần đây",
|
||||
"not-watching.description": "Không hiển thị các chủ đề trong chưa đọc, hiển thị trong gần đây",
|
||||
"ignoring.description": "Không hiển thị các chủ đề trong chưa đọc & gần đây",
|
||||
"watching.message": "Bạn hiện đang xem các cập nhật từ danh mục này và tất cả các danh mục phụ",
|
||||
"tracking.message": "Bạn hiện đang theo dõi các cập nhật từ danh mục này và tất cả các danh mục phụ",
|
||||
"notwatching.message": "Bạn không xem các cập nhật từ danh mục này và tất cả các danh mục phụ",
|
||||
"ignoring.message": "Bạn hiện đang bỏ qua các cập nhật từ danh mục này và tất cả các danh mục phụ",
|
||||
"watching.description": "Thông báo tôi chủ đề mới.<br/>Hiển thị chủ đề chưa đọc và gần đây",
|
||||
"tracking.description": "Hiển thị chủ đề chưa đọc và gần đây",
|
||||
"not-watching.description": "Không hiển thị chủ đề trong chưa đọc, hiển thị gần đây",
|
||||
"ignoring.description": "Không hiển thị chủ đề chưa đọc và gần đây",
|
||||
"watching.message": "Bây giờ bạn đang xem cập nhật từ danh mục này và tất cả các danh mục phụ",
|
||||
"tracking.message": "Bạn hiện đang theo dõi thông tin cập nhật từ danh mục này và tất cả các danh mục phụ",
|
||||
"notwatching.message": "Bạn không xem cập nhật từ danh mục này và tất cả các danh mục phụ",
|
||||
"ignoring.message": "Bây giờ bạn đang bỏ qua các cập nhật từ danh mục này và tất cả các danh mục phụ",
|
||||
"watched-categories": "Danh mục đã xem",
|
||||
"x-more-categories": "%1 danh mục khác"
|
||||
"x-more-categories": "%1 chuyên mục khác"
|
||||
}
|
||||
@@ -4,7 +4,7 @@
|
||||
"week": "Tuần",
|
||||
"month": "Tháng",
|
||||
"year": "Năm",
|
||||
"alltime": "Tất Cả Thời Gian",
|
||||
"alltime": "Mọi Lúc",
|
||||
"no-recent-topics": "Không có chủ đề gần đây.",
|
||||
"no-popular-topics": "Không có chủ đề phổ biến.",
|
||||
"load-new-posts": "Tải bài đăng mới",
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
"handle": "版块句柄",
|
||||
"handle.help": "您的版块句柄在其他网络中用作该版块的代表,类似于用户名。版块句柄不得与现有的用户名或用户组相匹配。",
|
||||
"description": "版块描述",
|
||||
"federatedDescription": "“联邦化”说明",
|
||||
"federatedDescription": "“联邦”说明",
|
||||
"federatedDescription.help": "当其他网站/应用程序查询时,该文本将附加到版块描述中。",
|
||||
"federatedDescription.default": "这是一个包含专题讨论的论坛版块。您可以通过提及该版块开始新的讨论。",
|
||||
"bg-color": "背景颜色",
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
|
||||
"server-filtering": "过滤",
|
||||
"count": "该 NodeBB 目前可检测到 <strong>%1</strong> 台服务器",
|
||||
"server.filter-help": "指定您希望禁止与 NodeBB 联邦化的服务器。或者,您也可以选择性地 <em>允许</em> 与特定服务器联邦化。两者只能选其一。",
|
||||
"server.filter-help": "指定您希望禁止与 NodeBB 联合的服务器。或者,您也可以选择性地 <em>允许</em> 与特定服务器联邦。两者只能选其一。",
|
||||
"server.filter-help-hostname": "请在下面输入实例主机名(如 <code>example.org</code> ),中间用换行符隔开。",
|
||||
"server.filter-allow-list": "将其用作允许列表"
|
||||
}
|
||||
@@ -7,7 +7,7 @@
|
||||
"help.title": "这是什么页面?",
|
||||
"help.intro": "欢迎来到联邦宇宙的一角",
|
||||
"help.fediverse": "联邦宇宙是一个由相互连接的应用程序和网站组成的网络,这些应用程序和网站可以相互对话,其用户也可以相互看到对方。本论坛是联盟式的,可以与该社交网络(或 “联邦宇宙”)互动。本页面是您在联邦宇宙中的一角。它仅由 <strong>你</strong> 关注的用户创建和分享的主题组成。",
|
||||
"help.build": "起初,这里可能没有很多主题;这很正常。随着时间的推移,当您开始关注其他用户时,您会在这里看到更多的内容。",
|
||||
"help.build": "开始时,这里可能没有很多主题;这很正常。随着时间的推移,当您开始关注其他用户时,您会在这里看到更多的内容。",
|
||||
"help.federating": "同样,如果本论坛以外的用户开始关注 <em>你</em> ,那么您的帖子也会开始出现在这些应用程序和网站上。",
|
||||
"help.next-generation": "这是新一代的社交媒体,从今天开始,贡献力量吧!",
|
||||
|
||||
|
||||
@@ -24,9 +24,6 @@ get:
|
||||
error:
|
||||
type: string
|
||||
description: Translation key for client-side localisation
|
||||
alreadyValidated:
|
||||
type: boolean
|
||||
description: set to true if the email was already validated
|
||||
required:
|
||||
- title
|
||||
- $ref: ../../components/schemas/CommonProps.yaml#/CommonProps
|
||||
@@ -4,6 +4,7 @@ require('../app');
|
||||
|
||||
// scripts-admin.js is generated during build, it contains javascript files
|
||||
// from plugins that add files to "acpScripts" block in plugin.json
|
||||
// eslint-disable-next-line
|
||||
require('../../scripts-admin');
|
||||
|
||||
app.onDomReady();
|
||||
|
||||
@@ -23,6 +23,7 @@ Chart.register(
|
||||
);
|
||||
|
||||
|
||||
// eslint-disable-next-line import/prefer-default-export
|
||||
export function init() {
|
||||
setupCharts();
|
||||
|
||||
|
||||
@@ -64,6 +64,7 @@ $(window).on('action:ajaxify.start', function () {
|
||||
usedTopicColors.length = 0;
|
||||
});
|
||||
|
||||
// eslint-disable-next-line import/prefer-default-export
|
||||
export function init() {
|
||||
app.enterRoom('admin');
|
||||
|
||||
|
||||
@@ -21,6 +21,7 @@ Chart.register(
|
||||
Filler
|
||||
);
|
||||
|
||||
// eslint-disable-next-line import/prefer-default-export
|
||||
export function init() {
|
||||
categorySelector.init($('[component="category-selector"]'), {
|
||||
onSelect: function (selectedCategory) {
|
||||
|
||||
@@ -3,6 +3,7 @@ import { error } from '../../modules/alerts';
|
||||
|
||||
import * as categorySelector from '../../modules/categorySelector';
|
||||
|
||||
// eslint-disable-next-line import/prefer-default-export
|
||||
export function init() {
|
||||
categorySelector.init($('[component="category-selector"]'), {
|
||||
onSelect: function (selectedCategory) {
|
||||
|
||||
@@ -30,6 +30,7 @@ Chart.register(
|
||||
let _current = null;
|
||||
let isMobile = false;
|
||||
|
||||
// eslint-disable-next-line import/prefer-default-export
|
||||
export function init({ set, dataset }) {
|
||||
const canvas = document.getElementById('analytics-traffic');
|
||||
const canvasCtx = canvas.getContext('2d');
|
||||
|
||||
@@ -38,13 +38,12 @@ ajaxify.widgets = { render: render };
|
||||
if (!pathname) {
|
||||
({ pathname } = urlObj);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
} catch (e) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const internalLink = utils.isInternalURI(urlObj, window.location, config.relative_path);
|
||||
|
||||
// eslint-disable-next-line no-script-url
|
||||
const hrefEmpty = href => href === undefined || href === '' || href === 'javascript:;';
|
||||
|
||||
if (item instanceof Element) {
|
||||
@@ -56,6 +55,7 @@ ajaxify.widgets = { render: render };
|
||||
return null;
|
||||
}
|
||||
|
||||
// eslint-disable-next-line no-script-url
|
||||
if (hrefEmpty(urlObj.href) || urlObj.protocol === 'javascript:' || pathname === '#' || pathname === '') {
|
||||
return null;
|
||||
}
|
||||
@@ -514,6 +514,7 @@ ajaxify.widgets = { render: render };
|
||||
cache: false,
|
||||
dataType: 'text',
|
||||
success: function (script) {
|
||||
// eslint-disable-next-line no-new-func
|
||||
const renderFunction = new Function('module', script);
|
||||
const moduleObj = { exports: {} };
|
||||
renderFunction(moduleObj);
|
||||
|
||||
@@ -4,6 +4,7 @@ require('./app');
|
||||
|
||||
// scripts-client.js is generated during build, it contains javascript files
|
||||
// from plugins that add files to "scripts" block in plugin.json
|
||||
// eslint-disable-next-line
|
||||
require('../scripts-client');
|
||||
|
||||
app.onDomReady();
|
||||
|
||||
@@ -26,8 +26,7 @@ define('forum/account/sessions', ['forum/account/header', 'components', 'api', '
|
||||
window.location.href = config.relative_path + '/login?error=' + errorObj.title;
|
||||
}
|
||||
alerts.error(errorObj.title);
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
} catch (e) {
|
||||
alerts.error('[[error:invalid-data]]');
|
||||
}
|
||||
});
|
||||
|
||||
@@ -70,7 +70,7 @@ define('forum/category', [
|
||||
const $this = $(this);
|
||||
const state = $this.attr('data-state');
|
||||
|
||||
api.put(`/categories/${encodeURIComponent(cid)}/watch`, { state }, (err) => {
|
||||
api.put(`/categories/${cid}/watch`, { state }, (err) => {
|
||||
if (err) {
|
||||
return alerts.error(err);
|
||||
}
|
||||
@@ -118,7 +118,7 @@ define('forum/category', [
|
||||
};
|
||||
|
||||
Category.toBottom = async () => {
|
||||
const { count } = await api.get(`/categories/${encodeURIComponent(ajaxify.data.category.cid)}/count`);
|
||||
const { count } = await api.get(`/categories/${ajaxify.data.category.cid}/count`);
|
||||
navigator.scrollBottom(count - 1);
|
||||
};
|
||||
|
||||
@@ -127,7 +127,7 @@ define('forum/category', [
|
||||
|
||||
hooks.fire('action:topics.loading');
|
||||
const params = utils.params();
|
||||
infinitescroll.loadMore(`/categories/${encodeURIComponent(ajaxify.data.cid)}/topics`, {
|
||||
infinitescroll.loadMore(`/categories/${ajaxify.data.cid}/topics`, {
|
||||
after: after,
|
||||
direction: direction,
|
||||
query: params,
|
||||
|
||||
@@ -132,7 +132,6 @@ define('forum/chats/manage', [
|
||||
try {
|
||||
data = await api.get(`/chats/${roomId}/users`, {});
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
listEl.find('li').text(await translator.translate('[[error:invalid-data]]'));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
/* eslint-disable no-redeclare */
|
||||
|
||||
'use strict';
|
||||
|
||||
const $ = require('jquery');
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
/* eslint-disable import/first */
|
||||
|
||||
export * from 'ace-builds';
|
||||
|
||||
// only import the modes and theme we use
|
||||
@@ -7,7 +9,8 @@ import 'ace-builds/src-noconflict/mode-html';
|
||||
import 'ace-builds/src-noconflict/ext-searchbox';
|
||||
import 'ace-builds/src-noconflict/theme-twilight';
|
||||
|
||||
|
||||
/* eslint-disable import/no-webpack-loader-syntax */
|
||||
/* eslint-disable import/no-unresolved */
|
||||
import htmlWorkerUrl from 'file-loader!ace-builds/src-noconflict/worker-html';
|
||||
import javascriptWorkerUrl from 'file-loader!ace-builds/src-noconflict/worker-javascript';
|
||||
import cssWorkerUrl from 'file-loader!ace-builds/src-noconflict/worker-css';
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
/* eslint-disable import/no-unresolved */
|
||||
|
||||
'use strict';
|
||||
|
||||
import { fire as fireHook } from 'hooks';
|
||||
|
||||
@@ -222,7 +222,6 @@ define('iconSelect', ['benchpress', 'bootbox'], function (Benchpress, bootbox) {
|
||||
{ id: 'android', label: 'Android (brands)', style: 'brands' },
|
||||
{ id: 'address-book', label: 'Address Book (solid)', style: 'solid' },
|
||||
];
|
||||
|
||||
iconSelect.init = function (el, onModified) {
|
||||
onModified = onModified || function () { };
|
||||
let selected = cleanFAClass(el[0].classList);
|
||||
@@ -231,7 +230,6 @@ define('iconSelect', ['benchpress', 'bootbox'], function (Benchpress, bootbox) {
|
||||
try {
|
||||
$(`#icons .nbb-fa-icons ${selected.styles.length ? '.' + selected.styles.join('.') : ''}.${selected.icon}`).addClass('selected');
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
selected = {
|
||||
icon: '',
|
||||
style: '',
|
||||
|
||||
@@ -270,8 +270,7 @@ define('search', [
|
||||
let term = data.term.replace(/^[ ?#]*/, '');
|
||||
try {
|
||||
term = encodeURIComponent(term);
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
} catch (e) {
|
||||
return alerts.error('[[error:invalid-search-term]]');
|
||||
}
|
||||
|
||||
@@ -292,8 +291,7 @@ define('search', [
|
||||
Search.getSearchPreferences = function () {
|
||||
try {
|
||||
return JSON.parse(storage.getItem('search-preferences') || '{}');
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
} catch (e) {
|
||||
return {};
|
||||
}
|
||||
};
|
||||
|
||||
@@ -2,10 +2,11 @@
|
||||
|
||||
|
||||
define('settings', ['hooks', 'alerts'], function (hooks, alerts) {
|
||||
// eslint-disable-next-line prefer-const
|
||||
let Settings;
|
||||
let onReady = [];
|
||||
let waitingJobs = 0;
|
||||
|
||||
// eslint-disable-next-line prefer-const
|
||||
let helper;
|
||||
|
||||
/**
|
||||
@@ -29,6 +30,7 @@ define('settings', ['hooks', 'alerts'], function (hooks, alerts) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// eslint-disable-next-line prefer-const
|
||||
helper = {
|
||||
/**
|
||||
@returns Object A deep clone of the given object.
|
||||
@@ -324,13 +326,12 @@ define('settings', ['hooks', 'alerts'], function (hooks, alerts) {
|
||||
use: function (settings) {
|
||||
try {
|
||||
settings._ = JSON.parse(settings._);
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
}
|
||||
} catch (_error) {}
|
||||
Settings.cfg = settings;
|
||||
},
|
||||
};
|
||||
|
||||
// eslint-disable-next-line prefer-const
|
||||
Settings = {
|
||||
helper: helper,
|
||||
plugins: {},
|
||||
@@ -473,8 +474,8 @@ define('settings', ['hooks', 'alerts'], function (hooks, alerts) {
|
||||
if (key && values.hasOwnProperty(key)) {
|
||||
try {
|
||||
values[key] = JSON.parse(values[key]);
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
} catch (e) {
|
||||
// Leave the value as is
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -105,8 +105,7 @@ define('settings/array', function () {
|
||||
separator = (function () {
|
||||
try {
|
||||
return $(separator);
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
} catch (_error) {
|
||||
return $(document.createTextNode(separator));
|
||||
}
|
||||
}());
|
||||
|
||||
@@ -68,8 +68,7 @@ define('settings/object', function () {
|
||||
separator = (function () {
|
||||
try {
|
||||
return $(separator);
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
} catch (_error) {
|
||||
return $(document.createTextNode(separator));
|
||||
}
|
||||
}());
|
||||
|
||||
@@ -107,8 +107,7 @@ define('uploader', ['jquery-form'], function () {
|
||||
if (typeof response === 'string') {
|
||||
try {
|
||||
return JSON.parse(response);
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
} catch (e) {
|
||||
return { error: '[[error:parse-error]]' };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -51,9 +51,7 @@ define('userFilter', ['api', 'hooks', 'slugify', 'benchpress'], function (api, h
|
||||
try {
|
||||
const userData = await api.get(`/api/user/${slugify(query)}`);
|
||||
result.users.push(userData);
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
}
|
||||
} catch (err) {}
|
||||
}
|
||||
}
|
||||
if (!result.users.length) {
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
'use strict';
|
||||
|
||||
// eslint-disable-next-line no-redeclare
|
||||
const io = require('socket.io-client');
|
||||
// eslint-disable-next-line no-redeclare
|
||||
const $ = require('jquery');
|
||||
|
||||
// eslint-disable-next-line import/no-unresolved
|
||||
const { alert } = require('alerts');
|
||||
|
||||
app = window.app || {};
|
||||
|
||||
@@ -272,6 +272,7 @@ const HTMLEntities = Object.freeze({
|
||||
'diams;': 9830,
|
||||
});
|
||||
|
||||
/* eslint-disable no-redeclare */
|
||||
const utils = {
|
||||
// https://github.com/substack/node-ent/blob/master/index.js
|
||||
decodeHTMLEntities: function (html) {
|
||||
@@ -466,8 +467,7 @@ const utils = {
|
||||
|
||||
try {
|
||||
return new Date(parseInt(timestamp, 10)).toISOString();
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
} catch (e) {
|
||||
return timestamp;
|
||||
}
|
||||
},
|
||||
@@ -631,8 +631,7 @@ const utils = {
|
||||
|
||||
try {
|
||||
str = JSON.parse(str);
|
||||
// eslint-disable-next-line no-unused-vars
|
||||
} catch (err) { /* empty */ }
|
||||
} catch (e) {}
|
||||
|
||||
return str;
|
||||
},
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
/* eslint-disable no-redeclare */
|
||||
|
||||
'use strict';
|
||||
|
||||
|
||||
@@ -37,7 +37,7 @@
|
||||
if ($el.css('background-size') == "cover") {
|
||||
var elementWidth = $el.innerWidth(),
|
||||
elementHeight = $el.innerHeight(),
|
||||
elementAspectRatio = elementWidth / elementHeight,
|
||||
elementAspectRatio = elementWidth / elementHeight;
|
||||
imageAspectRatio = image.width / image.height,
|
||||
scale = 1;
|
||||
|
||||
@@ -182,17 +182,18 @@
|
||||
}
|
||||
|
||||
$.fn.backgroundDraggable = function(options) {
|
||||
var options = options;
|
||||
var args = Array.prototype.slice.call(arguments, 1);
|
||||
|
||||
return this.each(function() {
|
||||
var $this = $(this);
|
||||
var plugin;
|
||||
|
||||
if (typeof options == 'undefined' || typeof options == 'object') {
|
||||
options = $.extend({}, $.fn.backgroundDraggable.defaults, options);
|
||||
plugin = new Plugin(this, options);
|
||||
var plugin = new Plugin(this, options);
|
||||
$this.data('dbg', plugin);
|
||||
} else if (typeof options == 'string' && $this.data('dbg')) {
|
||||
plugin = $this.data('dbg');
|
||||
var plugin = $this.data('dbg');
|
||||
Plugin.prototype[options].apply(plugin, args);
|
||||
}
|
||||
});
|
||||
@@ -201,6 +202,6 @@
|
||||
$.fn.backgroundDraggable.defaults = {
|
||||
bound: true,
|
||||
axis: undefined,
|
||||
units: 'pixels',
|
||||
units: 'pixels'
|
||||
};
|
||||
}(jQuery));
|
||||
@@ -15,6 +15,7 @@
|
||||
],
|
||||
"rangeStrategy": "pin",
|
||||
"matchPackageNames": [
|
||||
"!colors"
|
||||
]
|
||||
},
|
||||
{
|
||||
|
||||
@@ -7,9 +7,7 @@ const _ = require('lodash');
|
||||
const db = require('../database');
|
||||
const meta = require('../meta');
|
||||
const batch = require('../batch');
|
||||
const categories = require('../categories');
|
||||
const user = require('../user');
|
||||
const topics = require('../topics');
|
||||
const utils = require('../utils');
|
||||
const TTLCache = require('../cache/ttl');
|
||||
|
||||
@@ -22,12 +20,15 @@ const activitypub = module.parent.exports;
|
||||
|
||||
const Actors = module.exports;
|
||||
|
||||
Actors.qualify = async (ids, options = {}) => {
|
||||
Actors.assert = async (ids, options = {}) => {
|
||||
/**
|
||||
* Sanity-checks, cache handling, webfinger translations, so that only
|
||||
* an array of actor uris are handled by assert/assertGroup.
|
||||
*
|
||||
* This method is only called by assert/assertGroup (at least in core.)
|
||||
* Ensures that the passed in ids or webfinger handles are stored in database.
|
||||
* Options:
|
||||
* - update: boolean, forces re-fetch/process of the resolved id
|
||||
* Return one of:
|
||||
* - An array of newly processed ids
|
||||
* - false: if input incorrect (or webfinger handle cannot resolve)
|
||||
* - true: no new IDs processed; all passed-in IDs present.
|
||||
*/
|
||||
|
||||
// Handle single values
|
||||
@@ -46,6 +47,7 @@ Actors.qualify = async (ids, options = {}) => {
|
||||
ids = ids.filter(id => !utils.isNumber(id));
|
||||
|
||||
// Translate webfinger handles to uris
|
||||
const hostMap = new Map();
|
||||
ids = (await Promise.all(ids.map(async (id) => {
|
||||
const originalId = id;
|
||||
if (activitypub.helpers.isWebfinger(id)) {
|
||||
@@ -55,6 +57,7 @@ Actors.qualify = async (ids, options = {}) => {
|
||||
}
|
||||
|
||||
({ actorUri: id } = await activitypub.helpers.query(id));
|
||||
hostMap.set(id, host);
|
||||
}
|
||||
// ensure the final id is a valid URI
|
||||
if (!id || !activitypub.helpers.isUri(id)) {
|
||||
@@ -74,44 +77,18 @@ Actors.qualify = async (ids, options = {}) => {
|
||||
ids = ids.filter(uri => uri !== 'loopback' && new URL(uri).host !== nconf.get('url_parsed').host);
|
||||
}
|
||||
|
||||
// Separate those who need migration from user to category
|
||||
const migrate = new Set();
|
||||
if (options.qualifyGroup) {
|
||||
const exists = await db.exists(ids.map(id => `userRemote:${id}`));
|
||||
ids.forEach((id, idx) => {
|
||||
if (exists[idx]) {
|
||||
migrate.add(id);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Only assert those who haven't been seen recently (configurable), unless update flag passed in (force refresh)
|
||||
if (!options.update) {
|
||||
const upperBound = Date.now() - (1000 * 60 * 60 * 24 * meta.config.activitypubUserPruneDays);
|
||||
const lastCrawled = await db.sortedSetScores('usersRemote:lastCrawled', ids.map(id => ((typeof id === 'object' && id.hasOwnProperty('id')) ? id.id : id)));
|
||||
ids = ids.filter((id, idx) => {
|
||||
const timestamp = lastCrawled[idx];
|
||||
return migrate.has(id) || !timestamp || timestamp < upperBound;
|
||||
return !timestamp || timestamp < upperBound;
|
||||
});
|
||||
}
|
||||
|
||||
return ids;
|
||||
};
|
||||
|
||||
Actors.assert = async (ids, options = {}) => {
|
||||
/**
|
||||
* Ensures that the passed in ids or webfinger handles are stored in database.
|
||||
* Options:
|
||||
* - update: boolean, forces re-fetch/process of the resolved id
|
||||
* Return one of:
|
||||
* - An array of newly processed ids
|
||||
* - false: if input incorrect (or webfinger handle cannot resolve)
|
||||
* - true: no new IDs processed; all passed-in IDs present.
|
||||
*/
|
||||
|
||||
ids = await Actors.qualify(ids, options);
|
||||
if (!ids || !ids.length) {
|
||||
return ids;
|
||||
if (!ids.length) {
|
||||
return true;
|
||||
}
|
||||
|
||||
activitypub.helpers.log(`[activitypub/actors] Asserting ${ids.length} actor(s)`);
|
||||
@@ -121,7 +98,6 @@ Actors.assert = async (ids, options = {}) => {
|
||||
const urlMap = new Map();
|
||||
const followersUrlMap = new Map();
|
||||
const pubKeysMap = new Map();
|
||||
const categories = new Set();
|
||||
let actors = await Promise.all(ids.map(async (id) => {
|
||||
try {
|
||||
activitypub.helpers.log(`[activitypub/actors] Processing ${id}`);
|
||||
@@ -130,14 +106,8 @@ Actors.assert = async (ids, options = {}) => {
|
||||
let typeOk = false;
|
||||
if (Array.isArray(actor.type)) {
|
||||
typeOk = actor.type.some(type => activitypub._constants.acceptableActorTypes.has(type));
|
||||
if (!typeOk && actor.type.some(type => activitypub._constants.acceptableGroupTypes.has(type))) {
|
||||
categories.add(actor.id);
|
||||
}
|
||||
} else {
|
||||
typeOk = activitypub._constants.acceptableActorTypes.has(actor.type);
|
||||
if (!typeOk && activitypub._constants.acceptableGroupTypes.has(actor.type)) {
|
||||
categories.add(actor.id);
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
@@ -179,11 +149,7 @@ Actors.assert = async (ids, options = {}) => {
|
||||
if (e.code === 'ap_get_410') {
|
||||
const exists = await user.exists(id);
|
||||
if (exists) {
|
||||
try {
|
||||
await user.deleteAccount(id);
|
||||
} catch (e) {
|
||||
await activitypub.actors.remove(id);
|
||||
}
|
||||
await user.deleteAccount(id);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -191,12 +157,9 @@ Actors.assert = async (ids, options = {}) => {
|
||||
}
|
||||
}));
|
||||
actors = actors.filter(Boolean); // remove unresolvable actors
|
||||
if (!actors.length && !categories.size) {
|
||||
return [];
|
||||
}
|
||||
|
||||
// Build userData object for storage
|
||||
const profiles = (await activitypub.mocks.profile(actors)).filter(Boolean);
|
||||
const profiles = (await activitypub.mocks.profile(actors, hostMap)).filter(Boolean);
|
||||
const now = Date.now();
|
||||
|
||||
const bulkSet = profiles.reduce((memo, profile) => {
|
||||
@@ -252,188 +215,10 @@ Actors.assert = async (ids, options = {}) => {
|
||||
db.setObject('handle:uid', queries.handleAdd),
|
||||
]);
|
||||
|
||||
// Handle any actors that should be asserted as a group instead
|
||||
if (categories.size) {
|
||||
const assertion = await Actors.assertGroup(Array.from(categories), options);
|
||||
if (assertion === false) {
|
||||
return false;
|
||||
} else if (Array.isArray(assertion)) {
|
||||
return [...actors, ...assertion];
|
||||
}
|
||||
|
||||
// otherwise, assertGroup returned true and output can be safely ignored.
|
||||
}
|
||||
|
||||
return actors;
|
||||
};
|
||||
|
||||
Actors.assertGroup = async (ids, options = {}) => {
|
||||
/**
|
||||
* Ensures that the passed in ids or webfinger handles are stored in database.
|
||||
* Options:
|
||||
* - update: boolean, forces re-fetch/process of the resolved id
|
||||
* Return one of:
|
||||
* - An array of newly processed ids
|
||||
* - false: if input incorrect (or webfinger handle cannot resolve)
|
||||
* - true: no new IDs processed; all passed-in IDs present.
|
||||
*/
|
||||
|
||||
ids = await Actors.qualify(ids, {
|
||||
qualifyGroup: true,
|
||||
...options,
|
||||
});
|
||||
if (!ids) {
|
||||
return ids;
|
||||
}
|
||||
|
||||
activitypub.helpers.log(`[activitypub/actors] Asserting ${ids.length} group(s)`);
|
||||
|
||||
// NOTE: MAKE SURE EVERY DB ADDITION HAS A CORRESPONDING REMOVAL IN ACTORS.REMOVEGROUP!
|
||||
|
||||
const urlMap = new Map();
|
||||
const followersUrlMap = new Map();
|
||||
const pubKeysMap = new Map();
|
||||
let groups = await Promise.all(ids.map(async (id) => {
|
||||
try {
|
||||
activitypub.helpers.log(`[activitypub/actors] Processing group ${id}`);
|
||||
const actor = (typeof id === 'object' && id.hasOwnProperty('id')) ? id : await activitypub.get('uid', 0, id, { cache: process.env.CI === 'true' });
|
||||
|
||||
const typeOk = Array.isArray(actor.type) ?
|
||||
actor.type.some(type => activitypub._constants.acceptableGroupTypes.has(type)) :
|
||||
activitypub._constants.acceptableGroupTypes.has(actor.type);
|
||||
|
||||
if (
|
||||
!typeOk ||
|
||||
!activitypub._constants.requiredActorProps.every(prop => actor.hasOwnProperty(prop))
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Save url for backreference
|
||||
const url = Array.isArray(actor.url) ? actor.url.shift() : actor.url;
|
||||
if (url && url !== actor.id) {
|
||||
urlMap.set(url, actor.id);
|
||||
}
|
||||
|
||||
// Save followers url for backreference
|
||||
if (actor.hasOwnProperty('followers') && activitypub.helpers.isUri(actor.followers)) {
|
||||
followersUrlMap.set(actor.followers, actor.id);
|
||||
}
|
||||
|
||||
// Public keys
|
||||
pubKeysMap.set(actor.id, actor.publicKey);
|
||||
|
||||
return actor;
|
||||
} catch (e) {
|
||||
if (e.code === 'ap_get_410') {
|
||||
const exists = await categories.exists(id);
|
||||
if (exists) {
|
||||
await categories.purge(id, 0);
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}));
|
||||
groups = groups.filter(Boolean); // remove unresolvable actors
|
||||
|
||||
// Build userData object for storage
|
||||
const categoryObjs = (await activitypub.mocks.category(groups)).filter(Boolean);
|
||||
const now = Date.now();
|
||||
|
||||
const bulkSet = categoryObjs.reduce((memo, category) => {
|
||||
const key = `categoryRemote:${category.cid}`;
|
||||
memo.push([key, category], [`${key}:keys`, pubKeysMap.get(category.cid)]);
|
||||
return memo;
|
||||
}, []);
|
||||
if (urlMap.size) {
|
||||
bulkSet.push(['remoteUrl:cid', Object.fromEntries(urlMap)]);
|
||||
}
|
||||
if (followersUrlMap.size) {
|
||||
bulkSet.push(['followersUrl:cid', Object.fromEntries(followersUrlMap)]);
|
||||
}
|
||||
|
||||
const exists = await db.isSortedSetMembers('usersRemote:lastCrawled', categoryObjs.map(p => p.cid));
|
||||
const cidsForCurrent = categoryObjs.map((p, idx) => (exists[idx] ? p.cid : 0));
|
||||
const current = await categories.getCategoriesFields(cidsForCurrent, ['slug']);
|
||||
const queries = categoryObjs.reduce((memo, profile, idx) => {
|
||||
const { slug, name } = current[idx];
|
||||
|
||||
if (options.update || slug !== profile.slug) {
|
||||
if (cidsForCurrent[idx] !== 0 && slug) {
|
||||
// memo.searchRemove.push(['ap.preferredUsername:sorted', `${slug.toLowerCase()}:${profile.uid}`]);
|
||||
memo.handleRemove.push(slug.toLowerCase());
|
||||
}
|
||||
|
||||
memo.searchAdd.push(['categories:name', 0, `${profile.slug.slice(0, 200).toLowerCase()}:${profile.cid}`]);
|
||||
memo.handleAdd[profile.slug.toLowerCase()] = profile.cid;
|
||||
}
|
||||
|
||||
if (options.update || (profile.name && name !== profile.name)) {
|
||||
if (name && cidsForCurrent[idx] !== 0) {
|
||||
memo.searchRemove.push(['categories:name', `${name.toLowerCase()}:${profile.cid}`]);
|
||||
}
|
||||
|
||||
memo.searchAdd.push(['categories:name', 0, `${profile.name.toLowerCase()}:${profile.cid}`]);
|
||||
}
|
||||
|
||||
return memo;
|
||||
}, { searchRemove: [], searchAdd: [], handleRemove: [], handleAdd: {} });
|
||||
|
||||
// Removals
|
||||
await Promise.all([
|
||||
db.sortedSetRemoveBulk(queries.searchRemove),
|
||||
db.deleteObjectFields('handle:cid', queries.handleRemove),
|
||||
]);
|
||||
|
||||
await Promise.all([
|
||||
db.setObjectBulk(bulkSet),
|
||||
db.sortedSetAdd('usersRemote:lastCrawled', groups.map(() => now), groups.map(p => p.id)),
|
||||
db.sortedSetAddBulk(queries.searchAdd),
|
||||
db.setObject('handle:cid', queries.handleAdd),
|
||||
_migratePersonToGroup(categoryObjs),
|
||||
]);
|
||||
|
||||
return categoryObjs;
|
||||
};
|
||||
|
||||
async function _migratePersonToGroup(categoryObjs) {
|
||||
// 4.0.0-4.1.x asserted as:Group as users. This moves relevant stuff over and deletes the now-duplicate user.
|
||||
let ids = categoryObjs.map(category => category.cid);
|
||||
const slugs = categoryObjs.map(category => category.slug);
|
||||
const isUser = await db.isObjectFields('handle:uid', slugs);
|
||||
ids = ids.filter((id, idx) => isUser[idx]);
|
||||
if (!ids.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
await Promise.all(ids.map(async (id) => {
|
||||
const shares = await db.getSortedSetMembers(`uid:${id}:shares`);
|
||||
let cids = await topics.getTopicsFields(shares, ['cid']);
|
||||
cids = cids.map(o => o.cid);
|
||||
await Promise.all(shares.map(async (share, idx) => {
|
||||
const cid = cids[idx];
|
||||
if (cid === -1) {
|
||||
await topics.tools.move(share, {
|
||||
cid: id,
|
||||
uid: 'system',
|
||||
});
|
||||
}
|
||||
}));
|
||||
|
||||
const followers = await db.getSortedSetMembersWithScores(`followersRemote:${id}`);
|
||||
await db.sortedSetAdd(
|
||||
`cid:${id}:uid:watch:state`,
|
||||
followers.map(() => categories.watchStates.tracking),
|
||||
followers.map(({ value }) => value),
|
||||
);
|
||||
await user.deleteAccount(id);
|
||||
}));
|
||||
await categories.onTopicsMoved(ids);
|
||||
}
|
||||
|
||||
Actors.getLocalFollowers = async (id) => {
|
||||
// Returns local uids and cids that follow a remote actor (by id)
|
||||
const response = {
|
||||
uids: new Set(),
|
||||
cids: new Set(),
|
||||
@@ -443,27 +228,15 @@ Actors.getLocalFollowers = async (id) => {
|
||||
return response;
|
||||
}
|
||||
|
||||
const [isUser, isCategory] = await Promise.all([
|
||||
user.exists(id),
|
||||
categories.exists(id),
|
||||
]);
|
||||
const members = await db.getSortedSetMembers(`followersRemote:${id}`);
|
||||
|
||||
if (isUser) {
|
||||
const members = await db.getSortedSetMembers(`followersRemote:${id}`);
|
||||
|
||||
members.forEach((id) => {
|
||||
if (utils.isNumber(id)) {
|
||||
response.uids.add(parseInt(id, 10));
|
||||
} else if (id.startsWith('cid|') && utils.isNumber(id.slice(4))) {
|
||||
response.cids.add(parseInt(id.slice(4), 10));
|
||||
}
|
||||
});
|
||||
} else if (isCategory) {
|
||||
const members = await db.getSortedSetRangeByScore(`cid:${id}:uid:watch:state`, 0, -1, categories.watchStates.tracking, categories.watchStates.watching);
|
||||
members.forEach((uid) => {
|
||||
response.uids.add(uid);
|
||||
});
|
||||
}
|
||||
members.forEach((id) => {
|
||||
if (utils.isNumber(id)) {
|
||||
response.uids.add(parseInt(id, 10));
|
||||
} else if (id.startsWith('cid|') && utils.isNumber(id.slice(4))) {
|
||||
response.cids.add(parseInt(id.slice(4), 10));
|
||||
}
|
||||
});
|
||||
|
||||
return response;
|
||||
};
|
||||
@@ -533,108 +306,38 @@ Actors.remove = async (id) => {
|
||||
]);
|
||||
};
|
||||
|
||||
Actors.removeGroup = async (id) => {
|
||||
/**
|
||||
* Remove ActivityPub related metadata pertaining to a remote id
|
||||
*
|
||||
* Note: don't call this directly! It is called as part of categories.purge
|
||||
*/
|
||||
const exists = await db.isSortedSetMember('usersRemote:lastCrawled', id);
|
||||
if (!exists) {
|
||||
return false;
|
||||
}
|
||||
|
||||
let { slug, name, url, followersUrl } = await categories.getCategoryFields(id, ['slug', 'name', 'url', 'followersUrl']);
|
||||
slug = slug.toLowerCase();
|
||||
|
||||
const bulkRemove = [
|
||||
['categories:name', `${slug}:${id}`],
|
||||
];
|
||||
if (name) {
|
||||
bulkRemove.push(['categories:name', `${name.toLowerCase()}:${id}`]);
|
||||
}
|
||||
|
||||
await Promise.all([
|
||||
db.sortedSetRemoveBulk(bulkRemove),
|
||||
db.deleteObjectField('handle:cid', slug),
|
||||
db.deleteObjectField('followersUrl:cid', followersUrl),
|
||||
db.deleteObjectField('remoteUrl:cid', url),
|
||||
db.delete(`categoryRemote:${id}:keys`),
|
||||
]);
|
||||
|
||||
await Promise.all([
|
||||
db.delete(`categoryRemote:${id}`),
|
||||
db.sortedSetRemove('usersRemote:lastCrawled', id),
|
||||
]);
|
||||
};
|
||||
|
||||
Actors.prune = async () => {
|
||||
/**
|
||||
* Clear out remote user accounts that do not have content on the forum anywhere
|
||||
*/
|
||||
activitypub.helpers.log('[actors/prune] Started scheduled pruning of remote user accounts and categories');
|
||||
winston.info('[actors/prune] Started scheduled pruning of remote user accounts');
|
||||
|
||||
const days = parseInt(meta.config.activitypubUserPruneDays, 10);
|
||||
const timestamp = Date.now() - (1000 * 60 * 60 * 24 * days);
|
||||
const ids = await db.getSortedSetRangeByScore('usersRemote:lastCrawled', 0, 500, '-inf', timestamp);
|
||||
if (!ids.length) {
|
||||
activitypub.helpers.log('[actors/prune] No remote actors to prune, all done.');
|
||||
return {
|
||||
counts: {
|
||||
deleted: 0,
|
||||
missing: 0,
|
||||
preserved: 0,
|
||||
},
|
||||
preserved: new Set(),
|
||||
};
|
||||
const uids = await db.getSortedSetRangeByScore('usersRemote:lastCrawled', 0, 500, '-inf', timestamp);
|
||||
if (!uids.length) {
|
||||
winston.info('[actors/prune] No remote users to prune, all done.');
|
||||
return;
|
||||
}
|
||||
|
||||
activitypub.helpers.log(`[actors/prune] Found ${ids.length} remote actors last crawled more than ${days} days ago`);
|
||||
winston.info(`[actors/prune] Found ${uids.length} remote users last crawled more than ${days} days ago`);
|
||||
let deletionCount = 0;
|
||||
let deletionCountNonExisting = 0;
|
||||
let notDeletedDueToLocalContent = 0;
|
||||
const preservedIds = [];
|
||||
await batch.processArray(ids, async (ids) => {
|
||||
const exists = await Promise.all([
|
||||
db.exists(ids.map(id => `userRemote:${id}`)),
|
||||
db.exists(ids.map(id => `categoryRemote:${id}`)),
|
||||
]);
|
||||
const notDeletedUids = [];
|
||||
await batch.processArray(uids, async (uids) => {
|
||||
const exists = await db.exists(uids.map(uid => `userRemote:${uid}`));
|
||||
|
||||
let uids = new Set();
|
||||
let cids = new Set();
|
||||
const missing = new Set();
|
||||
ids.forEach((id, idx) => {
|
||||
switch (true) {
|
||||
case exists[0][idx]: {
|
||||
uids.add(id);
|
||||
break;
|
||||
}
|
||||
const uidsThatExist = uids.filter((uid, idx) => exists[idx]);
|
||||
const uidsThatDontExist = uids.filter((uid, idx) => !exists[idx]);
|
||||
|
||||
case exists[1][idx]: {
|
||||
cids.add(id);
|
||||
break;
|
||||
}
|
||||
|
||||
default: {
|
||||
missing.add(id);
|
||||
break;
|
||||
}
|
||||
}
|
||||
});
|
||||
uids = Array.from(uids);
|
||||
cids = Array.from(cids);
|
||||
|
||||
// const uidsThatExist = ids.filter((uid, idx) => exists[idx]);
|
||||
// const uidsThatDontExist = ids.filter((uid, idx) => !exists[idx]);
|
||||
|
||||
// Remote users
|
||||
const [postCounts, roomCounts, followCounts] = await Promise.all([
|
||||
db.sortedSetsCard(uids.map(uid => `uid:${uid}:posts`)),
|
||||
db.sortedSetsCard(uids.map(uid => `uid:${uid}:chat:rooms`)),
|
||||
Actors.getLocalFollowCounts(uids),
|
||||
db.sortedSetsCard(uidsThatExist.map(uid => `uid:${uid}:posts`)),
|
||||
db.sortedSetsCard(uidsThatExist.map(uid => `uid:${uid}:chat:rooms`)),
|
||||
Actors.getLocalFollowCounts(uidsThatExist),
|
||||
]);
|
||||
|
||||
await Promise.all(uids.map(async (uid, idx) => {
|
||||
await Promise.all(uidsThatExist.map(async (uid, idx) => {
|
||||
const { followers, following } = followCounts[idx];
|
||||
const postCount = postCounts[idx];
|
||||
const roomCount = roomCounts[idx];
|
||||
@@ -647,46 +350,20 @@ Actors.prune = async () => {
|
||||
}
|
||||
} else {
|
||||
notDeletedDueToLocalContent += 1;
|
||||
preservedIds.push(uid);
|
||||
notDeletedUids.push(uid);
|
||||
}
|
||||
}));
|
||||
|
||||
// Remote categories
|
||||
let counts = await categories.getCategoriesFields(cids, ['topic_count']);
|
||||
counts = counts.map(count => count.topic_count);
|
||||
await Promise.all(cids.map(async (cid, idx) => {
|
||||
const topicCount = counts[idx];
|
||||
if (topicCount === 0) {
|
||||
try {
|
||||
await categories.purge(cid, 0);
|
||||
deletionCount += 1;
|
||||
} catch (err) {
|
||||
winston.error(err.stack);
|
||||
}
|
||||
} else {
|
||||
notDeletedDueToLocalContent += 1;
|
||||
preservedIds.push(cid);
|
||||
}
|
||||
}));
|
||||
|
||||
deletionCountNonExisting += missing.size;
|
||||
await db.sortedSetRemove('usersRemote:lastCrawled', Array.from(missing));
|
||||
deletionCountNonExisting += uidsThatDontExist.length;
|
||||
await db.sortedSetRemove('usersRemote:lastCrawled', uidsThatDontExist);
|
||||
// update timestamp in usersRemote:lastCrawled so we don't try to delete users
|
||||
// with content over and over
|
||||
const now = Date.now();
|
||||
await db.sortedSetAdd('usersRemote:lastCrawled', preservedIds.map(() => now), preservedIds);
|
||||
await db.sortedSetAdd('usersRemote:lastCrawled', notDeletedUids.map(() => now), notDeletedUids);
|
||||
}, {
|
||||
batch: 50,
|
||||
interval: 1000,
|
||||
});
|
||||
|
||||
activitypub.helpers.log(`[actors/prune] ${deletionCount} remote users pruned. ${deletionCountNonExisting} did not exist. ${notDeletedDueToLocalContent} not deleted due to local content`);
|
||||
return {
|
||||
counts: {
|
||||
deleted: deletionCount,
|
||||
missing: deletionCountNonExisting,
|
||||
preserved: notDeletedDueToLocalContent,
|
||||
},
|
||||
preserved: new Set(preservedIds),
|
||||
};
|
||||
winston.info(`[actors/prune] ${deletionCount} remote users pruned. ${deletionCountNonExisting} does not exist. ${notDeletedDueToLocalContent} not deleted due to local content`);
|
||||
};
|
||||
|
||||
@@ -50,7 +50,7 @@ inbox.create = async (req) => {
|
||||
const asserted = await activitypub.notes.assert(0, object, { cid });
|
||||
if (asserted) {
|
||||
activitypub.feps.announce(object.id, req.body);
|
||||
// api.activitypub.add(req, { pid: object.id });
|
||||
api.activitypub.add(req, { pid: object.id });
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -8,7 +8,6 @@ const { CronJob } = require('cron');
|
||||
const request = require('../request');
|
||||
const db = require('../database');
|
||||
const meta = require('../meta');
|
||||
const categories = require('../categories');
|
||||
const posts = require('../posts');
|
||||
const messaging = require('../messaging');
|
||||
const user = require('../user');
|
||||
@@ -40,8 +39,7 @@ ActivityPub._constants = Object.freeze({
|
||||
acceptedPostTypes: [
|
||||
'Note', 'Page', 'Article', 'Question', 'Video',
|
||||
],
|
||||
acceptableActorTypes: new Set(['Application', 'Organization', 'Person', 'Service']),
|
||||
acceptableGroupTypes: new Set(['Group']),
|
||||
acceptableActorTypes: new Set(['Application', 'Group', 'Organization', 'Person', 'Service']),
|
||||
requiredActorProps: ['inbox', 'outbox'],
|
||||
acceptedProtocols: ['https', ...(process.env.CI === 'true' ? ['http'] : [])],
|
||||
acceptable: {
|
||||
@@ -115,28 +113,11 @@ ActivityPub.resolveInboxes = async (ids) => {
|
||||
}
|
||||
|
||||
await ActivityPub.actors.assert(ids);
|
||||
|
||||
// Remove non-asserted targets
|
||||
const exists = await db.isSortedSetMembers('usersRemote:lastCrawled', ids);
|
||||
ids = ids.filter((_, idx) => exists[idx]);
|
||||
|
||||
await batch.processArray(ids, async (currentIds) => {
|
||||
const isCategory = await db.exists(currentIds.map(id => `categoryRemote:${id}`));
|
||||
const [cids, uids] = currentIds.reduce(([cids, uids], id, idx) => {
|
||||
const array = isCategory[idx] ? cids : uids;
|
||||
array.push(id);
|
||||
return [cids, uids];
|
||||
}, [[], []]);
|
||||
const categoryData = await categories.getCategoriesFields(cids, ['inbox', 'sharedInbox']);
|
||||
const userData = await user.getUsersFields(uids, ['inbox', 'sharedInbox']);
|
||||
|
||||
currentIds.forEach((id) => {
|
||||
if (cids.includes(id)) {
|
||||
const data = categoryData[cids.indexOf(id)];
|
||||
inboxes.add(data.sharedInbox || data.inbox);
|
||||
} else if (uids.includes(id)) {
|
||||
const data = userData[uids.indexOf(id)];
|
||||
inboxes.add(data.sharedInbox || data.inbox);
|
||||
const usersData = await user.getUsersFields(currentIds, ['inbox', 'sharedInbox']);
|
||||
usersData.forEach((u) => {
|
||||
if (u && (u.sharedInbox || u.inbox)) {
|
||||
inboxes.add(u.sharedInbox || u.inbox);
|
||||
}
|
||||
});
|
||||
}, {
|
||||
@@ -414,8 +395,7 @@ ActivityPub.send = async (type, id, targets, payload) => {
|
||||
...payload,
|
||||
};
|
||||
|
||||
// Runs in background... potentially a better queue is required... later.
|
||||
batch.processArray(
|
||||
await batch.processArray(
|
||||
inboxes,
|
||||
async inboxBatch => Promise.all(inboxBatch.map(async uri => sendMessage(uri, id, type, payload))),
|
||||
{
|
||||
|
||||
@@ -129,7 +129,7 @@ Mocks._normalize = async (object) => {
|
||||
};
|
||||
};
|
||||
|
||||
Mocks.profile = async (actors) => {
|
||||
Mocks.profile = async (actors, hostMap) => {
|
||||
// Should only ever be called by activitypub.actors.assert
|
||||
const profiles = await Promise.all(actors.map(async (actor) => {
|
||||
if (!actor) {
|
||||
@@ -137,7 +137,7 @@ Mocks.profile = async (actors) => {
|
||||
}
|
||||
|
||||
const uid = actor.id;
|
||||
let hostname;
|
||||
let hostname = hostMap.get(uid);
|
||||
let {
|
||||
url, preferredUsername, published, icon, image,
|
||||
name, summary, followers, inbox, endpoints, tag,
|
||||
@@ -145,10 +145,12 @@ Mocks.profile = async (actors) => {
|
||||
preferredUsername = slugify(preferredUsername || name);
|
||||
const { followers: followerCount, following: followingCount } = await activitypub.actors.getLocalFollowCounts(uid);
|
||||
|
||||
try {
|
||||
({ hostname } = new URL(actor.id));
|
||||
} catch (e) {
|
||||
return null;
|
||||
if (!hostname) { // if not available via webfinger, infer from id
|
||||
try {
|
||||
({ hostname } = new URL(actor.id));
|
||||
} catch (e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
let picture;
|
||||
@@ -216,7 +218,7 @@ Mocks.profile = async (actors) => {
|
||||
uploadedpicture: undefined,
|
||||
'cover:url': !image || typeof image === 'string' ? image : image.url,
|
||||
'cover:position': '50% 50%',
|
||||
aboutme: posts.sanitize(summary),
|
||||
aboutme: summary,
|
||||
followerCount,
|
||||
followingCount,
|
||||
|
||||
@@ -233,73 +235,6 @@ Mocks.profile = async (actors) => {
|
||||
return profiles;
|
||||
};
|
||||
|
||||
Mocks.category = async (actors) => {
|
||||
const categories = await Promise.all(actors.map(async (actor) => {
|
||||
if (!actor) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const cid = actor.id;
|
||||
let hostname;
|
||||
let {
|
||||
url, preferredUsername, /* icon, */ image,
|
||||
name, summary, followers, inbox, endpoints, tag,
|
||||
} = actor;
|
||||
preferredUsername = slugify(preferredUsername || name);
|
||||
// const { followers: followerCount, following: followingCount } = await activitypub.actors.getLocalFollowCounts(uid);
|
||||
|
||||
try {
|
||||
({ hostname } = new URL(actor.id));
|
||||
} catch (e) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// No support for category avatars yet ;(
|
||||
// let picture;
|
||||
// if (icon) {
|
||||
// picture = typeof icon === 'string' ? icon : icon.url;
|
||||
// }
|
||||
const iconBackgrounds = await user.getIconBackgrounds();
|
||||
let bgColor = Array.prototype.reduce.call(preferredUsername, (cur, next) => cur + next.charCodeAt(), 0);
|
||||
bgColor = iconBackgrounds[bgColor % iconBackgrounds.length];
|
||||
|
||||
// Replace emoji in summary
|
||||
if (tag && Array.isArray(tag)) {
|
||||
tag
|
||||
.filter(tag => tag.type === 'Emoji' &&
|
||||
isEmojiShortcode.test(tag.name) &&
|
||||
tag.icon && tag.icon.mediaType && tag.icon.mediaType.startsWith('image/'))
|
||||
.forEach((tag) => {
|
||||
summary = summary.replace(new RegExp(tag.name, 'g'), `<img class="not-responsive emoji" src="${tag.icon.url}" title="${tag.name}" />`);
|
||||
});
|
||||
}
|
||||
|
||||
const payload = {
|
||||
cid,
|
||||
name,
|
||||
handle: preferredUsername,
|
||||
slug: `${preferredUsername}@${hostname}`,
|
||||
description: summary,
|
||||
descriptionParsed: posts.sanitize(summary),
|
||||
icon: 'fa-comments',
|
||||
color: '#fff',
|
||||
bgColor,
|
||||
backgroundImage: !image || typeof image === 'string' ? image : image.url,
|
||||
// followerCount,
|
||||
// followingCount,
|
||||
|
||||
url,
|
||||
inbox,
|
||||
sharedInbox: endpoints ? endpoints.sharedInbox : null,
|
||||
followersUrl: followers,
|
||||
};
|
||||
|
||||
return payload;
|
||||
}));
|
||||
|
||||
return categories;
|
||||
};
|
||||
|
||||
Mocks.post = async (objects) => {
|
||||
let single = false;
|
||||
if (!Array.isArray(objects)) {
|
||||
@@ -557,6 +492,7 @@ Mocks.notes.public = async (post) => {
|
||||
const published = post.timestampISO;
|
||||
const updated = post.edited ? post.editedISO : null;
|
||||
|
||||
// todo: post visibility
|
||||
const to = new Set([activitypub._constants.publicAddress]);
|
||||
const cc = new Set([`${nconf.get('url')}/uid/${post.user.uid}/followers`]);
|
||||
|
||||
@@ -701,15 +637,13 @@ Mocks.notes.public = async (post) => {
|
||||
* audience is exposed as part of 1b12 but is now ignored by Lemmy.
|
||||
* Remove this and most references to audience in 2026.
|
||||
*/
|
||||
let audience = utils.isNumber(post.category.cid) ? // default
|
||||
`${nconf.get('url')}/category/${post.category.cid}` : post.category.cid;
|
||||
let audience = `${nconf.get('url')}/category/${post.category.cid}`; // default
|
||||
if (inReplyTo) {
|
||||
const chain = await activitypub.notes.getParentChain(post.uid, inReplyTo);
|
||||
chain.forEach((post) => {
|
||||
audience = post.audience || audience;
|
||||
});
|
||||
}
|
||||
to.add(audience);
|
||||
|
||||
let object = {
|
||||
'@context': 'https://www.w3.org/ns/activitystreams',
|
||||
|
||||
@@ -79,7 +79,6 @@ Notes.assert = async (uid, input, options = { skipChecks: false }) => {
|
||||
const hasTid = !!tid;
|
||||
|
||||
const cid = hasTid ? await topics.getTopicField(tid, 'cid') : options.cid || -1;
|
||||
|
||||
if (options.cid && cid === -1) {
|
||||
// Move topic if currently uncategorized
|
||||
await topics.tools.move(tid, { cid: options.cid, uid: 'system' });
|
||||
@@ -98,24 +97,16 @@ Notes.assert = async (uid, input, options = { skipChecks: false }) => {
|
||||
if (hasTid) {
|
||||
mainPid = await topics.getTopicField(tid, 'mainPid');
|
||||
} else {
|
||||
// Check recipients/audience for category (local or remote)
|
||||
// Check recipients/audience for local category
|
||||
const set = activitypub.helpers.makeSet(_activitypub, ['to', 'cc', 'audience']);
|
||||
await activitypub.actors.assert(Array.from(set));
|
||||
|
||||
// Local
|
||||
const resolved = await Promise.all(Array.from(set).map(async id => await activitypub.helpers.resolveLocalId(id)));
|
||||
const recipientCids = resolved
|
||||
.filter(Boolean)
|
||||
.filter(({ type }) => type === 'category')
|
||||
.map(obj => obj.id);
|
||||
|
||||
// Remote
|
||||
const assertedGroups = await db.exists(Array.from(set).map(id => `categoryRemote:${id}`));
|
||||
const remoteCid = Array.from(set).filter((_, idx) => assertedGroups[idx]).shift();
|
||||
|
||||
if (remoteCid || recipientCids.length) {
|
||||
if (recipientCids.length) {
|
||||
// Overrides passed-in value, respect addressing from main post over booster
|
||||
options.cid = remoteCid || recipientCids.shift();
|
||||
options.cid = recipientCids.shift();
|
||||
}
|
||||
|
||||
// mainPid ok to leave as-is
|
||||
@@ -139,7 +130,7 @@ Notes.assert = async (uid, input, options = { skipChecks: false }) => {
|
||||
options.skipChecks || options.cid ||
|
||||
await assertRelation(chain[inputIndex !== -1 ? inputIndex : 0]);
|
||||
const privilege = `topics:${tid ? 'reply' : 'create'}`;
|
||||
const allowed = await privileges.categories.can(privilege, options.cid || cid, activitypub._constants.uid);
|
||||
const allowed = await privileges.categories.can(privilege, cid, activitypub._constants.uid);
|
||||
if (!hasRelation || !allowed) {
|
||||
if (!hasRelation) {
|
||||
activitypub.helpers.log(`[activitypub/notes.assert] Not asserting ${id} as it has no relation to existing tracked content.`);
|
||||
@@ -463,12 +454,6 @@ Notes.syncUserInboxes = async function (tid, uid) {
|
||||
uids.add(uid);
|
||||
});
|
||||
|
||||
// Category followers
|
||||
const categoryFollowers = await activitypub.actors.getLocalFollowers(cid);
|
||||
categoryFollowers.uids.forEach((uid) => {
|
||||
uids.add(uid);
|
||||
});
|
||||
|
||||
const keys = Array.from(uids).map(uid => `uid:${uid}:inbox`);
|
||||
const score = await db.sortedSetScore(`cid:${cid}:tids`, tid);
|
||||
|
||||
|
||||
@@ -37,22 +37,13 @@ function enabledCheck(next) {
|
||||
|
||||
activitypubApi.follow = enabledCheck(async (caller, { type, id, actor } = {}) => {
|
||||
// Privilege checks should be done upstream
|
||||
const acceptedTypes = ['uid', 'cid'];
|
||||
const assertion = await activitypub.actors.assert(actor);
|
||||
if (!acceptedTypes.includes(type) || !assertion || (Array.isArray(assertion) && assertion.length)) {
|
||||
if (!assertion || (Array.isArray(assertion) && assertion.length)) {
|
||||
throw new Error('[[error:activitypub.invalid-id]]');
|
||||
}
|
||||
|
||||
actor = actor.includes('@') ? await user.getUidByUserslug(actor) : actor;
|
||||
const [handle, isFollowing] = await Promise.all([
|
||||
user.getUserField(actor, 'username'),
|
||||
db.isSortedSetMember(type === 'uid' ? `followingRemote:${id}` : `cid:${id}:following`, actor),
|
||||
]);
|
||||
|
||||
if (isFollowing) { // already following
|
||||
return;
|
||||
}
|
||||
|
||||
const handle = await user.getUserField(actor, 'username');
|
||||
const timestamp = Date.now();
|
||||
|
||||
await db.sortedSetAdd(`followRequests:${type}.${id}`, timestamp, actor);
|
||||
@@ -70,22 +61,13 @@ activitypubApi.follow = enabledCheck(async (caller, { type, id, actor } = {}) =>
|
||||
|
||||
// should be .undo.follow
|
||||
activitypubApi.unfollow = enabledCheck(async (caller, { type, id, actor }) => {
|
||||
const acceptedTypes = ['uid', 'cid'];
|
||||
const assertion = await activitypub.actors.assert(actor);
|
||||
if (!acceptedTypes.includes(type) || !assertion) {
|
||||
if (!assertion) {
|
||||
throw new Error('[[error:activitypub.invalid-id]]');
|
||||
}
|
||||
|
||||
actor = actor.includes('@') ? await user.getUidByUserslug(actor) : actor;
|
||||
const [handle, isFollowing] = await Promise.all([
|
||||
user.getUserField(actor, 'username'),
|
||||
db.isSortedSetMember(type === 'uid' ? `followingRemote:${id}` : `cid:${id}:following`, actor),
|
||||
]);
|
||||
|
||||
if (!isFollowing) { // already not following
|
||||
return;
|
||||
}
|
||||
|
||||
const handle = await user.getUserField(actor, 'username');
|
||||
const timestamps = await db.sortedSetsScore([
|
||||
`followRequests:${type}.${id}`,
|
||||
type === 'uid' ? `followingRemote:${id}` : `cid:${id}:following`,
|
||||
@@ -147,7 +129,7 @@ activitypubApi.create.note = enabledCheck(async (caller, { pid, post }) => {
|
||||
await Promise.all([
|
||||
activitypub.send('uid', caller.uid, Array.from(targets), activity),
|
||||
activitypub.feps.announce(pid, activity),
|
||||
// utils.isNumber(post.cid) ? activitypubApi.add(caller, { pid }) : undefined,
|
||||
activitypubApi.add(caller, { pid }),
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -405,7 +387,6 @@ activitypubApi.flag = enabledCheck(async (caller, flag) => {
|
||||
await db.sortedSetAdd(`flag:${flag.flagId}:remote`, Date.now(), caller.uid);
|
||||
});
|
||||
|
||||
/*
|
||||
activitypubApi.add = enabledCheck((async (_, { pid }) => {
|
||||
let localId;
|
||||
if (String(pid).startsWith(nconf.get('url'))) {
|
||||
@@ -432,7 +413,7 @@ activitypubApi.add = enabledCheck((async (_, { pid }) => {
|
||||
target: `${nconf.get('url')}/topic/${tid}`,
|
||||
});
|
||||
}));
|
||||
*/
|
||||
|
||||
activitypubApi.undo.flag = enabledCheck(async (caller, flag) => {
|
||||
if (!activitypub.helpers.isUri(flag.targetId)) {
|
||||
return;
|
||||
|
||||
@@ -7,7 +7,6 @@ const events = require('../events');
|
||||
const user = require('../user');
|
||||
const groups = require('../groups');
|
||||
const privileges = require('../privileges');
|
||||
const utils = require('../utils');
|
||||
|
||||
const activitypubApi = require('./activitypub');
|
||||
|
||||
@@ -158,9 +157,7 @@ categoriesAPI.getTopics = async (caller, data) => {
|
||||
|
||||
categoriesAPI.setWatchState = async (caller, { cid, state, uid }) => {
|
||||
let targetUid = caller.uid;
|
||||
let cids = Array.isArray(cid) ? cid : [cid];
|
||||
cids = cids.map(cid => (utils.isNumber(cid) ? parseInt(cid, 10) : cid));
|
||||
|
||||
const cids = Array.isArray(cid) ? cid.map(cid => parseInt(cid, 10)) : [parseInt(cid, 10)];
|
||||
if (uid) {
|
||||
targetUid = uid;
|
||||
}
|
||||
|
||||
@@ -87,7 +87,9 @@ topicsAPI.create = async function (caller, data) {
|
||||
socketHelpers.notifyNew(caller.uid, 'newTopic', { posts: [result.postData], topic: result.topicData });
|
||||
|
||||
if (!isScheduling) {
|
||||
await activitypubApi.create.note(caller, { pid: result.postData.pid });
|
||||
setTimeout(() => {
|
||||
activitypubApi.create.note(caller, { pid: result.postData.pid });
|
||||
}, 5000);
|
||||
}
|
||||
|
||||
return result.topicData;
|
||||
@@ -123,7 +125,7 @@ topicsAPI.reply = async function (caller, data) {
|
||||
}
|
||||
|
||||
socketHelpers.notifyNew(caller.uid, 'newPost', result);
|
||||
await activitypubApi.create.note(caller, { post: postData });
|
||||
activitypubApi.create.note(caller, { post: postData });
|
||||
|
||||
return postData;
|
||||
};
|
||||
|
||||
@@ -36,8 +36,8 @@ module.exports = function (Categories) {
|
||||
return [];
|
||||
}
|
||||
|
||||
cids = cids.map(cid => (utils.isNumber(cid) ? parseInt(cid, 10) : cid));
|
||||
const keys = cids.map(cid => (utils.isNumber(cid) ? `category:${cid}` : `categoryRemote:${cid}`));
|
||||
cids = cids.map(cid => parseInt(cid, 10));
|
||||
const keys = cids.map(cid => `category:${cid}`);
|
||||
const categories = await db.getObjects(keys, fields);
|
||||
|
||||
// Handle cid -1
|
||||
@@ -87,11 +87,11 @@ module.exports = function (Categories) {
|
||||
};
|
||||
|
||||
Categories.setCategoryField = async function (cid, field, value) {
|
||||
await db.setObjectField(`${utils.isNumber(cid) ? 'category' : 'categoryRemote'}:${cid}`, field, value);
|
||||
await db.setObjectField(`category:${cid}`, field, value);
|
||||
};
|
||||
|
||||
Categories.incrementCategoryFieldBy = async function (cid, field, value) {
|
||||
await db.incrObjectFieldBy(`${utils.isNumber(cid) ? 'category' : 'categoryRemote'}:${cid}`, field, value);
|
||||
await db.incrObjectFieldBy(`category:${cid}`, field, value);
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
@@ -7,9 +7,7 @@ const plugins = require('../plugins');
|
||||
const topics = require('../topics');
|
||||
const groups = require('../groups');
|
||||
const privileges = require('../privileges');
|
||||
const activitypub = require('../activitypub');
|
||||
const cache = require('../cache');
|
||||
const utils = require('../utils');
|
||||
|
||||
module.exports = function (Categories) {
|
||||
Categories.purge = async function (cid, uid) {
|
||||
@@ -40,7 +38,6 @@ module.exports = function (Categories) {
|
||||
|
||||
await removeFromParent(cid);
|
||||
await deleteTags(cid);
|
||||
await activitypub.actors.removeGroup(cid);
|
||||
await db.deleteAll([
|
||||
`cid:${cid}:tids`,
|
||||
`cid:${cid}:tids:pinned`,
|
||||
@@ -54,7 +51,7 @@ module.exports = function (Categories) {
|
||||
`cid:${cid}:uid:watch:state`,
|
||||
`cid:${cid}:children`,
|
||||
`cid:${cid}:tag:whitelist`,
|
||||
`${utils.isNumber(cid) ? 'category' : 'categoryRemote'}:${cid}`,
|
||||
`category:${cid}`,
|
||||
]);
|
||||
const privilegeList = await privileges.categories.getPrivilegeList();
|
||||
await groups.destroy(privilegeList.map(privilege => `cid:${cid}:privileges:${privilege}`));
|
||||
|
||||
@@ -10,7 +10,6 @@ const plugins = require('../plugins');
|
||||
const privileges = require('../privileges');
|
||||
const cache = require('../cache');
|
||||
const meta = require('../meta');
|
||||
const utils = require('../utils');
|
||||
|
||||
const Categories = module.exports;
|
||||
|
||||
@@ -27,14 +26,9 @@ require('./search')(Categories);
|
||||
Categories.icons = require('./icon');
|
||||
|
||||
Categories.exists = async function (cids) {
|
||||
let keys;
|
||||
if (Array.isArray(cids)) {
|
||||
keys = cids.map(cid => (utils.isNumber(cid) ? `category:${cid}` : `categoryRemote:${cid}`));
|
||||
} else {
|
||||
keys = utils.isNumber(cids) ? `category:${cids}` : `categoryRemote:${cids}`;
|
||||
}
|
||||
|
||||
return await db.exists(keys);
|
||||
return await db.exists(
|
||||
Array.isArray(cids) ? cids.map(cid => `category:${cid}`) : `category:${cids}`
|
||||
);
|
||||
};
|
||||
|
||||
Categories.existsByHandle = async function (handle) {
|
||||
|
||||
@@ -3,9 +3,7 @@
|
||||
const _ = require('lodash');
|
||||
|
||||
const privileges = require('../privileges');
|
||||
const activitypub = require('../activitypub');
|
||||
const plugins = require('../plugins');
|
||||
const utils = require('../utils');
|
||||
const db = require('../database');
|
||||
|
||||
module.exports = function (Categories) {
|
||||
@@ -17,10 +15,6 @@ module.exports = function (Categories) {
|
||||
|
||||
const startTime = process.hrtime();
|
||||
|
||||
if (activitypub.helpers.isWebfinger(query)) {
|
||||
await activitypub.actors.assertGroup([query]);
|
||||
}
|
||||
|
||||
let cids = await findCids(query, data.hardCap);
|
||||
|
||||
const result = await plugins.hooks.fire('filter:categories.search', {
|
||||
@@ -77,12 +71,7 @@ module.exports = function (Categories) {
|
||||
match: `*${String(query).toLowerCase()}*`,
|
||||
limit: hardCap || 500,
|
||||
});
|
||||
return data.map((data) => {
|
||||
const split = data.split(':');
|
||||
split.shift();
|
||||
const cid = split.join(':');
|
||||
return utils.isNumber(cid) ? parseInt(cid, 10) : cid;
|
||||
});
|
||||
return data.map(data => parseInt(data.split(':').pop(), 10));
|
||||
}
|
||||
|
||||
async function getChildrenCids(cids, uid) {
|
||||
|
||||
@@ -9,7 +9,6 @@ const user = require('../user');
|
||||
const notifications = require('../notifications');
|
||||
const translator = require('../translator');
|
||||
const batch = require('../batch');
|
||||
const utils = require('../utils');
|
||||
|
||||
module.exports = function (Categories) {
|
||||
Categories.getCategoryTopics = async function (data) {
|
||||
@@ -187,7 +186,7 @@ module.exports = function (Categories) {
|
||||
}
|
||||
const promises = [
|
||||
db.sortedSetAdd(`cid:${cid}:pids`, postData.timestamp, postData.pid),
|
||||
db.incrObjectField(`${utils.isNumber(cid) ? 'category' : 'categoryRemote'}:${cid}`, 'post_count'),
|
||||
db.incrObjectField(`category:${cid}`, 'post_count'),
|
||||
];
|
||||
if (!pinned) {
|
||||
promises.push(db.sortedSetIncrBy(`cid:${cid}:tids:posts`, 1, postData.tid));
|
||||
@@ -255,29 +254,18 @@ module.exports = function (Categories) {
|
||||
notifications.push(notification, followers);
|
||||
};
|
||||
|
||||
Categories.sortTidsBySet = async (tids, sort) => {
|
||||
let cids = await topics.getTopicsFields(tids, ['cid']);
|
||||
cids = cids.map(({ cid }) => cid);
|
||||
|
||||
function getSet(cid, sort) {
|
||||
sort = sort || meta.config.categoryTopicSort || 'recently_replied';
|
||||
const sortToSet = {
|
||||
recently_replied: `cid:${cid}:tids`,
|
||||
recently_created: `cid:${cid}:tids:create`,
|
||||
most_posts: `cid:${cid}:tids:posts`,
|
||||
most_votes: `cid:${cid}:tids:votes`,
|
||||
most_views: `cid:${cid}:tids:views`,
|
||||
};
|
||||
|
||||
return sortToSet[sort];
|
||||
}
|
||||
|
||||
const scores = await Promise.all(tids.map(async (tid, idx) => {
|
||||
const cid = cids[idx];
|
||||
const orderBy = getSet(cid, sort);
|
||||
return await db.sortedSetScore(orderBy, tid);
|
||||
}));
|
||||
Categories.sortTidsBySet = async (tids, cid, sort) => {
|
||||
sort = sort || meta.config.categoryTopicSort || 'recently_replied';
|
||||
const sortToSet = {
|
||||
recently_replied: `cid:${cid}:tids`,
|
||||
recently_created: `cid:${cid}:tids:create`,
|
||||
most_posts: `cid:${cid}:tids:posts`,
|
||||
most_votes: `cid:${cid}:tids:votes`,
|
||||
most_views: `cid:${cid}:tids:views`,
|
||||
};
|
||||
|
||||
const orderBy = sortToSet[sort];
|
||||
const scores = await db.sortedSetScores(orderBy, tids);
|
||||
const sorted = tids
|
||||
.map((tid, idx) => [tid, scores[idx]])
|
||||
.sort(([, a], [, b]) => b - a)
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
const db = require('../database');
|
||||
const user = require('../user');
|
||||
const activitypub = require('../activitypub');
|
||||
const utils = require('../utils');
|
||||
|
||||
module.exports = function (Categories) {
|
||||
Categories.watchStates = {
|
||||
@@ -33,11 +32,7 @@ module.exports = function (Categories) {
|
||||
user.getSettings(uid),
|
||||
db.sortedSetsScore(keys, uid),
|
||||
]);
|
||||
|
||||
const fallbacks = cids.map(cid => (utils.isNumber(cid) ?
|
||||
Categories.watchStates[userSettings.categoryWatchState] : Categories.watchStates.notwatching));
|
||||
|
||||
return states.map((state, idx) => state || fallbacks[idx]);
|
||||
return states.map(state => state || Categories.watchStates[userSettings.categoryWatchState]);
|
||||
};
|
||||
|
||||
Categories.getIgnorers = async function (cid, start, stop) {
|
||||
|
||||
@@ -1,15 +1,160 @@
|
||||
'use strict';
|
||||
|
||||
// todo: replace with styleText from node:util in node 20+
|
||||
// override commander help formatting functions
|
||||
// to include color styling in the output
|
||||
// so the CLI looks nice
|
||||
|
||||
const { Command } = require('commander');
|
||||
const chalk = require('chalk');
|
||||
|
||||
// https://github.com/tj/commander.js/blob/master/examples/color-help.mjs
|
||||
module.exports = {
|
||||
styleTitle: str => chalk.bold(str),
|
||||
styleCommandText: str => chalk.cyan(str),
|
||||
styleCommandDescription: str => chalk.magenta(str),
|
||||
styleDescriptionText: str => chalk.italic(str),
|
||||
styleOptionText: str => chalk.green(str),
|
||||
styleArgumentText: str => chalk.yellow(str),
|
||||
styleSubcommandText: str => chalk.blue(str),
|
||||
const colors = [
|
||||
// depth = 0, top-level command
|
||||
{ command: 'yellow', option: 'cyan', arg: 'magenta' },
|
||||
// depth = 1, second-level commands
|
||||
{ command: 'green', option: 'blue', arg: 'red' },
|
||||
// depth = 2, third-level commands
|
||||
{ command: 'yellow', option: 'cyan', arg: 'magenta' },
|
||||
// depth = 3 fourth-level commands
|
||||
{ command: 'green', option: 'blue', arg: 'red' },
|
||||
];
|
||||
|
||||
function humanReadableArgName(arg) {
|
||||
const nameOutput = arg.name() + (arg.variadic === true ? '...' : '');
|
||||
|
||||
return arg.required ? `<${nameOutput}>` : `[${nameOutput}]`;
|
||||
}
|
||||
|
||||
function getControlCharacterSpaces(term) {
|
||||
const matches = term.match(/.\[\d+m/g);
|
||||
return matches ? matches.length * 5 : 0;
|
||||
}
|
||||
|
||||
// get depth of command
|
||||
// 0 = top, 1 = subcommand of top, etc
|
||||
Command.prototype.depth = function () {
|
||||
if (this._depth === undefined) {
|
||||
let depth = 0;
|
||||
let { parent } = this;
|
||||
while (parent) { depth += 1; parent = parent.parent; }
|
||||
|
||||
this._depth = depth;
|
||||
}
|
||||
return this._depth;
|
||||
};
|
||||
|
||||
module.exports = {
|
||||
commandUsage(cmd) {
|
||||
const depth = cmd.depth();
|
||||
|
||||
// Usage
|
||||
let cmdName = cmd._name;
|
||||
if (cmd._aliases[0]) {
|
||||
cmdName = `${cmdName}|${cmd._aliases[0]}`;
|
||||
}
|
||||
let parentCmdNames = '';
|
||||
let parentCmd = cmd.parent;
|
||||
let parentDepth = depth - 1;
|
||||
while (parentCmd) {
|
||||
parentCmdNames = `${chalk[colors[parentDepth].command](parentCmd.name())} ${parentCmdNames}`;
|
||||
|
||||
parentCmd = parentCmd.parent;
|
||||
parentDepth -= 1;
|
||||
}
|
||||
|
||||
// from Command.prototype.usage()
|
||||
const args = cmd._args.map(arg => chalk[colors[depth].arg](humanReadableArgName(arg)));
|
||||
const cmdUsage = [].concat(
|
||||
(cmd.options.length || cmd._hasHelpOption ? chalk[colors[depth].option]('[options]') : []),
|
||||
(cmd.commands.length ? chalk[colors[depth + 1].command]('[command]') : []),
|
||||
(cmd._args.length ? args : [])
|
||||
).join(' ');
|
||||
|
||||
return `${parentCmdNames}${chalk[colors[depth].command](cmdName)} ${cmdUsage}`;
|
||||
},
|
||||
subcommandTerm(cmd) {
|
||||
const depth = cmd.depth();
|
||||
|
||||
// Legacy. Ignores custom usage string, and nested commands.
|
||||
const args = cmd._args.map(arg => humanReadableArgName(arg)).join(' ');
|
||||
return chalk[colors[depth].command](cmd._name + (
|
||||
cmd._aliases[0] ? `|${cmd._aliases[0]}` : ''
|
||||
)) +
|
||||
chalk[colors[depth].option](cmd.options.length ? ' [options]' : '') + // simplistic check for non-help option
|
||||
chalk[colors[depth].arg](args ? ` ${args}` : '');
|
||||
},
|
||||
longestOptionTermLength(cmd, helper) {
|
||||
return helper.visibleOptions(cmd).reduce((max, option) => Math.max(
|
||||
max,
|
||||
helper.optionTerm(option).length - getControlCharacterSpaces(helper.optionTerm(option))
|
||||
), 0);
|
||||
},
|
||||
longestSubcommandTermLength(cmd, helper) {
|
||||
return helper.visibleCommands(cmd).reduce((max, command) => Math.max(
|
||||
max,
|
||||
helper.subcommandTerm(command).length - getControlCharacterSpaces(helper.subcommandTerm(command))
|
||||
), 0);
|
||||
},
|
||||
longestArgumentTermLength(cmd, helper) {
|
||||
return helper.visibleArguments(cmd).reduce((max, argument) => Math.max(
|
||||
max,
|
||||
helper.argumentTerm(argument).length - getControlCharacterSpaces(helper.argumentTerm(argument))
|
||||
), 0);
|
||||
},
|
||||
formatHelp(cmd, helper) {
|
||||
const depth = cmd.depth();
|
||||
|
||||
const termWidth = helper.padWidth(cmd, helper);
|
||||
const helpWidth = helper.helpWidth || 80;
|
||||
const itemIndentWidth = 2;
|
||||
const itemSeparatorWidth = 2; // between term and description
|
||||
function formatItem(term, description) {
|
||||
const padding = ' '.repeat((termWidth + itemSeparatorWidth) - (term.length - getControlCharacterSpaces(term)));
|
||||
if (description) {
|
||||
const fullText = `${term}${padding}${description}`;
|
||||
return helper.wrap(fullText, helpWidth - itemIndentWidth, termWidth + itemSeparatorWidth);
|
||||
}
|
||||
return term;
|
||||
}
|
||||
function formatList(textArray) {
|
||||
return textArray.join('\n').replace(/^/gm, ' '.repeat(itemIndentWidth));
|
||||
}
|
||||
|
||||
// Usage
|
||||
let output = [`Usage: ${helper.commandUsage(cmd)}`, ''];
|
||||
|
||||
// Description
|
||||
const commandDescription = helper.commandDescription(cmd);
|
||||
if (commandDescription.length > 0) {
|
||||
output = output.concat([commandDescription, '']);
|
||||
}
|
||||
|
||||
// Arguments
|
||||
const argumentList = helper.visibleArguments(cmd).map(argument => formatItem(
|
||||
chalk[colors[depth].arg](argument.term),
|
||||
argument.description
|
||||
));
|
||||
if (argumentList.length > 0) {
|
||||
output = output.concat(['Arguments:', formatList(argumentList), '']);
|
||||
}
|
||||
|
||||
// Options
|
||||
const optionList = helper.visibleOptions(cmd).map(option => formatItem(
|
||||
chalk[colors[depth].option](helper.optionTerm(option)),
|
||||
helper.optionDescription(option)
|
||||
));
|
||||
if (optionList.length > 0) {
|
||||
output = output.concat(['Options:', formatList(optionList), '']);
|
||||
}
|
||||
|
||||
// Commands
|
||||
const commandList = helper.visibleCommands(cmd).map(cmd => formatItem(
|
||||
helper.subcommandTerm(cmd),
|
||||
helper.subcommandDescription(cmd)
|
||||
));
|
||||
if (commandList.length > 0) {
|
||||
output = output.concat(['Commands:', formatList(commandList), '']);
|
||||
}
|
||||
|
||||
return output.join('\n');
|
||||
},
|
||||
};
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
/* eslint-disable import/order */
|
||||
|
||||
'use strict';
|
||||
|
||||
const fs = require('fs');
|
||||
|
||||
@@ -52,7 +52,7 @@ controller.list = async function (req, res) {
|
||||
delete data.children;
|
||||
|
||||
let tids = await categories.getTopicIds(cidQuery);
|
||||
tids = await categories.sortTidsBySet(tids, sort); // sorting not handled if cid is -1
|
||||
tids = await categories.sortTidsBySet(tids, -1, sort); // sorting not handled if cid is -1
|
||||
data.topicCount = tids.length;
|
||||
data.topics = await topics.getTopicsByTids(tids, { uid: req.uid });
|
||||
topics.calculateTopicIndices(data.topics, start);
|
||||
|
||||
@@ -21,8 +21,8 @@ eventsController.get = async function (req, res) {
|
||||
}
|
||||
|
||||
// Limit by date
|
||||
let from = req.query.start ? new Date(req.query.start) : undefined;
|
||||
let to = req.query.end ? new Date(req.query.end) : new Date();
|
||||
let from = req.query.start ? new Date(req.query.start) || undefined : undefined;
|
||||
let to = req.query.end ? new Date(req.query.end) || undefined : new Date();
|
||||
from = from && from.setUTCHours(0, 0, 0, 0); // setHours returns a unix timestamp (Number, not Date)
|
||||
to = to && to.setUTCHours(23, 59, 59, 999); // setHours returns a unix timestamp (Number, not Date)
|
||||
|
||||
|
||||
@@ -26,25 +26,14 @@ const validSorts = [
|
||||
];
|
||||
|
||||
categoryController.get = async function (req, res, next) {
|
||||
let cid = req.params.category_id;
|
||||
const cid = req.params.category_id;
|
||||
if (cid === '-1') {
|
||||
return helpers.redirect(res, `${res.locals.isAPI ? '/api' : ''}/world?${qs.stringify(req.query)}`);
|
||||
}
|
||||
|
||||
if (!utils.isNumber(cid)) {
|
||||
const assertion = await activitypub.actors.assertGroup([cid]);
|
||||
if (!activitypub.helpers.isUri(cid)) {
|
||||
cid = await db.getObjectField('handle:cid', cid);
|
||||
}
|
||||
|
||||
if (!assertion || !cid) {
|
||||
return next();
|
||||
}
|
||||
}
|
||||
|
||||
let currentPage = parseInt(req.query.page, 10) || 1;
|
||||
let topicIndex = utils.isNumber(req.params.topic_index) ? parseInt(req.params.topic_index, 10) - 1 : 0;
|
||||
if ((req.params.topic_index && !utils.isNumber(req.params.topic_index))) {
|
||||
if ((req.params.topic_index && !utils.isNumber(req.params.topic_index)) || !utils.isNumber(cid)) {
|
||||
return next();
|
||||
}
|
||||
|
||||
@@ -69,7 +58,7 @@ categoryController.get = async function (req, res, next) {
|
||||
return helpers.notAllowed(req, res);
|
||||
}
|
||||
|
||||
if (utils.isNumber(cid) && !res.locals.isAPI && !req.params.slug && (categoryFields.slug && categoryFields.slug !== `${cid}/`)) {
|
||||
if (!res.locals.isAPI && !req.params.slug && (categoryFields.slug && categoryFields.slug !== `${cid}/`)) {
|
||||
return helpers.redirect(res, `/category/${categoryFields.slug}?${qs.stringify(req.query)}`, true);
|
||||
}
|
||||
|
||||
|
||||
@@ -235,12 +235,6 @@ Controllers.confirmEmail = async (req, res) => {
|
||||
return renderPage();
|
||||
}
|
||||
try {
|
||||
if (req.loggedIn) {
|
||||
const emailValidated = await user.getUserField(req.uid, 'email:confirmed');
|
||||
if (emailValidated) {
|
||||
return renderPage({ alreadyValidated: true });
|
||||
}
|
||||
}
|
||||
await user.email.confirmByCode(req.params.code, req.session.id);
|
||||
if (req.session.registration) {
|
||||
// After confirmation, no need to send user back to email change form
|
||||
|
||||
@@ -46,7 +46,7 @@ Posts.get = async (req, res) => {
|
||||
|
||||
Posts.getIndex = async (req, res) => {
|
||||
const { pid } = req.params;
|
||||
const { sort } = req.body || {};
|
||||
const { sort } = req.body;
|
||||
|
||||
const index = await api.posts.getIndex(req, { pid, sort });
|
||||
if (index === null) {
|
||||
|
||||
@@ -106,7 +106,8 @@ events.log = async function (data) {
|
||||
events.getEvents = async function (options) {
|
||||
// backwards compatibility
|
||||
if (arguments.length > 1) {
|
||||
const args = [...arguments];
|
||||
// eslint-disable-next-line prefer-rest-params
|
||||
const args = Array.prototype.slice.call(arguments);
|
||||
options = {
|
||||
filter: args[0],
|
||||
start: args[1],
|
||||
|
||||
@@ -105,8 +105,7 @@ middleware.assertPayload = async function (req, res, next) {
|
||||
|
||||
// Cross-check key ownership against received actor
|
||||
await activitypub.actors.assert(actor);
|
||||
let compare = await db.getObjectsFields([`userRemote:${actor}:keys`, `categoryRemote:${actor}:keys`], ['id']);
|
||||
compare = compare.reduce((keyId, { id }) => keyId || id, '').replace(/#[\w-]+$/, '');
|
||||
const compare = ((await db.getObjectField(`userRemote:${actor}:keys`, 'id')) || '').replace(/#[\w-]+$/, '');
|
||||
const { signature } = req.headers;
|
||||
let keyId = new Map(signature.split(',').filter(Boolean).map((v) => {
|
||||
const index = v.indexOf('=');
|
||||
|
||||
@@ -287,9 +287,7 @@ middleware.validateAuth = helpers.try(async (req, res, next) => {
|
||||
|
||||
middleware.checkRequired = function (fields, req, res, next) {
|
||||
// Used in API calls to ensure that necessary parameters/data values are present
|
||||
const missing = fields.filter(
|
||||
field => req.body && !req.body.hasOwnProperty(field) && !req.query.hasOwnProperty(field)
|
||||
);
|
||||
const missing = fields.filter(field => !req.body.hasOwnProperty(field) && !req.query.hasOwnProperty(field));
|
||||
|
||||
if (!missing.length) {
|
||||
return next();
|
||||
|
||||
@@ -62,9 +62,8 @@ module.exports = function (middleware) {
|
||||
return await finishLogin(req, user);
|
||||
} else if (user.hasOwnProperty('master') && user.master === true) {
|
||||
// If the token received was a master token, a _uid must also be present for all calls
|
||||
const body = req.body || {};
|
||||
if (body.hasOwnProperty('_uid') || req.query.hasOwnProperty('_uid')) {
|
||||
user.uid = body._uid || req.query._uid;
|
||||
if (req.body.hasOwnProperty('_uid') || req.query.hasOwnProperty('_uid')) {
|
||||
user.uid = req.body._uid || req.query._uid;
|
||||
delete user.master;
|
||||
return await finishLogin(req, user);
|
||||
}
|
||||
|
||||
@@ -9,7 +9,6 @@ const user = require('../user');
|
||||
const categories = require('../categories');
|
||||
const plugins = require('../plugins');
|
||||
const translator = require('../translator');
|
||||
const utils = require('../utils');
|
||||
|
||||
const helpers = module.exports;
|
||||
|
||||
@@ -20,11 +19,6 @@ const uidToSystemGroup = {
|
||||
};
|
||||
|
||||
helpers.isUsersAllowedTo = async function (privilege, uids, cid) {
|
||||
// Remote categories inherit world pseudo-category privileges
|
||||
if (!utils.isNumber(cid)) {
|
||||
cid = -1;
|
||||
}
|
||||
|
||||
const [hasUserPrivilege, hasGroupPrivilege] = await Promise.all([
|
||||
groups.isMembers(uids, `cid:${cid}:privileges:${privilege}`),
|
||||
groups.isMembersOfGroupList(uids, `cid:${cid}:privileges:groups:${privilege}`),
|
||||
@@ -35,13 +29,6 @@ helpers.isUsersAllowedTo = async function (privilege, uids, cid) {
|
||||
};
|
||||
|
||||
helpers.isAllowedTo = async function (privilege, uidOrGroupName, cid) {
|
||||
// Remote categories (non-numeric) inherit world privileges
|
||||
if (Array.isArray(cid)) {
|
||||
cid = cid.map(cid => (utils.isNumber(cid) ? cid : -1));
|
||||
} else {
|
||||
cid = utils.isNumber(cid) ? cid : -1;
|
||||
}
|
||||
|
||||
let allowed;
|
||||
if (Array.isArray(privilege) && !Array.isArray(cid)) {
|
||||
allowed = await isAllowedToPrivileges(privilege, uidOrGroupName, cid);
|
||||
|
||||
@@ -9,13 +9,13 @@ const utils = require('../../utils');
|
||||
|
||||
module.exports = function (SocketTopics) {
|
||||
SocketTopics.isTagAllowed = async function (socket, data) {
|
||||
if (!data || !data.tag) {
|
||||
if (!data || !utils.isNumber(data.cid) || !data.tag) {
|
||||
throw new Error('[[error:invalid-data]]');
|
||||
}
|
||||
|
||||
const systemTags = (meta.config.systemTags || '').split(',');
|
||||
const [tagWhitelist, isPrivileged] = await Promise.all([
|
||||
utils.isNumber(data.cid) ? categories.getTagWhitelist([data.cid]) : [],
|
||||
categories.getTagWhitelist([data.cid]),
|
||||
user.isPrivileged(socket.uid),
|
||||
]);
|
||||
return isPrivileged ||
|
||||
|
||||
@@ -67,7 +67,7 @@ module.exports = function (Topics) {
|
||||
db.sortedSetsAdd(timestampedSortedSetKeys, timestamp, topicData.tid),
|
||||
db.sortedSetsAdd(countedSortedSetKeys, 0, topicData.tid),
|
||||
user.addTopicIdToUser(topicData.uid, topicData.tid, timestamp),
|
||||
db.incrObjectField(`${utils.isNumber(topicData.cid) ? 'category' : 'categoryRemote'}:${topicData.cid}`, 'topic_count'),
|
||||
db.incrObjectField(`category:${topicData.cid}`, 'topic_count'),
|
||||
utils.isNumber(tid) ? db.incrObjectField('global', 'topicCount') : null,
|
||||
Topics.createTags(data.tags, topicData.tid, timestamp),
|
||||
scheduled ? Promise.resolve() : categories.updateRecentTid(topicData.cid, topicData.tid),
|
||||
|
||||
@@ -145,8 +145,8 @@ module.exports = function (Topics) {
|
||||
const postCountChange = incr * topicData.postcount;
|
||||
await Promise.all([
|
||||
db.incrObjectFieldBy('global', 'postCount', postCountChange),
|
||||
db.incrObjectFieldBy(`${utils.isNumber(topicData.cid) ? 'category' : 'categoryRemote'}:${topicData.cid}`, 'post_count', postCountChange),
|
||||
db.incrObjectFieldBy(`${utils.isNumber(topicData.cid) ? 'category' : 'categoryRemote'}:${topicData.cid}`, 'topic_count', incr),
|
||||
db.incrObjectFieldBy(`category:${topicData.cid}`, 'post_count', postCountChange),
|
||||
db.incrObjectFieldBy(`category:${topicData.cid}`, 'topic_count', incr),
|
||||
]);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -74,7 +74,8 @@ Topics.getTopicsByTids = async function (tids, options) {
|
||||
.map(t => t && t.uid && t.uid.toString())
|
||||
.filter(v => utils.isNumber(v) || activitypub.helpers.isUri(v)));
|
||||
const cids = _.uniq(topics
|
||||
.map(t => t && t.cid && t.cid.toString()));
|
||||
.map(t => t && t.cid && t.cid.toString())
|
||||
.filter(v => utils.isNumber(v)));
|
||||
const guestTopics = topics.filter(t => t && t.uid === 0);
|
||||
|
||||
async function loadGuestHandles() {
|
||||
|
||||
@@ -4,7 +4,6 @@
|
||||
const db = require('../database');
|
||||
const plugins = require('../plugins');
|
||||
const posts = require('../posts');
|
||||
const utils = require('../utils');
|
||||
|
||||
module.exports = function (Topics) {
|
||||
const terms = {
|
||||
@@ -76,7 +75,7 @@ module.exports = function (Topics) {
|
||||
|
||||
// Topics in /world are excluded from /recent
|
||||
const cid = await Topics.getTopicField(tid, 'cid');
|
||||
if (!utils.isNumber(cid) || cid === -1) {
|
||||
if (cid === -1) {
|
||||
return await db.sortedSetRemove('topics:recent', data.tid);
|
||||
}
|
||||
|
||||
|
||||
@@ -233,7 +233,7 @@ module.exports = function (Topics) {
|
||||
};
|
||||
|
||||
topicTools.move = async function (tid, data) {
|
||||
const cid = utils.isNumber(data.cid) ? parseInt(data.cid, 10) : data.cid;
|
||||
const cid = parseInt(data.cid, 10);
|
||||
const topicData = await Topics.getTopicData(tid);
|
||||
if (!topicData) {
|
||||
throw new Error('[[error:no-topic]]');
|
||||
|
||||
@@ -146,6 +146,7 @@ Upgrade.process = async function (files, skipCount) {
|
||||
process.stdout.write(chalk.grey(' skipped\n'));
|
||||
|
||||
await db.sortedSetAdd('schemaLog', Date.now(), path.basename(file, '.js'));
|
||||
// eslint-disable-next-line no-continue
|
||||
continue;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
/* eslint-disable no-await-in-loop */
|
||||
|
||||
'use strict';
|
||||
|
||||
const db = require('../../database');
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
/* eslint-disable no-await-in-loop */
|
||||
|
||||
'use strict';
|
||||
|
||||
const db = require('../../database');
|
||||
|
||||
@@ -76,7 +76,7 @@ module.exports = {
|
||||
batch: 500,
|
||||
withScores: true,
|
||||
});
|
||||
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
await db.deleteAll(`uid:${uid}:chat:room:${roomId}:mids`);
|
||||
progress.incr(1);
|
||||
}
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
/* eslint-disable no-await-in-loop */
|
||||
|
||||
'use strict';
|
||||
|
||||
const db = require('../../database');
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
/* eslint-disable no-await-in-loop */
|
||||
|
||||
'use strict';
|
||||
|
||||
const db = require('../../database');
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
/* eslint-disable no-await-in-loop */
|
||||
|
||||
'use strict';
|
||||
|
||||
const db = require('../../database');
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
/* eslint-disable no-await-in-loop */
|
||||
|
||||
'use strict';
|
||||
|
||||
const db = require('../../database');
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
/* eslint-disable no-await-in-loop */
|
||||
|
||||
'use strict';
|
||||
|
||||
const db = require('../../database');
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
/* eslint-disable no-await-in-loop */
|
||||
|
||||
'use strict';
|
||||
|
||||
const db = require('../../database');
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
/* eslint-disable no-await-in-loop */
|
||||
|
||||
'use strict';
|
||||
|
||||
const db = require('../../database');
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
/* eslint-disable no-await-in-loop */
|
||||
|
||||
'use strict';
|
||||
|
||||
const db = require('../../database');
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
/* eslint-disable no-await-in-loop */
|
||||
|
||||
'use strict';
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
/* eslint-disable no-await-in-loop */
|
||||
|
||||
'use strict';
|
||||
|
||||
|
||||
@@ -3,10 +3,8 @@
|
||||
const _ = require('lodash');
|
||||
|
||||
const db = require('../database');
|
||||
const meta = require('../meta');
|
||||
const categories = require('../categories');
|
||||
const plugins = require('../plugins');
|
||||
const api = require('../api');
|
||||
const utils = require('../utils');
|
||||
|
||||
module.exports = function (User) {
|
||||
@@ -29,18 +27,7 @@ module.exports = function (User) {
|
||||
if (exists.includes(false)) {
|
||||
throw new Error('[[error:no-category]]');
|
||||
}
|
||||
|
||||
const apiMethod = state >= categories.watchStates.tracking ? 'follow' : 'unfollow';
|
||||
const follows = cids.filter(cid => !utils.isNumber(cid)).map(cid => api.activitypub[apiMethod]({ uid }, {
|
||||
type: 'uid',
|
||||
id: uid,
|
||||
actor: cid,
|
||||
})); // returns promises
|
||||
|
||||
await Promise.all([
|
||||
db.sortedSetsAdd(cids.map(cid => `cid:${cid}:uid:watch:state`), state, uid),
|
||||
...follows,
|
||||
]);
|
||||
await db.sortedSetsAdd(cids.map(cid => `cid:${cid}:uid:watch:state`), state, uid);
|
||||
};
|
||||
|
||||
User.getCategoryWatchState = async function (uid) {
|
||||
@@ -80,11 +67,7 @@ module.exports = function (User) {
|
||||
};
|
||||
|
||||
User.getCategoriesByStates = async function (uid, states) {
|
||||
const [localCids, remoteCids] = await Promise.all([
|
||||
categories.getAllCidsFromSet('categories:cid'),
|
||||
meta.config.activitypubEnabled ? db.getObjectValues('handle:cid') : [],
|
||||
]);
|
||||
const cids = localCids.concat(remoteCids);
|
||||
const cids = await categories.getAllCidsFromSet('categories:cid');
|
||||
if (!(parseInt(uid, 10) > 0)) {
|
||||
return cids;
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user